KarlQuant commited on
Commit
31b2a44
Β·
verified Β·
1 Parent(s): 44a0823

Update websocket_hub.py

Browse files
Files changed (1) hide show
  1. websocket_hub.py +288 -7
websocket_hub.py CHANGED
@@ -1,7 +1,7 @@
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
- β•‘ K1RL QUASAR β€” CENTRAL WEBSOCKET HUB v2.1-integrated β•‘
5
  β•‘ ────────────────────────────────────────────────────────────────────────────────── β•‘
6
  β•‘ β•‘
7
  β•‘ Architecture role: INGEST β†’ NORMALIZE β†’ BROADCAST β•‘
@@ -12,9 +12,13 @@
12
  β•‘ β€’ Hub NEVER writes back to publishers β•‘
13
  β•‘ β€’ Hub stores latest snapshot per asset (NO history) β•‘
14
  β•‘ β•‘
15
- β•‘ STRICT DATA MODEL (training + voting fields ONLY): β•‘
16
- β•‘ training: training_steps, actor_loss, critic_loss, avn_loss, avn_accuracy β•‘
17
- β•‘ voting: dominant_signal, buy_count, sell_count β•‘
 
 
 
 
18
  β•‘ β•‘
19
  β•‘ TRADE API (served natively β€” no patch script needed): β•‘
20
  β•‘ GET /api/trades β†’ full open + closed state + stats β•‘
@@ -22,7 +26,7 @@
22
  β•‘ GET /api/trades/closed β†’ recent closed trades + stats (?limit=N) β•‘
23
  β•‘ GET /api/health β†’ service health including trade counts β•‘
24
  β•‘ β•‘
25
- β•‘ VERSION: v2.1-integrated | 2026-03-31 β•‘
26
  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
27
  """
28
 
@@ -32,6 +36,8 @@ import glob
32
  import json
33
  import logging
34
  import os
 
 
35
  import time
36
  from collections import deque
37
  from datetime import datetime
@@ -473,7 +479,7 @@ except Exception:
473
  app = FastAPI(
474
  title="K1RL QUASAR Hub",
475
  description="Central WebSocket hub β€” ingest, normalize, broadcast (one-way)",
476
- version="2.1.0",
477
  )
478
  app.add_middleware(
479
  CORSMiddleware,
@@ -701,7 +707,7 @@ async def api_health():
701
  state = _trade_parser.get_state()
702
  return JSONResponse({
703
  "service": "websocket_hub",
704
- "version": "v2.1-integrated",
705
  "status": "running",
706
  "log_files": len(glob.glob(os.path.join(_LOG_DIR, "*.log"))),
707
  "trade_open": len(state["open"]),
@@ -710,6 +716,281 @@ async def api_health():
710
  })
711
 
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  # ══════════════════════════════════════════════════════════════════════════════════════
714
  # SECTION 8 β€” DASHBOARD UI ROUTES
715
  # ══════════════════════════════════════════════════════════════════════════════════════
 
1
  #!/usr/bin/env python3
2
  """
3
  ╔══════════════════════════════════════════════════════════════════════════════════════╗
4
+ β•‘ K1RL QUASAR β€” CENTRAL WEBSOCKET HUB v2.2-ranker-logs β•‘
5
  β•‘ ────────────────────────────────────────────────────────────────────────────────── β•‘
6
  β•‘ β•‘
7
  β•‘ Architecture role: INGEST β†’ NORMALIZE β†’ BROADCAST β•‘
 
12
  β•‘ β€’ Hub NEVER writes back to publishers β•‘
13
  β•‘ β€’ Hub stores latest snapshot per asset (NO history) β•‘
14
  β•‘ β•‘
15
+ β•‘ RANKER LOGS API (FIX v2.2 β€” moved here from hub_dashboard_service port 8052): β•‘
16
+ β•‘ GET /api/ranker/logs/recent β†’ recent log entries (?limit=N&category=X) β•‘
17
+ β•‘ GET /api/ranker/logs/stats β†’ log statistics β•‘
18
+ β•‘ GET /api/ranker/logs/asset/X β†’ logs for asset X β•‘
19
+ β•‘ GET /api/ranker/logs/level/X β†’ logs by level β•‘
20
+ β•‘ GET /api/ranker/logs/export β†’ download JSON β•‘
21
+ β•‘ GET /api/ranker/logs/debug β†’ file discovery diagnostics β•‘
22
  β•‘ β•‘
23
  β•‘ TRADE API (served natively β€” no patch script needed): β•‘
24
  β•‘ GET /api/trades β†’ full open + closed state + stats β•‘
 
26
  β•‘ GET /api/trades/closed β†’ recent closed trades + stats (?limit=N) β•‘
27
  β•‘ GET /api/health β†’ service health including trade counts β•‘
28
  β•‘ β•‘
29
+ β•‘ VERSION: v2.2-ranker-logs | 2026-04-04 β•‘
30
  β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
31
  """
32
 
 
36
  import json
37
  import logging
38
  import os
39
+ import re
40
+ import threading
41
  import time
42
  from collections import deque
43
  from datetime import datetime
 
479
  app = FastAPI(
480
  title="K1RL QUASAR Hub",
481
  description="Central WebSocket hub β€” ingest, normalize, broadcast (one-way)",
482
+ version="2.2.0",
483
  )
484
  app.add_middleware(
485
  CORSMiddleware,
 
707
  state = _trade_parser.get_state()
708
  return JSONResponse({
709
  "service": "websocket_hub",
710
+ "version": "v2.2-ranker-logs",
711
  "status": "running",
712
  "log_files": len(glob.glob(os.path.join(_LOG_DIR, "*.log"))),
713
  "trade_open": len(state["open"]),
 
716
  })
717
 
718
 
719
+ # ══════════════════════════════════════════════════════════════════════════════════════
720
+ # SECTION 7b β€” RANKER LOGS API (FIX: moved here so routes live on port 7860)
721
+ # ══════════════════════════════════════════════════════════════════════════════════════
722
+ #
723
+ # ROOT CAUSE of HTTP 404 on /api/ranker/logs/*:
724
+ # - hub_dashboard_service.py (Flask) runs on port 8052 β€” NOT publicly accessible on HF Spaces
725
+ # - websocket_hub.py (FastAPI/uvicorn) runs on port 7860 β€” the ONLY public port
726
+ # - The browser fetches /api/ranker/logs/recent β†’ hits port 7860 β†’ no route β†’ 404
727
+ #
728
+ # FIX: FileBasedLoggerAdapter + all /api/ranker/logs/* routes added directly here.
729
+ # The ranker writes logs to ./ranker_logs (= /app/ranker_logs). This adapter reads
730
+ # those files directly β€” no dependency on hub_dashboard_service or in-memory ranker.
731
+
732
+ _TRAINING_RE_HUB = re.compile(
733
+ r'step=(\d+)\s*\|\s*loss=([\d.]+)\s*\|\s*lr=([\d.eE+\-]+)\s*\|\s*assets=(\d+)'
734
+ )
735
+ _JSON_BLOB_RE_HUB = re.compile(r'(\{.*\})\s*$')
736
+
737
+
738
+ def _enrich_training_entry(entry: dict) -> dict:
739
+ """Attach parsed `data` dict to TRAINING entries so dashboard KPI cards populate."""
740
+ if entry.get("category", "").upper() != "TRAINING":
741
+ return entry
742
+ if entry.get("data"):
743
+ return entry
744
+ msg = entry.get("message", "")
745
+ m = _TRAINING_RE_HUB.search(msg)
746
+ if m:
747
+ entry["data"] = {
748
+ "step": int(m.group(1)),
749
+ "loss": float(m.group(2)),
750
+ "lr": float(m.group(3)),
751
+ "asset_count": int(m.group(4)),
752
+ }
753
+ return entry
754
+ jm = _JSON_BLOB_RE_HUB.search(msg)
755
+ if jm:
756
+ try:
757
+ blob = json.loads(jm.group(1))
758
+ if "step" in blob:
759
+ entry["data"] = {
760
+ "step": blob.get("step", 0),
761
+ "loss": blob.get("loss", 0.0),
762
+ "lr": blob.get("lr", 0.0),
763
+ "asset_count": blob.get("asset_count", blob.get("assets", 0)),
764
+ }
765
+ except (ValueError, KeyError):
766
+ pass
767
+ return entry
768
+
769
+
770
+ class FileBasedLoggerAdapter:
771
+ """
772
+ Reads ranker log files from disk and exposes the RankerLogger interface
773
+ expected by the /api/ranker/logs/* endpoints.
774
+ No in-memory ranker process required.
775
+ """
776
+ _CAT_RE = re.compile(r'\|\s*(INFO|DEBUG|WARNING|ERROR|CRITICAL)\s*\|\s*([A-Z_]+)\s*\|')
777
+ _ASSET_RE = re.compile(r'\|\s*(?:TRADE|SIGNAL)\s*\|\s*(\w+)\s*\|')
778
+ _TS_RE = re.compile(r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]')
779
+
780
+ def __init__(self, log_dir: str):
781
+ self._log_dir = log_dir
782
+ self._lock = threading.RLock()
783
+
784
+ def _find_files(self) -> list:
785
+ candidate_dirs = [
786
+ self._log_dir,
787
+ "/app/ranker_logs",
788
+ str(Path(__file__).parent / "ranker_logs"),
789
+ "./ranker_logs",
790
+ "/home/user/ranker_logs",
791
+ "/tmp/ranker_logs",
792
+ ]
793
+ all_files: list = []
794
+ seen: set = set()
795
+ for d in candidate_dirs:
796
+ for f in sorted(glob.glob(str(Path(d) / "*.log*"))):
797
+ if f not in seen:
798
+ seen.add(f)
799
+ all_files.append(f)
800
+ return all_files
801
+
802
+ def _read_lines(self, n_tail: int = 500) -> list:
803
+ files = self._find_files()
804
+ raw: list = []
805
+ for fpath in files[-3:]:
806
+ try:
807
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
808
+ raw.extend(f.readlines()[-n_tail:])
809
+ except OSError:
810
+ pass
811
+ raw.reverse() # newest first
812
+ return raw
813
+
814
+ def _line_to_entry(self, line: str) -> Optional[dict]:
815
+ ts_m = self._TS_RE.search(line)
816
+ if not ts_m:
817
+ return None
818
+ cat_m = self._CAT_RE.search(line)
819
+ level = cat_m.group(1) if cat_m else "INFO"
820
+ cat = cat_m.group(2).strip() if cat_m else ""
821
+ ast_m = self._ASSET_RE.search(line)
822
+ asset = ast_m.group(1) if ast_m else None
823
+ return {
824
+ "timestamp": ts_m.group(1),
825
+ "level": level,
826
+ "category": cat,
827
+ "message": line.strip(),
828
+ "asset": asset,
829
+ "data": None,
830
+ }
831
+
832
+ def get_recent(self, n: int = 50, category: Optional[str] = None) -> list:
833
+ entries: list = []
834
+ for line in self._read_lines(n_tail=max(n * 3, 200)):
835
+ e = self._line_to_entry(line)
836
+ if e is None:
837
+ continue
838
+ if category and category.upper() not in line.upper():
839
+ continue
840
+ entries.append(e)
841
+ if len(entries) >= n:
842
+ break
843
+ return entries
844
+
845
+ def get_by_asset(self, asset: str, n: int = 30) -> list:
846
+ entries: list = []
847
+ for line in self._read_lines(n_tail=500):
848
+ if asset.upper() not in line.upper():
849
+ continue
850
+ e = self._line_to_entry(line)
851
+ if e:
852
+ entries.append(e)
853
+ if len(entries) >= n:
854
+ break
855
+ return entries
856
+
857
+ def get_by_level(self, level: str, n: int = 50) -> list:
858
+ entries: list = []
859
+ for line in self._read_lines(n_tail=500):
860
+ e = self._line_to_entry(line)
861
+ if e and e["level"].upper() == level.upper():
862
+ entries.append(e)
863
+ if len(entries) >= n:
864
+ break
865
+ return entries
866
+
867
+ def get_stats(self) -> dict:
868
+ by_category: dict = {}
869
+ by_level: dict = {}
870
+ by_asset: dict = {}
871
+ errors: dict = {}
872
+ total: int = 0
873
+ for line in self._read_lines(n_tail=2000):
874
+ e = self._line_to_entry(line)
875
+ if not e:
876
+ continue
877
+ total += 1
878
+ by_level[e["level"]] = by_level.get(e["level"], 0) + 1
879
+ by_category[e["category"]] = by_category.get(e["category"], 0) + 1
880
+ if e["asset"]:
881
+ by_asset[e["asset"]] = by_asset.get(e["asset"], 0) + 1
882
+ if e["level"] in ("ERROR", "CRITICAL"):
883
+ errors[e["category"]] = errors.get(e["category"], 0) + 1
884
+ return {
885
+ "total_events": total,
886
+ "by_level": by_level,
887
+ "by_category": by_category,
888
+ "by_asset": by_asset,
889
+ "errors": errors,
890
+ "buffer_size": total,
891
+ "buffer_capacity": total,
892
+ }
893
+
894
+ def export_json(self, filepath: str, n: int = 500) -> None:
895
+ entries = self.get_recent(n)
896
+ with open(filepath, "w") as f:
897
+ json.dump({
898
+ "export_time": datetime.utcnow().isoformat(),
899
+ "count": len(entries),
900
+ "logs": entries,
901
+ }, f, indent=2)
902
+
903
+ def clear_buffer(self) -> None:
904
+ pass # file-based β€” nothing to clear
905
+
906
+
907
+ # Singleton adapter β€” reads from the same /app/ranker_logs the ranker writes to
908
+ _log_adapter = FileBasedLoggerAdapter(log_dir=_LOG_DIR)
909
+
910
+
911
+ @app.get("/api/ranker/logs/recent")
912
+ async def api_ranker_logs_recent(limit: int = 50, category: Optional[str] = None):
913
+ """GET /api/ranker/logs/recent?limit=80&category=TRAINING"""
914
+ try:
915
+ entries = _log_adapter.get_recent(n=limit, category=category)
916
+ entries = [_enrich_training_entry(e) for e in entries]
917
+ return JSONResponse({
918
+ "logs": entries,
919
+ "count": len(entries),
920
+ "stats": _log_adapter.get_stats(),
921
+ })
922
+ except Exception as exc:
923
+ logger.exception(f"[api_ranker_logs_recent] {exc}")
924
+ return JSONResponse({"logs": [], "count": 0, "error": str(exc)}, status_code=200)
925
+
926
+
927
+ @app.get("/api/ranker/logs/stats")
928
+ async def api_ranker_logs_stats():
929
+ """GET /api/ranker/logs/stats"""
930
+ try:
931
+ return JSONResponse(_log_adapter.get_stats())
932
+ except Exception as exc:
933
+ return JSONResponse({"error": str(exc)}, status_code=500)
934
+
935
+
936
+ @app.get("/api/ranker/logs/asset/{asset}")
937
+ async def api_ranker_logs_asset(asset: str, limit: int = 30):
938
+ """GET /api/ranker/logs/asset/V75?limit=30"""
939
+ try:
940
+ entries = _log_adapter.get_by_asset(asset, n=limit)
941
+ return JSONResponse({"asset": asset, "logs": entries, "count": len(entries)})
942
+ except Exception as exc:
943
+ return JSONResponse({"asset": asset, "logs": [], "count": 0, "error": str(exc)})
944
+
945
+
946
+ @app.get("/api/ranker/logs/level/{level}")
947
+ async def api_ranker_logs_level(level: str, limit: int = 50):
948
+ """GET /api/ranker/logs/level/ERROR?limit=50"""
949
+ try:
950
+ entries = _log_adapter.get_by_level(level, n=limit)
951
+ return JSONResponse({"level": level.upper(), "logs": entries, "count": len(entries)})
952
+ except Exception as exc:
953
+ return JSONResponse({"level": level.upper(), "logs": [], "count": 0, "error": str(exc)})
954
+
955
+
956
+ @app.get("/api/ranker/logs/export")
957
+ async def api_ranker_logs_export(limit: int = 500):
958
+ """GET /api/ranker/logs/export β€” download JSON"""
959
+ from fastapi.responses import FileResponse as _FileResponse
960
+ try:
961
+ export_path = "/tmp/ranker_logs_export.json"
962
+ _log_adapter.export_json(export_path, n=limit)
963
+ return _FileResponse(
964
+ export_path,
965
+ media_type="application/json",
966
+ filename="ranker_logs_export.json",
967
+ )
968
+ except Exception as exc:
969
+ return JSONResponse({"error": str(exc)}, status_code=500)
970
+
971
+
972
+ @app.post("/api/ranker/logs/clear")
973
+ async def api_ranker_logs_clear():
974
+ """POST /api/ranker/logs/clear"""
975
+ try:
976
+ _log_adapter.clear_buffer()
977
+ return JSONResponse({"status": "cleared"})
978
+ except Exception as exc:
979
+ return JSONResponse({"error": str(exc)}, status_code=500)
980
+
981
+
982
+ @app.get("/api/ranker/logs/debug")
983
+ async def api_ranker_logs_debug():
984
+ """GET /api/ranker/logs/debug β€” show which log files are found"""
985
+ files = _log_adapter._find_files()
986
+ return JSONResponse({
987
+ "log_dir": _LOG_DIR,
988
+ "files_found": files,
989
+ "file_count": len(files),
990
+ "stats": _log_adapter.get_stats(),
991
+ })
992
+
993
+
994
  # ══════════════════════════════════════════════════════════════════════════════════════
995
  # SECTION 8 β€” DASHBOARD UI ROUTES
996
  # ══════════════════════════════════════════════════════════════════════════════════════