Your Name commited on
Commit
24d8dad
·
1 Parent(s): 3ff79ee

Complete Futures luxury dashboard data integration

Browse files
hermes_overlay/tools/futures_dashboard_api.py CHANGED
@@ -41,6 +41,7 @@ from trading.futures_execution import get_futures_positions as _get_futures_posi
41
  from trading.futures_execution import get_futures_account as _get_futures_account
42
  from trading.futures_execution import get_trading_mode as _get_trading_mode
43
  from trading.trade_cycle import run_futures_cycle as _run_futures_cycle
 
44
  from trading.symbols import normalize_symbol
45
 
46
  router = APIRouter()
@@ -52,7 +53,6 @@ _SYMBOL_CACHE_PATH = Path(os.environ.get("HERMES_SYMBOL_CACHE_PATH", "/opt/data/
52
  _TEMPLATE_PATH = Path(__file__).with_name("templates") / "hermes_futures_desk_luxury.html"
53
  # UI discovery only. These are deliberately unverified; they never authorize execution.
54
  _EMERGENCY_BASES = tuple("""BTC ETH USDT BNB SOL XRP USDC ADA AVAX DOGE DOT TRX LINK MATIC LTC BCH UNI ATOM ETC XLM NEAR FIL APT ARB OP ICP SUI INJ MKR AAVE ALGO VET QNT EGLD FTM THETA XTZ EOS FLOW GRT SAND MANA AXS CHZ SNX CRV LDO RUNE KAS PEPE WIF BONK FLOKI SHIB HBAR IMX SEI TIA JUP JTO PYTH WLD FET RNDR TAO KAVA COMP DAI GALA ENJ BAT ZEC DASH XMR NEO IOTA KSM WAVES ZIL ONE CELO ROSE MINA LUNC LUNA GMT GST APE YGG ILV MAGIC DYDX GMX PERP ENS BLUR LPT ARKM STRK DYM MANTA ALT ZK JASMY SFP CAKE BSV BS BCH SV CFX AGIX OCEAN OGN IOST IOTX ICX QTUM ONT ZRX KNC OMG SKL ANKR STORJ SC HNT AUDIO API3 BAND UMA NMR REQ RLC CTSI CELR COTI DENT HOT WIN JST SUN TWT BNT KDA XVS ALPHA BAKE BEL BETA C98 DAR DODO LINA LIT MASK MDT MLN NKN NULS OXT PHA QI QKC RAD RARE RAY REEF REN RIF RPL RVN SFP SPELL STG SUSHI TLM TOMO TRB TROY UFT UNFI VTHO WOO WRX XEC XNO XRD XVS YFI ZEN ASTR AURORA BICO CKB CSPR DUSK FLUX GLMR MOVR OSMO SCRT STX EGLD JOE KLAY KMD LQTY LRC LPT METIS NTRN ORDI PENDLE PIXEL PORTAL RON SKL STRAX SUPER SXP TNSR TON TURBO VANA WAXP XAI XCH XCN XEM XMR XRD YGG ZETA ZRO ACE AEVO AGLD AI ALICE AMB AMP ANKR APE API3 ASTR ATA AXS BADGER BAL BICO BLZ BOME BOND BSW C98 CANTO CELO CFX COMBO COS COTI CYBER DATA DCR DEGO DENT DEXE DGB DIA DODO DOGS DUSK EDU ELF ELF ELA FIDA FIO FLM FORT FLOKI FRONT GTC HIFI HIGH HIVE HOOK HOT ICP ID IDIA ILV IMX IRIS JOE KAVA KDA KEY KLAY LEVER LINA LOOKS LQTY LSK LTO MBOX MDT MEME MERL METER MINA MOB MOG MOCA MOVR NFP NKN NMR NTRN OAX OCEAN OGN OM ONG ONDO ORBS OSMO OXT PAXG PEOPLE PEPE PERP PHB PHA PIVX PONKE POND POWR PROM PSG PUNDIX QI QUICK RAD RARE RATS RAY RDNT REI REQ RLC RON ROSE RSR RSS3 RUNE SAGA SAND SC SCRT SFP SHIB SKL SLERF SNT SNX SOLV SONIC SPELL SPX SRM STEEM STG STMX STORJ STRK STX SSV SWEAT TAIKO TKO TLM TNSR TOKEN TON TRB TROY TURBO TUT USTC VANA VELO VET VOXEL WAXP WIF WIN WLD WOO XAI XEC XEL XLM XNO XRD XTZ YFI ZEC ZEN ZK ZRO ZRX""".split())
55
- _TELEGRAM_PAGE_SUFFIX = """<script>(async()=>{const c=document.createElement('div');c.className='card full';c.innerHTML='<h2 class=section-title>Telegram analysis bot</h2><div id=telegramStatus>Loading...</div>';document.querySelector('.grid')?.appendChild(c);try{const r=await fetch('/api/telegram/status');const s=await r.json();document.getElementById('telegramStatus').textContent=(s.enabled?'Enabled':'Disabled')+' · '+s.mode+' · webhook '+(s.webhookEndpointReady?'ready':'not configured')+' · proxy '+(s.proxyConfigured?'yes':'no')+' · relay '+(s.relayConfigured?'yes':'no')+' · authorized users '+s.authorizedUserCount}catch(e){document.getElementById('telegramStatus').textContent='Status unavailable'}})();</script>"""
56
 
57
 
58
  class _CycleRequest(BaseModel):
@@ -110,13 +110,20 @@ def _is_future_iso(value: object) -> bool:
110
  return False
111
 
112
 
113
- async def _probe(url: str, timeout: float = 3.0) -> str:
 
114
  try:
115
  async with httpx.AsyncClient() as client:
116
  resp = await client.get(url, timeout=timeout)
117
- return "ok" if resp.status_code < 400 else "degraded"
118
- except Exception:
119
- return "unreachable"
 
 
 
 
 
 
120
 
121
 
122
  async def _mark_price(ccxt_perp_symbol: str) -> float | None:
@@ -264,13 +271,22 @@ async def _load_symbols() -> tuple[list[dict], str]:
264
  @router.get("/api/futures/status")
265
  async def futures_status() -> JSONResponse:
266
  state = _dash_state.snapshot()
267
- ds4_health, ds2_health = await _probe(f"{DS4_BASE}/api/short-hunter/health"), \
268
- await _probe(f"{DS2_BASE}/api/market")
 
 
 
 
 
 
 
 
269
  account = await _get_futures_account()
270
  positions = await _get_futures_positions()
271
  return JSONResponse({
272
- "primaryDatasource": {"status": ds4_health, "url": DS4_BASE},
273
- "secondaryDatasource": {"status": ds2_health, "url": DS2_BASE},
 
274
  "fieldSources": state.get("field_sources", {}),
275
  "currentSignal": state.get("current_signal"),
276
  "signalReasons": state.get("signal_reasons", []),
@@ -292,6 +308,7 @@ async def futures_status() -> JSONResponse:
292
  "openPositionCount": len(positions.get("positions", [])),
293
  "updatedAt": state.get("updated_at"),
294
  "serverTime": time.time(),
 
295
  })
296
 
297
 
@@ -302,23 +319,54 @@ async def futures_symbols() -> JSONResponse:
302
  for item in items:
303
  item.setdefault("source", source)
304
  item["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(updated)))
305
- return JSONResponse({"symbols": items, "source": source, "updatedAt": items[0]["updatedAt"] if items else None})
 
 
 
 
306
 
307
 
308
  @router.get("/api/futures/positions")
309
  async def futures_positions() -> JSONResponse:
310
  positions = await _get_futures_positions()
311
- enriched = []
312
- for p in positions.get("positions", []):
313
- mark = await _mark_price(p["symbol"])
 
 
 
 
314
  unrealized = None
315
  if mark:
316
  direction = 1 if p["side"] == "long" else -1
317
  unrealized = (mark - p["entryPrice"]) * p["size"] * direction
318
- enriched.append({**p, "markPrice": mark, "unrealizedPnl": unrealized})
 
319
  return JSONResponse({"mode": positions.get("mode"), "positions": enriched})
320
 
321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  @router.post("/api/futures/analyze", dependencies=[Depends(_authenticated_dashboard_request)])
323
  async def futures_analyze(request: _CycleRequest) -> JSONResponse:
324
  """Run analysis only and return a server-referenced deterministic plan."""
@@ -456,13 +504,7 @@ async def futures_page() -> HTMLResponse:
456
  try:
457
  page = _TEMPLATE_PATH.read_text(encoding="utf-8")
458
  except OSError:
459
- # Keep the existing inline page as a safe compatibility fallback if a
460
- # legacy installation has not yet copied the template directory.
461
- page = _PAGE
462
- if "</body>" in page:
463
- page = page.replace("</body>", _TELEGRAM_PAGE_SUFFIX + "</body>", 1)
464
- else:
465
- page += _TELEGRAM_PAGE_SUFFIX
466
  return HTMLResponse(page)
467
 
468
 
 
41
  from trading.futures_execution import get_futures_account as _get_futures_account
42
  from trading.futures_execution import get_trading_mode as _get_trading_mode
43
  from trading.trade_cycle import run_futures_cycle as _run_futures_cycle
44
+ from trading.dual_datasource_client import get_market_context as _get_market_context
45
  from trading.symbols import normalize_symbol
46
 
47
  router = APIRouter()
 
53
  _TEMPLATE_PATH = Path(__file__).with_name("templates") / "hermes_futures_desk_luxury.html"
54
  # UI discovery only. These are deliberately unverified; they never authorize execution.
55
  _EMERGENCY_BASES = tuple("""BTC ETH USDT BNB SOL XRP USDC ADA AVAX DOGE DOT TRX LINK MATIC LTC BCH UNI ATOM ETC XLM NEAR FIL APT ARB OP ICP SUI INJ MKR AAVE ALGO VET QNT EGLD FTM THETA XTZ EOS FLOW GRT SAND MANA AXS CHZ SNX CRV LDO RUNE KAS PEPE WIF BONK FLOKI SHIB HBAR IMX SEI TIA JUP JTO PYTH WLD FET RNDR TAO KAVA COMP DAI GALA ENJ BAT ZEC DASH XMR NEO IOTA KSM WAVES ZIL ONE CELO ROSE MINA LUNC LUNA GMT GST APE YGG ILV MAGIC DYDX GMX PERP ENS BLUR LPT ARKM STRK DYM MANTA ALT ZK JASMY SFP CAKE BSV BS BCH SV CFX AGIX OCEAN OGN IOST IOTX ICX QTUM ONT ZRX KNC OMG SKL ANKR STORJ SC HNT AUDIO API3 BAND UMA NMR REQ RLC CTSI CELR COTI DENT HOT WIN JST SUN TWT BNT KDA XVS ALPHA BAKE BEL BETA C98 DAR DODO LINA LIT MASK MDT MLN NKN NULS OXT PHA QI QKC RAD RARE RAY REEF REN RIF RPL RVN SFP SPELL STG SUSHI TLM TOMO TRB TROY UFT UNFI VTHO WOO WRX XEC XNO XRD XVS YFI ZEN ASTR AURORA BICO CKB CSPR DUSK FLUX GLMR MOVR OSMO SCRT STX EGLD JOE KLAY KMD LQTY LRC LPT METIS NTRN ORDI PENDLE PIXEL PORTAL RON SKL STRAX SUPER SXP TNSR TON TURBO VANA WAXP XAI XCH XCN XEM XMR XRD YGG ZETA ZRO ACE AEVO AGLD AI ALICE AMB AMP ANKR APE API3 ASTR ATA AXS BADGER BAL BICO BLZ BOME BOND BSW C98 CANTO CELO CFX COMBO COS COTI CYBER DATA DCR DEGO DENT DEXE DGB DIA DODO DOGS DUSK EDU ELF ELF ELA FIDA FIO FLM FORT FLOKI FRONT GTC HIFI HIGH HIVE HOOK HOT ICP ID IDIA ILV IMX IRIS JOE KAVA KDA KEY KLAY LEVER LINA LOOKS LQTY LSK LTO MBOX MDT MEME MERL METER MINA MOB MOG MOCA MOVR NFP NKN NMR NTRN OAX OCEAN OGN OM ONG ONDO ORBS OSMO OXT PAXG PEOPLE PEPE PERP PHB PHA PIVX PONKE POND POWR PROM PSG PUNDIX QI QUICK RAD RARE RATS RAY RDNT REI REQ RLC RON ROSE RSR RSS3 RUNE SAGA SAND SC SCRT SFP SHIB SKL SLERF SNT SNX SOLV SONIC SPELL SPX SRM STEEM STG STMX STORJ STRK STX SSV SWEAT TAIKO TKO TLM TNSR TOKEN TON TRB TROY TURBO TUT USTC VANA VELO VET VOXEL WAXP WIF WIN WLD WOO XAI XEC XEL XLM XNO XRD XTZ YFI ZEC ZEN ZK ZRO ZRX""".split())
 
56
 
57
 
58
  class _CycleRequest(BaseModel):
 
110
  return False
111
 
112
 
113
+ async def _probe(url: str, timeout: float = 3.0) -> dict:
114
+ started = time.perf_counter()
115
  try:
116
  async with httpx.AsyncClient() as client:
117
  resp = await client.get(url, timeout=timeout)
118
+ status = "healthy" if resp.status_code < 400 else "degraded"
119
+ return {"status": status, "endpoint": url, "httpStatus": resp.status_code,
120
+ "latencyMs": round((time.perf_counter()-started)*1000, 1),
121
+ "lastSuccessful": time.time() if resp.status_code < 400 else None,
122
+ "failureReason": None if resp.status_code < 400 else f"HTTP {resp.status_code}"}
123
+ except Exception as exc:
124
+ return {"status": "unavailable", "endpoint": url,
125
+ "latencyMs": round((time.perf_counter()-started)*1000, 1),
126
+ "lastSuccessful": None, "failureReason": type(exc).__name__}
127
 
128
 
129
  async def _mark_price(ccxt_perp_symbol: str) -> float | None:
 
271
  @router.get("/api/futures/status")
272
  async def futures_status() -> JSONResponse:
273
  state = _dash_state.snapshot()
274
+ ds4_health, ds2_primary, ds2_fallback = await asyncio.gather(
275
+ _probe(f"{DS4_BASE}/api/short-hunter/health"),
276
+ _probe(f"{DS2_BASE}/real/api/market/tickers"),
277
+ _probe(f"{DS2_BASE}/api/coins/top"),
278
+ )
279
+ def health_obj(value):
280
+ return value if isinstance(value, dict) else {"status": value}
281
+ ds4_health, ds2_primary, ds2_fallback = map(health_obj, (ds4_health, ds2_primary, ds2_fallback))
282
+ ds2_health = ds2_primary if ds2_primary.get("status") in {"healthy", "ok"} else ds2_fallback
283
+ ds2_health["fallbackEndpoint"] = ds2_fallback.get("endpoint")
284
  account = await _get_futures_account()
285
  positions = await _get_futures_positions()
286
  return JSONResponse({
287
+ "primaryDatasource": {**ds4_health, "url": DS4_BASE},
288
+ "secondaryDatasource": {**ds2_health, "url": DS2_BASE,
289
+ "primaryEndpoint": ds2_primary.get("endpoint")},
290
  "fieldSources": state.get("field_sources", {}),
291
  "currentSignal": state.get("current_signal"),
292
  "signalReasons": state.get("signal_reasons", []),
 
308
  "openPositionCount": len(positions.get("positions", [])),
309
  "updatedAt": state.get("updated_at"),
310
  "serverTime": time.time(),
311
+ "marketDataStatus": "healthy" if ds4_health.get("status") == "healthy" else "degraded",
312
  })
313
 
314
 
 
319
  for item in items:
320
  item.setdefault("source", source)
321
  item["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(updated)))
322
+ verified = sum(1 for item in items if item.get("futuresVerified") is True)
323
+ return JSONResponse({"symbols": items, "source": source,
324
+ "catalogCount": len(items), "verifiedFuturesCount": verified,
325
+ "marketOnlyCount": max(0, len(items)-verified),
326
+ "updatedAt": items[0]["updatedAt"] if items else None})
327
 
328
 
329
  @router.get("/api/futures/positions")
330
  async def futures_positions() -> JSONResponse:
331
  positions = await _get_futures_positions()
332
+ sem = asyncio.Semaphore(4)
333
+ async def enrich(p):
334
+ async with sem:
335
+ try:
336
+ mark = await asyncio.wait_for(_mark_price(p["symbol"]), timeout=4.0)
337
+ except Exception:
338
+ mark = None
339
  unrealized = None
340
  if mark:
341
  direction = 1 if p["side"] == "long" else -1
342
  unrealized = (mark - p["entryPrice"]) * p["size"] * direction
343
+ return {**p, "markPrice": mark, "unrealizedPnl": unrealized}
344
+ enriched = await asyncio.gather(*(enrich(p) for p in positions.get("positions", [])))
345
  return JSONResponse({"mode": positions.get("mode"), "positions": enriched})
346
 
347
 
348
+ @router.get("/api/futures/market", dependencies=[Depends(_authenticated_dashboard_request)])
349
+ async def futures_market(symbol: str = "BTCUSDT", interval: str = "5m", limit: int = 120) -> JSONResponse:
350
+ """Return real merged OHLCV and market metrics for the chart."""
351
+ if interval not in {"1m", "5m", "15m", "1h"}:
352
+ raise HTTPException(status_code=400, detail="Unsupported interval")
353
+ context = await _get_market_context(symbol, timeframe=interval)
354
+ merged = context.get("merged") or {}
355
+ candles = merged.get("ohlcv") or []
356
+ if limit > 0:
357
+ candles = candles[-min(limit, 500):]
358
+ ticker = merged.get("ticker") or {}
359
+ return JSONResponse({"symbol": context.get("symbol"), "interval": interval,
360
+ "candles": candles, "ticker": ticker,
361
+ "funding": merged.get("funding"),
362
+ "openInterest": merged.get("openInterest"),
363
+ "source": context.get("sources", {}).get("ohlcv", "unavailable"),
364
+ "sources": context.get("sources", {}),
365
+ "fetchedAt": context.get("fetchedAt"),
366
+ "warnings": context.get("warnings", []),
367
+ "noTradeGuard": context.get("noTradeGuard", True)})
368
+
369
+
370
  @router.post("/api/futures/analyze", dependencies=[Depends(_authenticated_dashboard_request)])
371
  async def futures_analyze(request: _CycleRequest) -> JSONResponse:
372
  """Run analysis only and return a server-referenced deterministic plan."""
 
504
  try:
505
  page = _TEMPLATE_PATH.read_text(encoding="utf-8")
506
  except OSError:
507
+ page = "<!doctype html><title>Futures Desk unavailable</title><p>The Futures dashboard template is not installed.</p>"
 
 
 
 
 
 
508
  return HTMLResponse(page)
509
 
510
 
hermes_overlay/tools/templates/hermes_futures_desk_luxury.html CHANGED
@@ -440,6 +440,10 @@ details.tech-shell[open] summary svg{transform:rotate(90deg)}
440
  }
441
  @media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
442
  </style>
 
 
 
 
443
  </head>
444
  <body>
445
  <div class="app-shell">
@@ -530,18 +534,23 @@ details.tech-shell[open] summary svg{transform:rotate(90deg)}
530
  </div>
531
  <div class="action-cluster">
532
  <button id="analyze" class="btn primary"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>Analyze market</button>
533
- <button id="execute" class="btn secondary" disabled><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Execute paper trade</button>
534
  </div>
535
  </div>
536
  </section>
537
 
538
  <section class="grid">
539
- <div id="decisionCard" class="card decision state-NO_TRADE">
 
 
 
 
 
540
  <div class="decision-icon"><span class="medallion amber"><svg id="decisionIconSvg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg></span></div>
541
  <div class="decision-body">
542
  <div class="section-title">Decision</div>
543
- <strong id="decision">NO_TRADE</strong>
544
- <div id="decisionHint" class="hint">Trading is blocked until all safety gates pass.</div>
545
  </div>
546
  </div>
547
 
@@ -617,7 +626,7 @@ details.tech-shell[open] summary svg{transform:rotate(90deg)}
617
  <div id="signalsCard" class="card span-5 accS"><h2 class="section-title"><span class="medallion sapphire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg></span>Signal analysis</h2><div id="signals" class="rows"><div class="empty">No valid signal components were available.</div></div></div>
618
 
619
  <div id="advisoryCard" class="card span-7 accL"><h2 class="section-title"><span class="medallion violet"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 1 7 7c0 3-2 4-2 7H7c0-3-2-4-2-7a7 7 0 0 1 7-7z"/><path d="M9 21h6"/><path d="M12 6v6l3 2"/></svg></span>Remote advisory</h2><div id="advisoryPanel" class="empty">Disabled until Analyze is run with Remote advisory enabled.</div></div>
620
- <div id="accountCard" class="card span-5 accP"><div class="account-head"><h2 class="section-title"><span class="medallion amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="14" rx="2.5"/><path d="M3 10h18"/><path d="M7 3.5h6"/></svg></span>Account &amp; positions</h2></div><div id="account" class="rows"><div class="empty">Waiting for account status…</div></div><div id="positionsTable" class="positions" hidden></div></div>
621
 
622
  <div id="sourcesCard" class="card source-wrap">
623
  <div class="source-grid">
@@ -626,6 +635,7 @@ details.tech-shell[open] summary svg{transform:rotate(90deg)}
626
  <div id="ds2Card" class="card source"><h2 class="section-title"><span class="medallion sapphire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg></span>Datasource 2 · Complementary</h2><div id="ds2" class="empty">Loading…</div></div>
627
  </div>
628
  </div>
 
629
 
630
  <div class="card full accP"><h2 class="section-title"><span class="medallion amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg></span>Trade rejection reasons</h2><div id="reasons" class="empty">No plan analyzed yet.</div></div>
631
 
@@ -651,6 +661,8 @@ details.tech-shell[open] summary svg{transform:rotate(90deg)}
651
  const $=id=>document.getElementById(id);
652
  const esc=x=>String(x??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
653
  const fmt=x=>x===null||x===undefined||x===''?'—':esc(x);
 
 
654
  let symbols=[],activeIndex=-1,analysisBusy=false,latestPlan=null,latestStatus=null,statusBusy=false,positionsBusy=false,confirmResolver=null,hydratedFromStatus=false,selectedSymbolMeta=null;
655
 
656
  async function apiFetch(url,options={},timeoutMs=15000){
@@ -801,13 +813,16 @@ function formatLocalTime(iso){
801
  const d=new Date(iso);return isNaN(d.getTime())?String(iso):'Valid until '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
802
  }
803
  function updateExecutionAvailability(){
804
- if(!latestPlan)return;
805
  const d=latestPlan.decision||'NO_TRADE';
806
  const validExpiry=latestPlan.expires_at&&Date.parse(latestPlan.expires_at)>Date.now();
807
  const currentSymbol=normalizedSymbol(),planSymbol=String(latestPlan.symbol||'').toUpperCase();
808
  const currentRisk=riskValue(),planRisk=latestPlan.risk_profile||'';
809
  const verified=latestPlan.futuresVerified===true || selectedSymbolMeta?.symbol===planSymbol&&selectedSymbolMeta?.futuresVerified===true;
810
- $('execute').disabled=analysisBusy||!latestPlan.planId||!(d==='LONG'||d==='SHORT')||!!latestPlan.noTradeGuard||!latestPlan.risk_approved||!verified||!validExpiry||!!latestPlan.executed||currentSymbol!==planSymbol||currentRisk!==planRisk;
 
 
 
811
  const track=$('expiryTrack').children;
812
  if(validExpiry&&latestPlan.created_at){
813
  const total=Date.parse(latestPlan.expires_at)-Date.parse(latestPlan.created_at),left=Date.parse(latestPlan.expires_at)-Date.now();
@@ -836,9 +851,9 @@ function renderPlan(p){
836
  $('expiry').title=p.expires_at||'';
837
  $('expirySub').textContent=formatLocalTime(p.expires_at);
838
  $('planRows').innerHTML=[
839
- rowCopy('Entry',p.entry),rowCopy('Stop Loss',p.stop_loss),rowCopy('Take Profit',p.take_profit),
840
  row('Reward-to-risk',p.reward_to_risk),row('Leverage',p.effective_leverage??p.requested_leverage),
841
- row('Quantity',p.quantity),row('Slippage',p.estimated_slippage_percent),row('Created',p.created_at),
842
  rowCopy('Plan ID',p.planId)
843
  ].join('');
844
  const comps=Object.entries(p.components||{});
@@ -853,12 +868,12 @@ function renderPlan(p){
853
 
854
  function renderPositions(list,mode){
855
  const positions=Array.isArray(list)?list:[];
856
- if(!positions.length){$('positionsTable').hidden=true;$('positionsTable').innerHTML='';return}
857
  $('positionsTable').hidden=false;
858
  $('positionsTable').innerHTML='<table class="acct"><thead><tr><th>Symbol</th><th>Side</th><th>Quantity</th><th>Entry</th><th>Mark</th><th>PnL</th></tr></thead><tbody>'+positions.map(p=>{
859
  const side=String(p.side||p.positionSide||'—').toUpperCase(),sideClass=side.includes('LONG')?'long':side.includes('SHORT')?'short':'';
860
  const pnl=p.unrealizedPnl??p.pnl??p.unrealized_pnl,pnlNum=parseFloat(pnl),pnlClass=isFinite(pnlNum)?(pnlNum>=0?'pnl-pos':'pnl-neg'):'';
861
- return '<tr><td>'+fmt(p.symbol)+'</td><td><span class="pos-side '+sideClass+'">'+fmt(side)+'</span></td><td>'+fmt(p.quantity??p.size??p.qty??p.positionAmt)+'</td><td>'+fmt(p.entryPrice??p.entry_price)+'</td><td>'+fmt(p.markPrice??p.mark_price)+'</td><td class="'+pnlClass+'">'+fmt(pnl)+'</td></tr>';
862
  }).join('')+'</tbody></table>';
863
  }
864
  async function refreshPositions(){
@@ -874,10 +889,10 @@ function renderStatus(s){
874
  latestStatus=s;
875
  $('mode').textContent=fmt(s.tradingMode);
876
  $('account').innerHTML=[
877
- row('Paper equity',s.equity),row('Daily PnL',s.realizedPnlToday),
878
  row('Open positions',s.openPositionCount??0),row('Latest execution',s.latestPaperExecutionResult?.status||'None')
879
  ].join('');
880
- const sw=(s.warnings||[]).join(' '),bs=/HTTP 451/i.test(sw)?'unavailable':Object.values(s.fieldSources||{}).some(x=>x==='binance_public')?'degraded':'available';
881
  const badges=[['DS4',s.primaryDatasource?.status],['Binance',bs],['DS2',s.secondaryDatasource?.status],['Remote Advisory',s.latestTradePlan?.external_advisory?(s.latestTradePlan.external_advisory.status||'available'):'info']];
882
  $('badges').innerHTML=badges.map(x=>{
883
  const cls=x[1]==='info'?'info':statusClass(x[1]);
@@ -886,6 +901,10 @@ function renderStatus(s){
886
  const now=new Date().toLocaleTimeString();
887
  $('refresh').textContent='Last refresh '+now;$('footerTime').textContent=now;
888
  renderSources(s);
 
 
 
 
889
 
890
  /* hydrate the workspace with the server's last known plan on first load only */
891
  if(!hydratedFromStatus&&!latestPlan&&s.latestTradePlan){
@@ -915,11 +934,11 @@ function setComboOpen(open){
915
  $('symbolSearch').setAttribute('aria-expanded',String(open));
916
  if(!open)activeIndex=-1;
917
  }
918
- function chooseSymbol(symbol){$('symbolSearch').value=String(symbol||'').toUpperCase();selectedSymbolMeta=symbols.find(x=>x.symbol===$('symbolSearch').value)||null;setComboOpen(false);updateExecutionAvailability()}
919
  function showList(){
920
  const q=$('symbolSearch').value.trim().toUpperCase();
921
  const found=symbols.filter(x=>(String(x.symbol||'')+' '+String(x.name||'')+' '+String(x.baseAsset||'')+' '+String(x.quoteAsset||'')+' '+String(x.rank||'')).toUpperCase().includes(q)).slice(0,80);
922
- $('symbolList').innerHTML=found.length?found.map(x=>'<div class="combo-item" role="option" tabindex="-1" data-symbol="'+esc(x.symbol)+'"><span><b>'+esc(x.symbol)+'</b><small>'+esc(x.name||'')+' · '+esc(x.baseAsset)+' / '+esc(x.quoteAsset)+'</small></span><small class="'+(x.futuresVerified?'verified':'unverified')+'">'+(x.futuresVerified?'✓ Verified Futures':'Market-only')+'</small></div>').join(''):'<div class="empty">No active pair found. Validated manual entry is allowed.</div>';
923
  setComboOpen(true);
924
  document.querySelectorAll('.combo-item').forEach(el=>el.onclick=()=>chooseSymbol(el.dataset.symbol));
925
  }
@@ -949,11 +968,23 @@ async function loadSymbols(){
949
  try{
950
  const p=await apiFetch('/api/futures/symbols');symbols=Array.isArray(p.symbols)?p.symbols.filter(x=>x&&x.symbol):[];
951
  if(latestPlan){latestPlan.futuresVerified=symbols.some(x=>x.symbol===String(latestPlan.symbol||'').toUpperCase()&&x.futuresVerified===true);updateExecutionAvailability()}
952
- $('symbolState').textContent=symbols.length?symbols.length+' active pairs · source '+p.source:'Symbol service unavailable; validated manual entry enabled';
953
  renderChips();
954
  }catch(e){$('symbolState').textContent='Symbol service unavailable; validated manual entry enabled'}
955
  }
956
  function normalizedSymbol(){return $('symbolSearch').value.trim().toUpperCase()}
 
 
 
 
 
 
 
 
 
 
 
 
957
  function setAnalysisBusy(busy){
958
  analysisBusy=busy;$('analyze').disabled=busy;$('loading').classList.toggle('show',busy);$('analyze').setAttribute('aria-busy',String(busy));
959
  if(busy)$('execute').disabled=true;else updateExecutionAvailability();
@@ -967,7 +998,7 @@ $('analyze').onclick=async()=>{
967
  const p=await apiFetch('/api/futures/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol,risk_profile:riskValue(),include_external_context:$('advisory').checked})});
968
  renderPlan(p);toast(p.decision==='NO_TRADE'?'Analysis complete — no trade':'Analysis complete — '+p.decision+' plan ready','ok');
969
  }catch(e){
970
- latestPlan=null;$('execute').disabled=true;$('reasons').innerHTML='<div class="empty">'+esc(e.message)+'</div>';$('decision').textContent='NO_TRADE';$('decisionCard').className='card decision state-NO_TRADE';
971
  toast(e.message||'Analysis failed','err');
972
  }finally{clearInterval(timer);$('elapsed').textContent='0.0s';setAnalysisBusy(false)}
973
  };
@@ -986,7 +1017,7 @@ $('execute').onclick=async()=>{
986
  function tickClock(){$('clock').textContent=new Date().toLocaleTimeString()}
987
  setInterval(tickClock,1000);tickClock();
988
  setInterval(()=>{if(latestPlan?.expires_at){$('expiry').textContent=expiryCountdown(latestPlan.expires_at);updateExecutionAvailability()}},1000);
989
- loadSymbols();refresh();
990
  setInterval(()=>{if(!document.hidden)refresh()},5000);
991
  document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
992
  </script>
 
440
  }
441
  @media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
442
  </style>
443
+ <style>
444
+ .market-card{min-height:280px}.market-header,.chart-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px}.market-header strong{font-size:24px}.market-header .muted{display:block;font-size:12px;margin-top:4px}.market-chart{width:100%;height:220px;margin-top:12px;background:linear-gradient(180deg,#111d29,#0a121a);border:1px solid var(--line);border-radius:12px}.chart-empty{fill:var(--muted);font-size:14px}.source-badge{color:var(--blue);font-size:10px;border:1px solid #315a80;border-radius:999px;padding:2px 5px}.state-NOT_ANALYZED{border-color:var(--line)}
445
+ @media(max-width:720px){.market-card{grid-column:span 12}.market-header,.chart-toolbar{align-items:flex-start;flex-direction:column}.market-chart{height:180px}.action-cluster{align-items:stretch}.action-cluster .mobile-note{max-width:none}}
446
+ </style>
447
  </head>
448
  <body>
449
  <div class="app-shell">
 
534
  </div>
535
  <div class="action-cluster">
536
  <button id="analyze" class="btn primary"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>Analyze market</button>
537
+ <button id="execute" class="btn secondary" disabled><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Execute paper trade</button><div id="executeReason" class="mobile-note">Run analysis first</div>
538
  </div>
539
  </div>
540
  </section>
541
 
542
  <section class="grid">
543
+ <div id="marketCard" class="card span-7 market-card">
544
+ <div class="market-header"><div><div class="section-title">Selected market</div><strong id="marketSymbol">BTCUSDT</strong><span id="marketMeta" class="muted">Waiting for market data</span></div><strong id="marketPrice" class="num">—</strong></div>
545
+ <div class="chart-toolbar"><span id="chartSource" class="muted">Chart unavailable</span><div id="chartIntervals" class="segmented"><button type="button" data-interval="1m">1m</button><button type="button" data-interval="5m" class="active">5m</button><button type="button" data-interval="15m">15m</button><button type="button" data-interval="1h">1h</button></div></div>
546
+ <svg id="marketChart" class="market-chart" viewBox="0 0 900 260" preserveAspectRatio="none" role="img" aria-label="OHLCV market chart"><text x="450" y="130" text-anchor="middle" class="chart-empty">No real OHLCV data available</text></svg>
547
+ </div>
548
+ <div id="decisionCard" class="card decision state-NOT_ANALYZED">
549
  <div class="decision-icon"><span class="medallion amber"><svg id="decisionIconSvg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg></span></div>
550
  <div class="decision-body">
551
  <div class="section-title">Decision</div>
552
+ <strong id="decision">Waiting for analysis</strong>
553
+ <div id="decisionHint" class="hint">Run an analysis to evaluate the current market.</div>
554
  </div>
555
  </div>
556
 
 
626
  <div id="signalsCard" class="card span-5 accS"><h2 class="section-title"><span class="medallion sapphire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg></span>Signal analysis</h2><div id="signals" class="rows"><div class="empty">No valid signal components were available.</div></div></div>
627
 
628
  <div id="advisoryCard" class="card span-7 accL"><h2 class="section-title"><span class="medallion violet"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 1 7 7c0 3-2 4-2 7H7c0-3-2-4-2-7a7 7 0 0 1 7-7z"/><path d="M9 21h6"/><path d="M12 6v6l3 2"/></svg></span>Remote advisory</h2><div id="advisoryPanel" class="empty">Disabled until Analyze is run with Remote advisory enabled.</div></div>
629
+ <div id="accountCard" class="card span-5 accP"><div class="account-head"><h2 class="section-title"><span class="medallion amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="14" rx="2.5"/><path d="M3 10h18"/><path d="M7 3.5h6"/></svg></span>Account &amp; positions</h2></div><div id="account" class="rows"><div class="empty">Waiting for account status…</div></div><div id="positionsTable" class="positions"><div class="empty">No open Paper positions</div></div></div>
630
 
631
  <div id="sourcesCard" class="card source-wrap">
632
  <div class="source-grid">
 
635
  <div id="ds2Card" class="card source"><h2 class="section-title"><span class="medallion sapphire"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg></span>Datasource 2 · Complementary</h2><div id="ds2" class="empty">Loading…</div></div>
636
  </div>
637
  </div>
638
+ <div id="telegramCard" class="card full"><h2 class="section-title">System health · Telegram</h2><div id="telegramStatus" class="empty">Status unavailable</div></div>
639
 
640
  <div class="card full accP"><h2 class="section-title"><span class="medallion amber"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg></span>Trade rejection reasons</h2><div id="reasons" class="empty">No plan analyzed yet.</div></div>
641
 
 
661
  const $=id=>document.getElementById(id);
662
  const esc=x=>String(x??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
663
  const fmt=x=>x===null||x===undefined||x===''?'—':esc(x);
664
+ const numFmt=x=>{const n=Number(x);return Number.isFinite(n)?n.toLocaleString(undefined,{maximumFractionDigits:8}):'—'};
665
+ const money=x=>{const n=Number(x);return Number.isFinite(n)?n.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})+' USDT':'—'};
666
  let symbols=[],activeIndex=-1,analysisBusy=false,latestPlan=null,latestStatus=null,statusBusy=false,positionsBusy=false,confirmResolver=null,hydratedFromStatus=false,selectedSymbolMeta=null;
667
 
668
  async function apiFetch(url,options={},timeoutMs=15000){
 
813
  const d=new Date(iso);return isNaN(d.getTime())?String(iso):'Valid until '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
814
  }
815
  function updateExecutionAvailability(){
816
+ if(!latestPlan){$('execute').disabled=true;$('executeReason').textContent='Run analysis first';return}
817
  const d=latestPlan.decision||'NO_TRADE';
818
  const validExpiry=latestPlan.expires_at&&Date.parse(latestPlan.expires_at)>Date.now();
819
  const currentSymbol=normalizedSymbol(),planSymbol=String(latestPlan.symbol||'').toUpperCase();
820
  const currentRisk=riskValue(),planRisk=latestPlan.risk_profile||'';
821
  const verified=latestPlan.futuresVerified===true || selectedSymbolMeta?.symbol===planSymbol&&selectedSymbolMeta?.futuresVerified===true;
822
+ const disabled=analysisBusy||!latestPlan.planId||!(d==='LONG'||d==='SHORT')||!!latestPlan.noTradeGuard||!latestPlan.risk_approved||!verified||!validExpiry||!!latestPlan.executed||currentSymbol!==planSymbol||currentRisk!==planRisk;
823
+ $('execute').disabled=disabled;
824
+ const reason=analysisBusy?'Analysis in progress':!latestPlan.planId?'Run analysis first':d!=='LONG'&&d!=='SHORT'?'No directional plan':latestPlan.noTradeGuard?'noTradeGuard active':!latestPlan.risk_approved?'Risk approval failed':!verified?'Market-only symbol':!validExpiry?'Plan expired':latestPlan.executed?'Plan already executed':currentSymbol!==planSymbol?'Symbol changed':currentRisk!==planRisk?'Risk profile changed':'Ready';
825
+ $('executeReason').textContent=reason;
826
  const track=$('expiryTrack').children;
827
  if(validExpiry&&latestPlan.created_at){
828
  const total=Date.parse(latestPlan.expires_at)-Date.parse(latestPlan.created_at),left=Date.parse(latestPlan.expires_at)-Date.now();
 
851
  $('expiry').title=p.expires_at||'';
852
  $('expirySub').textContent=formatLocalTime(p.expires_at);
853
  $('planRows').innerHTML=[
854
+ rowCopy('Entry',money(p.entry)),rowCopy('Stop Loss',money(p.stop_loss)),rowCopy('Take Profit',money(p.take_profit)),
855
  row('Reward-to-risk',p.reward_to_risk),row('Leverage',p.effective_leverage??p.requested_leverage),
856
+ row('Quantity',numFmt(p.quantity)),row('Slippage',p.estimated_slippage_percent==null?'—':p.estimated_slippage_percent+'%'),row('Created',p.created_at),
857
  rowCopy('Plan ID',p.planId)
858
  ].join('');
859
  const comps=Object.entries(p.components||{});
 
868
 
869
  function renderPositions(list,mode){
870
  const positions=Array.isArray(list)?list:[];
871
+ if(!positions.length){$('positionsTable').hidden=false;$('positionsTable').innerHTML='<div class="empty">No open Paper positions</div>';return}
872
  $('positionsTable').hidden=false;
873
  $('positionsTable').innerHTML='<table class="acct"><thead><tr><th>Symbol</th><th>Side</th><th>Quantity</th><th>Entry</th><th>Mark</th><th>PnL</th></tr></thead><tbody>'+positions.map(p=>{
874
  const side=String(p.side||p.positionSide||'—').toUpperCase(),sideClass=side.includes('LONG')?'long':side.includes('SHORT')?'short':'';
875
  const pnl=p.unrealizedPnl??p.pnl??p.unrealized_pnl,pnlNum=parseFloat(pnl),pnlClass=isFinite(pnlNum)?(pnlNum>=0?'pnl-pos':'pnl-neg'):'';
876
+ return '<tr><td>'+fmt(p.symbol)+'</td><td><span class="pos-side '+sideClass+'">'+fmt(side)+'</span></td><td>'+numFmt(p.quantity??p.size??p.qty??p.positionAmt)+'</td><td>'+money(p.entryPrice??p.entry_price)+'</td><td>'+money(p.markPrice??p.mark_price)+'</td><td class="'+pnlClass+'">'+money(pnl)+'</td></tr>';
877
  }).join('')+'</tbody></table>';
878
  }
879
  async function refreshPositions(){
 
889
  latestStatus=s;
890
  $('mode').textContent=fmt(s.tradingMode);
891
  $('account').innerHTML=[
892
+ row('Paper equity',money(s.equity)),row('Daily PnL',money(s.realizedPnlToday)),
893
  row('Open positions',s.openPositionCount??0),row('Latest execution',s.latestPaperExecutionResult?.status||'None')
894
  ].join('');
895
+ const sw=(s.warnings||[]).join(' '),bs=/HTTP 451/i.test(sw)?'unavailable':Object.values(s.fieldSources||{}).some(x=>x==='binance_public')?'degraded':'standby';
896
  const badges=[['DS4',s.primaryDatasource?.status],['Binance',bs],['DS2',s.secondaryDatasource?.status],['Remote Advisory',s.latestTradePlan?.external_advisory?(s.latestTradePlan.external_advisory.status||'available'):'info']];
897
  $('badges').innerHTML=badges.map(x=>{
898
  const cls=x[1]==='info'?'info':statusClass(x[1]);
 
901
  const now=new Date().toLocaleTimeString();
902
  $('refresh').textContent='Last refresh '+now;$('footerTime').textContent=now;
903
  renderSources(s);
904
+ apiFetch('/api/telegram/status',{},5000).then(t=>{
905
+ const state=!t.enabled?'Disabled':!t.authorizedUserCount?'Owner setup required':(t.proxyConfigured||t.relayConfigured?'Ready':'Relay/proxy required for proactive alerts');
906
+ $('telegramStatus').innerHTML='<span class="badge '+(state==='Ready'?'ok':state==='Disabled'?'info':'degraded')+'">'+esc(state)+'</span><div class="muted">Webhook '+(t.webhookEndpointReady?'ready':'not configured')+' · mode '+esc(t.mode||'webhook')+'</div>';
907
+ }).catch(()=>{$('telegramStatus').textContent='Status unavailable'});
908
 
909
  /* hydrate the workspace with the server's last known plan on first load only */
910
  if(!hydratedFromStatus&&!latestPlan&&s.latestTradePlan){
 
934
  $('symbolSearch').setAttribute('aria-expanded',String(open));
935
  if(!open)activeIndex=-1;
936
  }
937
+ function chooseSymbol(symbol){$('symbolSearch').value=String(symbol||'').toUpperCase();selectedSymbolMeta=symbols.find(x=>x.symbol===$('symbolSearch').value)||null;setComboOpen(false);updateExecutionAvailability();loadChart()}
938
  function showList(){
939
  const q=$('symbolSearch').value.trim().toUpperCase();
940
  const found=symbols.filter(x=>(String(x.symbol||'')+' '+String(x.name||'')+' '+String(x.baseAsset||'')+' '+String(x.quoteAsset||'')+' '+String(x.rank||'')).toUpperCase().includes(q)).slice(0,80);
941
+ $('symbolList').innerHTML=found.length?found.map(x=>'<div class="combo-item" role="option" tabindex="-1" data-symbol="'+esc(x.symbol)+'"><span><b>'+esc(x.symbol)+'</b><small>'+esc(x.name||'')+' · '+esc(x.baseAsset)+' / '+esc(x.quoteAsset)+'</small></span><small><span class="source-badge">'+esc(x.source||'unknown')+'</span> <span class="'+(x.futuresVerified?'verified':'unverified')+'">'+(x.futuresVerified?'✓ Verified Futures':'Market-only')+'</span></small></div>').join(''):'<div class="empty">No active pair found. Validated manual entry is allowed.</div>';
942
  setComboOpen(true);
943
  document.querySelectorAll('.combo-item').forEach(el=>el.onclick=()=>chooseSymbol(el.dataset.symbol));
944
  }
 
968
  try{
969
  const p=await apiFetch('/api/futures/symbols');symbols=Array.isArray(p.symbols)?p.symbols.filter(x=>x&&x.symbol):[];
970
  if(latestPlan){latestPlan.futuresVerified=symbols.some(x=>x.symbol===String(latestPlan.symbol||'').toUpperCase()&&x.futuresVerified===true);updateExecutionAvailability()}
971
+ $('symbolState').textContent=symbols.length?`${p.catalogCount??symbols.length} catalog entries · ${p.verifiedFuturesCount??symbols.filter(x=>x.futuresVerified).length} verified Futures · ${p.marketOnlyCount??symbols.filter(x=>!x.futuresVerified).length} market-only`:'Symbol service unavailable; validated manual entry enabled';
972
  renderChips();
973
  }catch(e){$('symbolState').textContent='Symbol service unavailable; validated manual entry enabled'}
974
  }
975
  function normalizedSymbol(){return $('symbolSearch').value.trim().toUpperCase()}
976
+ let chartInterval='5m',chartBusy=false;
977
+ function renderChart(data){
978
+ const candles=Array.isArray(data?.candles)?data.candles:[],svg=$('marketChart');
979
+ $('chartSource').textContent=candles.length?(data.source||'unavailable')+' · '+(data.fetchedAt?new Date(data.fetchedAt*1000).toLocaleTimeString():'fresh'):'Chart unavailable — no real candles returned';
980
+ if(!candles.length){svg.innerHTML='<text x="450" y="130" text-anchor="middle" class="chart-empty">No real OHLCV data available</text>';return}
981
+ const vals=candles.map(c=>Number(c.close??c[4])).filter(Number.isFinite),lo=Math.min(...vals),hi=Math.max(...vals),span=hi-lo||1;
982
+ const points=vals.map((v,i)=>`${(i/(vals.length-1||1))*880+10},${240-((v-lo)/span)*220}`).join(' ');
983
+ svg.innerHTML='<polyline fill="none" stroke="var(--gold)" stroke-width="2" points="'+points+'"/><line x1="10" y1="240" x2="890" y2="240" stroke="var(--line)"/>';
984
+ const last=vals.at(-1);$('marketPrice').textContent=money(last);$('marketSymbol').textContent=data.symbol||normalizedSymbol();
985
+ }
986
+ async function loadChart(){if(chartBusy)return;chartBusy=true;try{renderChart(await apiFetch('/api/futures/market?symbol='+encodeURIComponent(normalizedSymbol()||'BTCUSDT')+'&interval='+chartInterval+'&limit=120',{},12000))}catch(e){renderChart({candles:[]});$('chartSource').textContent='Chart unavailable'}finally{chartBusy=false}}
987
+ document.querySelectorAll('#chartIntervals button').forEach(btn=>btn.onclick=()=>{document.querySelectorAll('#chartIntervals button').forEach(b=>b.classList.remove('active'));btn.classList.add('active');chartInterval=btn.dataset.interval;loadChart()});
988
  function setAnalysisBusy(busy){
989
  analysisBusy=busy;$('analyze').disabled=busy;$('loading').classList.toggle('show',busy);$('analyze').setAttribute('aria-busy',String(busy));
990
  if(busy)$('execute').disabled=true;else updateExecutionAvailability();
 
998
  const p=await apiFetch('/api/futures/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol,risk_profile:riskValue(),include_external_context:$('advisory').checked})});
999
  renderPlan(p);toast(p.decision==='NO_TRADE'?'Analysis complete — no trade':'Analysis complete — '+p.decision+' plan ready','ok');
1000
  }catch(e){
1001
+ latestPlan=null;$('execute').disabled=true;$('reasons').innerHTML='<div class="empty">'+esc(e.message)+'</div>';$('decision').textContent='Analysis failed';$('decisionHint').textContent='No executable plan was produced.';$('decisionCard').className='card decision state-ANALYSIS_FAILED';$('executeReason').textContent='Analysis failed';
1002
  toast(e.message||'Analysis failed','err');
1003
  }finally{clearInterval(timer);$('elapsed').textContent='0.0s';setAnalysisBusy(false)}
1004
  };
 
1017
  function tickClock(){$('clock').textContent=new Date().toLocaleTimeString()}
1018
  setInterval(tickClock,1000);tickClock();
1019
  setInterval(()=>{if(latestPlan?.expires_at){$('expiry').textContent=expiryCountdown(latestPlan.expires_at);updateExecutionAvailability()}},1000);
1020
+ loadSymbols();refresh();loadChart();
1021
  setInterval(()=>{if(!document.hidden)refresh()},5000);
1022
  document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
1023
  </script>