KarlQuant commited on
Commit
888a130
·
verified ·
1 Parent(s): 151a3f1

Update hub_dashboard_service.py

Browse files
Files changed (1) hide show
  1. hub_dashboard_service.py +617 -175
hub_dashboard_service.py CHANGED
@@ -1,17 +1,18 @@
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
- ║ K1RL QUASAR — HUB DASHBOARD SERVICE (FIXED v2.5)
5
  ║ ────────────────────────────────────────────────────────────────────────────────── ║
6
  ║ Architecture role: READ-ONLY subscriber → serves dashboard UI ║
7
- ║ VERSION: v2.5 (FIXED Training Logs 404 + Category Filter) | 2026-04-04
8
  ║ ║
9
- CRITICAL FIXES IN v2.5:
10
- ║ ✅ FIX #5.1: Ensure log directory exists before _log_adapter initialization
11
- ║ ✅ FIX #5.2: Fix category filtering to use field match instead of substring
12
- ║ ✅ FIX #5.3: HTML now requests ?category=TRAINING for training logs
13
- ║ ✅ FIX #5.4: Validate Flask routes are registered before app.run()
14
- ║ ✅ FIX #5.5: Add explicit error logging for 404 debugging
 
15
  ╚══════════════════════════════════════════════════════════════════════════════════════╝
16
  """
17
 
@@ -51,39 +52,390 @@ _HTML_PATH = os.environ.get(
51
  _LOG_DIR = os.environ.get("RANKER_LOG_DIR", "/app/ranker_logs")
52
  _METRIC_HISTORY_LEN = int(os.environ.get("QUASAR_METRIC_HISTORY", "200"))
53
 
54
- # ╔═══════════════════════════════════════════════════════════════════════════════════╗
55
- # FIX v2.5.1: ENSURE LOG DIRECTORY EXISTS
56
- # ╚═══════════════════════════════════════════════════════════════════════════════════╝
57
- try:
58
- log_dir_path = Path(_LOG_DIR)
59
- log_dir_path.mkdir(parents=True, exist_ok=True)
60
- logger.info(f"[FIX v2.5.1] ✅ Log directory ensured: {_LOG_DIR}")
61
- except Exception as e:
62
- logger.error(f"[FIX v2.5.1] ❌ Failed to create log directory: {e}")
63
- sys.exit(1)
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- # [Previous SECTION 1 — TRADE LOG PARSER code remains unchanged...]
67
- # [Previous SECTION 2a — HUB SUBSCRIBER CLIENT code remains unchanged...]
68
- # [Previous SECTION 2b FILE-BASED LOGGER ADAPTER code with FIX below...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  class FileBasedLoggerAdapter:
71
  """
72
- Implements the RankerLogger interface expected by /api/ranker/logs/* routes.
73
- Reads from disk log files instead of an in-memory buffer.
74
-
75
- FIX v2.5.2: Category filtering now uses field match instead of substring match
76
  """
77
 
78
  # ── Shared compiled patterns ───────────────────────────────────────────────
79
  _CAT_RE = re.compile(r'\|\s*(INFO|DEBUG|WARNING|ERROR|CRITICAL)\s*\|\s*([A-Z_]+)\s*\|')
80
- _ASSET_RE = re.compile(r'\|\s*(?:TRADE|SIGNAL|TRAINING)\s*\|\s*(\w+)\s*\|')
81
  _TS_RE = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]')
82
 
83
  def __init__(self, log_dir: str = _LOG_DIR):
84
  self._log_dir = log_dir
85
  self._lock = threading.RLock()
86
- logger.info(f"[FileBasedLoggerAdapter] Initialized with log_dir={log_dir}")
 
 
 
 
87
 
88
  # ── Internal helpers ───────────────────────────────────────────────────────
89
 
@@ -97,9 +449,7 @@ class FileBasedLoggerAdapter:
97
  for cdir in candidate_dirs:
98
  found = sorted(glob.glob(str(Path(cdir) / "*.log*")))
99
  if found:
100
- logger.debug(f"[_find_files] Found {len(found)} log files in {cdir}")
101
  return found
102
- logger.warning(f"[_find_files] ⚠️ No log files found in any candidate directory")
103
  return []
104
 
105
  def _read_lines(self, n_tail: int = 500) -> list:
@@ -109,11 +459,8 @@ class FileBasedLoggerAdapter:
109
  for fpath in files[-3:]:
110
  try:
111
  with open(fpath, "r", encoding="utf-8", errors="replace") as f:
112
- lines = f.readlines()
113
- raw.extend(lines[-n_tail:])
114
- logger.debug(f"[_read_lines] Read {len(lines)} lines from {fpath}")
115
- except OSError as e:
116
- logger.error(f"[_read_lines] Error reading {fpath}: {e}")
117
  pass
118
  raw.reverse() # newest first
119
  return raw
@@ -127,9 +474,9 @@ class FileBasedLoggerAdapter:
127
  cat = cat_m.group(2).strip() if cat_m else ""
128
  ast_m = self._ASSET_RE.search(line)
129
  asset = ast_m.group(1) if ast_m else None
 
130
  return {
131
  "timestamp": ts_m.group(1),
132
- "ts": datetime.strptime(ts_m.group(1), "%Y-%m-%d %H:%M:%S").timestamp(),
133
  "level": level,
134
  "category": cat,
135
  "message": line.strip(),
@@ -140,27 +487,17 @@ class FileBasedLoggerAdapter:
140
  # ── RankerLogger interface ─────────────────────────────────────────────────
141
 
142
  def get_recent(self, n: int = 50, category: Optional[str] = None) -> list:
143
- """
144
- FIX v2.5.2: Changed category filtering from substring match to field match
145
- BEFORE: if category and category.upper() not in line.upper():
146
- AFTER: if category and entry.get("category", "").upper() != category.upper():
147
- """
148
  entries = []
149
  for line in self._read_lines(n_tail=max(n * 3, 200)):
150
  e = self._line_to_entry(line)
151
  if e is None:
152
  continue
153
-
154
- # FIX v2.5.2: Use field match instead of substring match
155
- if category:
156
- if e.get("category", "").upper() != category.upper():
157
- continue
158
-
159
  entries.append(e)
160
  if len(entries) >= n:
161
  break
162
-
163
- logger.debug(f"[get_recent] Returned {len(entries)} entries (category={category})")
164
  return entries
165
 
166
  def get_by_asset(self, asset: str, n: int = 30) -> list:
@@ -171,75 +508,212 @@ class FileBasedLoggerAdapter:
171
  e = self._line_to_entry(line)
172
  if e:
173
  entries.append(e)
174
- if len(entries) >= n:
175
- break
176
  return entries
177
 
178
  def get_by_level(self, level: str, n: int = 50) -> list:
179
  entries = []
180
  for line in self._read_lines(n_tail=500):
181
- if level.upper() not in line.upper():
182
- continue
183
  e = self._line_to_entry(line)
184
- if e:
185
  entries.append(e)
186
- if len(entries) >= n:
187
- break
188
  return entries
189
 
190
  def get_stats(self) -> dict:
191
- """Aggregate statistics from all logs."""
192
- stats = {
193
- "total_events": 0,
194
- "by_level": defaultdict(int),
195
- "by_category": defaultdict(int),
196
- "by_asset": defaultdict(int),
197
- "errors": defaultdict(int),
198
- }
199
- for line in self._read_lines(n_tail=1000):
200
  e = self._line_to_entry(line)
201
- if e:
202
- stats["total_events"] += 1
203
- stats["by_level"][e["level"]] += 1
204
- stats["by_category"][e["category"]] += 1
205
- if e["asset"]:
206
- stats["by_asset"][e["asset"]] += 1
207
- if e["level"] in ("ERROR", "CRITICAL"):
208
- stats["errors"][e["level"]] += 1
209
- return {k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in stats.items()}
210
-
211
- def export_json(self, filepath: str, n: int = 500) -> None:
212
- """Export recent logs as JSON."""
213
- entries = self.get_recent(n=n)
214
- with open(filepath, "w") as f:
215
- json.dump(entries, f, indent=2)
216
- logger.info(f"[export_json] Exported {len(entries)} entries to {filepath}")
 
 
217
 
218
- def clear_buffer(self) -> None:
219
- """No-op for file-based adapter."""
 
 
 
 
 
 
 
 
 
 
 
220
  pass
221
 
222
 
223
- # [Previous SECTION 3 — STATE MANAGEMENT code remains unchanged...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  # ══════════════════════════════════════════════════════════════════════════════════════
226
- # SECTION 4 — FLASK APP INITIALIZATION (FIX v2.5.4)
227
  # ══════════════════════════════════════════════════════════════════════════════════════
228
 
 
 
 
 
 
 
 
 
229
  app = Flask(__name__)
230
  CORS(app)
231
 
232
  # ── Instantiate the file-based log adapter (used by all /api/ranker/logs/* routes) ──
233
  _log_adapter = FileBasedLoggerAdapter(log_dir=_LOG_DIR)
234
- logger.info(f"[APP_INIT] Flask app created with CORS enabled")
235
- logger.info(f"[APP_INIT] FileBasedLoggerAdapter initialized")
236
 
237
  # ══════════════════════════════════════════════════════════════════════════════════════
238
- # SECTION 5 — RANKER LOG ROUTES (INLINENO BLUEPRINT DEPENDENCY)
239
  # ══════════════════════════════════════════════════════════════════════════════════════
240
  #
241
- # FIX v2.5: Routes are defined inline so service is fully self-contained.
242
- # All routes have explicit error logging for debugging (FIX v2.5.5).
 
 
 
243
 
244
  _TRAINING_RE_INLINE = re.compile(
245
  r'step=(\d+)\s*\|\s*loss=([\d.]+)\s*\|\s*lr=([\d.eE+\-]+)\s*\|\s*assets=(\d+)'
@@ -279,30 +753,21 @@ def _enrich_training(entry: dict) -> dict:
279
  return entry
280
 
281
 
282
- # ╔═══════════════════════════════════════════════════════════════════════════════════╗
283
- # FIX v2.5.5: ALL ROUTES HAVE EXPLICIT ERROR LOGGING FOR DEBUGGING
284
- # ╚═══════════════════════════════════════════════════════════════════════════════════╝
285
-
286
  @app.route("/api/ranker/logs/recent", methods=["GET"])
287
  def api_logs_recent():
288
  """GET /api/ranker/logs/recent?limit=50&category=TRAINING"""
289
  try:
290
  limit = int(request.args.get("limit", 50))
291
  category = request.args.get("category")
292
- logger.debug(f"[api_logs_recent] limit={limit}, category={category}")
293
-
294
  entries = _log_adapter.get_recent(n=limit, category=category)
295
  entries = [_enrich_training(e) for e in entries]
296
-
297
- response = {
298
  "logs": entries,
299
  "count": len(entries),
300
  "stats": _log_adapter.get_stats(),
301
- }
302
- logger.debug(f"[api_logs_recent] ✅ Returned {len(entries)} logs")
303
- return jsonify(response)
304
  except Exception as exc:
305
- logger.exception(f"[api_logs_recent] ❌ ERROR: {exc}")
306
  return jsonify({"logs": [], "count": 0, "error": str(exc)}), 200
307
 
308
 
@@ -310,11 +775,9 @@ def api_logs_recent():
310
  def api_logs_stats():
311
  """GET /api/ranker/logs/stats"""
312
  try:
313
- stats = _log_adapter.get_stats()
314
- logger.debug(f"[api_logs_stats] ✅ Returned stats")
315
- return jsonify(stats)
316
  except Exception as exc:
317
- logger.exception(f"[api_logs_stats] ❌ ERROR: {exc}")
318
  return jsonify({"total_events": 0, "by_level": {}, "by_category": {},
319
  "by_asset": {}, "errors": {}, "error": str(exc)}), 200
320
 
@@ -325,10 +788,9 @@ def api_logs_asset(asset: str):
325
  try:
326
  limit = int(request.args.get("limit", 30))
327
  entries = _log_adapter.get_by_asset(asset, n=limit)
328
- logger.debug(f"[api_logs_asset] asset={asset}, limit={limit}, returned {len(entries)}")
329
  return jsonify({"asset": asset, "logs": entries, "count": len(entries)})
330
  except Exception as exc:
331
- logger.exception(f"[api_logs_asset] ❌ ERROR: {exc}")
332
  return jsonify({"asset": asset, "logs": [], "count": 0, "error": str(exc)}), 200
333
 
334
 
@@ -338,10 +800,9 @@ def api_logs_level(level: str):
338
  try:
339
  limit = int(request.args.get("limit", 50))
340
  entries = _log_adapter.get_by_level(level, n=limit)
341
- logger.debug(f"[api_logs_level] level={level}, limit={limit}, returned {len(entries)}")
342
  return jsonify({"level": level.upper(), "logs": entries, "count": len(entries)})
343
  except Exception as exc:
344
- logger.exception(f"[api_logs_level] ❌ ERROR: {exc}")
345
  return jsonify({"level": level.upper(), "logs": [], "count": 0, "error": str(exc)}), 200
346
 
347
 
@@ -352,7 +813,6 @@ def api_logs_export():
352
  limit = int(request.args.get("limit", 500))
353
  export_path = Path("/tmp/ranker_logs_export.json")
354
  _log_adapter.export_json(str(export_path), n=limit)
355
- logger.info(f"[api_logs_export] ✅ Exported logs to {export_path}")
356
  return send_file(
357
  export_path,
358
  mimetype="application/json",
@@ -360,7 +820,7 @@ def api_logs_export():
360
  download_name="ranker_logs_export.json",
361
  )
362
  except Exception as exc:
363
- logger.exception(f"[api_logs_export] ❌ ERROR: {exc}")
364
  return jsonify({"error": str(exc)}), 500
365
 
366
 
@@ -369,54 +829,11 @@ def api_logs_clear():
369
  """POST /api/ranker/logs/clear — no-op for file-based adapter"""
370
  try:
371
  _log_adapter.clear_buffer()
372
- logger.info(f"[api_logs_clear] ✅ Cleared (no-op for file adapter)")
373
  return jsonify({"status": "cleared"})
374
  except Exception as exc:
375
- logger.exception(f"[api_logs_clear] ❌ ERROR: {exc}")
376
  return jsonify({"error": str(exc)}), 500
377
 
378
 
379
- # ╔═══════════════════════════════════════════════════════════════════════════════════╗
380
- # FIX v2.5.4: EXPLICIT ROUTE REGISTRATION VALIDATION
381
- # ╚═══════════════════════════════════════════════════════════════════════════════════╝
382
-
383
- def _validate_routes():
384
- """
385
- Verify all /api/ranker/logs/* routes are properly registered.
386
- Call this before app.run() to catch registration issues early.
387
- """
388
- required_routes = {
389
- "/api/ranker/logs/recent": "GET",
390
- "/api/ranker/logs/stats": "GET",
391
- "/api/ranker/logs/asset/<asset>": "GET",
392
- "/api/ranker/logs/level/<level>": "GET",
393
- "/api/ranker/logs/export": "GET",
394
- "/api/ranker/logs/clear": "POST",
395
- }
396
-
397
- registered_routes = {}
398
- for rule in app.url_map.iter_rules():
399
- registered_routes[rule.rule] = list(rule.methods - {"HEAD", "OPTIONS"})
400
-
401
- logger.info("[_validate_routes] Checking route registration...")
402
- all_registered = True
403
- for route, expected_method in required_routes.items():
404
- if route in registered_routes:
405
- methods = registered_routes[route]
406
- if expected_method in methods:
407
- logger.info(f" ✅ {route} [{expected_method}] is registered")
408
- else:
409
- logger.error(f" ❌ {route} exists but {expected_method} is NOT registered (has {methods})")
410
- all_registered = False
411
- else:
412
- logger.error(f" ❌ {route} is NOT registered at all!")
413
- all_registered = False
414
-
415
- if all_registered:
416
- logger.info("[_validate_routes] ✅ All routes validated successfully")
417
- else:
418
- logger.error("[_validate_routes] ❌ Some routes are missing or misconfigured!")
419
- sys.exit(1)
420
 
421
 
422
  @app.route("/")
@@ -432,32 +849,57 @@ def index():
432
  )
433
 
434
 
435
- @app.route("/api/health")
436
- def health():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  return jsonify({
438
- "status": "ok",
439
- "version": "v2.5-fixed",
440
- "log_dir": str(_LOG_DIR),
441
- "log_files_found": len(_log_adapter._find_files()),
442
  })
443
 
444
 
 
 
 
 
 
 
445
  if __name__ == "__main__":
446
- logger.info("╔═════════════════════════════════════════════════════════════════════╗")
447
- logger.info("║ K1RL QUASAR HUB DASHBOARD SERVICE v2.5 (FULLY FIXED) ║")
448
- logger.info("╚═════════════════════════════════════════════════════════════════════╝")
449
  logger.info(f"Dashboard: http://localhost:{_DASHBOARD_PORT}")
450
  logger.info(f"Log directory: {_LOG_DIR}")
451
- logger.info("")
452
- logger.info("✅ FIXES APPLIED IN v2.5:")
453
- logger.info(" ✅ FIX v2.5.1: Log directory ensured before adapter initialization")
454
- logger.info(" ✅ FIX v2.5.2: Category filtering fixed (field match vs substring)")
455
- logger.info(" ✅ FIX v2.5.3: HTML requests ?category=TRAINING parameter")
456
- logger.info(" ✅ FIX v2.5.4: Explicit route registration validation")
457
- logger.info(" ✅ FIX v2.5.5: All routes have explicit error logging")
458
- logger.info("")
459
-
460
- # FIX v2.5.4: Validate routes before starting
461
- _validate_routes()
462
-
463
  app.run(host="0.0.0.0", port=_DASHBOARD_PORT, debug=False, use_reloader=False)
 
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
+ ║ K1RL QUASAR — HUB DASHBOARD SERVICE (with Trade Log Parser) — FIXED v2.4
5
  ║ ────────────────────────────────────────────────────────────────────────────────── ║
6
  ║ Architecture role: READ-ONLY subscriber → serves dashboard UI ║
7
+ ║ VERSION: v2.4 (SELF-CONTAINED all /api/ranker/logs/* routes inline)
8
  ║ ║
9
+ ║ FIXES APPLIED:
10
+ ║ ✅ FIX v2.4: All /api/ranker/logs/* routes moved inline (Blueprint removed)
11
+ ║ ✅ FIX v2.4: Category filter uses exact field match (not substring)
12
+ ║ ✅ FIX v2.4: Log directory auto-created on startup if missing
13
+ ║ ✅ FIX #1: Include rotated log files (*.log, *.log.1, *.log.2, etc.)
14
+ ║ ✅ FIX #2: Improved regex to catch all trade close formats
15
+ ║ ✅ FIX #3: Added unrealized P&L tracking for open positions ║
16
  ╚══════════════════════════════════════════════════════════════════════════════════════╝
17
  """
18
 
 
52
  _LOG_DIR = os.environ.get("RANKER_LOG_DIR", "/app/ranker_logs")
53
  _METRIC_HISTORY_LEN = int(os.environ.get("QUASAR_METRIC_HISTORY", "200"))
54
 
 
 
 
 
 
 
 
 
 
 
55
 
56
+ # ══════════════════════════════════════════════════════════════════════════════════════
57
+ # SECTION 1 — TRADE LOG PARSER (FIXED v2.2)
58
+ # ══════════════════════════════════════════════════════════════════════════════════════
59
+
60
+ class TradeLogParser:
61
+ """
62
+ Tails ranker log files and maintains open/closed trade state.
63
+ Runs in a background thread, refreshing every 2 seconds.
64
+
65
+ FIXED v2.2:
66
+ ✅ FIX #1: Now reads *.log* pattern to include rotated files (.log.1, .log.2, etc.)
67
+ ✅ FIX #2: Improved regex to catch all trade close formats (normal, fallback, timeout)
68
+ ✅ FIX #3: Tracks unrealized P&L for open positions
69
+
70
+ Expected log format from ranker_logging.py:
71
+ [2026-03-30 16:20:40] | INFO | TRADE | CRASH500 | TRADE OPENED | ID=CRASH500_123 | Dir=long | Entry=3524.6485 | Qty=0.000284
72
+ [2026-03-30 16:20:39] | INFO | TRADE | CRASH500 | TRADE CLOSED | ID=CRASH500_456 | pnl=-3.5246 | return=+0.01%
73
+ [2026-03-30 16:20:45] | INFO | TRADE | V75 | Closed V75 (no-cid fallback) | reward=... | pnl=-2.0
74
+ [2026-03-30 16:20:50] | INFO | TRADE | CRASH500 | TRADE FORCE-CLOSED (timeout) | reward=... | profit=-1.5
75
+ """
76
+
77
+ # Regex patterns matching the actual log format from ranker_logging.py:
78
+ TRADE_OPEN_RE = re.compile(
79
+ r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+) \| Qty=([\d.]+)'
80
+ )
81
+ TRADE_OPEN_RE_NOQTY = re.compile(
82
+ r'TRADE OPENED \| ID=(\S+) \| Dir=(\w+) \| Entry=([\d.]+)'
83
+ )
84
+
85
+ # FIXED v2.2: Improved regex to catch ALL trade close formats
86
+ # Matches: "TRADE CLOSED | ID=xxx | pnl=X"
87
+ # "no-cid fallback) | ... | pnl=X"
88
+ # "FORCE-CLOSED (timeout) | ... | pnl=X"
89
+ # "profit=X" (alternative field name)
90
+ TRADE_CLOSE_RE = re.compile(
91
+ r'(?:TRADE CLOSED|no-cid fallback|FORCE-CLOSED.*?timeout).*?(?:pnl|profit)=([+-]?[\d.]+)'
92
+ )
93
+
94
+ # FIX v2.3: Dual-format regex for trade close lines that carry exit_price.
95
+ # Root cause (v2.2 bug): ranker_logging.trade_close() wrote exit_price ONLY
96
+ # into the trailing JSON metadata blob, e.g.:
97
+ # TRADE CLOSED | ID=... | pnl=... | return=...% | {"exit_price": 4364.21}
98
+ # but this regex looked for it as a pipe-delimited text field:
99
+ # TRADE CLOSED | ID=... | pnl=... | exit_price=... <- never present
100
+ # so TRADE_CLOSE_RE_WITH_EXIT never matched and exit_price was always None.
101
+ #
102
+ # Fix has TWO parts:
103
+ # 1. ranker_logging.py now writes exit_price into the message text too.
104
+ # 2. This regex matches BOTH formats so old log files still parse correctly:
105
+ # Group 3 - pipe-delimited text field (new format, post-fix)
106
+ # Group 4 - JSON metadata field (old format, pre-fix)
107
+ TRADE_CLOSE_RE_WITH_EXIT = re.compile(
108
+ r'TRADE CLOSED \| ID=(\S+) \| pnl=([+-]?[\d.]+)'
109
+ r'.*?(?:\| exit_price=([\d.]+)|"exit_price":\s*([\d.]+))'
110
+ )
111
+
112
+ # Fallback for Line 1: pnl + return%, no exit_price
113
+ TRADE_CLOSE_RE_STRICT = re.compile(
114
+ r'TRADE CLOSED \| ID=(\S+) \| pnl=([+-]?[\d.]+)'
115
+ )
116
+
117
+ TRADE_CLOSE_RE_FALLBACK = re.compile(
118
+ r'no-cid fallback.*?pnl=([+-]?[\d.]+)'
119
+ )
120
+
121
+ TRADE_CLOSE_RE_TIMEOUT = re.compile(
122
+ r'FORCE-CLOSED.*?timeout.*?(?:pnl|profit)=([+-]?[\d.]+)'
123
+ )
124
+
125
+ # Safety-net: Rotation close lines appear BEFORE the TRADE CLOSED lines and
126
+ # contain the underlying asset exit price. Format (from ranker log):
127
+ # [Rotation] 📤 Closing V75 — no longer in top-3 | price=33999.8690 | trade_id=V75_xxx
128
+ # We store this price on the open-trade record so it's available when the
129
+ # TRADE CLOSED line arrives. If Line 2 (exit_price=) also arrives, it takes
130
+ # precedence (same value, but more authoritative).
131
+ ROTATION_CLOSE_RE = re.compile(
132
+ r'\[Rotation\].*?Closing\b.*?\|\s*price=([\d.]+).*?\|\s*trade_id=(\S+)'
133
+ )
134
+
135
+ # Asset sits between the 4th and 5th pipe-separated fields:
136
+ # "[ts] | LEVEL | TRADE | <ASSET> | ..."
137
+ TRADE_ASSET_RE = re.compile(r'\|\s*TRADE\s*\|\s*(\w+)\s*\|')
138
+
139
+ def __init__(self, log_dir: str = _LOG_DIR):
140
+ self.log_dir = Path(log_dir)
141
+ self._open: Dict[str, dict] = {}
142
+ self._closed: List[dict] = []
143
+ self._last_pos: Dict[str, int] = {}
144
+ self._lock = threading.RLock()
145
+ self._stats = {
146
+ "total_opened": 0,
147
+ "total_closed": 0,
148
+ "total_pnl": 0.0,
149
+ "win_count": 0,
150
+ "loss_count": 0,
151
+ "unrealized_pnl": 0.0, # NEW: Track unrealized P&L from open positions
152
+ }
153
+ self._running = False
154
+ self._thread: Optional[threading.Thread] = None
155
+
156
+ # Create log directory if it doesn't exist
157
+ self.log_dir.mkdir(parents=True, exist_ok=True)
158
+ logger.info(f"[TradeLogParser] Initialized | log_dir={self.log_dir}")
159
+
160
+ def start_background(self, interval: float = 2.0) -> None:
161
+ """Launch a daemon thread that calls refresh() every `interval` seconds."""
162
+ if self._running:
163
+ return
164
+
165
+ self._running = True
166
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="TradeLogParser")
167
+ self._thread.start()
168
+ logger.info(f"[TradeLogParser] Started — watching {self.log_dir} (interval={interval}s)")
169
+
170
+ def _loop(self) -> None:
171
+ """Background loop."""
172
+ while self._running:
173
+ try:
174
+ self.refresh()
175
+ except Exception as e:
176
+ logger.error(f"[TradeLogParser] refresh error: {e}")
177
+ time.sleep(2.0)
178
 
179
+ def refresh(self) -> None:
180
+ """
181
+ Find all log files, read new lines since last position.
182
+
183
+ FIXED v2.2: Now uses *.log* pattern to include rotated files.
184
+ On first call for each file, always scan from the beginning so trades
185
+ that were written before the service started are not missed.
186
+ """
187
+ # FIX #1: Changed from "*.log" to "*.log*" to include rotated files
188
+ pattern = str(self.log_dir / "*.log*")
189
+ files = sorted(glob.glob(pattern))
190
+
191
+ if not files:
192
+ # Also check for .txt files as fallback
193
+ pattern = str(self.log_dir / "*.txt")
194
+ files = sorted(glob.glob(pattern))
195
+
196
+ for fpath in files:
197
+ self._tail_file(fpath)
198
+
199
+ def _tail_file(self, fpath: str) -> None:
200
+ """Read only new bytes from fpath since last call.
201
+ First encounter: start from byte 0 (full scan) so pre-existing trades are loaded."""
202
+ try:
203
+ size = os.path.getsize(fpath)
204
+ except OSError:
205
+ return
206
+
207
+ # Use 0 as default so a file seen for the first time is fully scanned
208
+ last = self._last_pos.get(fpath, 0)
209
+ if size <= last:
210
+ return
211
+
212
+ try:
213
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
214
+ f.seek(last)
215
+ new_lines = f.readlines()
216
+ self._last_pos[fpath] = f.tell()
217
+ except OSError:
218
+ return
219
+
220
+ for line in new_lines:
221
+ self._parse_line(line)
222
+
223
+ def _parse_line(self, line: str) -> None:
224
+ """Extract trade events from a single log line."""
225
+
226
+ # Extract asset from the line (if present)
227
+ asset_match = self.TRADE_ASSET_RE.search(line)
228
+ asset = asset_match.group(1) if asset_match else None
229
+
230
+ # ── TRADE OPENED ─────────────────────────────────────────────────────────
231
+ m = self.TRADE_OPEN_RE.search(line)
232
+ if m:
233
+ trade_id, direction, entry, qty = m.group(1), m.group(2), float(m.group(3)), float(m.group(4))
234
+ else:
235
+ m2 = self.TRADE_OPEN_RE_NOQTY.search(line)
236
+ if m2:
237
+ trade_id, direction, entry, qty = m2.group(1), m2.group(2), float(m2.group(3)), 0.0
238
+ else:
239
+ m2 = None
240
+ m = m2 # unify the branch below
241
+
242
+ if m:
243
+ direction = direction.upper()
244
+ ts = self._parse_timestamp(line)
245
+
246
+ with self._lock:
247
+ self._open[trade_id] = {
248
+ "trade_id": trade_id,
249
+ "asset": asset or trade_id.split('_')[0],
250
+ "direction": direction,
251
+ "entry": entry,
252
+ "qty": qty,
253
+ "opened_at": ts,
254
+ "status": "OPEN",
255
+ }
256
+ self._stats["total_opened"] += 1
257
+
258
+ logger.debug(f"[TradeLogParser] OPEN: {trade_id} | {direction} @ {entry} qty={qty}")
259
+ return
260
+
261
+ # ── TRADE CLOSED ─────────────────────────────────────────────────────────
262
+ # Try Line-2 format first (has exit_price — dedicated regex, no ambiguity)
263
+ m2e = self.TRADE_CLOSE_RE_WITH_EXIT.search(line)
264
+ pnl = None
265
+ trade_id = None
266
+ _exit_price = None
267
+
268
+ if m2e:
269
+ trade_id = m2e.group(1)
270
+ pnl = float(m2e.group(2))
271
+ # Group 3 = pipe-delimited "| exit_price=..." (new format, post-fix)
272
+ # Group 4 = JSON metadata '"exit_price": ...' (old format, pre-fix)
273
+ _exit_price = float(m2e.group(3) or m2e.group(4))
274
+ logger.debug(
275
+ f"[TradeLogParser] Matched CLOSE+EXIT: {trade_id} "
276
+ f"pnl={pnl} exit_price={_exit_price}"
277
+ )
278
+ else:
279
+ # Try Line-1 format (pnl + return%, no exit_price)
280
+ m = self.TRADE_CLOSE_RE_STRICT.search(line)
281
+ if m:
282
+ trade_id = m.group(1)
283
+ pnl = float(m.group(2))
284
+ logger.debug(f"[TradeLogParser] Matched CLOSE(no exit): {trade_id} pnl={pnl}")
285
+ else:
286
+ # Try fallback format (no-cid)
287
+ m = self.TRADE_CLOSE_RE_FALLBACK.search(line)
288
+ if m:
289
+ pnl = float(m.group(1))
290
+ logger.debug(f"[TradeLogParser] Matched FALLBACK close: pnl={pnl}")
291
+ else:
292
+ # Try timeout format
293
+ m = self.TRADE_CLOSE_RE_TIMEOUT.search(line)
294
+ if m:
295
+ pnl = float(m.group(1))
296
+ logger.debug(f"[TradeLogParser] Matched TIMEOUT close: pnl={pnl}")
297
+
298
+ # If we found a PnL value (any format), log the closed trade
299
+ # _exit_price is set in every branch above that sets pnl; default None otherwise
300
+ if pnl is not None:
301
+ ts = self._parse_timestamp(line)
302
+
303
+ with self._lock:
304
+ # FIX 4: The bot emits TWO "TRADE CLOSED" lines per close event:
305
+ # Line 1 — has pnl + return but NO exit_price (matched first)
306
+ # Line 2 — has pnl + exit_price + status + contract_id
307
+ # Previously Line 1 created the closed record (exit_price=None) and
308
+ # Line 2 created a duplicate with exit_price but no direction/entry.
309
+ # Fix: if a closed record with the same trade_id already exists,
310
+ # just patch its exit_price in-place and skip re-appending.
311
+ if trade_id:
312
+ existing_idx = next(
313
+ (i for i, t in enumerate(self._closed)
314
+ if t.get("trade_id") == trade_id),
315
+ None
316
+ )
317
+ if existing_idx is not None:
318
+ # Second log line for the same close — update exit_price if we
319
+ # now have it, then stop (don't double-count stats).
320
+ if _exit_price is not None:
321
+ self._closed[existing_idx]["exit_price"] = _exit_price
322
+ logger.debug(
323
+ f"[TradeLogParser] CLOSE patch exit_price: "
324
+ f"{trade_id} exit_price={_exit_price}"
325
+ )
326
+ return
327
+
328
+ # Try to find the matching open trade by trade_id if available
329
+ if trade_id:
330
+ trade = self._open.pop(trade_id, None)
331
+ else:
332
+ # Fallback: unknown trade_id (from fallback/timeout path)
333
+ trade = None
334
+
335
+ closed = {
336
+ "trade_id": trade_id or "UNKNOWN",
337
+ "asset": asset or (trade.get("asset") if trade else "?"),
338
+ "pnl": pnl,
339
+ "closed_at": ts,
340
+ "status": "CLOSED",
341
+ "exit_price": _exit_price,
342
+ }
343
+
344
+ if trade:
345
+ closed["direction"] = trade.get("direction", "?")
346
+ closed["entry"] = trade.get("entry", 0.0)
347
+
348
+ self._closed.append(closed)
349
+ self._stats["total_closed"] += 1
350
+ self._stats["total_pnl"] += pnl
351
+
352
+ if pnl >= 0:
353
+ self._stats["win_count"] += 1
354
+ else:
355
+ self._stats["loss_count"] += 1
356
+
357
+ logger.debug(f"[TradeLogParser] CLOSE: {trade_id or '?'} | pnl={pnl:+.2f}")
358
+ return
359
+
360
+ @staticmethod
361
+ def _parse_timestamp(line: str) -> str:
362
+ """Extract ISO timestamp from log line prefix."""
363
+ # Format: [2026-03-30 16:20:40] | ...
364
+ match = re.search(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]', line)
365
+ if match:
366
+ return match.group(1).replace(" ", "T")
367
+ return datetime.utcnow().isoformat()[:19]
368
+
369
+ def get_state(self) -> dict:
370
+ """Return current trade state."""
371
+ with self._lock:
372
+ open_trades = list(self._open.values())
373
+ closed_trades = list(reversed(self._closed[-100:])) # newest first
374
+
375
+ stats = dict(self._stats)
376
+ stats["win_rate"] = (
377
+ round(stats["win_count"] / stats["total_closed"] * 100, 1)
378
+ if stats["total_closed"] > 0 else 0.0
379
+ )
380
+
381
+ return {
382
+ "open": open_trades,
383
+ "closed": closed_trades,
384
+ "stats": stats,
385
+ }
386
+
387
+ def update_unrealized_pnl(self, unrealized_dict: Dict[str, float]) -> None:
388
+ """
389
+ FIX #3: Update unrealized P&L for open positions from external source (WebSocket price feed).
390
+ Call this every tick when you have current market prices.
391
+
392
+ Args:
393
+ unrealized_dict: {trade_id: unrealized_pnl_value, ...}
394
+ """
395
+ with self._lock:
396
+ total_unrealized = sum(unrealized_dict.values())
397
+ self._stats["unrealized_pnl"] = total_unrealized
398
+
399
+ # Update individual open trade unrealized values
400
+ for trade_id, unrealized in unrealized_dict.items():
401
+ if trade_id in self._open:
402
+ self._open[trade_id]["unrealized_pnl"] = unrealized
403
+
404
+ def stop(self) -> None:
405
+ self._running = False
406
+ if self._thread:
407
+ self._thread.join(timeout=3)
408
+
409
+
410
+ # ══════════════════════════════════════════════════════════════════════════════════════
411
+ # SECTION 2b — FILE-BASED LOGGER ADAPTER
412
+ # ═══════════════════════════════════════════════════════════════════════════════���══════
413
+ # The ranker_logs_api Blueprint expects a RankerLogger-style object with get_recent(),
414
+ # get_by_asset(), get_by_level(), get_stats(), export_json(), and clear_buffer().
415
+ # This adapter satisfies that interface by reading from the same log FILES that the
416
+ # TradeLogParser uses — no in-memory ranker process required in the dashboard service.
417
 
418
  class FileBasedLoggerAdapter:
419
  """
420
+ Implements the RankerLogger interface expected by ranker_logs_api.py Blueprint,
421
+ but reads from disk log files instead of an in-memory buffer.
422
+ This lets the dashboard service power ALL Blueprint endpoints without needing a
423
+ live RankerLogger instance.
424
  """
425
 
426
  # ── Shared compiled patterns ───────────────────────────────────────────────
427
  _CAT_RE = re.compile(r'\|\s*(INFO|DEBUG|WARNING|ERROR|CRITICAL)\s*\|\s*([A-Z_]+)\s*\|')
428
+ _ASSET_RE = re.compile(r'\|\s*(?:TRADE|SIGNAL)\s*\|\s*(\w+)\s*\|')
429
  _TS_RE = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]')
430
 
431
  def __init__(self, log_dir: str = _LOG_DIR):
432
  self._log_dir = log_dir
433
  self._lock = threading.RLock()
434
+ # FIX v2.4 Bug#3: Auto-create log directory so ranker can start writing without a manual mkdir
435
+ try:
436
+ Path(self._log_dir).mkdir(parents=True, exist_ok=True)
437
+ except OSError as _mkdir_err:
438
+ logger.warning(f"[FileBasedLoggerAdapter] Could not create log dir {self._log_dir}: {_mkdir_err}")
439
 
440
  # ── Internal helpers ───────────────────────────────────────────────────────
441
 
 
449
  for cdir in candidate_dirs:
450
  found = sorted(glob.glob(str(Path(cdir) / "*.log*")))
451
  if found:
 
452
  return found
 
453
  return []
454
 
455
  def _read_lines(self, n_tail: int = 500) -> list:
 
459
  for fpath in files[-3:]:
460
  try:
461
  with open(fpath, "r", encoding="utf-8", errors="replace") as f:
462
+ raw.extend(f.readlines()[-n_tail:])
463
+ except OSError:
 
 
 
464
  pass
465
  raw.reverse() # newest first
466
  return raw
 
474
  cat = cat_m.group(2).strip() if cat_m else ""
475
  ast_m = self._ASSET_RE.search(line)
476
  asset = ast_m.group(1) if ast_m else None
477
+ # Build a minimal dict compatible with what the Blueprint's callers expect.
478
  return {
479
  "timestamp": ts_m.group(1),
 
480
  "level": level,
481
  "category": cat,
482
  "message": line.strip(),
 
487
  # ── RankerLogger interface ─────────────────────────────────────────────────
488
 
489
  def get_recent(self, n: int = 50, category: Optional[str] = None) -> list:
 
 
 
 
 
490
  entries = []
491
  for line in self._read_lines(n_tail=max(n * 3, 200)):
492
  e = self._line_to_entry(line)
493
  if e is None:
494
  continue
495
+ # FIX v2.4 Bug#4: Use exact category field match, not substring search on raw line
496
+ if category and e.get("category", "").upper() != category.upper():
497
+ continue
 
 
 
498
  entries.append(e)
499
  if len(entries) >= n:
500
  break
 
 
501
  return entries
502
 
503
  def get_by_asset(self, asset: str, n: int = 30) -> list:
 
508
  e = self._line_to_entry(line)
509
  if e:
510
  entries.append(e)
511
+ if len(entries) >= n:
512
+ break
513
  return entries
514
 
515
  def get_by_level(self, level: str, n: int = 50) -> list:
516
  entries = []
517
  for line in self._read_lines(n_tail=500):
 
 
518
  e = self._line_to_entry(line)
519
+ if e and e["level"].upper() == level.upper():
520
  entries.append(e)
521
+ if len(entries) >= n:
522
+ break
523
  return entries
524
 
525
  def get_stats(self) -> dict:
526
+ by_category: dict = {}
527
+ by_level: dict = {}
528
+ by_asset: dict = {}
529
+ errors: dict = {}
530
+ total = 0
531
+ for line in self._read_lines(n_tail=2000):
 
 
 
532
  e = self._line_to_entry(line)
533
+ if not e:
534
+ continue
535
+ total += 1
536
+ by_level[e["level"]] = by_level.get(e["level"], 0) + 1
537
+ by_category[e["category"]] = by_category.get(e["category"], 0) + 1
538
+ if e["asset"]:
539
+ by_asset[e["asset"]] = by_asset.get(e["asset"], 0) + 1
540
+ if e["level"] in ("ERROR", "CRITICAL"):
541
+ errors[e["category"]] = errors.get(e["category"], 0) + 1
542
+ return {
543
+ "total_events": total,
544
+ "by_level": by_level,
545
+ "by_category": by_category,
546
+ "by_asset": by_asset,
547
+ "errors": errors,
548
+ "buffer_size": total,
549
+ "buffer_capacity": total,
550
+ }
551
 
552
+ def export_json(self, filepath: str, n: int = 500):
553
+ import json as _json
554
+ entries = self.get_recent(n)
555
+ with open(filepath, "w") as f:
556
+ _json.dump({
557
+ "export_time": datetime.utcnow().isoformat(),
558
+ "count": len(entries),
559
+ "logs": entries,
560
+ }, f, indent=2)
561
+
562
+ def clear_buffer(self):
563
+ # File-based adapter has no in-memory buffer to clear.
564
+ # No-op — files are managed by the ranker process itself.
565
  pass
566
 
567
 
568
+
569
+
570
+ from dataclasses import dataclass, field
571
+
572
+ @dataclass
573
+ class AssetSnapshot:
574
+ space_name: str
575
+ signal: float = 0.0
576
+ confidence: float = 0.0
577
+ last_updated: float = 0.0
578
+
579
+ class DashboardState:
580
+ """Centralized state for the dashboard — collects snapshots from hub."""
581
+
582
+ def __init__(self):
583
+ self._snapshots: Dict[str, AssetSnapshot] = {}
584
+ self._lock = threading.RLock()
585
+
586
+ def update_from_snapshot(self, space_name: str, snap_dict: dict) -> None:
587
+ with self._lock:
588
+ if space_name not in self._snapshots:
589
+ self._snapshots[space_name] = AssetSnapshot(space_name=space_name)
590
+ snap = self._snapshots[space_name]
591
+ snap.signal = snap_dict.get("signal", 0.0)
592
+ snap.confidence = snap_dict.get("confidence", 0.0)
593
+ snap.last_updated = snap_dict.get("last_updated", time.time())
594
+
595
+ def get_state(self) -> dict:
596
+ with self._lock:
597
+ snaps = [
598
+ {
599
+ "space_name": s.space_name,
600
+ "signal": round(s.signal, 4),
601
+ "confidence": round(s.confidence, 4),
602
+ }
603
+ for s in self._snapshots.values()
604
+ ]
605
+ return {"snapshots": snaps}
606
+
607
+ class HubSubscriberClient:
608
+ """Subscribes to the central hub for live metric updates."""
609
+
610
+ def __init__(self, state: DashboardState):
611
+ self.state = state
612
+ self.hub_url = f"ws://{_HUB_HOST}:7860/ws/subscribe"
613
+ self._ws = None
614
+ self._running = False
615
+ self._thread = None
616
+ self._reconnect_count = 0
617
+ self._MAX_BACKOFF = 30
618
+
619
+ def start(self) -> None:
620
+ if self._running:
621
+ return
622
+ self._running = True
623
+ self._thread = threading.Thread(
624
+ target=self._run_loop, daemon=True, name="HubSubscriberClient"
625
+ )
626
+ self._thread.start()
627
+ logger.info(f"[HubSubscriberClient] Starting → {self.hub_url}")
628
+
629
+ def stop(self) -> None:
630
+ self._running = False
631
+ if self._ws:
632
+ try:
633
+ self._ws.close()
634
+ except Exception:
635
+ pass
636
+ if self._thread:
637
+ self._thread.join(timeout=3)
638
+
639
+ def _run_loop(self) -> None:
640
+ while self._running:
641
+ try:
642
+ self._connect_and_run()
643
+ except Exception as e:
644
+ logger.error(f"[HubSubscriberClient] error: {e}")
645
+ if not self._running:
646
+ break
647
+ backoff = min(self._MAX_BACKOFF, 2 ** min(self._reconnect_count, 4))
648
+ logger.info(
649
+ f"[HubSubscriberClient] reconnect in {backoff}s "
650
+ f"(attempt #{self._reconnect_count + 1})"
651
+ )
652
+ time.sleep(backoff)
653
+ self._reconnect_count += 1
654
+
655
+ def _connect_and_run(self) -> None:
656
+ self._ws = websocket.WebSocketApp(
657
+ self.hub_url,
658
+ on_message = self._on_message,
659
+ on_open = lambda ws: logger.info("[HubSubscriberClient] ✅ Connected"),
660
+ on_error = lambda ws, e: logger.warning(f"[HubSubscriberClient] WS error: {e}"),
661
+ on_close = lambda ws, c, m: logger.info(f"[HubSubscriberClient] closed code={c}"),
662
+ )
663
+ self._ws.run_forever(
664
+ ping_interval = 30,
665
+ ping_timeout = 10,
666
+ reconnect = 0,
667
+ )
668
+
669
+ def _on_message(self, ws, raw: str) -> None:
670
+ try:
671
+ msg = json.loads(raw)
672
+ kind = msg.get("type", "")
673
+
674
+ if kind == "metrics_update":
675
+ space = msg.get("space_name", "")
676
+ snapshot = msg.get("snapshot", {})
677
+ if space and snapshot:
678
+ self.state.update_from_snapshot(space, snapshot)
679
+
680
+ elif kind == "initial_state":
681
+ for space, snapshot in msg.get("snapshots", {}).items():
682
+ if space and snapshot:
683
+ self.state.update_from_snapshot(space, snapshot)
684
+
685
+ except Exception as e:
686
+ logger.debug(f"[HubSubscriberClient] parse error: {e}")
687
+
688
 
689
  # ══════════════════════════════════════════════════════════════════════════════════════
690
+ # SECTION 3 — FLASK APP
691
  # ══════════════════════════════════════════════════════════════════════════════════════
692
 
693
+ _state = DashboardState()
694
+ _trade_parser = TradeLogParser(log_dir=_LOG_DIR)
695
+ _trade_parser.start_background()
696
+
697
+ # Start hub subscriber so _state stays in sync with live metrics
698
+ _hub_subscriber = HubSubscriberClient(state=_state)
699
+ _hub_subscriber.start()
700
+
701
  app = Flask(__name__)
702
  CORS(app)
703
 
704
  # ── Instantiate the file-based log adapter (used by all /api/ranker/logs/* routes) ──
705
  _log_adapter = FileBasedLoggerAdapter(log_dir=_LOG_DIR)
706
+
 
707
 
708
  # ══════════════════════════════════════════════════════════════════════════════════════
709
+ # SECTION 4 — RANKER LOG ROUTES (self-containedno Blueprint dependency)
710
  # ══════════════════════════════════════════════════════════════════════════════════════
711
  #
712
+ # FIX v2.4: These routes were previously delegated to ranker_logs_api.py Blueprint.
713
+ # That Blueprint was never registered, so every /api/ranker/logs/* call returned 404.
714
+ # Routes are now defined inline so hub_dashboard_service.py is fully self-contained.
715
+ # FileBasedLoggerAdapter (above) satisfies the full RankerLogger interface by reading
716
+ # the ranker's disk log files — no in-process ranker instance required.
717
 
718
  _TRAINING_RE_INLINE = re.compile(
719
  r'step=(\d+)\s*\|\s*loss=([\d.]+)\s*\|\s*lr=([\d.eE+\-]+)\s*\|\s*assets=(\d+)'
 
753
  return entry
754
 
755
 
 
 
 
 
756
  @app.route("/api/ranker/logs/recent", methods=["GET"])
757
  def api_logs_recent():
758
  """GET /api/ranker/logs/recent?limit=50&category=TRAINING"""
759
  try:
760
  limit = int(request.args.get("limit", 50))
761
  category = request.args.get("category")
 
 
762
  entries = _log_adapter.get_recent(n=limit, category=category)
763
  entries = [_enrich_training(e) for e in entries]
764
+ return jsonify({
 
765
  "logs": entries,
766
  "count": len(entries),
767
  "stats": _log_adapter.get_stats(),
768
+ })
 
 
769
  except Exception as exc:
770
+ logger.exception(f"[api_logs_recent] error: {exc}")
771
  return jsonify({"logs": [], "count": 0, "error": str(exc)}), 200
772
 
773
 
 
775
  def api_logs_stats():
776
  """GET /api/ranker/logs/stats"""
777
  try:
778
+ return jsonify(_log_adapter.get_stats())
 
 
779
  except Exception as exc:
780
+ logger.exception(f"[api_logs_stats] error: {exc}")
781
  return jsonify({"total_events": 0, "by_level": {}, "by_category": {},
782
  "by_asset": {}, "errors": {}, "error": str(exc)}), 200
783
 
 
788
  try:
789
  limit = int(request.args.get("limit", 30))
790
  entries = _log_adapter.get_by_asset(asset, n=limit)
 
791
  return jsonify({"asset": asset, "logs": entries, "count": len(entries)})
792
  except Exception as exc:
793
+ logger.exception(f"[api_logs_asset] error: {exc}")
794
  return jsonify({"asset": asset, "logs": [], "count": 0, "error": str(exc)}), 200
795
 
796
 
 
800
  try:
801
  limit = int(request.args.get("limit", 50))
802
  entries = _log_adapter.get_by_level(level, n=limit)
 
803
  return jsonify({"level": level.upper(), "logs": entries, "count": len(entries)})
804
  except Exception as exc:
805
+ logger.exception(f"[api_logs_level] error: {exc}")
806
  return jsonify({"level": level.upper(), "logs": [], "count": 0, "error": str(exc)}), 200
807
 
808
 
 
813
  limit = int(request.args.get("limit", 500))
814
  export_path = Path("/tmp/ranker_logs_export.json")
815
  _log_adapter.export_json(str(export_path), n=limit)
 
816
  return send_file(
817
  export_path,
818
  mimetype="application/json",
 
820
  download_name="ranker_logs_export.json",
821
  )
822
  except Exception as exc:
823
+ logger.exception(f"[api_logs_export] error: {exc}")
824
  return jsonify({"error": str(exc)}), 500
825
 
826
 
 
829
  """POST /api/ranker/logs/clear — no-op for file-based adapter"""
830
  try:
831
  _log_adapter.clear_buffer()
 
832
  return jsonify({"status": "cleared"})
833
  except Exception as exc:
 
834
  return jsonify({"error": str(exc)}), 500
835
 
836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
 
838
 
839
  @app.route("/")
 
849
  )
850
 
851
 
852
+ @app.route("/api/state")
853
+ def api_state():
854
+ """Full dashboard state — polled by hub_dashboard.html every 2 s."""
855
+ return jsonify(_state.get_state())
856
+
857
+
858
+ @app.route("/api/rankings")
859
+ def api_rankings():
860
+ """Get current rankings."""
861
+ return jsonify({"rankings": _state.get_state()["snapshots"]})
862
+
863
+
864
+ @app.route("/api/trades")
865
+ def api_trades():
866
+ """Returns open trades, recent closed trades, and summary stats."""
867
+ return jsonify(_trade_parser.get_state())
868
+
869
+
870
+ @app.route("/api/trades/open")
871
+ def api_trades_open():
872
+ """Get only open trades."""
873
+ state = _trade_parser.get_state()
874
+ return jsonify({"open": state["open"]})
875
+
876
+
877
+ @app.route("/api/trades/closed")
878
+ def api_trades_closed():
879
+ """Get closed trades and stats."""
880
+ limit = int(request.args.get("limit", 50))
881
+ state = _trade_parser.get_state()
882
  return jsonify({
883
+ "closed": state["closed"][:limit],
884
+ "stats": state["stats"]
 
 
885
  })
886
 
887
 
888
+
889
+ @app.route("/api/health")
890
+ def health():
891
+ return jsonify({"status": "ok", "version": "v2.4-fixed"})
892
+
893
+
894
  if __name__ == "__main__":
895
+ logger.info("=== K1RL QUASAR HUB DASHBOARD SERVICE v2.4 (SELF-CONTAINED) ===")
 
 
896
  logger.info(f"Dashboard: http://localhost:{_DASHBOARD_PORT}")
897
  logger.info(f"Log directory: {_LOG_DIR}")
898
+ logger.info("Fixes applied:")
899
+ logger.info(" FIX v2.4: All /api/ranker/logs/* routes inline — no Blueprint dependency")
900
+ logger.info(" ✅ FIX v2.4: Training KPI enrichment (_enrich_training) applied on /recent")
901
+ logger.info(" ✅ FIX #1: Now reading *.log* (includes rotated files)")
902
+ logger.info(" ✅ FIX #2: Improved regex to catch all trade close formats")
903
+ logger.info(" ✅ FIX #4: Dedicated exit_price regex (no optional-group ambiguity)")
904
+
 
 
 
 
 
905
  app.run(host="0.0.0.0", port=_DASHBOARD_PORT, debug=False, use_reloader=False)