File size: 24,434 Bytes
6cdec4b
 
 
 
 
 
 
 
 
 
 
9e26574
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e26574
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e4e49f
fd39eb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e4e49f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cdec4b
3e4e49f
 
 
 
 
 
 
 
 
ecfd55a
c6255e6
3e4e49f
6cdec4b
3e4e49f
6cdec4b
3e4e49f
 
6cdec4b
 
ecfd55a
 
6cdec4b
ecfd55a
 
6cdec4b
 
 
3e4e49f
 
 
 
 
6cdec4b
 
3e4e49f
 
fd39eb4
6cdec4b
c6255e6
 
 
 
 
 
 
 
 
 
 
6cdec4b
 
 
 
8de16b3
 
 
 
 
 
 
 
 
 
 
 
 
6cdec4b
 
ecfd55a
 
 
 
 
 
 
 
 
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e26574
 
6cdec4b
 
9e26574
6cdec4b
 
 
9e26574
 
 
6cdec4b
 
 
 
 
 
9e26574
 
6cdec4b
9e26574
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e26574
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
3e4e49f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cdec4b
 
 
 
 
 
 
9e26574
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd39eb4
6cdec4b
fd39eb4
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
9e26574
 
6cdec4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
"""Trade Copilot — FastAPI server.

Endpoints:
  GET /            → dashboard HTML (served from static/index.html)
  GET /api/scan    → ranked shortlist JSON
  GET /api/card/{symbol} → full trade card for one symbol
  GET /api/status  → health + active source

All computation is deterministic — no AI in the data path.
"""
from __future__ import annotations
import math, statistics, time, os, re
from pathlib import Path
from functools import lru_cache
from datetime import datetime, timezone

import asyncio
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse, FileResponse

from exchange import pick_source, SOURCES, BingX, Binance
from scorer import score_symbol
from catalyst import fetch_fear_greed, fetch_coin_news, score_catalyst, fetch_crypto_headlines
from signals import scan_universe, build_signal_universe
from backtest import backtest_symbol
from signal_log import log_signal, resolve_signals, get_accuracy, get_symbol_accuracy

# CryptoPanic token — set in HF Spaces Secrets as CRYPTOPANIC_TOKEN
# Never hardcode or log this value
CRYPTOPANIC_TOKEN = os.environ.get("CRYPTOPANIC_TOKEN", "")

logger = logging.getLogger("main")

ALERT_INTERVAL = 300  # 5 minutes

# ── Synthetic-instrument hygiene (Strategy Fix Plan F5b) ───────────────────
# BingX lists non-crypto CFD proxies (oil, forex, metals, index futures) on the
# same USDT-M perpetual endpoint as real crypto. 10/50 alerts in the Jul 8-11
# batch were these — unanalyzable (no independent data source) and off-mission.
# Patterns below are derived from the 7 confirmed synthetic symbols found in
# analysis/replay_results.csv (NO_DATA rows), not guessed:
#   CO1OILBRENT2USD-USDT, NCCO1OILWTI2USD-USDT, NCCOGOLD2USD-USDT,
#   NCCOXAG2USD-USDT, NCFXGBP2JPY-USDT, NCSISP5002USD-USDT, SINASDAQ1002USD-USDT
# The 3rd pattern (digit + 3-letter fake-FX code immediately before "-USDT")
# alone catches all 7 observed cases and is low-false-positive: real crypto
# tickers (BTC-USDT, 1000PEPE-USDT, etc.) never end in <digit><3 letters>-USDT.
EXCLUDE_SYNTHETICS = os.environ.get("EXCLUDE_SYNTHETICS", "true").lower() == "true"
_SYNTHETIC_PATTERNS = [
    re.compile(r"^(CO1|NCFX|NCS|NCCO|SIN)", re.IGNORECASE),                # known prefixes
    re.compile(r"(XAU|XAG|NGAS|NATGAS|BRENT|WTI|GOLD|SILVER|NASDAQ|SP500)", re.IGNORECASE),  # commodity/index keywords
    re.compile(r"\d[A-Z]{3}-USDT$"),                                       # embedded fake-FX quote suffix
]


def is_synthetic(symbol: str) -> bool:
    return any(p.search(symbol) for p in _SYNTHETIC_PATTERNS)


# ── Alert trigger rule (confidence + PA confluence grade) ──────────────────
# Changed 13 Jul 2026 per explicit instruction: previously any card with
# confidence >= 8.8 fired an alert, no grade check. Now the confidence floor
# is lowered to 7.0, but a chart-pattern (confluence) grade of A or A+ is
# REQUIRED regardless of how high confidence is — grade B/C/D never fires,
# even at confidence 9+. Single shared gate used by both the background loop
# and manual /api/scan so the two paths can't drift apart again (that exact
# drift was the earlier website/Telegram discrepancy bug).
ALERT_CONF_MIN = 7.0
ALERT_GRADES_OK = {"A+", "A"}


def _passes_alert_gate(card: dict) -> bool:
    if card.get("vetoed", False):
        return False
    if card.get("confidence", 0) < ALERT_CONF_MIN:
        return False
    grade = (card.get("price_action") or {}).get("confluence_grade")
    return grade in ALERT_GRADES_OK


# ── Website -> Telegram alert config sync ──────────────────────────────────
# Root cause fixed here: the dashboard's account/risk/top/leverage settings
# used to live only in the browser tab and were never seen by the background
# alert loop, which hardcoded its own numbers instead. This in-memory store
# is the shared source of truth both sides now read/write. It only ever
# contains a key once the user has ACTIVELY changed that setting on the
# website — untouched keys fall back to today's existing defaults below, so
# nothing changes for anyone who never touches the settings. Resets on Space
# restart/redeploy, same as the existing dedup cache (see telegram_alert.py).
_alert_config: dict = {}
ALERT_CONFIG_BOUNDS = {
    "top": (1, 50),
    "user_leverage": (1.0, 100.0),
    "risk_pct": (0.01, 100.0),
    "account": (1.0, 10_000_000.0),
}


def _clamp(key: str, value: float) -> float:
    lo, hi = ALERT_CONFIG_BOUNDS[key]
    return max(lo, min(hi, value))


async def _alert_loop():
    """Background task: scan every 5 min and fire Telegram alerts for high-confidence cards.

    account/risk_pct/user_leverage/top scan-depth are read fresh from
    _alert_config each cycle so the website's live settings govern what the
    background loop does (fixes the website/Telegram discrepancy — previously
    this loop ignored the dashboard entirely and used fixed literals). Falls
    back to the original defaults (ALERT_ACCOUNT secret, 6x, 1%, top-30) for
    any setting the user hasn't touched yet.
    """
    from telegram_alert import send_alert, prune_sent_cache, send_raw_message
    from signal_log import log_signal
    # Fallback account size from HF Secret — used only until the website sets a custom one
    try:
        default_account = float(os.environ.get("ALERT_ACCOUNT", "10"))
    except ValueError:
        default_account = 10.0
    logger.info("Alert loop starting — default account=$%.2f (website can override live)", default_account)

    await asyncio.sleep(60)  # give app time to fully boot before first scan
    loop_count   = 0
    HEARTBEAT_EVERY = 72   # 72 × 5 min = 6 hours
    while True:
        loop_count += 1
        prune_sent_cache()  # prune stale dedup entries every cycle
        try:
            src = get_source()
            secondary = get_secondary()
            alert_account  = _alert_config.get("account", default_account)
            alert_risk_pct = _alert_config.get("risk_pct", 1.0)
            alert_leverage = _alert_config.get("user_leverage", 6.0)
            alert_top      = int(_alert_config.get("top", 30))
            candidates, _synth_excluded = rank_universe(src, src.min_vol, alert_top)
            for c in candidates:
                try:
                    card = score_symbol(src, c["symbol"], alert_account, alert_risk_pct,
                                        secondary=secondary, user_leverage=alert_leverage)
                    if _passes_alert_gate(card):
                        tv_sym = src.tv_symbol(card["symbol"])
                        sent = await send_alert(card, tv_sym)
                        # Only log alerts that actually reached Telegram (sent=True) —
                        # send_alert can still return False here on its own 4h dedup,
                        # even though this card cleared the confidence gate. Logging
                        # was previously missing entirely from this loop (root cause
                        # of "are alerts even being saved" — they weren't, anywhere).
                        if sent:
                            try:
                                log_signal(card)
                            except Exception as e:
                                logger.debug("log_signal failed for %s: %s", card.get("symbol"), e)
                        await asyncio.sleep(1)   # small gap between sends
                except Exception as e:
                    logger.debug("Alert scan error for %s: %s", c.get("symbol"), e)
                await asyncio.sleep(0.5)  # gentle rate limit between symbols

            # Resolve any logged signals past their 48h window. Cheap on most
            # cycles (resolve_signals skips anything not yet due — only fetches
            # klines for signals actually ready to check). Previously this only
            # ran if someone manually hit POST /api/resolve-signals, which
            # nothing did — logged signals would sit as "pending" forever even
            # past 48h without this.
            try:
                res = resolve_signals(src)
                if res.get("resolved"):
                    logger.info("Resolved %d signal(s): %s", res["resolved"], res)
            except Exception as e:
                logger.debug("resolve_signals failed: %s", e)
        except Exception as e:
            logger.error("Alert loop error: %s", e)

        # Heartbeat canary — every 6 h send a "system alive" message to Telegram
        if loop_count % HEARTBEAT_EVERY == 0:
            now_str = datetime.now(timezone.utc).strftime("%H:%M UTC · %d %b")
            await send_raw_message(
                f"✅ <b>Trade Copilot alive</b>\n"
                f"<i>Heartbeat #{loop_count // HEARTBEAT_EVERY} · {now_str}</i>"
            )

        await asyncio.sleep(ALERT_INTERVAL)


# Event loop reference — captured at startup so sync endpoints can schedule coroutines
_event_loop: asyncio.AbstractEventLoop | None = None


@asynccontextmanager
async def lifespan(app):
    global _event_loop
    _event_loop = asyncio.get_running_loop()
    task = asyncio.create_task(_alert_loop())
    try:
        yield
    finally:
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass


app = FastAPI(title="Trade Copilot", version="1.0", lifespan=lifespan)

# Cache the active source for 5 min (avoids re-pinging on every request)
_source_cache: dict = {}
_source_ts: float = 0
SOURCE_TTL = 300

# Secondary source for cross-validation (Binance — deeper OI/funding data)
_binance: Binance | None = None
_binance_ok: bool = False
_binance_ts: float = 0
BINANCE_TTL = 300


def get_source():
    global _source_cache, _source_ts
    if time.time() - _source_ts > SOURCE_TTL or not _source_cache.get("src"):
        src = pick_source()
        _source_cache = {"src": src}
        _source_ts = time.time()
    return _source_cache["src"]


def get_secondary():
    """Returns Binance instance if reachable, else None. Used for cross-validation."""
    global _binance, _binance_ok, _binance_ts
    if time.time() - _binance_ts < BINANCE_TTL:
        return _binance if _binance_ok else None
    try:
        b = Binance()
        b.ping()
        _binance = b
        _binance_ok = True
    except Exception:
        _binance = None
        _binance_ok = False
    _binance_ts = time.time()
    return _binance if _binance_ok else None


def rank_universe(src, min_volume: float, top_n: int) -> tuple[list[dict], int]:
    """Returns (picked_candidates, synthetics_excluded_count)."""
    perps = src.universe()
    rows = []
    synth_excluded = 0
    for t in src.tickers():
        if t["symbol"] not in perps:
            continue
        if EXCLUDE_SYNTHETICS and is_synthetic(t["symbol"]):
            synth_excluded += 1
            continue
        qv = t["quote_volume_usd"]
        lo, hi = t.get("low", 0), t.get("high", 0)
        if qv < min_volume or lo <= 0:
            continue
        t["range_24h_pct"] = (hi - lo) / lo * 100
        rows.append(t)
    if synth_excluded:
        logger.info("rank_universe: %d synthetic instruments excluded", synth_excluded)
    if len(rows) < 3:
        return [{"symbol": s} for s in src.anchors], synth_excluded

    log_vols = [math.log(r["quote_volume_usd"]) for r in rows]
    ranges = [r["range_24h_pct"] for r in rows]
    mv, sv = statistics.mean(log_vols), statistics.stdev(log_vols)
    mr, sr = statistics.mean(ranges), statistics.stdev(ranges)
    for r, lv, rg in zip(rows, log_vols, ranges):
        r["scan_score"] = round(0.5 * (lv - mv) / sv + 0.5 * (rg - mr) / sr, 3)

    rows.sort(key=lambda r: r["scan_score"], reverse=True)
    picked = rows[:top_n]
    picked_syms = {r["symbol"] for r in picked}
    for sym in src.anchors:
        if sym not in picked_syms:
            extra = next((r for r in rows if r["symbol"] == sym), None)
            if extra:
                extra["anchor"] = True
                picked.append(extra)
    return picked, synth_excluded


# ── API routes ─────────────────────────────────────────────────────────────

@app.get("/api/status")
def status():
    try:
        src = get_source()
        return {"ok": True, "source": src.name, "market": src.market_note,
                "ts": datetime.now(timezone.utc).isoformat()}
    except Exception as e:
        return JSONResponse({"ok": False, "error": str(e)}, status_code=503)


@app.get("/api/alert-config")
def get_alert_config():
    """Current website->Telegram alert sync settings. is_custom=False means
    the background loop is still using its original hardcoded defaults."""
    return {
        "account": _alert_config.get("account"),
        "risk_pct": _alert_config.get("risk_pct"),
        "top": _alert_config.get("top"),
        "user_leverage": _alert_config.get("user_leverage"),
        "is_custom": bool(_alert_config),
    }


@app.post("/api/alert-config")
def set_alert_config(account: float = None, risk_pct: float = None,
                      top: int = None, user_leverage: float = None):
    """Called by the dashboard whenever the user changes a setting. The
    background alert loop picks these up on its next 5-min cycle."""
    if account is not None:
        _alert_config["account"] = _clamp("account", account)
    if risk_pct is not None:
        _alert_config["risk_pct"] = _clamp("risk_pct", risk_pct)
    if top is not None:
        _alert_config["top"] = int(_clamp("top", top))
    if user_leverage is not None:
        _alert_config["user_leverage"] = _clamp("user_leverage", user_leverage)
    logger.info("Alert config updated from website: %s", _alert_config)
    return {"ok": True, "config": _alert_config}


@app.get("/api/scan")
def scan(top: int = 8, min_volume: float = None, account: float = 100000,
         risk_pct: float = 1.0,
         user_leverage: float = Query(default=6.0, ge=1.0, le=100.0)):
    try:
        src = get_source()
        floor = min_volume or src.min_vol
        candidates, synth_excluded = rank_universe(src, floor, top)
        secondary = get_secondary()
        cards = []
        for c in candidates:
            try:
                card = score_symbol(src, c["symbol"], account, risk_pct,
                                    secondary=secondary, user_leverage=user_leverage)
                card["rank_info"] = {k: c.get(k) for k in
                                     ("scan_score","change_24h_pct","range_24h_pct",
                                      "quote_volume_usd","anchor")}
                cards.append(card)
                # Auto-log every non-vetoed signal for live accuracy tracking
                try:
                    log_signal(card)
                except Exception:
                    pass
                # Fire Telegram alert per _passes_alert_gate (confidence >= 7.0 AND PA grade A/A+)
                try:
                    if _passes_alert_gate(card) and _event_loop is not None:
                        from telegram_alert import send_alert
                        tv_sym = src.tv_symbol(card["symbol"])
                        asyncio.run_coroutine_threadsafe(
                            send_alert(card, tv_sym, manual=True), _event_loop
                        )
                except Exception:
                    pass
                time.sleep(0.05)
            except Exception as e:
                cards.append({"symbol": c["symbol"], "error": str(e)[:120]})
        cards.sort(key=lambda c: c.get("confidence", 0), reverse=True)
        return {"fetched_at": datetime.now(timezone.utc).isoformat(),
                "source": src.name, "market": src.market_note, "cards": cards,
                "synthetics_excluded": synth_excluded}
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/prices")
def prices(symbols: str = ""):
    """Lightweight price ticker — returns current price + 24h change for a
    comma-separated list of symbols. Used for real-time card price updates."""
    try:
        src = get_source()
        requested = {s.strip() for s in symbols.split(",") if s.strip()}
        tickers = src.tickers()
        result = {}
        for t in tickers:
            sym = t.get("symbol", "")
            if not requested or sym in requested:
                result[sym] = {
                    "price": t.get("last_price"),
                    "change_24h_pct": t.get("change_24h_pct"),
                    "high": t.get("high"),
                    "low": t.get("low"),
                    "quote_volume_usd": t.get("quote_volume_usd"),
                }
        return {"ts": datetime.now(timezone.utc).isoformat(), "prices": result}
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/card/{symbol}")
def card(symbol: str, account: float = 100000, risk_pct: float = 1.0,
         user_leverage: float = Query(default=6.0, ge=1.0, le=100.0)):
    try:
        src = get_source()
        secondary = get_secondary()
        result = score_symbol(src, symbol.upper(), account, risk_pct,
                              secondary=secondary, user_leverage=user_leverage)
        # Log the signal for live accuracy tracking
        try:
            log_signal(result)
        except Exception:
            pass
        return result
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/signals")
def signals():
    """Scan mid-cap perps (volume ranks 6-50) for event-driven signals.

    Uses 5 independent detectors: RSI extreme cross, volume surge,
    BB squeeze release, funding rate extreme, and 24h price surge.
    Each fires independently — no combined gate required.
    BTC and ETH always appended as market-wide sentiment anchors.

    Results cached 5 min per symbol inside signals.py.
    """
    try:
        src = get_source()
        secondary = get_secondary()

        # Build universe: mid-cap ranks 6-50 by USD volume + BTC/ETH anchors
        try:
            symbols, ticker_map = build_signal_universe(src)
        except Exception:
            symbols = getattr(src, "anchors", [])[:20]
            ticker_map = {}

        alerts = scan_universe(src, symbols, secondary=secondary, ticker_map=ticker_map)
        return {
            "fetched_at": datetime.now(timezone.utc).isoformat(),
            "source": src.name,
            "scanned": len(symbols),
            "alerts": alerts,
        }
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/news")
def news():
    """Free crypto news — CoinGecko trending + CoinDesk + Cointelegraph RSS.

    No API key required. Returns:
      trending_coins: top 7 coins by CoinGecko search volume in last 24h
      headlines:      latest headlines from CoinDesk + Cointelegraph RSS
                      each tagged with coins_mentioned[]
    Cached 10 min.
    """
    try:
        result = fetch_crypto_headlines()
        return result
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/signal-accuracy")
def signal_accuracy(symbol: str = ""):
    """Live signal accuracy — real win rate from logged + resolved signals.

    This is ground truth: every card the scanner generates gets logged,
    then checked 48h later to see if TP1 or SL was hit first.

    ?symbol=BTC-USDT  (optional — returns per-symbol breakdown)
    """
    try:
        overall = get_accuracy()
        if symbol:
            overall["symbol_breakdown"] = get_symbol_accuracy(symbol.upper())
        return overall
    except Exception as e:
        raise HTTPException(503, str(e))


@app.post("/api/resolve-signals")
def resolve_signals_endpoint():
    """Trigger resolution of all pending signals that are past their 48h window.

    Called manually or by a scheduler. Checks price range on exchange
    to determine if TP1 or SL was touched during the hold period.
    """
    try:
        src = get_source()
        result = resolve_signals(src)
        return result
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/catalyst")
def catalyst(symbol: str = ""):
    """Debug endpoint — returns raw Fear & Greed + CryptoPanic data.
    ?symbol=BTC-USDT  (optional; returns coin news if provided)
    """
    try:
        fng = fetch_fear_greed()
        result: dict = {
            "fear_greed": {
                "value": fng.get("value"),
                "label": fng.get("label"),
                "score_mod": fng.get("score_mod"),
                "error": fng.get("error"),
            }
        }
        if symbol:
            news = fetch_coin_news(symbol.upper())
            combined_score, notes, _raw = score_catalyst(symbol.upper())
            result["symbol"] = symbol.upper()
            result["news"] = {
                "score": news.get("score"),
                "label": news.get("label"),
                "source": news.get("source"),
                "items": news.get("items", []),
                "error": news.get("error"),
            }
            result["catalyst_score"] = combined_score
            result["notes"] = notes
        result["cryptopanic_key_set"] = bool(CRYPTOPANIC_TOKEN)
        result["ts"] = datetime.now(timezone.utc).isoformat()
        return result
    except Exception as e:
        raise HTTPException(503, str(e))


@app.get("/api/test-telegram")
def test_telegram():
    """Diagnose Telegram relay: probe Worker reachability then send message."""
    import requests as req
    worker_url = os.environ.get("CLOUDFLARE_WORKER_URL", "")
    if not worker_url:
        return JSONResponse({"ok": False, "error": "CLOUDFLARE_WORKER_URL not set in HF Secrets"}, status_code=500)

    # Probe: GET the worker (expect 405 - proves HF can reach workers.dev)
    probe = {}
    try:
        r = req.get(worker_url, timeout=8)
        probe["worker_get"] = {"status": r.status_code, "body": r.text[:80]}
    except Exception as e:
        probe["worker_get"] = {"error": type(e).__name__ + ": " + str(e)[:120]}

    # Send the Telegram message with longer timeout
    msg = "Test: Trade Copilot relay check via Cloudflare Worker."
    try:
        resp = req.post(worker_url, json={"text": msg, "parse_mode": "HTML"}, timeout=30)
        body = resp.json()
        if resp.status_code == 200:
            return {"ok": True, "message": "Test message sent via CF Worker", "probe": probe}
        return JSONResponse({"ok": False, "status": resp.status_code, "error": body, "probe": probe}, status_code=500)
    except Exception as e:
        return JSONResponse({"ok": False, "error": type(e).__name__ + ": " + str(e)[:200], "probe": probe}, status_code=500)


@app.get("/api/backtest/{symbol}")
def backtest(symbol: str):
    """Walk-forward backtest for one symbol over ~6 months of 1h candles.

    Win condition: TP2 hit before SL.
    Results cached 6h — first call is slow (~5-15s), subsequent calls instant.

    Returns:
      total_trades, win_rate, avg_r, max_drawdown_r,
      by_state {BULL/BEAR/RANGING: {trades, wins, win_rate, avg_r}},
      by_direction {long/short: ...},
      equity_curve [float list],
      recent_trades [last 50]
    """
    try:
        src = get_source()
        result = backtest_symbol(src, symbol.upper())
        return result
    except Exception as e:
        raise HTTPException(503, str(e))


# ── Static dashboard ───────────────────────────────────────────────────────

static_dir = Path(__file__).parent / "static"
if static_dir.exists():
    app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")

@app.get("/")
def root():
    index = static_dir / "index.html"
    if index.exists():
        return FileResponse(str(index))
    return {"message": "Trade Copilot API running. Use /api/scan or /api/status."}


if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 7860))
    uvicorn.run("main:app", host="0.0.0.0", port=port)