KarlQuant commited on
Commit
fd32a74
Β·
verified Β·
1 Parent(s): 7a9897e

Upload hub_dashboard_service.py.py

Browse files
Files changed (1) hide show
  1. hub_dashboard_service.py.py +449 -0
hub_dashboard_service.py.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
31
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
32
+ datefmt="%Y-%m-%d %H:%M:%S",
33
+ stream=sys.stdout,
34
+ )
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:
129
+ return
130
+
131
+ last = self._last_pos.get(fpath, 0)
132
+ if size <= last:
133
+ return
134
+
135
+ try:
136
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
137
+ f.seek(last)
138
+ new_lines = f.readlines()
139
+ self._last_pos[fpath] = f.tell()
140
+ except OSError:
141
+ return
142
+
143
+ for line in new_lines:
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
+ # ══════════════════════════════════════════════════════════════════════════════════════
287
+ # SECTION 3 β€” FLASK APP
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)
304
+ return (
305
+ "<h1>hub_dashboard.html not found</h1>"
306
+ f"<p>Expected: <code>{_HTML_PATH}</code></p>",
307
+ 404,
308
+ )
309
+
310
+
311
+ @app.route("/api/state")
312
+ def api_state():
313
+ """Full dashboard state β€” polled by hub_dashboard.html every 2 s."""
314
+ return jsonify(_state.get_state())
315
+
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()