KarlQuant commited on
Commit
2e08586
Β·
verified Β·
1 Parent(s): 1d61f10

Update hub_dashboard_service.py

Browse files
Files changed (1) hide show
  1. hub_dashboard_service.py +272 -409
hub_dashboard_service.py CHANGED
@@ -1,40 +1,30 @@
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
- β•‘ K1RL QUASAR β€” HUB DASHBOARD SERVICE (WITH RANKER LOGS INTEGRATION) β•‘
5
  β•‘ ────────────────────────────────────────────────────────────────────────────────── β•‘
6
  β•‘ Architecture role: READ-ONLY subscriber β†’ serves dashboard UI β•‘
7
- β•‘ VERSION: v1.1 (UPDATED) | 2026-03-26 β•‘
8
- β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•οΏ½οΏ½οΏ½β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
9
  """
10
 
11
  import json
12
  import logging
13
  import os
14
  import sys
 
 
15
  import threading
16
  import time
17
- from collections import deque
18
  from datetime import datetime
19
  from pathlib import Path
20
  from typing import Dict, List, Optional
21
 
22
- import re
23
- import glob
24
-
25
  import websocket
26
- from flask import Flask, Response, jsonify, request, send_from_directory
27
  from flask_cors import CORS
28
 
29
- # ── Trade log regexes ─────────────────────────────────────────────────────────────────
30
- TRADE_OPEN_RE = re.compile(r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)')
31
- TRADE_CLOSE_RE = re.compile(r'TRADE CLOSED.*?PnL=([+-]?[\d.]+)')
32
- TRADE_ID_RE = re.compile(r'\[(\w+)\] TRADE CLOSED')
33
- REWARD_RE = re.compile(r'Closed (\w+) \| reward=([+-]?[\d.]+) \| pnl=([+-]?[\d.]+) \| portfolio_dd=([\d.]+)%')
34
-
35
- from ranker_logging import RankerLogger, EventCategory, LogLevel
36
- from ranker_logs_api import ranker_logs_bp, init_ranker_logs_api
37
-
38
  # ── Logging ───────────────────────────────────────────────────────────────────────────
39
  logging.basicConfig(
40
  level=logging.INFO,
@@ -45,216 +35,94 @@ logging.basicConfig(
45
  logger = logging.getLogger("HubDashboardService")
46
 
47
  # ── Config ────────────────────────────────────────────────────────────────────────────
48
- _HUB_HOST = os.environ.get("QUASAR_HUB_HOST", "karlquant-quasar-executo.hf.space")
49
  _DASHBOARD_PORT = int(os.environ.get("DASHBOARD_PORT", "8051"))
50
- _HTML_PATH = os.environ.get(
51
  "DASHBOARD_HTML",
52
  str(Path(__file__).parent / "hub_dashboard.html"),
53
  )
54
-
55
  _METRIC_HISTORY_LEN = int(os.environ.get("QUASAR_METRIC_HISTORY", "200"))
56
 
57
 
58
  # ══════════════════════════════════════════════════════════════════════════════════════
59
- # SECTION 1 β€” IN-MEMORY STATE STORE
60
  # ══════════════════════════════════════════════════════════════════════════════════════
61
 
62
- class DashboardState:
63
- """
64
- Thread-safe cache of everything the dashboard needs.
65
- """
66
-
67
- def __init__(self, history_len: int = _METRIC_HISTORY_LEN):
68
- self._lock = threading.RLock()
69
- self._history_len = history_len
70
-
71
- self.snapshots: Dict[str, dict] = {}
72
- self.metric_history: Dict[str, deque] = {}
73
-
74
- self.hub_connected = False
75
- self.start_time = time.time()
76
- self.messages_rx = 0
77
- self.last_update_ts = 0.0
78
- self.reconnect_count = 0
79
-
80
- self._ranked: List[dict] = []
81
-
82
- def ingest_snapshot(self, space_name: str, snapshot: dict) -> None:
83
- """Accept a full snapshot dict."""
84
- with self._lock:
85
- self.snapshots[space_name] = snapshot
86
- self.messages_rx += 1
87
- self.last_update_ts = time.time()
88
- self._append_metric_history(space_name, snapshot)
89
- self._recompute_rankings()
90
-
91
- def ingest_bulk(self, snapshots: dict) -> None:
92
- """Accept bulk payload."""
93
- with self._lock:
94
- for name, snap in snapshots.items():
95
- self.snapshots[name] = snap
96
- self._append_metric_history(name, snap)
97
- self.last_update_ts = time.time()
98
- self._recompute_rankings()
99
-
100
- def _append_metric_history(self, space_name: str, snap: dict) -> None:
101
- """Push latest training numbers onto the per-asset deque."""
102
- if space_name not in self.metric_history:
103
- self.metric_history[space_name] = deque(maxlen=self._history_len)
104
- training = snap.get("training", {})
105
- if training:
106
- self.metric_history[space_name].append({
107
- "ts": snap.get("last_updated", time.time()),
108
- "training_steps": training.get("training_steps", 0),
109
- "actor_loss": training.get("actor_loss", 0.0),
110
- "critic_loss": training.get("critic_loss", 0.0),
111
- "avn_loss": training.get("avn_loss", 0.0),
112
- "avn_accuracy": training.get("avn_accuracy", 0.0),
113
- })
114
-
115
- def _recompute_rankings(self) -> None:
116
- """Replicate ranker formula: score = signal_confidence - avn_accuracy"""
117
- ranked = []
118
- for name, snap in self.snapshots.items():
119
- training = snap.get("training", {})
120
- voting = snap.get("voting", {})
121
- buy = voting.get("buy_count", 0)
122
- sell = voting.get("sell_count", 0)
123
- total = buy + sell
124
- sig_conf = (max(buy, sell) / total) if total > 0 else 0.0
125
- avn_acc = training.get("avn_accuracy", 0.0)
126
- score = sig_conf - avn_acc
127
- ranked.append({
128
- "rank": 0,
129
- "space_name": name,
130
- "score": round(score, 4),
131
- "signal_confidence": round(sig_conf, 4),
132
- "avn_accuracy": round(avn_acc, 4),
133
- "dominant_signal": voting.get("dominant_signal", "NEUTRAL"),
134
- "buy_count": buy,
135
- "sell_count": sell,
136
- "training_steps": training.get("training_steps", 0),
137
- "actor_loss": training.get("actor_loss", 0.0),
138
- "critic_loss": training.get("critic_loss", 0.0),
139
- "avn_loss": training.get("avn_loss", 0.0),
140
- "last_updated": snap.get("last_updated", 0.0),
141
- })
142
- ranked.sort(key=lambda r: r["score"], reverse=True)
143
- for i, r in enumerate(ranked):
144
- r["rank"] = i + 1
145
- self._ranked = ranked
146
-
147
- def get_state(self) -> dict:
148
- with self._lock:
149
- return {
150
- "rankings": list(self._ranked),
151
- "metric_history": {
152
- name: list(h)
153
- for name, h in self.metric_history.items()
154
- },
155
- "health": self._health_snapshot(),
156
- "timestamp": datetime.utcnow().isoformat() + "Z",
157
- }
158
-
159
- def get_rankings(self) -> List[dict]:
160
- with self._lock:
161
- return list(self._ranked)
162
-
163
- def get_metric_history(self, limit: int = 100) -> dict:
164
- with self._lock:
165
- return {
166
- name: list(h)[-limit:]
167
- for name, h in self.metric_history.items()
168
- }
169
-
170
- def _health_snapshot(self) -> dict:
171
- return {
172
- "hub_connected": self.hub_connected,
173
- "spaces_connected": len(self.snapshots),
174
- "messages_rx": self.messages_rx,
175
- "last_update_ts": self.last_update_ts,
176
- "last_update_ago": round(time.time() - self.last_update_ts, 1)
177
- if self.last_update_ts else None,
178
- "uptime_seconds": round(time.time() - self.start_time, 0),
179
- "reconnect_count": self.reconnect_count,
180
- }
181
-
182
- def get_health(self) -> dict:
183
- with self._lock:
184
- return self._health_snapshot()
185
-
186
-
187
-
188
- # ══════════════════════════════════════════════════════════════════════════════════════
189
- # SECTION 1b β€” TRADE LOG PARSER
190
- # ══════════════════════════════════════════════════════���═══════════════════════════════
191
  class TradeLogParser:
192
  """
193
  Tails ranker log files and maintains open/closed trade state.
194
  Runs in a background thread, refreshing every 2 seconds.
 
 
 
 
195
  """
196
 
197
- # Updated regex to match ranker's actual log format
198
- TRADE_OPEN_RE = re.compile(r'\[([^\]]+)\] TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)')
199
- TRADE_CLOSE_RE = re.compile(r'\[([^\]]+)\] TRADE CLOSED \| reward=[+-]?[\d.]+ \| pnl=([+-]?[\d.]+)')
200
- TRADE_ID_RE = re.compile(r'\[([^\]]+)\] TRADE CLOSED')
201
- REWARD_RE = re.compile(r'Closed (\w+) \| reward=([+-]?[\d.]+) \| pnl=([+-]?[\d.]+) \| portfolio_dd=([\d.]+)%')
202
-
203
- def __init__(self, log_dir: str = "./ranker_logs"):
204
- self._lock = threading.RLock()
205
- self._open: dict = {} # trade_id β†’ trade dict
206
- self._closed: list = [] # list of closed trade dicts (most recent last)
207
- self._log_dir = log_dir
208
- self._last_pos: dict = {} # filepath β†’ byte offset (tail mode)
 
 
 
 
209
  self._stats = {
210
  "total_opened": 0,
211
  "total_closed": 0,
212
- "total_pnl": 0.0,
213
- "win_count": 0,
214
- "loss_count": 0,
215
  }
216
- self._debug_counter = 0 # for debugging
 
 
 
 
 
217
 
218
  def start_background(self, interval: float = 2.0) -> None:
219
- def _loop():
220
- while True:
221
- try:
222
- self.refresh()
223
- except Exception as e:
224
- logger.error(f"[TradeLogParser] refresh error: {e}")
225
- time.sleep(interval)
226
-
227
- t = threading.Thread(target=_loop, daemon=True, name="TradeLogParser")
228
- t.start()
229
- logger.info(f"[TradeLogParser] Started β€” watching {self._log_dir}")
 
 
 
 
 
 
230
 
231
  def refresh(self) -> None:
232
- pattern = os.path.join(self._log_dir, "*.log")
 
233
  files = sorted(glob.glob(pattern))
 
234
  if not files:
235
- pattern = os.path.join(self._log_dir, "*.txt")
 
236
  files = sorted(glob.glob(pattern))
237
-
238
  for fpath in files:
239
  self._tail_file(fpath)
240
 
241
- def get_state(self) -> dict:
242
- with self._lock:
243
- open_trades = list(self._open.values())
244
- closed_trades = list(reversed(self._closed[-100:]))
245
- stats = dict(self._stats)
246
- stats["win_rate"] = (
247
- round(stats["win_count"] / stats["total_closed"] * 100, 1)
248
- if stats["total_closed"] > 0 else 0.0
249
- )
250
- return {
251
- "open": open_trades,
252
- "closed": closed_trades,
253
- "stats": stats,
254
- "timestamp": datetime.utcnow().isoformat() + "Z",
255
- }
256
-
257
  def _tail_file(self, fpath: str) -> None:
 
258
  try:
259
  size = os.path.getsize(fpath)
260
  except OSError:
@@ -276,202 +144,143 @@ class TradeLogParser:
276
  self._parse_line(line)
277
 
278
  def _parse_line(self, line: str) -> None:
279
- # Debug first few lines
280
- if self._debug_counter < 10:
281
- logger.debug(f"[TradeLogParser] Line: {line.strip()}")
282
- self._debug_counter += 1
283
-
 
284
  # ── TRADE OPENED ─────────────────────────────────────────────────────────
285
  m = self.TRADE_OPEN_RE.search(line)
286
  if m:
287
- asset, trade_id, direction, entry = m.group(1), m.group(2), m.group(3), float(m.group(4))
288
- ts = self._parse_ts(line)
 
 
 
 
 
289
  with self._lock:
290
  self._open[trade_id] = {
291
- "trade_id": trade_id,
292
- "asset": asset,
293
- "direction": direction,
294
- "entry": entry,
295
- "opened_at": ts,
296
- "status": "OPEN",
297
  }
298
  self._stats["total_opened"] += 1
299
- logger.info(f"[TradeLogParser] Opened: {trade_id} {asset} {direction}")
 
300
  return
301
 
302
  # ── TRADE CLOSED ─────────────────────────────────────────────────────────
303
- m_close = self.TRADE_CLOSE_RE.search(line)
304
- m_id = self.TRADE_ID_RE.search(line)
305
- if m_close and m_id:
306
- asset = m_id.group(1)
307
- pnl = float(m_close.group(2))
308
- ts = self._parse_ts(line)
309
-
310
  with self._lock:
311
- matched_id = None
312
- for tid, trade in self._open.items():
313
- if trade["asset"] == asset:
314
- matched_id = tid
315
- break
316
-
317
- trade = self._open.pop(matched_id, None) if matched_id else None
318
  closed = {
319
- "trade_id": matched_id or asset,
320
- "asset": asset,
321
- "direction": trade["direction"] if trade else "?",
322
- "entry": trade["entry"] if trade else 0.0,
323
- "pnl": pnl,
324
- "closed_at": ts,
325
- "status": "CLOSED",
326
  }
 
 
 
 
 
327
  self._closed.append(closed)
328
  self._stats["total_closed"] += 1
329
  self._stats["total_pnl"] += pnl
 
330
  if pnl >= 0:
331
  self._stats["win_count"] += 1
332
  else:
333
  self._stats["loss_count"] += 1
334
- logger.info(f"[TradeLogParser] Closed: {asset} pnl={pnl}")
 
335
  return
336
 
337
- # ── REWARD / portfolio_dd line ────────────────────────────────────────────
338
- m = self.REWARD_RE.search(line)
339
- if m and self._closed:
340
- reward = float(m.group(2))
341
- with self._lock:
342
- self._closed[-1]["reward"] = reward
343
- self._closed[-1]["portfolio_dd"] = float(m.group(4))
344
-
345
  @staticmethod
346
- def _parse_ts(line: str) -> str:
347
- parts = line.split("|")
348
- if parts:
349
- ts = parts[0].strip()
350
- if len(ts) >= 19:
351
- return ts[:19].replace(" ", "T")
352
  return datetime.utcnow().isoformat()[:19]
353
 
354
-
355
-
356
-
357
- class HubSubscriber:
358
- """Connects to hub and maintains per-asset snapshots."""
359
-
360
- _MAX_BACKOFF = 30
361
-
362
- def __init__(
363
- self,
364
- state: DashboardState,
365
- hub_host: str = _HUB_HOST,
366
- ranker_logger: Optional[RankerLogger] = None,
367
- ):
368
- self.state = state
369
- self.hub_url = f"wss://{hub_host}/ws/subscribe"
370
- self.ranker_logger = ranker_logger
371
- self._ws: Optional[websocket.WebSocketApp] = None
372
- self._running = False
373
- self._thread: Optional[threading.Thread] = None
374
- self._reconnect_count = 0
375
-
376
- def start(self) -> None:
377
- if self._running:
378
- return
379
- self._running = True
380
- self._thread = threading.Thread(
381
- target=self._run_loop, daemon=True, name="HubSubscriber",
382
- )
383
- self._thread.start()
384
- logger.info(f"HubSubscriber started β†’ {self.hub_url}")
385
 
386
  def stop(self) -> None:
 
387
  self._running = False
388
- if self._ws:
389
- try:
390
- self._ws.close()
391
- except Exception:
392
- pass
393
  if self._thread:
394
  self._thread.join(timeout=5)
395
 
396
- def _run_loop(self) -> None:
397
- while self._running:
398
- try:
399
- self._connect()
400
- except Exception as e:
401
- logger.error(f"[HubSubscriber] Error: {e}")
402
- if not self._running:
403
- break
404
- self.state.hub_connected = False
405
- self.state.reconnect_count = self._reconnect_count
406
- backoff = min(self._MAX_BACKOFF, 2 ** min(self._reconnect_count, 4))
407
- logger.info(f"[HubSubscriber] Reconnecting in {backoff}s…")
408
- time.sleep(backoff)
409
- self._reconnect_count += 1
410
-
411
- def _connect(self) -> None:
412
- self._ws = websocket.WebSocketApp(
413
- self.hub_url,
414
- on_open = self._on_open,
415
- on_message = self._on_message,
416
- on_error = self._on_error,
417
- on_close = self._on_close,
418
- )
419
- self._ws.run_forever(
420
- ping_interval = 25,
421
- ping_timeout = 10,
422
- sslopt = {"check_hostname": True},
423
- )
424
-
425
- def _on_open(self, ws) -> None:
426
- self.state.hub_connected = True
427
- self._reconnect_count = 0
428
- if self.ranker_logger:
429
- self.ranker_logger.connection_event("Hub WebSocket", "connected")
430
- logger.info("[HubSubscriber] βœ… Connected to hub")
431
-
432
- def _on_message(self, ws, raw: str) -> None:
433
- try:
434
- msg = json.loads(raw)
435
- mtype = msg.get("type", "")
436
-
437
- if mtype == "initial_state":
438
- snaps = msg.get("snapshots", {})
439
- self.state.ingest_bulk(snaps)
440
- logger.info(f"[HubSubscriber] Initial state: {len(snaps)} spaces")
441
-
442
- elif mtype == "metrics_update":
443
- space = msg.get("space_name")
444
- snap = msg.get("snapshot", {})
445
- if space and snap:
446
- self.state.ingest_snapshot(space, snap)
447
- if self.ranker_logger:
448
- training = snap.get("training", {})
449
- voting = snap.get("voting", {})
450
- self.ranker_logger.hub_update(
451
- asset=space,
452
- avn_accuracy=training.get("avn_accuracy", 0.0),
453
- signal_confidence=(
454
- max(voting.get("buy_count", 0), voting.get("sell_count", 0)) /
455
- (voting.get("buy_count", 0) + voting.get("sell_count", 0) or 1)
456
- )
457
- )
458
-
459
- except json.JSONDecodeError:
460
- logger.warning("[HubSubscriber] Malformed JSON")
461
- except Exception as e:
462
- logger.error(f"[HubSubscriber] Message error: {e}")
463
-
464
- def _on_error(self, ws, error) -> None:
465
- logger.error(f"[HubSubscriber] WS error: {error}")
466
- self.state.hub_connected = False
467
- if self.ranker_logger:
468
- self.ranker_logger.connection_event("Hub WebSocket", "error", str(error))
469
-
470
- def _on_close(self, ws, code, msg) -> None:
471
- self.state.hub_connected = False
472
- logger.info(f"[HubSubscriber] Connection closed (code={code})")
473
- if self.ranker_logger:
474
- self.ranker_logger.connection_event("Hub WebSocket", "disconnected")
475
 
476
 
477
  # ══════════════════════════════════════════════════════════════════════════════════════
@@ -479,25 +288,16 @@ class HubSubscriber:
479
  # ══════════════════════════════════════════════════════════════════════════════════════
480
 
481
  _state = DashboardState()
482
- _ranker_logger = RankerLogger(
483
- buffer_size = 1000,
484
- log_dir = "./ranker_logs",
485
- on_event = None,
486
- )
487
-
488
- _trade_parser = TradeLogParser(log_dir="./ranker_logs")
489
- _trade_parser.start_background(interval=2.0)
490
 
491
  app = Flask(__name__)
492
  CORS(app)
493
 
494
- # Register ranker logs API
495
- init_ranker_logs_api(_ranker_logger)
496
- app.register_blueprint(ranker_logs_bp)
497
-
498
 
499
  @app.route("/")
500
  def index():
 
501
  html_path = Path(_HTML_PATH)
502
  if html_path.exists():
503
  return send_from_directory(str(html_path.parent), html_path.name)
@@ -516,71 +316,134 @@ def api_state():
516
 
517
  @app.route("/api/rankings")
518
  def api_rankings():
519
- return jsonify({"rankings": _state.get_rankings()})
520
-
521
-
522
- @app.route("/api/metrics/history")
523
- def api_metrics_history():
524
- limit = int(request.args.get("limit", 100))
525
- return jsonify(_state.get_metric_history(limit=limit))
526
 
527
 
528
  @app.route("/api/trades")
529
  def api_trades():
530
- """
531
- Returns open trades, recent closed trades, and summary stats.
532
- Parsed live from ranker log files in ./ranker_logs/.
533
- """
534
  return jsonify(_trade_parser.get_state())
535
 
536
 
537
  @app.route("/api/trades/open")
538
  def api_trades_open():
539
- return jsonify({"open": _trade_parser.get_state()["open"]})
 
 
540
 
541
 
542
  @app.route("/api/trades/closed")
543
  def api_trades_closed():
 
544
  limit = int(request.args.get("limit", 50))
545
  state = _trade_parser.get_state()
546
- return jsonify({"closed": state["closed"][:limit], "stats": state["stats"]})
 
 
 
547
 
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
 
 
 
 
550
  return jsonify({
551
  "service": "hub_dashboard_service",
552
- "version": "v1.1",
 
 
 
553
  "timestamp": datetime.utcnow().isoformat() + "Z",
554
- **_state.get_health(),
555
  })
556
 
557
 
558
- def _start_background_services() -> None:
559
- sub = HubSubscriber(_state, hub_host=_HUB_HOST, ranker_logger=_ranker_logger)
560
- sub.start()
561
 
562
- global _subscriber
563
- _subscriber = sub
564
 
 
 
 
565
 
566
- _subscriber = None
567
 
568
- _start_background_services()
 
 
569
 
570
- logger.info("=" * 70)
571
- logger.info(f"K1RL QUASAR β€” Hub Dashboard Service (v1.1 with Ranker Logs)")
572
- logger.info(f"Hub WebSocket : wss://{_HUB_HOST}/ws/subscribe")
573
- logger.info(f"Dashboard HTML : {_HTML_PATH}")
574
- logger.info(f"Service port : {_DASHBOARD_PORT}")
575
- logger.info(f"Logs API : /api/ranker/logs/*")
576
- logger.info(f"Log directory : ./ranker_logs")
577
- logger.info("=" * 70)
 
 
 
 
 
 
 
 
 
 
578
 
579
 
580
  if __name__ == "__main__":
581
- app.run(
582
- host = "0.0.0.0",
583
- port = _DASHBOARD_PORT,
584
- debug = False,
585
- threaded = True,
586
- )
 
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
+ β•‘ K1RL QUASAR β€” HUB DASHBOARD SERVICE (with Trade Log Parser) β•‘
5
  β•‘ ────────────────────────────────────────────────────────────────────────────────── β•‘
6
  β•‘ Architecture role: READ-ONLY subscriber β†’ serves dashboard UI β•‘
7
+ β•‘ VERSION: v2.0 (FIXED) | 2026-03-30 β•‘
8
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
9
  """
10
 
11
  import json
12
  import logging
13
  import os
14
  import sys
15
+ import re
16
+ import glob
17
  import threading
18
  import time
19
+ from collections import deque, defaultdict
20
  from datetime import datetime
21
  from pathlib import Path
22
  from typing import Dict, List, Optional
23
 
 
 
 
24
  import websocket
25
+ from flask import Flask, jsonify, request, send_from_directory
26
  from flask_cors import CORS
27
 
 
 
 
 
 
 
 
 
 
28
  # ── Logging ───────────────────────────────────────────────────────────────────────────
29
  logging.basicConfig(
30
  level=logging.INFO,
 
35
  logger = logging.getLogger("HubDashboardService")
36
 
37
  # ── Config ────────────────────────────────────────────────────────────────────────────
38
+ _HUB_HOST = os.environ.get("QUASAR_HUB_HOST", "karlquant-quasar-executo.hf.space")
39
  _DASHBOARD_PORT = int(os.environ.get("DASHBOARD_PORT", "8051"))
40
+ _HTML_PATH = os.environ.get(
41
  "DASHBOARD_HTML",
42
  str(Path(__file__).parent / "hub_dashboard.html"),
43
  )
44
+ _LOG_DIR = os.environ.get("RANKER_LOG_DIR", "/app/ranker_logs")
45
  _METRIC_HISTORY_LEN = int(os.environ.get("QUASAR_METRIC_HISTORY", "200"))
46
 
47
 
48
  # ══════════════════════════════════════════════════════════════════════════════════════
49
+ # SECTION 1 β€” TRADE LOG PARSER (FIXED for actual log format)
50
  # ══════════════════════════════════════════════════════════════════════════════════════
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  class TradeLogParser:
53
  """
54
  Tails ranker log files and maintains open/closed trade state.
55
  Runs in a background thread, refreshing every 2 seconds.
56
+
57
+ Expected log format from ranker_logging.py:
58
+ [2026-03-30 16:20:40] | INFO | TRADE | CRASH500 | TRADE OPENED | ID=CRASH500_123 | Dir=long | Entry=3524.6485 | Qty=0.000284
59
+ [2026-03-30 16:20:39] | INFO | TRADE | CRASH500 | TRADE CLOSED | ID=CRASH500_456 | pnl=-3.5246 | return=+0.01%
60
  """
61
 
62
+ # Regex patterns matching the actual log format
63
+ # Pattern for OPEN: ... | TRADE OPENED | ID=xxx | Dir=xxx | Entry=xxx
64
+ TRADE_OPEN_RE = re.compile(r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)')
65
+
66
+ # Pattern for CLOSE: ... | TRADE CLOSED | ID=xxx | pnl=xxx | return=xxx
67
+ TRADE_CLOSE_RE = re.compile(r'TRADE CLOSED \| ID=(\S+) \| pnl=([+-]?[\d.]+)')
68
+
69
+ # Pattern to extract asset from the log line (the part after TRADE |)
70
+ TRADE_ASSET_RE = re.compile(r'TRADE \| (\w+) \|')
71
+
72
+ def __init__(self, log_dir: str = _LOG_DIR):
73
+ self.log_dir = Path(log_dir)
74
+ self._open: Dict[str, dict] = {}
75
+ self._closed: List[dict] = []
76
+ self._last_pos: Dict[str, int] = {}
77
+ self._lock = threading.RLock()
78
  self._stats = {
79
  "total_opened": 0,
80
  "total_closed": 0,
81
+ "total_pnl": 0.0,
82
+ "win_count": 0,
83
+ "loss_count": 0,
84
  }
85
+ self._running = False
86
+ self._thread: Optional[threading.Thread] = None
87
+
88
+ # Create log directory if it doesn't exist
89
+ self.log_dir.mkdir(parents=True, exist_ok=True)
90
+ logger.info(f"[TradeLogParser] Initialized | log_dir={self.log_dir}")
91
 
92
  def start_background(self, interval: float = 2.0) -> None:
93
+ """Launch a daemon thread that calls refresh() every `interval` seconds."""
94
+ if self._running:
95
+ return
96
+
97
+ self._running = True
98
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="TradeLogParser")
99
+ self._thread.start()
100
+ logger.info(f"[TradeLogParser] Started β€” watching {self.log_dir} (interval={interval}s)")
101
+
102
+ def _loop(self) -> None:
103
+ """Background loop."""
104
+ while self._running:
105
+ try:
106
+ self.refresh()
107
+ except Exception as e:
108
+ logger.error(f"[TradeLogParser] refresh error: {e}")
109
+ time.sleep(2.0)
110
 
111
  def refresh(self) -> None:
112
+ """Find all log files, read new lines since last position."""
113
+ pattern = str(self.log_dir / "*.log")
114
  files = sorted(glob.glob(pattern))
115
+
116
  if not files:
117
+ # Also check for .txt files
118
+ pattern = str(self.log_dir / "*.txt")
119
  files = sorted(glob.glob(pattern))
120
+
121
  for fpath in files:
122
  self._tail_file(fpath)
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  def _tail_file(self, fpath: str) -> None:
125
+ """Read only new bytes from fpath since last call."""
126
  try:
127
  size = os.path.getsize(fpath)
128
  except OSError:
 
144
  self._parse_line(line)
145
 
146
  def _parse_line(self, line: str) -> None:
147
+ """Extract trade events from a single log line."""
148
+
149
+ # Extract asset from the line (if present)
150
+ asset_match = self.TRADE_ASSET_RE.search(line)
151
+ asset = asset_match.group(1) if asset_match else None
152
+
153
  # ── TRADE OPENED ─────────────────────────────────────────────────────────
154
  m = self.TRADE_OPEN_RE.search(line)
155
  if m:
156
+ trade_id, direction, entry = m.group(1), m.group(2), float(m.group(3))
157
+ # Normalize direction: long -> LONG, short -> SHORT
158
+ direction = direction.upper()
159
+
160
+ # Parse timestamp from log line
161
+ ts = self._parse_timestamp(line)
162
+
163
  with self._lock:
164
  self._open[trade_id] = {
165
+ "trade_id": trade_id,
166
+ "asset": asset or trade_id.split('_')[0],
167
+ "direction": direction,
168
+ "entry": entry,
169
+ "opened_at": ts,
170
+ "status": "OPEN",
171
  }
172
  self._stats["total_opened"] += 1
173
+
174
+ logger.debug(f"[TradeLogParser] OPEN: {trade_id} | {direction} @ {entry}")
175
  return
176
 
177
  # ── TRADE CLOSED ─────────────────────────────────────────────────────────
178
+ m = self.TRADE_CLOSE_RE.search(line)
179
+ if m:
180
+ trade_id, pnl = m.group(1), float(m.group(2))
181
+ ts = self._parse_timestamp(line)
182
+
 
 
183
  with self._lock:
184
+ # Find the matching open trade
185
+ trade = self._open.pop(trade_id, None)
186
+
 
 
 
 
187
  closed = {
188
+ "trade_id": trade_id,
189
+ "asset": asset or (trade.get("asset") if trade else trade_id.split('_')[0]),
190
+ "pnl": pnl,
191
+ "closed_at": ts,
192
+ "status": "CLOSED",
 
 
193
  }
194
+
195
+ if trade:
196
+ closed["direction"] = trade.get("direction", "?")
197
+ closed["entry"] = trade.get("entry", 0.0)
198
+
199
  self._closed.append(closed)
200
  self._stats["total_closed"] += 1
201
  self._stats["total_pnl"] += pnl
202
+
203
  if pnl >= 0:
204
  self._stats["win_count"] += 1
205
  else:
206
  self._stats["loss_count"] += 1
207
+
208
+ logger.debug(f"[TradeLogParser] CLOSE: {trade_id} | pnl={pnl:+.2f}")
209
  return
210
 
 
 
 
 
 
 
 
 
211
  @staticmethod
212
+ def _parse_timestamp(line: str) -> str:
213
+ """Extract ISO timestamp from log line prefix."""
214
+ # Format: [2026-03-30 16:20:40] | ...
215
+ match = re.search(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]', line)
216
+ if match:
217
+ return match.group(1).replace(" ", "T")
218
  return datetime.utcnow().isoformat()[:19]
219
 
220
+ def get_state(self) -> dict:
221
+ """Return current trade state."""
222
+ with self._lock:
223
+ open_trades = list(self._open.values())
224
+ closed_trades = list(reversed(self._closed[-100:])) # newest first
225
+
226
+ stats = dict(self._stats)
227
+ stats["win_rate"] = (
228
+ round(stats["win_count"] / stats["total_closed"] * 100, 1)
229
+ if stats["total_closed"] > 0 else 0.0
230
+ )
231
+
232
+ return {
233
+ "open": open_trades,
234
+ "closed": closed_trades,
235
+ "stats": stats,
236
+ "timestamp": datetime.utcnow().isoformat() + "Z",
237
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  def stop(self) -> None:
240
+ """Stop the background thread."""
241
  self._running = False
 
 
 
 
 
242
  if self._thread:
243
  self._thread.join(timeout=5)
244
 
245
+
246
+ # ══════════════════════════════════════════════════════════════════════════════════════
247
+ # SECTION 2 β€” IN-MEMORY STATE STORE (minimal for dashboard)
248
+ # ══════════════════════════════════════════════════════════════════════════════════════
249
+
250
+ class DashboardState:
251
+ """Thread-safe cache of everything the dashboard needs."""
252
+
253
+ def __init__(self, history_len: int = _METRIC_HISTORY_LEN):
254
+ self._lock = threading.RLock()
255
+ self._history_len = history_len
256
+
257
+ self.hub_connected = False
258
+ self.start_time = time.time()
259
+ self.messages_rx = 0
260
+ self.last_update_ts = 0.0
261
+
262
+ self._rankings: List[dict] = []
263
+ self._snapshots: Dict[str, dict] = {}
264
+
265
+ def get_state(self) -> dict:
266
+ with self._lock:
267
+ return {
268
+ "rankings": list(self._rankings),
269
+ "metric_history": {},
270
+ "health": self._health_snapshot(),
271
+ "timestamp": datetime.utcnow().isoformat() + "Z",
272
+ }
273
+
274
+ def _health_snapshot(self) -> dict:
275
+ return {
276
+ "hub_connected": self.hub_connected,
277
+ "spaces_connected": len(self._snapshots),
278
+ "messages_rx": self.messages_rx,
279
+ "last_update_ts": self.last_update_ts,
280
+ "last_update_ago": round(time.time() - self.last_update_ts, 1) if self.last_update_ts else None,
281
+ "uptime_seconds": round(time.time() - self.start_time, 0),
282
+ "reconnect_count": 0,
283
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
 
286
  # ══════════════════════════════════════════════════════════════════════════════════════
 
288
  # ══════════════════════════════════════════════════════════════════════════════════════
289
 
290
  _state = DashboardState()
291
+ _trade_parser = TradeLogParser(log_dir=_LOG_DIR)
292
+ _trade_parser.start_background()
 
 
 
 
 
 
293
 
294
  app = Flask(__name__)
295
  CORS(app)
296
 
 
 
 
 
297
 
298
  @app.route("/")
299
  def index():
300
+ """Serve the dashboard HTML."""
301
  html_path = Path(_HTML_PATH)
302
  if html_path.exists():
303
  return send_from_directory(str(html_path.parent), html_path.name)
 
316
 
317
  @app.route("/api/rankings")
318
  def api_rankings():
319
+ """Get current rankings."""
320
+ return jsonify({"rankings": _state.get_state()["rankings"]})
 
 
 
 
 
321
 
322
 
323
  @app.route("/api/trades")
324
  def api_trades():
325
+ """Returns open trades, recent closed trades, and summary stats."""
 
 
 
326
  return jsonify(_trade_parser.get_state())
327
 
328
 
329
  @app.route("/api/trades/open")
330
  def api_trades_open():
331
+ """Get only open trades."""
332
+ state = _trade_parser.get_state()
333
+ return jsonify({"open": state["open"]})
334
 
335
 
336
  @app.route("/api/trades/closed")
337
  def api_trades_closed():
338
+ """Get closed trades and stats."""
339
  limit = int(request.args.get("limit", 50))
340
  state = _trade_parser.get_state()
341
+ return jsonify({
342
+ "closed": state["closed"][:limit],
343
+ "stats": state["stats"]
344
+ })
345
 
346
 
347
+ @app.route("/api/ranker/logs/recent")
348
+ def api_logs_recent():
349
+ """Get recent ranker logs (minimal implementation)."""
350
+ limit = int(request.args.get("limit", 50))
351
+
352
+ # Read directly from log file
353
+ logs = []
354
+ pattern = str(Path(_LOG_DIR) / "*.log")
355
+ files = sorted(glob.glob(pattern))
356
+
357
+ for fpath in files[-3:]: # Last 3 log files
358
+ try:
359
+ with open(fpath, "r") as f:
360
+ lines = f.readlines()[-limit:]
361
+ for line in lines:
362
+ # Parse log line into structured format
363
+ match = re.match(r'\[([^\]]+)\] \| (\w+) \| (\w+) \| (.*)', line.strip())
364
+ if match:
365
+ logs.append({
366
+ "ts": match.group(1),
367
+ "level": match.group(2),
368
+ "category": match.group(3),
369
+ "message": match.group(4),
370
+ })
371
+ except Exception:
372
+ pass
373
+
374
+ return jsonify({"logs": logs[-limit:], "count": len(logs)})
375
+
376
+
377
+ @app.route("/api/ranker/logs/stats")
378
+ def api_logs_stats():
379
+ """Get log statistics."""
380
+ pattern = str(Path(_LOG_DIR) / "*.log")
381
+ files = glob.glob(pattern)
382
+
383
+ total_lines = 0
384
+ for fpath in files:
385
+ try:
386
+ with open(fpath, "r") as f:
387
+ total_lines += sum(1 for _ in f)
388
+ except Exception:
389
+ pass
390
+
391
+ return jsonify({
392
+ "total_events": total_lines,
393
+ "by_level": {"INFO": total_lines},
394
+ "by_category": {},
395
+ "by_asset": {},
396
+ "errors": {}
397
+ })
398
 
399
+
400
+ @app.route("/api/health")
401
+ def api_health():
402
+ """Service health check."""
403
  return jsonify({
404
  "service": "hub_dashboard_service",
405
+ "version": "v2.0",
406
+ "status": "running",
407
+ "log_dir": str(_LOG_DIR),
408
+ "log_files": len(glob.glob(str(Path(_LOG_DIR) / "*.log"))),
409
  "timestamp": datetime.utcnow().isoformat() + "Z",
410
+ **_state.get_state()["health"],
411
  })
412
 
413
 
414
+ @app.errorhandler(404)
415
+ def not_found(error):
416
+ return jsonify({"error": "Endpoint not found"}), 404
417
 
 
 
418
 
419
+ @app.errorhandler(500)
420
+ def internal_error(error):
421
+ return jsonify({"error": "Internal server error"}), 500
422
 
 
423
 
424
+ # ══════════════════════════════════════════════════════════════════════════════════════
425
+ # SECTION 4 β€” MAIN
426
+ # ══════════════════════════════════════════════════════════════════════════════════════
427
 
428
+ def main():
429
+ """Start the dashboard service."""
430
+ logger.info("=" * 70)
431
+ logger.info(f"K1RL QUASAR β€” Hub Dashboard Service (v2.0 with Trade Log Parser)")
432
+ logger.info(f"Dashboard HTML : {_HTML_PATH}")
433
+ logger.info(f"Log directory : {_LOG_DIR}")
434
+ logger.info(f"Service port : {_DASHBOARD_PORT}")
435
+ logger.info("=" * 70)
436
+
437
+ # Ensure log directory exists
438
+ Path(_LOG_DIR).mkdir(parents=True, exist_ok=True)
439
+
440
+ app.run(
441
+ host="0.0.0.0",
442
+ port=_DASHBOARD_PORT,
443
+ debug=False,
444
+ threaded=True,
445
+ )
446
 
447
 
448
  if __name__ == "__main__":
449
+ main()