KarlQuant commited on
Commit
8ae2268
·
verified ·
1 Parent(s): e0da561

Update hub_dashboard_service.py

Browse files
Files changed (1) hide show
  1. hub_dashboard_service.py +161 -288
hub_dashboard_service.py CHANGED
@@ -1,10 +1,15 @@
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
 
@@ -46,7 +51,7 @@ _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:
@@ -54,14 +59,19 @@ class TradeLogParser:
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 from ranker_logging.py:
63
- # [2026-03-30 17:14:35] | INFO | TRADE | V100_1s | TRADE OPENED | ID=... | Dir=long | Entry=0.0000 | Qty=10.0
64
- # [2026-03-30 17:14:35] | INFO | TRADE | CRASH500 | TRADE CLOSED | ID=... | pnl=-3.52 | return=+0.01%
65
  TRADE_OPEN_RE = re.compile(
66
  r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+) \| Qty=([\d.]+)'
67
  )
@@ -69,8 +79,27 @@ class TradeLogParser:
69
  r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)'
70
  )
71
 
72
- # Pattern for CLOSE: ... | TRADE CLOSED | ID=xxx | pnl=xxx | return=xxx
73
- TRADE_CLOSE_RE = re.compile(r'TRADE CLOSED \| ID=(\S+) \| pnl=([+-]?[\d.]+)')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  # Asset sits between the 4th and 5th pipe-separated fields:
76
  # "[ts] | LEVEL | TRADE | <ASSET> | ..."
@@ -88,6 +117,7 @@ class TradeLogParser:
88
  "total_pnl": 0.0,
89
  "win_count": 0,
90
  "loss_count": 0,
 
91
  }
92
  self._running = False
93
  self._thread: Optional[threading.Thread] = None
@@ -116,15 +146,19 @@ class TradeLogParser:
116
  time.sleep(2.0)
117
 
118
  def refresh(self) -> None:
119
- """Find all log files, read new lines since last position.
 
 
 
120
  On first call for each file, always scan from the beginning so trades
121
  that were written before the service started are not missed.
122
  """
123
- pattern = str(self.log_dir / "*.log")
 
124
  files = sorted(glob.glob(pattern))
125
 
126
  if not files:
127
- # Also check for .txt files
128
  pattern = str(self.log_dir / "*.txt")
129
  files = sorted(glob.glob(pattern))
130
 
@@ -193,19 +227,43 @@ class TradeLogParser:
193
  logger.debug(f"[TradeLogParser] OPEN: {trade_id} | {direction} @ {entry} qty={qty}")
194
  return
195
 
196
- # ── TRADE CLOSED ─────────────────────────────────────────────────────────
197
- m = self.TRADE_CLOSE_RE.search(line)
 
 
 
 
198
  if m:
199
  trade_id, pnl = m.group(1), float(m.group(2))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  ts = self._parse_timestamp(line)
201
 
202
  with self._lock:
203
- # Find the matching open trade
204
- trade = self._open.pop(trade_id, None)
 
 
 
 
205
 
206
  closed = {
207
- "trade_id": trade_id,
208
- "asset": asset or (trade.get("asset") if trade else trade_id.split('_')[0]),
209
  "pnl": pnl,
210
  "closed_at": ts,
211
  "status": "CLOSED",
@@ -224,7 +282,7 @@ class TradeLogParser:
224
  else:
225
  self._stats["loss_count"] += 1
226
 
227
- logger.debug(f"[TradeLogParser] CLOSE: {trade_id} | pnl={pnl:+.2f}")
228
  return
229
 
230
  @staticmethod
@@ -252,149 +310,83 @@ class TradeLogParser:
252
  "open": open_trades,
253
  "closed": closed_trades,
254
  "stats": stats,
255
- "timestamp": datetime.utcnow().isoformat() + "Z",
256
  }
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def stop(self) -> None:
259
- """Stop the background thread."""
260
  self._running = False
261
  if self._thread:
262
- self._thread.join(timeout=5)
263
 
264
 
265
  # ══════════════════════════════════════════════════════════════════════════════════════
266
- # SECTION 2 — IN-MEMORY STATE STORE (minimal for dashboard)
267
  # ══════════════════════════════════════════════════════════════════════════════════════
268
 
 
 
 
 
 
 
 
 
 
269
  class DashboardState:
270
- """Thread-safe cache of everything the dashboard needs."""
271
 
272
- def __init__(self, history_len: int = _METRIC_HISTORY_LEN):
 
273
  self._lock = threading.RLock()
274
- self._history_len = history_len
275
-
276
- self.hub_connected = False
277
- self.start_time = time.time()
278
- self.messages_rx = 0
279
- self.last_update_ts = 0.0
280
-
281
- self._rankings: List[dict] = []
282
- self._snapshots: Dict[str, dict] = {}
283
- self._history: Dict[str, deque] = {} # rolling per-space metric history
284
-
285
- def update_from_snapshot(self, space_name: str, snapshot: dict) -> None:
286
- """Record a metrics_update frame from the hub WebSocket."""
287
  with self._lock:
288
- self._snapshots[space_name] = snapshot
289
- training = snapshot.get("training", {})
290
-
291
- # Only append a history point when training has non-zero loss/accuracy
292
- if any(
293
- training.get(k, 0) != 0
294
- for k in ("actor_loss", "critic_loss", "avn_loss", "avn_accuracy")
295
- ):
296
- if space_name not in self._history:
297
- self._history[space_name] = deque(maxlen=self._history_len)
298
- self._history[space_name].append({
299
- "ts": snapshot.get("last_updated", time.time()),
300
- "actor_loss": training.get("actor_loss", 0.0),
301
- "critic_loss": training.get("critic_loss", 0.0),
302
- "avn_loss": training.get("avn_loss", 0.0),
303
- "avn_accuracy": training.get("avn_accuracy", 0.0),
304
- "training_steps": training.get("training_steps", 0),
305
- })
306
-
307
- self.messages_rx += 1
308
- self.last_update_ts = time.time()
309
- self.hub_connected = True
310
-
311
- # Recompute rankings from the latest snapshot collection
312
- self._rankings = self._compute_rankings()
313
-
314
- def _compute_rankings(self) -> List[dict]:
315
- ranked: List[dict] = []
316
- for name, snap in self._snapshots.items():
317
- training = snap.get("training", {})
318
- voting = snap.get("voting", {})
319
- buy = voting.get("buy_count", 0)
320
- sell = voting.get("sell_count", 0)
321
- total = buy + sell
322
- sig_conf = (max(buy, sell) / total) if total > 0 else 0.0
323
- avn_acc = training.get("avn_accuracy", 0.0)
324
- score = round(sig_conf - avn_acc, 6)
325
- ranked.append({
326
- "rank": 0,
327
- "space_name": name,
328
- "score": score,
329
- "signal_confidence": round(sig_conf, 6),
330
- "avn_accuracy": round(avn_acc, 6),
331
- "dominant_signal": voting.get("dominant_signal", "NEUTRAL"),
332
- "buy_count": buy,
333
- "sell_count": sell,
334
- "training_steps": training.get("training_steps", 0),
335
- "actor_loss": training.get("actor_loss", 0.0),
336
- "critic_loss": training.get("critic_loss", 0.0),
337
- "avn_loss": training.get("avn_loss", 0.0),
338
- "last_updated": snap.get("last_updated", 0.0),
339
- })
340
- ranked.sort(key=lambda r: r["score"], reverse=True)
341
- for i, r in enumerate(ranked):
342
- r["rank"] = i + 1
343
- return ranked
344
 
345
  def get_state(self) -> dict:
346
  with self._lock:
347
- return {
348
- "rankings": list(self._rankings),
349
- "metric_history": {name: list(dq) for name, dq in self._history.items()},
350
- "health": self._health_snapshot(),
351
- "timestamp": datetime.utcnow().isoformat() + "Z",
352
- }
353
-
354
- def _health_snapshot(self) -> dict:
355
- return {
356
- "hub_connected": self.hub_connected,
357
- "spaces_connected": len(self._snapshots),
358
- "messages_rx": self.messages_rx,
359
- "last_update_ts": self.last_update_ts,
360
- "last_update_ago": round(time.time() - self.last_update_ts, 1) if self.last_update_ts else None,
361
- "uptime_seconds": round(time.time() - self.start_time, 0),
362
- "reconnect_count": 0,
363
- }
364
-
365
-
366
- # ══════════════════════════════════════════════════════════════════════════════════════
367
- # SECTION 2B — HUB SUBSCRIBER CLIENT
368
- #
369
- # Connects to websocket_hub.py via /ws/subscribe and feeds incoming
370
- # metrics_update / initial_state frames into DashboardState.
371
- # Runs in a daemon thread with exponential-back-off reconnection.
372
- # ══════════════════════════════════════════════════════════════════════════════════════
373
-
374
- _HUB_WS_URL = os.environ.get(
375
- "QUASAR_HUB_WS",
376
- "ws://127.0.0.1:7860/ws/subscribe", # local default; override via env var
377
- )
378
 
379
  class HubSubscriberClient:
380
- """
381
- READ-ONLY WebSocket subscriber that keeps DashboardState in sync with
382
- whatever the hub knows, including live training metrics.
383
-
384
- Messages handled:
385
- initial_state — {snapshots: {space: snapshot}}
386
- • metrics_update — {space_name: str, snapshot: dict}
387
- """
388
-
389
- _MAX_BACKOFF = 30
390
-
391
- def __init__(self, state: "DashboardState", hub_url: str = _HUB_WS_URL):
392
- self.state = state
393
- self.hub_url = hub_url
394
- self._ws: Optional[websocket.WebSocketApp] = None
395
  self._running = False
396
- self._thread: Optional[threading.Thread] = None
397
  self._reconnect_count = 0
 
398
 
399
  def start(self) -> None:
400
  if self._running:
@@ -504,7 +496,7 @@ def api_state():
504
  @app.route("/api/rankings")
505
  def api_rankings():
506
  """Get current rankings."""
507
- return jsonify({"rankings": _state.get_state()["rankings"]})
508
 
509
 
510
  @app.route("/api/trades")
@@ -540,175 +532,56 @@ def api_logs_recent():
540
  logs = []
541
 
542
  # ── Collect the 3 newest log files ───────────────────────────────────────
543
- pattern = str(Path(_LOG_DIR) / "*.log")
 
544
  files = sorted(glob.glob(pattern))
545
  if not files:
546
- return jsonify({"logs": [], "count": 0, "error": f"No *.log files in {_LOG_DIR}"})
547
 
548
  # Read enough lines from the tail of each file
549
  raw_lines = []
550
  for fpath in files[-3:]:
551
  try:
552
  with open(fpath, "r", encoding="utf-8", errors="replace") as f:
553
- raw_lines.extend(f.readlines())
 
554
  except OSError:
555
  pass
556
 
557
- # Keep only the last `limit` lines before parsing so we don't over-parse
558
- raw_lines = raw_lines[-limit * 3:]
559
-
560
- # ── Log line format (from ranker_logging.py to_file_line) ───────────────
561
- # WITH asset:
562
- # [2026-03-30 17:14:35] | INFO | TRADE | V100_1s | TRADE OPENED | ID=… | …
563
- # WITHOUT asset:
564
- # [2026-03-30 17:14:35] | DEBUG | RANKING | rankings | top=… | …
565
- #
566
- # Padding: level=8 chars, category=15 chars → use \s+ / \s* to absorb them.
567
- #
568
- # Strategy: capture everything after the category into `rest`, then decide
569
- # whether the first token of `rest` is a known short asset id or the start
570
- # of the message text.
571
-
572
- # Categories that always carry an asset field
573
- ASSET_CATEGORIES = {"TRADE", "SIGNAL", "PROCESSING"}
574
-
575
- LINE_RE = re.compile(
576
- r'^\[([^\]]+)\] \| ' # [timestamp]
577
- r'(\w+)\s*\| ' # LEVEL (padded) |
578
- r'(\w+)\s*' # CATEGORY (padded, no trailing | yet)
579
- r'(?:\| (\S+) )?\| ' # optional | asset |
580
- r'(.*)' # rest of message
581
- )
582
 
583
- for line in raw_lines:
584
- line = line.rstrip('\n')
585
- m = LINE_RE.match(line)
586
- if not m:
587
  continue
588
-
589
- ts_str, level, cat, asset, message = m.groups()
590
- level = level.strip()
591
- cat = cat.strip()
592
-
593
- # When the optional asset group matched something that is NOT a real
594
- # asset (e.g. the word "rankings" on a RANKING line), pull it back
595
- # into the message.
596
- if asset and cat not in ASSET_CATEGORIES:
597
- message = f"{asset} | {message}" if message else asset
598
- asset = None
599
-
600
- entry = {
601
- "ts": ts_str,
602
- "level": level,
603
- "category": cat,
604
- "message": message or "",
605
- }
606
- if asset:
607
- entry["asset"] = asset
608
-
609
- if category and cat != category:
610
  continue
611
 
612
- logs.append(entry)
613
-
614
- logs = logs[-limit:]
615
- return jsonify({"logs": logs, "count": len(logs)})
616
-
617
 
618
- @app.route("/api/ranker/logs/stats")
619
- def api_logs_stats():
620
- """Get real log statistics — counts by level, category and asset."""
621
- pattern = str(Path(_LOG_DIR) / "*.log")
622
- files = glob.glob(pattern)
623
 
624
- total = 0
625
- by_level: Dict[str, int] = defaultdict(int)
626
- by_cat: Dict[str, int] = defaultdict(int)
627
- by_asset: Dict[str, int] = defaultdict(int)
628
- errors: Dict[str, int] = defaultdict(int)
629
 
630
- STAT_RE = re.compile(
631
- r'^\[([^\]]+)\] \| (\w+)\s*\| (\w+)\s*(?:\| (\S+) )?\|'
632
- )
633
-
634
- for fpath in files:
635
- try:
636
- with open(fpath, "r", encoding="utf-8", errors="replace") as f:
637
- for line in f:
638
- m = STAT_RE.match(line)
639
- if not m:
640
- continue
641
- _, level, cat, asset = m.groups()
642
- level = level.strip()
643
- cat = cat.strip()
644
- total += 1
645
- by_level[level] += 1
646
- by_cat[cat] += 1
647
- if asset:
648
- by_asset[asset] += 1
649
- if level in ("ERROR", "CRITICAL"):
650
- errors[cat] += 1
651
- except OSError:
652
- pass
653
-
654
- return jsonify({
655
- "total_events": total,
656
- "by_level": dict(by_level),
657
- "by_category": dict(by_cat),
658
- "by_asset": dict(by_asset),
659
- "errors": dict(errors),
660
- "log_files": len(files),
661
- "log_dir": str(_LOG_DIR),
662
- })
663
 
664
 
665
  @app.route("/api/health")
666
- def api_health():
667
- """Service health check."""
668
- return jsonify({
669
- "service": "hub_dashboard_service",
670
- "version": "v2.0",
671
- "status": "running",
672
- "log_dir": str(_LOG_DIR),
673
- "log_files": len(glob.glob(str(Path(_LOG_DIR) / "*.log"))),
674
- "timestamp": datetime.utcnow().isoformat() + "Z",
675
- **_state.get_state()["health"],
676
- })
677
-
678
-
679
- @app.errorhandler(404)
680
- def not_found(error):
681
- return jsonify({"error": "Endpoint not found"}), 404
682
-
683
-
684
- @app.errorhandler(500)
685
- def internal_error(error):
686
- return jsonify({"error": "Internal server error"}), 500
687
-
688
-
689
- # ══════════════════════════════════════════════════════════════════════════════════════
690
- # SECTION 4 — MAIN
691
- # ══════════════════════════════════════════════════════════════════════════════════════
692
-
693
- def main():
694
- """Start the dashboard service."""
695
- logger.info("=" * 70)
696
- logger.info(f"K1RL QUASAR — Hub Dashboard Service (v2.0 with Trade Log Parser)")
697
- logger.info(f"Dashboard HTML : {_HTML_PATH}")
698
- logger.info(f"Log directory : {_LOG_DIR}")
699
- logger.info(f"Service port : {_DASHBOARD_PORT}")
700
- logger.info("=" * 70)
701
-
702
- # Ensure log directory exists
703
- Path(_LOG_DIR).mkdir(parents=True, exist_ok=True)
704
-
705
- app.run(
706
- host="0.0.0.0",
707
- port=_DASHBOARD_PORT,
708
- debug=False,
709
- threaded=True,
710
- )
711
 
712
 
713
  if __name__ == "__main__":
714
- main()
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
+ ║ K1RL QUASAR — HUB DASHBOARD SERVICE (with Trade Log Parser) — FIXED v2.1
5
  ║ ────────────────────────────────────────────────────────────────────────────────── ║
6
  ║ Architecture role: READ-ONLY subscriber → serves dashboard UI ║
7
+ ║ VERSION: v2.1 (FIXED for log rotation + improved regex) | 2026-04-04
8
+ ║ ║
9
+ ║ FIXES APPLIED: ║
10
+ ║ ✅ FIX #1: Include rotated log files (*.log, *.log.1, *.log.2, etc.) ║
11
+ ║ ✅ FIX #2: Improved regex to catch all trade close formats ║
12
+ ║ ✅ FIX #3: Added unrealized P&L tracking for open positions ║
13
  ╚══════════════════════════════════════════════════════════════════════════════════════╝
14
  """
15
 
 
51
 
52
 
53
  # ══════════════════════════════════════════════════════════════════════════════════════
54
+ # SECTION 1 — TRADE LOG PARSER (FIXED v2.1)
55
  # ══════════════════════════════════════════════════════════════════════════════════════
56
 
57
  class TradeLogParser:
 
59
  Tails ranker log files and maintains open/closed trade state.
60
  Runs in a background thread, refreshing every 2 seconds.
61
 
62
+ FIXED v2.1:
63
+ ✅ FIX #1: Now reads *.log* pattern to include rotated files (.log.1, .log.2, etc.)
64
+ ✅ FIX #2: Improved regex to catch all trade close formats (normal, fallback, timeout)
65
+ ✅ FIX #3: Tracks unrealized P&L for open positions
66
+
67
  Expected log format from ranker_logging.py:
68
  [2026-03-30 16:20:40] | INFO | TRADE | CRASH500 | TRADE OPENED | ID=CRASH500_123 | Dir=long | Entry=3524.6485 | Qty=0.000284
69
  [2026-03-30 16:20:39] | INFO | TRADE | CRASH500 | TRADE CLOSED | ID=CRASH500_456 | pnl=-3.5246 | return=+0.01%
70
+ [2026-03-30 16:20:45] | INFO | TRADE | V75 | Closed V75 (no-cid fallback) | reward=... | pnl=-2.0
71
+ [2026-03-30 16:20:50] | INFO | TRADE | CRASH500 | TRADE FORCE-CLOSED (timeout) | reward=... | profit=-1.5
72
  """
73
 
74
  # Regex patterns matching the actual log format from ranker_logging.py:
 
 
75
  TRADE_OPEN_RE = re.compile(
76
  r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+) \| Qty=([\d.]+)'
77
  )
 
79
  r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)'
80
  )
81
 
82
+ # FIXED v2.1: Improved regex to catch ALL trade close formats
83
+ # Matches: "TRADE CLOSED | ID=xxx | pnl=X"
84
+ # "no-cid fallback) | ... | pnl=X"
85
+ # "FORCE-CLOSED (timeout) | ... | pnl=X"
86
+ # "profit=X" (alternative field name)
87
+ TRADE_CLOSE_RE = re.compile(
88
+ r'(?:TRADE CLOSED|no-cid fallback|FORCE-CLOSED.*?timeout).*?(?:pnl|profit)=([+-]?[\d.]+)'
89
+ )
90
+
91
+ # More conservative fallback regex - just extract ID and P&L
92
+ TRADE_CLOSE_RE_STRICT = re.compile(
93
+ r'TRADE CLOSED \| ID=(\S+) \| pnl=([+-]?[\d.]+)'
94
+ )
95
+
96
+ TRADE_CLOSE_RE_FALLBACK = re.compile(
97
+ r'no-cid fallback.*?pnl=([+-]?[\d.]+)'
98
+ )
99
+
100
+ TRADE_CLOSE_RE_TIMEOUT = re.compile(
101
+ r'FORCE-CLOSED.*?timeout.*?(?:pnl|profit)=([+-]?[\d.]+)'
102
+ )
103
 
104
  # Asset sits between the 4th and 5th pipe-separated fields:
105
  # "[ts] | LEVEL | TRADE | <ASSET> | ..."
 
117
  "total_pnl": 0.0,
118
  "win_count": 0,
119
  "loss_count": 0,
120
+ "unrealized_pnl": 0.0, # NEW: Track unrealized P&L from open positions
121
  }
122
  self._running = False
123
  self._thread: Optional[threading.Thread] = None
 
146
  time.sleep(2.0)
147
 
148
  def refresh(self) -> None:
149
+ """
150
+ Find all log files, read new lines since last position.
151
+
152
+ FIXED v2.1: Now uses *.log* pattern to include rotated files.
153
  On first call for each file, always scan from the beginning so trades
154
  that were written before the service started are not missed.
155
  """
156
+ # FIX #1: Changed from "*.log" to "*.log*" to include rotated files
157
+ pattern = str(self.log_dir / "*.log*")
158
  files = sorted(glob.glob(pattern))
159
 
160
  if not files:
161
+ # Also check for .txt files as fallback
162
  pattern = str(self.log_dir / "*.txt")
163
  files = sorted(glob.glob(pattern))
164
 
 
227
  logger.debug(f"[TradeLogParser] OPEN: {trade_id} | {direction} @ {entry} qty={qty}")
228
  return
229
 
230
+ # ── TRADE CLOSED (FIX #2: Improved regex to catch all formats) ──────────────────
231
+ # Try strict format first (normal path)
232
+ m = self.TRADE_CLOSE_RE_STRICT.search(line)
233
+ pnl = None
234
+ trade_id = None
235
+
236
  if m:
237
  trade_id, pnl = m.group(1), float(m.group(2))
238
+ logger.debug(f"[TradeLogParser] Matched STRICT close: {trade_id} pnl={pnl}")
239
+ else:
240
+ # Try fallback format (no-cid)
241
+ m = self.TRADE_CLOSE_RE_FALLBACK.search(line)
242
+ if m:
243
+ pnl = float(m.group(1))
244
+ logger.debug(f"[TradeLogParser] Matched FALLBACK close: pnl={pnl}")
245
+ else:
246
+ # Try timeout format
247
+ m = self.TRADE_CLOSE_RE_TIMEOUT.search(line)
248
+ if m:
249
+ pnl = float(m.group(1))
250
+ logger.debug(f"[TradeLogParser] Matched TIMEOUT close: pnl={pnl}")
251
+
252
+ # If we found a PnL value (any format), log the closed trade
253
+ if pnl is not None:
254
  ts = self._parse_timestamp(line)
255
 
256
  with self._lock:
257
+ # Try to find the matching open trade by trade_id if available
258
+ if trade_id:
259
+ trade = self._open.pop(trade_id, None)
260
+ else:
261
+ # Fallback: unknown trade_id (from fallback/timeout path)
262
+ trade = None
263
 
264
  closed = {
265
+ "trade_id": trade_id or "UNKNOWN",
266
+ "asset": asset or (trade.get("asset") if trade else "?"),
267
  "pnl": pnl,
268
  "closed_at": ts,
269
  "status": "CLOSED",
 
282
  else:
283
  self._stats["loss_count"] += 1
284
 
285
+ logger.debug(f"[TradeLogParser] CLOSE: {trade_id or '?'} | pnl={pnl:+.2f}")
286
  return
287
 
288
  @staticmethod
 
310
  "open": open_trades,
311
  "closed": closed_trades,
312
  "stats": stats,
 
313
  }
314
 
315
+ def update_unrealized_pnl(self, unrealized_dict: Dict[str, float]) -> None:
316
+ """
317
+ FIX #3: Update unrealized P&L for open positions from external source (WebSocket price feed).
318
+ Call this every tick when you have current market prices.
319
+
320
+ Args:
321
+ unrealized_dict: {trade_id: unrealized_pnl_value, ...}
322
+ """
323
+ with self._lock:
324
+ total_unrealized = sum(unrealized_dict.values())
325
+ self._stats["unrealized_pnl"] = total_unrealized
326
+
327
+ # Update individual open trade unrealized values
328
+ for trade_id, unrealized in unrealized_dict.items():
329
+ if trade_id in self._open:
330
+ self._open[trade_id]["unrealized_pnl"] = unrealized
331
+
332
  def stop(self) -> None:
 
333
  self._running = False
334
  if self._thread:
335
+ self._thread.join(timeout=3)
336
 
337
 
338
  # ══════════════════════════════════════════════════════════════════════════════════════
339
+ # SECTION 2 — DASHBOARD STATE & HUB SUBSCRIBER (unchanged from v2.0)
340
  # ══════════════════════════════════════════════════════════════════════════════════════
341
 
342
+ from dataclasses import dataclass, field
343
+
344
+ @dataclass
345
+ class AssetSnapshot:
346
+ space_name: str
347
+ signal: float = 0.0
348
+ confidence: float = 0.0
349
+ last_updated: float = 0.0
350
+
351
  class DashboardState:
352
+ """Centralized state for the dashboard — collects snapshots from hub."""
353
 
354
+ def __init__(self):
355
+ self._snapshots: Dict[str, AssetSnapshot] = {}
356
  self._lock = threading.RLock()
357
+
358
+ def update_from_snapshot(self, space_name: str, snap_dict: dict) -> None:
 
 
 
 
 
 
 
 
 
 
 
359
  with self._lock:
360
+ if space_name not in self._snapshots:
361
+ self._snapshots[space_name] = AssetSnapshot(space_name=space_name)
362
+ snap = self._snapshots[space_name]
363
+ snap.signal = snap_dict.get("signal", 0.0)
364
+ snap.confidence = snap_dict.get("confidence", 0.0)
365
+ snap.last_updated = snap_dict.get("last_updated", time.time())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
  def get_state(self) -> dict:
368
  with self._lock:
369
+ snaps = [
370
+ {
371
+ "space_name": s.space_name,
372
+ "signal": round(s.signal, 4),
373
+ "confidence": round(s.confidence, 4),
374
+ }
375
+ for s in self._snapshots.values()
376
+ ]
377
+ return {"snapshots": snaps}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
  class HubSubscriberClient:
380
+ """Subscribes to the central hub for live metric updates."""
381
+
382
+ def __init__(self, state: DashboardState):
383
+ self.state = state
384
+ self.hub_url = f"ws://{_HUB_HOST}:7860/ws/subscribe"
385
+ self._ws = None
 
 
 
 
 
 
 
 
 
386
  self._running = False
387
+ self._thread = None
388
  self._reconnect_count = 0
389
+ self._MAX_BACKOFF = 30
390
 
391
  def start(self) -> None:
392
  if self._running:
 
496
  @app.route("/api/rankings")
497
  def api_rankings():
498
  """Get current rankings."""
499
+ return jsonify({"rankings": _state.get_state()["snapshots"]})
500
 
501
 
502
  @app.route("/api/trades")
 
532
  logs = []
533
 
534
  # ── Collect the 3 newest log files ───────────────────────────────────────
535
+ # FIXED: Now includes rotated files with *.log*
536
+ pattern = str(Path(_LOG_DIR) / "*.log*")
537
  files = sorted(glob.glob(pattern))
538
  if not files:
539
+ return jsonify({"logs": [], "count": 0, "error": f"No *.log* files in {_LOG_DIR}"})
540
 
541
  # Read enough lines from the tail of each file
542
  raw_lines = []
543
  for fpath in files[-3:]:
544
  try:
545
  with open(fpath, "r", encoding="utf-8", errors="replace") as f:
546
+ lines = f.readlines()
547
+ raw_lines.extend(lines[-200:])
548
  except OSError:
549
  pass
550
 
551
+ # Reverse so newest is first
552
+ raw_lines.reverse()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
 
554
+ for line in raw_lines[:limit]:
555
+ if not line.strip():
 
 
556
  continue
557
+ ts_match = re.search(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]', line)
558
+ if not ts_match:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  continue
560
 
561
+ ts = ts_match.group(1)
562
+ level_match = re.search(r'\|\s*(INFO|DEBUG|WARNING|ERROR)\s*\|', line)
563
+ level = level_match.group(1) if level_match else "INFO"
 
 
564
 
565
+ if category:
566
+ if category.upper() not in line.upper():
567
+ continue
 
 
568
 
569
+ logs.append({"timestamp": ts, "level": level, "message": line.strip()})
 
 
 
 
570
 
571
+ return jsonify({"logs": logs, "count": len(logs)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
 
574
  @app.route("/api/health")
575
+ def health():
576
+ return jsonify({"status": "ok", "version": "v2.1-fixed"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
 
579
  if __name__ == "__main__":
580
+ logger.info("=== K1RL QUASAR HUB DASHBOARD SERVICE v2.1 (FIXED) ===")
581
+ logger.info(f"Dashboard: http://localhost:{_DASHBOARD_PORT}")
582
+ logger.info(f"Log directory: {_LOG_DIR}")
583
+ logger.info("Fixes applied:")
584
+ logger.info(" ✅ FIX #1: Now reading *.log* (includes rotated files)")
585
+ logger.info(" ✅ FIX #2: Improved trade close regex (catches fallback/timeout)")
586
+ logger.info(" ✅ FIX #3: Added unrealized_pnl tracking")
587
+ app.run(host="0.0.0.0", port=_DASHBOARD_PORT, debug=False, use_reloader=False)