feat: scan buttons + daily auto-scan scheduler
Browse files- Eligible page: Tara dropdown (BIST30/BIST100/Tum BIST) + Yenile
- Scan progress banner with real progress bar (stage2/stage1)
- Auto-scan scheduler: runs BIST100 every day at 00:00 TR time
- bist_all/all universe support in run_bist100_scan.py
- Partial results warning when scan not completed
huggingface-space/app.py
CHANGED
|
@@ -52,6 +52,77 @@ _trading_worker_thread = None
|
|
| 52 |
_scan_thread = None
|
| 53 |
_scan_status = {"running": False, "progress": "", "started_at": None}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def _get_trading_status():
|
| 57 |
"""Read trading status from file-based state."""
|
|
|
|
| 52 |
_scan_thread = None
|
| 53 |
_scan_status = {"running": False, "progress": "", "started_at": None}
|
| 54 |
|
| 55 |
+
# ─── Daily Auto-Scan Scheduler ────────────────────────
|
| 56 |
+
# Runs BIST100 scan every day at 00:00 Turkey time (21:00 UTC)
|
| 57 |
+
import threading
|
| 58 |
+
import time as _time
|
| 59 |
+
|
| 60 |
+
def _daily_scan_scheduler():
|
| 61 |
+
"""Background thread: triggers a full BIST100 scan daily at midnight TR time."""
|
| 62 |
+
global _scan_thread, _scan_status
|
| 63 |
+
from datetime import timezone, timedelta
|
| 64 |
+
|
| 65 |
+
TR_TZ = timezone(timedelta(hours=3))
|
| 66 |
+
|
| 67 |
+
while True:
|
| 68 |
+
try:
|
| 69 |
+
now_tr = datetime.now(TR_TZ)
|
| 70 |
+
# Next midnight TR
|
| 71 |
+
tomorrow = (now_tr + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
| 72 |
+
wait_seconds = (tomorrow - now_tr).total_seconds()
|
| 73 |
+
print(f"[scheduler] Next auto-scan at {tomorrow.isoformat()} (in {wait_seconds/3600:.1f}h)")
|
| 74 |
+
_time.sleep(wait_seconds)
|
| 75 |
+
|
| 76 |
+
# Check if scan already running
|
| 77 |
+
if _scan_status.get("running"):
|
| 78 |
+
print("[scheduler] Scan already running, skipping scheduled scan")
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
print("[scheduler] Starting daily BIST100 auto-scan...")
|
| 82 |
+
_scan_status = {
|
| 83 |
+
"running": True,
|
| 84 |
+
"progress": "Günlük otomatik tarama başlatılıyor (BIST100)...",
|
| 85 |
+
"started_at": datetime.now().isoformat(),
|
| 86 |
+
"universe": "bist100",
|
| 87 |
+
"scheduled": True,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
def _auto_scan():
|
| 91 |
+
global _scan_status
|
| 92 |
+
try:
|
| 93 |
+
from run_bist100_scan import run_scan
|
| 94 |
+
_scan_status["progress"] = "Stage 1: Likidite filtresi..."
|
| 95 |
+
run_scan(universe="bist100", force=True, stage1_only=False)
|
| 96 |
+
_scan_status = {
|
| 97 |
+
"running": False,
|
| 98 |
+
"progress": "Günlük tarama tamamlandı!",
|
| 99 |
+
"finished_at": datetime.now().isoformat(),
|
| 100 |
+
"universe": "bist100",
|
| 101 |
+
"scheduled": True,
|
| 102 |
+
}
|
| 103 |
+
print(f"[scheduler] Daily scan completed at {datetime.now().isoformat()}")
|
| 104 |
+
except Exception as e:
|
| 105 |
+
_scan_status = {
|
| 106 |
+
"running": False,
|
| 107 |
+
"progress": f"Günlük tarama hatası: {e}",
|
| 108 |
+
"error": str(e),
|
| 109 |
+
"universe": "bist100",
|
| 110 |
+
"scheduled": True,
|
| 111 |
+
}
|
| 112 |
+
print(f"[scheduler] Daily scan error: {e}")
|
| 113 |
+
|
| 114 |
+
_scan_thread = threading.Thread(target=_auto_scan, daemon=True, name="daily-bist-scan")
|
| 115 |
+
_scan_thread.start()
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"[scheduler] Scheduler error: {e}")
|
| 119 |
+
_time.sleep(3600) # wait an hour on error and retry
|
| 120 |
+
|
| 121 |
+
# Start scheduler on import
|
| 122 |
+
_scheduler_thread = threading.Thread(target=_daily_scan_scheduler, daemon=True, name="scan-scheduler")
|
| 123 |
+
_scheduler_thread.start()
|
| 124 |
+
print("[scheduler] Daily auto-scan scheduler started (00:00 TR / 21:00 UTC)")
|
| 125 |
+
|
| 126 |
|
| 127 |
def _get_trading_status():
|
| 128 |
"""Read trading status from file-based state."""
|
huggingface-space/run_bist100_scan.py
CHANGED
|
@@ -242,8 +242,13 @@ def get_universe(name: str) -> List[str]:
|
|
| 242 |
"""Get stock list from official Borsa Istanbul CSV."""
|
| 243 |
from data.index_constituents import get_index_constituents
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
try:
|
| 246 |
-
result = get_index_constituents(
|
| 247 |
symbols = result.symbols
|
| 248 |
logger.info("Fetched %d stocks from %s", len(symbols), name)
|
| 249 |
return symbols
|
|
|
|
| 242 |
"""Get stock list from official Borsa Istanbul CSV."""
|
| 243 |
from data.index_constituents import get_index_constituents
|
| 244 |
|
| 245 |
+
# Normalize aliases
|
| 246 |
+
lookup = name.lower().replace("-", "_").replace(" ", "_")
|
| 247 |
+
if lookup in ("bist_all", "all", "tum", "tum_bist"):
|
| 248 |
+
lookup = "all"
|
| 249 |
+
|
| 250 |
try:
|
| 251 |
+
result = get_index_constituents(lookup)
|
| 252 |
symbols = result.symbols
|
| 253 |
logger.info("Fetched %d stocks from %s", len(symbols), name)
|
| 254 |
return symbols
|
nextjs-app/src/app/eligible/page.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
| 22 |
CircleDot,
|
| 23 |
Eye,
|
| 24 |
X,
|
|
|
|
|
|
|
| 25 |
} from 'lucide-react'
|
| 26 |
|
| 27 |
// ─── Types ─────────────────────────────────
|
|
@@ -232,6 +234,8 @@ export default function EligiblePage() {
|
|
| 232 |
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards')
|
| 233 |
const [scanLoading, setScanLoading] = useState(false)
|
| 234 |
const [signalFilter, setSignalFilter] = useState<string>('all')
|
|
|
|
|
|
|
| 235 |
const isScanCompleted = Boolean(data?.scanCompleted)
|
| 236 |
const stage2Done = data?.stage2Done ?? null
|
| 237 |
const stage1Passed = data?.stage1PassedCount ?? null
|
|
@@ -460,18 +464,6 @@ export default function EligiblePage() {
|
|
| 460 |
{/* Header */}
|
| 461 |
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
| 462 |
<div>
|
| 463 |
-
|
| 464 |
-
{data?.ok && !isScanCompleted && (
|
| 465 |
-
<div className="mt-4 rounded-md border bg-background p-3 text-sm">
|
| 466 |
-
<div className="font-medium">Tarama devam ediyor (kısmi sonuçlar)</div>
|
| 467 |
-
<div className="text-muted-foreground">
|
| 468 |
-
{data.scanProgress ||
|
| 469 |
-
(stage2Done !== null && stage1Passed !== null
|
| 470 |
-
? `Stage 2: ${stage2Done}/${stage1Passed} işlendi`
|
| 471 |
-
: "Tarama ilerliyor...")}
|
| 472 |
-
</div>
|
| 473 |
-
</div>
|
| 474 |
-
)}
|
| 475 |
<h1 className="text-2xl font-bold flex items-center gap-2">
|
| 476 |
<Shield className="w-7 h-7 text-green-400" />
|
| 477 |
ML Eligible Hisseler & Trading Sinyalleri
|
|
@@ -485,17 +477,102 @@ export default function EligiblePage() {
|
|
| 485 |
{signals?.timestamp && ` | Sinyaller: ${new Date(signals.timestamp).toLocaleString('tr-TR')}`}
|
| 486 |
</p>
|
| 487 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
</div>
|
| 489 |
-
<button
|
| 490 |
-
onClick={() => { loadEligible().then((res) => { if (res?.eligible?.length) loadSignals(res.eligible.map((e) => e.symbol)) }) }}
|
| 491 |
-
disabled={signalsLoading}
|
| 492 |
-
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm disabled:opacity-50"
|
| 493 |
-
>
|
| 494 |
-
<RefreshCw className={`w-4 h-4 ${signalsLoading ? 'animate-spin' : ''}`} />
|
| 495 |
-
{signalsLoading ? 'Analiz ediliyor...' : 'Yenile'}
|
| 496 |
-
</button>
|
| 497 |
</div>
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
{/* Summary Cards */}
|
| 500 |
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
|
| 501 |
<SummaryCard icon={<Shield className="w-5 h-5 text-green-400" />} value={summary.eligibleCount} label="Eligible" color="text-green-400" />
|
|
|
|
| 22 |
CircleDot,
|
| 23 |
Eye,
|
| 24 |
X,
|
| 25 |
+
Search,
|
| 26 |
+
Clock,
|
| 27 |
} from 'lucide-react'
|
| 28 |
|
| 29 |
// ─── Types ─────────────────────────────────
|
|
|
|
| 234 |
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards')
|
| 235 |
const [scanLoading, setScanLoading] = useState(false)
|
| 236 |
const [signalFilter, setSignalFilter] = useState<string>('all')
|
| 237 |
+
const [showScanMenu, setShowScanMenu] = useState(false)
|
| 238 |
+
const isScanRunning = Boolean(data?.scanRunning) || scanLoading
|
| 239 |
const isScanCompleted = Boolean(data?.scanCompleted)
|
| 240 |
const stage2Done = data?.stage2Done ?? null
|
| 241 |
const stage1Passed = data?.stage1PassedCount ?? null
|
|
|
|
| 464 |
{/* Header */}
|
| 465 |
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
| 466 |
<div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
<h1 className="text-2xl font-bold flex items-center gap-2">
|
| 468 |
<Shield className="w-7 h-7 text-green-400" />
|
| 469 |
ML Eligible Hisseler & Trading Sinyalleri
|
|
|
|
| 477 |
{signals?.timestamp && ` | Sinyaller: ${new Date(signals.timestamp).toLocaleString('tr-TR')}`}
|
| 478 |
</p>
|
| 479 |
)}
|
| 480 |
+
<p className="text-gray-400 text-xs mt-0.5 flex items-center gap-1">
|
| 481 |
+
<Clock className="w-3 h-3" /> Otomatik tarama: Her gün 00:00 (TR)
|
| 482 |
+
</p>
|
| 483 |
+
</div>
|
| 484 |
+
<div className="flex items-center gap-2">
|
| 485 |
+
{/* Scan Button */}
|
| 486 |
+
<div className="relative">
|
| 487 |
+
<button
|
| 488 |
+
onClick={() => setShowScanMenu(!showScanMenu)}
|
| 489 |
+
disabled={isScanRunning}
|
| 490 |
+
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
| 491 |
+
isScanRunning
|
| 492 |
+
? 'bg-blue-100 text-blue-600 cursor-wait'
|
| 493 |
+
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
| 494 |
+
}`}
|
| 495 |
+
>
|
| 496 |
+
{isScanRunning ? (
|
| 497 |
+
<><RefreshCw className="w-4 h-4 animate-spin" /> Taranıyor...</>
|
| 498 |
+
) : (
|
| 499 |
+
<><Search className="w-4 h-4" /> Tara</>
|
| 500 |
+
)}
|
| 501 |
+
</button>
|
| 502 |
+
{showScanMenu && !isScanRunning && (
|
| 503 |
+
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1 min-w-[200px]">
|
| 504 |
+
<button
|
| 505 |
+
onClick={() => { setShowScanMenu(false); startScan('bist30') }}
|
| 506 |
+
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-50 flex items-center gap-2"
|
| 507 |
+
>
|
| 508 |
+
<Zap className="w-4 h-4 text-blue-500" />
|
| 509 |
+
<div>
|
| 510 |
+
<div className="font-medium">BIST30 Hızlı Tara</div>
|
| 511 |
+
<div className="text-xs text-gray-400">~15-30 dakika</div>
|
| 512 |
+
</div>
|
| 513 |
+
</button>
|
| 514 |
+
<button
|
| 515 |
+
onClick={() => { setShowScanMenu(false); startScan('bist100') }}
|
| 516 |
+
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-50 flex items-center gap-2"
|
| 517 |
+
>
|
| 518 |
+
<BarChart3 className="w-4 h-4 text-green-500" />
|
| 519 |
+
<div>
|
| 520 |
+
<div className="font-medium">BIST100 Tam Tara</div>
|
| 521 |
+
<div className="text-xs text-gray-400">~60-90 dakika</div>
|
| 522 |
+
</div>
|
| 523 |
+
</button>
|
| 524 |
+
<button
|
| 525 |
+
onClick={() => { setShowScanMenu(false); startScan('bist_all') }}
|
| 526 |
+
className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-50 flex items-center gap-2 border-t border-gray-100"
|
| 527 |
+
>
|
| 528 |
+
<Activity className="w-4 h-4 text-purple-500" />
|
| 529 |
+
<div>
|
| 530 |
+
<div className="font-medium">Tüm BIST Tara</div>
|
| 531 |
+
<div className="text-xs text-gray-400">~2-4 saat</div>
|
| 532 |
+
</div>
|
| 533 |
+
</button>
|
| 534 |
+
</div>
|
| 535 |
+
)}
|
| 536 |
+
</div>
|
| 537 |
+
{/* Refresh Button */}
|
| 538 |
+
<button
|
| 539 |
+
onClick={() => { loadEligible().then((res) => { if (res?.eligible?.length) loadSignals(res.eligible.map((e) => e.symbol)) }) }}
|
| 540 |
+
disabled={signalsLoading}
|
| 541 |
+
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm disabled:opacity-50"
|
| 542 |
+
>
|
| 543 |
+
<RefreshCw className={`w-4 h-4 ${signalsLoading ? 'animate-spin' : ''}`} />
|
| 544 |
+
{signalsLoading ? 'Analiz ediliyor...' : 'Yenile'}
|
| 545 |
+
</button>
|
| 546 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
</div>
|
| 548 |
|
| 549 |
+
{/* Scan progress banner */}
|
| 550 |
+
{isScanRunning && (
|
| 551 |
+
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3 flex items-center gap-3">
|
| 552 |
+
<RefreshCw className="w-5 h-5 text-blue-500 animate-spin flex-shrink-0" />
|
| 553 |
+
<div className="flex-1 min-w-0">
|
| 554 |
+
<div className="text-sm font-medium text-blue-800">Tarama devam ediyor</div>
|
| 555 |
+
<div className="text-xs text-blue-600">
|
| 556 |
+
{data.scanProgress ||
|
| 557 |
+
(stage2Done !== null && stage1Passed !== null
|
| 558 |
+
? `Stage 2: ${stage2Done}/${stage1Passed} hisse işlendi`
|
| 559 |
+
: 'Tarama ilerliyor...')}
|
| 560 |
+
</div>
|
| 561 |
+
</div>
|
| 562 |
+
{stage2Done !== null && stage1Passed !== null && stage1Passed > 0 && (
|
| 563 |
+
<div className="w-24 h-2 bg-blue-200 rounded-full overflow-hidden flex-shrink-0">
|
| 564 |
+
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${Math.round((stage2Done / stage1Passed) * 100)}%` }} />
|
| 565 |
+
</div>
|
| 566 |
+
)}
|
| 567 |
+
</div>
|
| 568 |
+
)}
|
| 569 |
+
|
| 570 |
+
{!isScanRunning && !isScanCompleted && data?.ok && (
|
| 571 |
+
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
|
| 572 |
+
Kısmi sonuçlar gösteriliyor. Tam sonuçlar için yeni tarama başlatın.
|
| 573 |
+
</div>
|
| 574 |
+
)}
|
| 575 |
+
|
| 576 |
{/* Summary Cards */}
|
| 577 |
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
|
| 578 |
<SummaryCard icon={<Shield className="w-5 h-5 text-green-400" />} value={summary.eligibleCount} label="Eligible" color="text-green-400" />
|