|
|
| """
|
| ╔══════════════════════════════════════════════════════════════════════════════════════╗
|
| ║ K1RL QUASAR — COMBINED METRICS READER v3.0 ║
|
| ║ ────────────────────────────────────────────────────────────────────────────────── ║
|
| ║ ║
|
| ║ Merges two previously separate metric sources into one file, one shared cache, ║
|
| ║ one publisher, and one combined WebSocket payload to the hub. ║
|
| ║ ║
|
| ║ ┌─────────────────────────────────────────────────────────────────────────────┐ ║
|
| ║ │ SOURCE A — LogMetricsReader (v1.1-standalone logic, preserved exactly) │ ║
|
| ║ │ • Tails quasar_engine.log every 5 s (subprocess tail → Python fallback) │ ║
|
| ║ │ • Regex-parses TRAINING metrics: │ ║
|
| ║ │ training_steps, actor_loss, critic_loss, avn_loss, avn_accuracy │ ║
|
| ║ │ • Field-level cache (merge_into) — never zeros a field not seen this │ ║
|
| ║ │ poll (agent + AVN train at different cadences) │ ║
|
| ║ │ • Also parses dominant_signal / buy_count / sell_count from log — │ ║
|
| ║ │ used ONLY as voting fallback when Redis has not yet fired │ ║
|
| ║ └─────────────────────────────────────────────────────────────────────────────┘ ║
|
| ║ ║
|
| ║ ┌─────────────────────────────────────────────────────────────────────────────┐ ║
|
| ║ │ SOURCE B — RedisSignalReader (v2.0-redis-signals logic, preserved exactly) │ ║
|
| ║ │ • Subscribes to V75_1S:final_signals Redis channel │ ║
|
| ║ │ • Event-driven — fires on every BUY/SELL signal (not polled) │ ║
|
| ║ │ • Maintains cumulative rolling session counters: buy_count, sell_count │ ║
|
| ║ │ • Full parse chain transplanted verbatim from Rewards.py v5.2.1 │ ║
|
| ║ │ • AUTHORITATIVE source for voting once first signal is received │ ║
|
| ║ │ • Asyncio loop in its own daemon thread (never touches host loop) │ ║
|
| ║ │ • Gracefully disabled if redis_config_v75_1s / redis_connection_ │ ║
|
| ║ │ manager imports fail — log-based voting continues as fallback │ ║
|
| ║ └─────────────────────────────────────────────────────────────────────────────┘ ║
|
| ║ ║
|
| ║ ┌─────────────────────────────────────────────────────────────────────────────┐ ║
|
| ║ │ SHARED BRIDGE — MetricsCache (new in v3.0) │ ║
|
| ║ │ • Thread-safe — both readers write into it concurrently │ ║
|
| ║ │ • Training always comes from log │ ║
|
| ║ │ • Voting source priority: Redis (once active) > log fallback │ ║
|
| ║ │ • Returns (TrainingMetrics | None, VotingMetrics | None) snapshot after │ ║
|
| ║ │ every update so the caller can immediately dispatch the right publish │ ║
|
| ║ └─────────────────────────────────────────────────────────────────────────────┘ ║
|
| ║ ║
|
| ║ ONE publisher, ONE WebSocket connection, ONE combined payload. ║
|
| ║ ║
|
| ║ Integration — identical to v1.1 and v2.0, no app.py changes needed: ║
|
| ║ from log_metrics_reader import start_log_publisher ║
|
| ║ _publisher, _reader = start_log_publisher() ║
|
| ║ ║
|
| ║ Smoke test (log parsing only, no hub connection needed): ║
|
| ║ python log_metrics_reader.py ║
|
| ║ python log_metrics_reader.py /path/to/quasar_engine.log ║
|
| ║ ║
|
| ║ pip dependencies: websocket-client>=1.6.0 ║
|
| ║ redis, redis_config_v75_1s, ║
|
| ║ redis_connection_manager ← optional, graceful fallback ║
|
| ║ ║
|
| ║ VERSION: v3.0.1-combined | 2026-04-20 | V75_1S ║
|
| ╚══════════════════════════════════════════════════════════════════════════════════════╝
|
| """
|
|
|
| import asyncio
|
| import json
|
| import logging
|
| import os
|
| import re
|
| import subprocess
|
| import sys
|
| import threading
|
| import time
|
| from dataclasses import dataclass
|
| from typing import Optional, Tuple
|
|
|
| import websocket
|
|
|
| logging.basicConfig(
|
| level=logging.INFO,
|
| format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| datefmt="%Y-%m-%d %H:%M:%S",
|
| stream=sys.stdout,
|
| )
|
| logger = logging.getLogger("LogMetricsReader")
|
|
|
|
|
|
|
|
|
| _REDIS_AVAILABLE = False
|
| ABLY_SIGNAL_CHANNEL: str = ""
|
| try:
|
| from redis_config_v75_1s import (
|
| REDIS_URL,
|
| REDIS_PASSWORD,
|
| REDIS_DB_FEATURES,
|
| prefixed_channel,
|
| )
|
| from redis_connection_manager import RedisAblyClient, RedisMessage
|
| ABLY_SIGNAL_CHANNEL = prefixed_channel("final_signals")
|
| _REDIS_AVAILABLE = True
|
| logger.info("Redis imports OK — RedisSignalReader will be active")
|
| except ImportError as _redis_import_err:
|
| logger.warning(
|
| f"Redis imports unavailable — RedisSignalReader disabled. "
|
| f"Voting will fall back to log-based extraction. ({_redis_import_err})"
|
| )
|
|
|
|
|
|
|
| _DEFAULT_HUB_HOST = os.environ.get("QUASAR_HUB_HOST", "karlquant-quasar-executo.hf.space")
|
| _DEFAULT_LOG_PATH = os.environ.get("QUASAR_LOG_PATH", "/home/user/app/logs/quasar_engine.log")
|
| _DEFAULT_POLL_INTERVAL = float(os.environ.get("QUASAR_POLL_INTERVAL", "5.0"))
|
| _DEFAULT_TAIL_LINES = int(os.environ.get("QUASAR_TAIL_LINES", "600"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @dataclass
|
| class TrainingMetrics:
|
| """
|
| Training fields sent to the hub.
|
| Source: quasar_engine.log (LogMetricsReader) — ONLY source.
|
| Redis never produces these fields.
|
| """
|
| training_steps: int = 0
|
| actor_loss: float = 0.0
|
| critic_loss: float = 0.0
|
| avn_loss: float = 0.0
|
| avn_accuracy: float = 0.0
|
|
|
|
|
| @dataclass
|
| class VotingMetrics:
|
| """
|
| Voting fields sent to the hub.
|
| Source priority: Redis (authoritative) > log (fallback).
|
| Redis uses cumulative rolling counters; log uses snapshot values from lines.
|
| """
|
| dominant_signal: str = "NEUTRAL"
|
| buy_count: int = 0
|
| sell_count: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @dataclass
|
| class _RawMetrics:
|
| """
|
| Intermediate container for a single log parse pass.
|
| All fields start None — only fields that actually matched a regex are set.
|
| This is what enables merge_into() to preserve last known good values.
|
| """
|
| training_steps: Optional[int] = None
|
| actor_loss: Optional[float] = None
|
| critic_loss: Optional[float] = None
|
| avn_loss: Optional[float] = None
|
| avn_accuracy: Optional[float] = None
|
| dominant_signal: Optional[str] = None
|
| buy_count: Optional[int] = None
|
| sell_count: Optional[int] = None
|
|
|
| def has_training(self) -> bool:
|
| return any(v is not None for v in (
|
| self.training_steps, self.actor_loss,
|
| self.critic_loss, self.avn_loss, self.avn_accuracy,
|
| ))
|
|
|
| def has_voting(self) -> bool:
|
| return any(v is not None for v in (
|
| self.dominant_signal, self.buy_count, self.sell_count,
|
| ))
|
|
|
| def to_training(self) -> TrainingMetrics:
|
| return TrainingMetrics(
|
| training_steps = self.training_steps or 0,
|
| actor_loss = self.actor_loss or 0.0,
|
| critic_loss = self.critic_loss or 0.0,
|
| avn_loss = self.avn_loss or 0.0,
|
|
|
|
|
| avn_accuracy = (self.avn_accuracy / 100.0
|
| if (self.avn_accuracy or 0.0) > 1.0
|
| else (self.avn_accuracy or 0.0)),
|
| )
|
|
|
| def to_voting(self) -> VotingMetrics:
|
| signal = (self.dominant_signal or "NEUTRAL").upper()
|
| if signal not in {"BUY", "SELL", "NEUTRAL"}:
|
| signal = "NEUTRAL"
|
| return VotingMetrics(
|
| dominant_signal = signal,
|
| buy_count = self.buy_count or 0,
|
| sell_count = self.sell_count or 0,
|
| )
|
|
|
| def merge_into(self, fresh: "_RawMetrics") -> None:
|
| """
|
| Merge a freshly-parsed _RawMetrics INTO this persistent cache.
|
| Only fields that were actually found this poll (not None) overwrite
|
| the cached value. Fields absent from the latest parse are left
|
| unchanged — preserving the last known good value.
|
|
|
| This prevents AVN metrics (logged at a slower cadence than actor/critic)
|
| from being zeroed out every time the agent is mid-training-step.
|
| """
|
| if fresh.training_steps is not None: self.training_steps = fresh.training_steps
|
| if fresh.actor_loss is not None: self.actor_loss = fresh.actor_loss
|
| if fresh.critic_loss is not None: self.critic_loss = fresh.critic_loss
|
| if fresh.avn_loss is not None: self.avn_loss = fresh.avn_loss
|
| if fresh.avn_accuracy is not None: self.avn_accuracy = fresh.avn_accuracy
|
| if fresh.dominant_signal is not None: self.dominant_signal = fresh.dominant_signal
|
| if fresh.buy_count is not None: self.buy_count = fresh.buy_count
|
| if fresh.sell_count is not None: self.sell_count = fresh.sell_count
|
|
|
|
|
| def _parse_lines(lines: list) -> _RawMetrics:
|
| """
|
| Scan log lines in REVERSE (newest-first) and extract the most recent
|
| value for each metric field. Stops scanning a field once it is found.
|
| Patterns kept identical to dashboard_service.py.
|
| """
|
| m = _RawMetrics()
|
|
|
| for line in reversed(lines):
|
|
|
|
|
| if m.training_steps is None and 'avn_training_steps:' in line:
|
| hit = re.search(r'avn_training_steps:\s*(\d+)', line)
|
| if hit:
|
| m.training_steps = int(hit.group(1))
|
|
|
|
|
| if m.actor_loss is None:
|
| if 'Actor Loss:' in line:
|
| hit = re.search(r'Actor Loss:\s*([-\d.]+)', line)
|
| elif "'actor_loss':" in line or '"actor_loss":' in line:
|
| hit = re.search(r"""['"]actor_loss['"]\s*:\s*([-\d.]+)""", line)
|
| else:
|
| hit = None
|
| if hit:
|
| m.actor_loss = float(hit.group(1))
|
|
|
|
|
| if m.critic_loss is None:
|
| if 'Critic Loss:' in line:
|
| hit = re.search(r'Critic Loss:\s*([-\d.]+)', line)
|
| elif "'critic_loss':" in line or '"critic_loss":' in line:
|
| hit = re.search(r"""['"]critic_loss['"]\s*:\s*([-\d.]+)""", line)
|
| else:
|
| hit = None
|
| if hit:
|
| m.critic_loss = float(hit.group(1))
|
|
|
|
|
| if m.avn_loss is None:
|
| if 'Avg Loss:' in line or '🎯 Avg Loss:' in line:
|
| hit = re.search(r'Avg Loss:\s*([-\d.]+)', line)
|
| elif "'avn_loss':" in line or '"avn_loss":' in line:
|
| hit = re.search(r"""['"]avn_loss['"]\s*:\s*([-\d.]+)""", line)
|
| else:
|
| hit = None
|
| if hit:
|
| m.avn_loss = float(hit.group(1))
|
|
|
|
|
| if m.avn_accuracy is None and ('AVN Accuracy:' in line or 'Avg Accuracy:' in line):
|
| hit = re.search(r'(?:AVN|Avg) Accuracy:\s*([\d.]+)%?', line)
|
| if hit:
|
| m.avn_accuracy = float(hit.group(1))
|
|
|
|
|
| if m.dominant_signal is None and re.search(r'[Dd]ominant[\s_][Ss]ignal|Signal:', line):
|
| hit = re.search(r'(?:[Dd]ominant[\s_][Ss]ignal|Signal):\s*(BUY|SELL|NEUTRAL)', line)
|
| if hit:
|
| m.dominant_signal = hit.group(1).upper()
|
|
|
|
|
| if m.buy_count is None and re.search(
|
| r'[Bb]uy[\s_][Cc]ount|[Bb]uy[\s_][Vv]otes|buy_count', line
|
| ):
|
| hit = re.search(
|
| r'(?:[Bb]uy[\s_](?:[Cc]ount|[Vv]otes)|buy_count)[:\s=]+(\d+)', line
|
| )
|
| if hit:
|
| m.buy_count = int(hit.group(1))
|
|
|
|
|
| if m.sell_count is None and re.search(
|
| r'[Ss]ell[\s_][Cc]ount|[Ss]ell[\s_][Vv]otes|sell_count', line
|
| ):
|
| hit = re.search(
|
| r'(?:[Ss]ell[\s_](?:[Cc]ount|[Vv]otes)|sell_count)[:\s=]+(\d+)', line
|
| )
|
| if hit:
|
| m.sell_count = int(hit.group(1))
|
|
|
|
|
| if (m.training_steps is not None and m.actor_loss is not None and
|
| m.critic_loss is not None and m.avn_loss is not None and
|
| m.avn_accuracy is not None and m.dominant_signal is not None and
|
| m.buy_count is not None and m.sell_count is not None):
|
| break
|
|
|
| return m
|
|
|
|
|
| def _tail_log(log_path: str, n_lines: int) -> list:
|
| """
|
| Return last n_lines from log_path as a list of strings.
|
| Uses subprocess tail (fast). Falls back to pure-Python on Windows dev boxes.
|
| """
|
| if not os.path.exists(log_path):
|
| return []
|
| try:
|
| result = subprocess.run(
|
| ['tail', f'-{n_lines}', log_path],
|
| capture_output=True, text=True, timeout=5,
|
| )
|
| return result.stdout.split('\n') if result.returncode == 0 else []
|
| except FileNotFoundError:
|
| try:
|
| with open(log_path, 'r', errors='replace') as fh:
|
| return fh.readlines()[-n_lines:]
|
| except Exception:
|
| return []
|
| except Exception as e:
|
| logger.warning(f"Log tail error: {e}")
|
| return []
|
|
|
|
|
| def extract_metrics_from_log(
|
| log_path: str = _DEFAULT_LOG_PATH,
|
| n_lines: int = _DEFAULT_TAIL_LINES,
|
| ) -> Optional[_RawMetrics]:
|
| """Public helper — parse log and return raw metrics (or None if nothing found)."""
|
| lines = _tail_log(log_path, n_lines)
|
| if not lines:
|
| return None
|
| raw = _parse_lines(lines)
|
| return raw if (raw.has_training() or raw.has_voting()) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
| class MetricsCache:
|
| """
|
| Thread-safe shared state that merges training data (from log) and voting
|
| data (from Redis or log fallback) before every publish.
|
|
|
| ╔═══════════════════════════════════════════════════════════════════════╗
|
| ║ SOURCE RULES ║
|
| ║ ║
|
| ║ TRAINING → log only, always. ║
|
| ║ Redis never produces training fields. ║
|
| ║ Uses _RawMetrics.merge_into() so individual fields ║
|
| ║ (e.g. avn_accuracy) are NOT zeroed between log cadences.║
|
| ║ ║
|
| ║ VOTING → Redis (authoritative) once the first signal arrives. ║
|
| ║ Uses cumulative rolling counters (_redis_buy/_sell) ║
|
| ║ that only increase — never reset to zero mid-session. ║
|
| ║ ║
|
| ║ Log fallback: active ONLY while _redis_active is False. ║
|
| ║ Also uses merge_into() so snapshot values persist ║
|
| ║ across polls that don't contain voting lines. ║
|
| ║ Once Redis fires its first signal, log-based voting ║
|
| ║ is permanently suppressed. ║
|
| ╚═══════════════════════════════════════════════════════════════════════╝
|
|
|
| Both update_from_log() and update_from_redis() return the current
|
| (TrainingMetrics | None, VotingMetrics | None) snapshot so the caller
|
| can immediately dispatch the correct publish_* method.
|
| Either value is None when that source has not yet produced any data.
|
| """
|
|
|
| def __init__(self) -> None:
|
| self._lock = threading.Lock()
|
|
|
|
|
| self._training_raw: _RawMetrics = _RawMetrics()
|
|
|
|
|
| self._redis_buy: int = 0
|
| self._redis_sell: int = 0
|
| self._redis_active: bool = False
|
|
|
|
|
| self._log_voting_raw: _RawMetrics = _RawMetrics()
|
|
|
|
|
|
|
| def update_from_log(
|
| self, fresh: _RawMetrics
|
| ) -> Tuple[Optional[TrainingMetrics], Optional[VotingMetrics]]:
|
| """
|
| Called by LogMetricsReader after every successful log parse.
|
|
|
| Always merges training fields from fresh into _training_raw.
|
| Merges voting fields only when Redis has not yet become active.
|
|
|
| Returns the current (TrainingMetrics, VotingMetrics) combined snapshot.
|
| """
|
| with self._lock:
|
|
|
| self._training_raw.merge_into(fresh)
|
|
|
|
|
| if not self._redis_active:
|
| self._log_voting_raw.merge_into(fresh)
|
|
|
| return self._snapshot_unlocked()
|
|
|
| def update_from_redis(
|
| self, action: str
|
| ) -> Tuple[Optional[TrainingMetrics], Optional[VotingMetrics]]:
|
| """
|
| Called by RedisSignalReader on every validated BUY or SELL signal.
|
|
|
| Marks Redis as permanently active (suppresses log voting from this point on).
|
| Increments the rolling session counter for the given action.
|
|
|
| Returns the current (TrainingMetrics, VotingMetrics) combined snapshot.
|
| TrainingMetrics may be None if the log reader has not yet completed its
|
| first poll since startup (2 s delay before first poll).
|
| """
|
| with self._lock:
|
| self._redis_active = True
|
| if action == "BUY":
|
| self._redis_buy += 1
|
| else:
|
| self._redis_sell += 1
|
|
|
| return self._snapshot_unlocked()
|
|
|
|
|
|
|
| def _snapshot_unlocked(
|
| self,
|
| ) -> Tuple[Optional[TrainingMetrics], Optional[VotingMetrics]]:
|
| tm = self._training_raw.to_training() if self._training_raw.has_training() else None
|
| vm = self._best_voting_unlocked()
|
| return tm, vm
|
|
|
| def _best_voting_unlocked(self) -> Optional[VotingMetrics]:
|
| """
|
| Returns the best available VotingMetrics from whichever source is active.
|
|
|
| Redis wins once active. Its buy/sell counters are cumulative session totals
|
| — very different from the log snapshot values, which reflect whatever was
|
| last written to the log.
|
|
|
| Returns None only when neither source has produced any data yet.
|
| """
|
| if self._redis_active:
|
| b, s = self._redis_buy, self._redis_sell
|
| dominant = "BUY" if b >= s else "SELL"
|
| return VotingMetrics(dominant_signal=dominant, buy_count=b, sell_count=s)
|
| elif self._log_voting_raw.has_voting():
|
| return self._log_voting_raw.to_voting()
|
| return None
|
|
|
| def get_stats(self) -> dict:
|
| with self._lock:
|
| return {
|
| "redis_active": self._redis_active,
|
| "redis_buy": self._redis_buy,
|
| "redis_sell": self._redis_sell,
|
| "training_ready": self._training_raw.has_training(),
|
| "log_voting_ready": self._log_voting_raw.has_voting(),
|
| "voting_source": "redis" if self._redis_active else (
|
| "log" if self._log_voting_raw.has_voting() else "none"
|
| ),
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class AssetSpacePublisher:
|
| """
|
| Send-only WebSocket publisher. Runs in a background daemon thread.
|
| Auto-reconnects with capped exponential back-off.
|
|
|
| Shared between LogMetricsReader and RedisSignalReader — ONE connection,
|
| ONE rate-limiter, ONE stream to the hub. Both readers call publish_*
|
| methods on the same instance; rate limiting prevents flooding.
|
| """
|
|
|
| _MAX_BACKOFF: int = 30
|
|
|
| def __init__(
|
| self,
|
| space_name: str,
|
| hub_url: str,
|
| min_publish_interval: float = 0.5,
|
| ):
|
| self.space_name = space_name
|
| self.hub_url = hub_url
|
| self.min_publish_interval = min_publish_interval
|
|
|
| self._ws: Optional[websocket.WebSocketApp] = None
|
| self._connected = False
|
| self._running = False
|
| self._thread: Optional[threading.Thread] = None
|
| self._reconnect_count = 0
|
|
|
| self._cache_lock = threading.Lock()
|
| self._latest_training: Optional[TrainingMetrics] = None
|
| self._latest_voting: Optional[VotingMetrics] = None
|
|
|
| self._rate_lock = threading.Lock()
|
| self._last_send_ts = {"training": 0.0, "voting": 0.0, "combined": 0.0}
|
|
|
| self._stats = {
|
| "messages_sent": 0,
|
| "bytes_sent": 0,
|
| "reconnect_count": 0,
|
| "last_send_time": 0.0,
|
| "dropped_rate": 0,
|
| }
|
|
|
|
|
|
|
| def start(self) -> None:
|
| if self._running:
|
| return
|
| self._running = True
|
| self._thread = threading.Thread(
|
| target=self._run_loop,
|
| daemon=True,
|
| name=f"Publisher-{self.space_name}",
|
| )
|
| self._thread.start()
|
| logger.info(f"[{self.space_name}] Publisher started → {self.hub_url}")
|
|
|
| def stop(self) -> None:
|
| self._running = False
|
| if self._ws:
|
| try:
|
| self._ws.close()
|
| except Exception:
|
| pass
|
| if self._thread:
|
| self._thread.join(timeout=3)
|
| logger.info(f"[{self.space_name}] Publisher stopped")
|
|
|
| @property
|
| def is_connected(self) -> bool:
|
| return self._connected
|
|
|
| def get_stats(self) -> dict:
|
| return {**self._stats, "connected": self._connected, "running": self._running}
|
|
|
|
|
|
|
| def _run_loop(self) -> None:
|
| while self._running:
|
| try:
|
| self._connect_and_run()
|
| except Exception as e:
|
| logger.error(f"[{self.space_name}] Connection error: {e}")
|
| if not self._running:
|
| break
|
| backoff = min(self._MAX_BACKOFF, 2 ** min(self._reconnect_count, 4))
|
| logger.info(
|
| f"[{self.space_name}] Reconnecting in {backoff}s… "
|
| f"(attempt #{self._reconnect_count + 1})"
|
| )
|
| time.sleep(backoff)
|
| self._reconnect_count += 1
|
| self._stats["reconnect_count"] = self._reconnect_count
|
|
|
| def _connect_and_run(self) -> None:
|
| self._ws = websocket.WebSocketApp(
|
| self.hub_url,
|
| on_open = self._on_open,
|
| on_message = self._on_message,
|
| on_error = self._on_error,
|
| on_close = self._on_close,
|
| )
|
| self._ws.run_forever(
|
| ping_interval = 30,
|
| ping_timeout = 10,
|
| sslopt = {"check_hostname": True},
|
| )
|
|
|
|
|
|
|
| def _on_open(self, ws) -> None:
|
| self._connected = True
|
| self._reconnect_count = 0
|
| self._stats["reconnect_count"] = 0
|
| logger.info(f"[{self.space_name}] ✅ Connected to hub")
|
| self._send_raw({"type": "identify", "space": self.space_name})
|
|
|
| with self._cache_lock:
|
| t, v = self._latest_training, self._latest_voting
|
| if t:
|
| self._send_training_payload(t)
|
| if v:
|
| self._send_voting_payload(v)
|
|
|
| def _on_message(self, ws, message: str) -> None:
|
| logger.warning(
|
| f"[{self.space_name}] ⚠️ Unexpected hub message — discarded: {message[:80]}"
|
| )
|
|
|
| def _on_error(self, ws, error) -> None:
|
| logger.error(f"[{self.space_name}] WebSocket error: {error}")
|
| self._connected = False
|
|
|
| def _on_close(self, ws, code, msg) -> None:
|
| self._connected = False
|
| logger.info(f"[{self.space_name}] Connection closed (code={code})")
|
|
|
|
|
|
|
| def _rate_ok(self, key: str) -> bool:
|
| if self.min_publish_interval <= 0:
|
| return True
|
| now = time.time()
|
| with self._rate_lock:
|
| if now - self._last_send_ts[key] >= self.min_publish_interval:
|
| self._last_send_ts[key] = now
|
| return True
|
| self._stats["dropped_rate"] += 1
|
| return False
|
|
|
|
|
|
|
| def _send_raw(self, payload: dict) -> bool:
|
| if not (self._ws and self._connected):
|
| return False
|
| try:
|
| text = json.dumps(payload)
|
| self._ws.send(text)
|
| self._stats["messages_sent"] += 1
|
| self._stats["bytes_sent"] += len(text)
|
| self._stats["last_send_time"] = time.time()
|
| return True
|
| except Exception as e:
|
| logger.error(f"[{self.space_name}] Send error: {e}")
|
| self._connected = False
|
| return False
|
|
|
| def _send_training_payload(self, m: TrainingMetrics) -> bool:
|
| return self._send_raw({
|
| "type": "training",
|
| "data": {
|
| "training_steps": m.training_steps,
|
| "actor_loss": m.actor_loss,
|
| "critic_loss": m.critic_loss,
|
| "avn_loss": m.avn_loss,
|
| "avn_accuracy": m.avn_accuracy,
|
| },
|
| })
|
|
|
| def _send_voting_payload(self, m: VotingMetrics) -> bool:
|
| return self._send_raw({
|
| "type": "voting",
|
| "data": {
|
| "dominant_signal": m.dominant_signal,
|
| "buy_count": m.buy_count,
|
| "sell_count": m.sell_count,
|
| },
|
| })
|
|
|
|
|
|
|
| def publish_training(self, metrics: TrainingMetrics) -> bool:
|
| with self._cache_lock:
|
| self._latest_training = metrics
|
| if not self._rate_ok("training"):
|
| return False
|
| return self._send_training_payload(metrics)
|
|
|
| def publish_voting(self, metrics: VotingMetrics) -> bool:
|
| with self._cache_lock:
|
| self._latest_voting = metrics
|
| if not self._rate_ok("voting"):
|
| return False
|
| return self._send_voting_payload(metrics)
|
|
|
| def publish_combined(self, training: TrainingMetrics, voting: VotingMetrics) -> bool:
|
| with self._cache_lock:
|
| self._latest_training = training
|
| self._latest_voting = voting
|
| if not self._rate_ok("combined"):
|
| return False
|
| return self._send_raw({
|
| "type": "metrics",
|
| "training": {
|
| "training_steps": training.training_steps,
|
| "actor_loss": training.actor_loss,
|
| "critic_loss": training.critic_loss,
|
| "avn_loss": training.avn_loss,
|
| "avn_accuracy": training.avn_accuracy,
|
| },
|
| "voting": {
|
| "dominant_signal": voting.dominant_signal,
|
| "buy_count": voting.buy_count,
|
| "sell_count": voting.sell_count,
|
| },
|
| })
|
|
|
| def publish_heartbeat(self) -> bool:
|
| return self._send_raw({"type": "heartbeat"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class LogMetricsReader:
|
| """
|
| Polls quasar_engine.log every poll_interval seconds.
|
|
|
| Authoritative source for: training_steps, actor_loss, critic_loss,
|
| avn_loss, avn_accuracy
|
| Fallback source for: dominant_signal, buy_count, sell_count
|
| (only used while _redis_active is False in cache)
|
| """
|
|
|
| def __init__(
|
| self,
|
| publisher: AssetSpacePublisher,
|
| cache: MetricsCache,
|
| log_path: str = _DEFAULT_LOG_PATH,
|
| poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
| tail_lines: int = _DEFAULT_TAIL_LINES,
|
| ):
|
| self.publisher = publisher
|
| self.cache = cache
|
| self.log_path = log_path
|
| self.poll_interval = poll_interval
|
| self.tail_lines = tail_lines
|
|
|
| self._running = False
|
| self._thread: Optional[threading.Thread] = None
|
|
|
| self._stats = {
|
| "polls": 0,
|
| "published": 0,
|
| "parse_errors": 0,
|
| "missing_log": 0,
|
| }
|
|
|
| def start(self) -> None:
|
| if self._running:
|
| return
|
| self._running = True
|
| self._thread = threading.Thread(
|
| target = self._poll_loop,
|
| daemon = True,
|
| name = f"LogReader-{self.publisher.space_name}",
|
| )
|
| self._thread.start()
|
| logger.info(
|
| f"[{self.publisher.space_name}] LogMetricsReader started "
|
| f"(log={self.log_path}, interval={self.poll_interval}s)"
|
| )
|
|
|
| def stop(self) -> None:
|
| self._running = False
|
| if self._thread:
|
| self._thread.join(timeout=self.poll_interval + 2)
|
| logger.info(f"[{self.publisher.space_name}] LogMetricsReader stopped")
|
|
|
| def get_stats(self) -> dict:
|
| return {**self._stats, "running": self._running}
|
|
|
|
|
|
|
| def _poll_loop(self) -> None:
|
| time.sleep(2.0)
|
| while self._running:
|
| try:
|
| self._poll_once()
|
| except Exception as e:
|
| logger.error(f"[{self.publisher.space_name}] Poll error: {e}")
|
| self._stats["parse_errors"] += 1
|
| time.sleep(self.poll_interval)
|
|
|
| def _poll_once(self) -> None:
|
| self._stats["polls"] += 1
|
|
|
| if not os.path.exists(self.log_path):
|
| if self._stats["missing_log"] % 12 == 0:
|
| logger.warning(
|
| f"[{self.publisher.space_name}] Log not found: {self.log_path}"
|
| )
|
| self._stats["missing_log"] += 1
|
| self.publisher.publish_heartbeat()
|
| return
|
|
|
| lines = _tail_log(self.log_path, self.tail_lines)
|
| if not lines:
|
| return
|
|
|
| fresh = _parse_lines(lines)
|
| if not fresh.has_training() and not fresh.has_voting():
|
| logger.debug(
|
| f"[{self.publisher.space_name}] No metrics matched in last "
|
| f"{self.tail_lines} lines — heartbeat"
|
| )
|
| self.publisher.publish_heartbeat()
|
| return
|
|
|
|
|
|
|
| tm, vm = self.cache.update_from_log(fresh)
|
|
|
| ok = self._dispatch(tm, vm)
|
| if ok:
|
| self._stats["published"] += 1
|
| logger.debug(
|
| f"[{self.publisher.space_name}] Log poll → published "
|
| f"steps={getattr(tm, 'training_steps', '?')} "
|
| f"signal={getattr(vm, 'dominant_signal', '?')} "
|
| f"voting_src={self.cache.get_stats()['voting_source']}"
|
| )
|
|
|
| def _dispatch(
|
| self,
|
| tm: Optional[TrainingMetrics],
|
| vm: Optional[VotingMetrics],
|
| ) -> bool:
|
| if tm and vm:
|
| return self.publisher.publish_combined(tm, vm)
|
| elif tm:
|
| return self.publisher.publish_training(tm)
|
| elif vm:
|
| return self.publisher.publish_voting(vm)
|
| else:
|
| self.publisher.publish_heartbeat()
|
| return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| class RedisSignalReader:
|
| """
|
| Subscribes to V75_1S:final_signals and feeds validated signals into
|
| MetricsCache, then triggers a combined publish via AssetSpacePublisher.
|
|
|
| Only instantiated when _REDIS_AVAILABLE is True.
|
| If Redis imports failed, this class is never constructed and log-based
|
| voting continues to serve the hub uninterrupted.
|
| """
|
|
|
| def __init__(self, publisher: AssetSpacePublisher, cache: MetricsCache):
|
| self.publisher = publisher
|
| self.cache = cache
|
|
|
| self._running = False
|
| self._thread: Optional[threading.Thread] = None
|
| self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
| self._stats = {
|
| "signals_received": 0,
|
| "signals_parsed": 0,
|
| "signals_dropped": 0,
|
| "published": 0,
|
| }
|
|
|
| def start(self) -> None:
|
| if self._running:
|
| return
|
| self._running = True
|
| self._thread = threading.Thread(
|
| target=self._run_loop,
|
| daemon=True,
|
| name=f"RedisSignalReader-{self.publisher.space_name}",
|
| )
|
| self._thread.start()
|
| logger.info(
|
| f"[{self.publisher.space_name}] RedisSignalReader started "
|
| f"→ channel={ABLY_SIGNAL_CHANNEL}"
|
| )
|
|
|
| def stop(self) -> None:
|
| self._running = False
|
| if self._loop and self._loop.is_running():
|
| self._loop.call_soon_threadsafe(self._loop.stop)
|
| if self._thread:
|
| self._thread.join(timeout=5)
|
| logger.info(f"[{self.publisher.space_name}] RedisSignalReader stopped")
|
|
|
| def get_stats(self) -> dict:
|
| return {**self._stats, "running": self._running}
|
|
|
|
|
|
|
| def _run_loop(self) -> None:
|
| """
|
| Runs in a daemon thread. Creates a fresh asyncio event loop so this
|
| reader never interferes with any event loop the host process may have.
|
| """
|
| self._loop = asyncio.new_event_loop()
|
| asyncio.set_event_loop(self._loop)
|
| try:
|
| self._loop.run_until_complete(self._async_main())
|
| except Exception as e:
|
| logger.error(
|
| f"[{self.publisher.space_name}] RedisSignalReader loop error: {e}"
|
| )
|
| finally:
|
| self._loop.close()
|
|
|
|
|
|
|
| async def _async_main(self) -> None:
|
| """
|
| Connection parameters mirror Rewards.py.RewardsEngine.initialize():
|
| • redis_url = REDIS_URL (from redis_config_v75_1s)
|
| • password = REDIS_PASSWORD
|
| • use_streams = True
|
| • database = REDIS_DB_FEATURES (V75_1S: DB 0)
|
| """
|
| ably = RedisAblyClient(
|
| redis_url=REDIS_URL,
|
| password=REDIS_PASSWORD,
|
| use_streams=True,
|
| database=REDIS_DB_FEATURES,
|
| )
|
|
|
| channel = ably.channels.get(ABLY_SIGNAL_CHANNEL)
|
|
|
| def _on_signal(message) -> None:
|
| self._stats["signals_received"] += 1
|
|
|
| parsed = self._parse_signal_message(message)
|
| if parsed is None:
|
| self._stats["signals_dropped"] += 1
|
| return
|
|
|
| self._stats["signals_parsed"] += 1
|
| action = parsed["action"]
|
|
|
|
|
| tm, vm = self.cache.update_from_redis(action)
|
|
|
|
|
| if tm and vm:
|
| ok = self.publisher.publish_combined(tm, vm)
|
| elif vm:
|
| ok = self.publisher.publish_voting(vm)
|
| else:
|
| ok = False
|
|
|
| if ok:
|
| self._stats["published"] += 1
|
| logger.info(
|
| f"[{self.publisher.space_name}] 🔔 Signal {action} "
|
| f"@ {parsed['entry_price']:.5f} | "
|
| f"keys={len(parsed['signal_keys'])} | "
|
| f"buy={vm.buy_count if vm else '?'} "
|
| f"sell={vm.sell_count if vm else '?'} "
|
| f"dominant={vm.dominant_signal if vm else '?'}"
|
| )
|
|
|
| await channel.subscribe("message", _on_signal)
|
| logger.info(
|
| f"[{self.publisher.space_name}] ✅ Subscribed to {ABLY_SIGNAL_CHANNEL} "
|
| f"(V75_1S namespace, DB={REDIS_DB_FEATURES})"
|
| )
|
|
|
|
|
| while self._running:
|
| await asyncio.sleep(1.0)
|
|
|
| ably.close()
|
|
|
|
|
|
|
| def _parse_signal_message(self, message) -> Optional[dict]:
|
| """
|
| Full 10-step parse chain from Rewards.py v5.2.1-V75_1S.
|
| Returns a dict with keys: action, signal_keys, entry_price, payload.
|
| Returns None silently for any malformed payload — never raises.
|
| """
|
| try:
|
|
|
| data = message.data if isinstance(message, RedisMessage) else message
|
|
|
| if isinstance(data, dict) and "data" in data:
|
| data = data["data"]
|
|
|
| if isinstance(data, str):
|
| data = json.loads(data)
|
|
|
| action = data.get("final_action", data.get("action", "")).upper()
|
|
|
| signal_keys = data.get("signal_keys", [])
|
|
|
| entry_price = data.get("price", 0.0)
|
|
|
| if action not in ("BUY", "SELL"):
|
| return None
|
|
|
| if not entry_price or entry_price == 0.0:
|
| return None
|
|
|
| if not isinstance(signal_keys, list):
|
| signal_keys = [str(signal_keys)]
|
|
|
| return {
|
| "action": action,
|
| "signal_keys": signal_keys[:8],
|
| "entry_price": float(entry_price),
|
| "payload": data,
|
| }
|
| except Exception:
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| class CombinedReader:
|
| """
|
| Wraps LogMetricsReader and (optionally) RedisSignalReader behind a single
|
| object so app.py can use the unchanged two-tuple assignment:
|
|
|
| _publisher, _reader = start_log_publisher()
|
|
|
| and still call _reader.stop() / _reader.get_stats() without caring whether
|
| Redis is active or not.
|
| """
|
|
|
| def __init__(
|
| self,
|
| log_reader: LogMetricsReader,
|
| redis_reader: Optional[RedisSignalReader],
|
| cache: MetricsCache,
|
| ):
|
| self.log_reader = log_reader
|
| self.redis_reader = redis_reader
|
| self.cache = cache
|
|
|
| def stop(self) -> None:
|
| self.log_reader.stop()
|
| if self.redis_reader:
|
| self.redis_reader.stop()
|
|
|
| def get_stats(self) -> dict:
|
| return {
|
| "log_reader": self.log_reader.get_stats(),
|
| "redis_reader": (self.redis_reader.get_stats()
|
| if self.redis_reader else {"enabled": False}),
|
| "cache": self.cache.get_stats(),
|
| "redis_available": _REDIS_AVAILABLE,
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| def start_log_publisher(
|
| log_path: str = _DEFAULT_LOG_PATH,
|
| poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
| tail_lines: int = _DEFAULT_TAIL_LINES,
|
| hub_host: str = _DEFAULT_HUB_HOST,
|
| space_name: Optional[str] = None,
|
|
|
|
|
| **_legacy_kwargs,
|
| ) -> Tuple[AssetSpacePublisher, CombinedReader]:
|
| """
|
| One-line drop-in. Signature backward-compatible with v1.1 and v2.0.
|
|
|
| Wires together:
|
| ONE AssetSpacePublisher (single WebSocket connection to hub)
|
| ONE MetricsCache (shared state bridge between log and Redis)
|
| ONE LogMetricsReader (polls log → training + fallback voting)
|
| ONE RedisSignalReader (Redis events → authoritative voting) [if available]
|
| ONE CombinedReader (thin wrapper returned as _reader)
|
|
|
| Usage in app.py — UNCHANGED from v1.1 / v2.0:
|
| from log_metrics_reader import start_log_publisher
|
| _publisher, _reader = start_log_publisher()
|
|
|
| Or with explicit space name (as in current app.py):
|
| _publisher, _reader = start_log_publisher(space_name="V75_1S")
|
| """
|
| if space_name is None:
|
| raw = os.environ.get("SPACE_ID", "")
|
| space_name = raw.split("/", 1)[-1] if "/" in raw else (raw or "UnknownSpace")
|
|
|
| hub_url = f"wss://{hub_host}/ws/publish/{space_name}"
|
|
|
| publisher = AssetSpacePublisher(
|
| space_name = space_name,
|
| hub_url = hub_url,
|
| min_publish_interval = max(poll_interval * 0.9, 0.5),
|
| )
|
| cache = MetricsCache()
|
|
|
| log_reader = LogMetricsReader(
|
| publisher = publisher,
|
| cache = cache,
|
| log_path = log_path,
|
| poll_interval = poll_interval,
|
| tail_lines = tail_lines,
|
| )
|
|
|
| redis_reader: Optional[RedisSignalReader] = None
|
| if _REDIS_AVAILABLE:
|
| redis_reader = RedisSignalReader(publisher=publisher, cache=cache)
|
| else:
|
| logger.warning(
|
| f"[{space_name}] RedisSignalReader not started "
|
| f"(Redis imports unavailable). "
|
| f"Voting metrics will be sourced from log only."
|
| )
|
|
|
|
|
| publisher.start()
|
| log_reader.start()
|
| if redis_reader:
|
| redis_reader.start()
|
|
|
| combined = CombinedReader(
|
| log_reader = log_reader,
|
| redis_reader = redis_reader,
|
| cache = cache,
|
| )
|
|
|
| logger.info(
|
| f"[{space_name}] ✅ Combined metrics pipeline active — "
|
| f"log=✅ redis={'✅' if redis_reader else '⚠️ disabled'} "
|
| f"hub={hub_url}"
|
| )
|
|
|
| return publisher, combined
|
|
|
|
|
|
|
|
|
|
|
|
|
| if __name__ == "__main__":
|
| log = sys.argv[1] if len(sys.argv) > 1 else _DEFAULT_LOG_PATH
|
|
|
| print(f"\n{'=' * 62}")
|
| print(f" K1RL QUASAR — Combined Metrics Reader v3.0 Smoke Test")
|
| print(f"{'=' * 62}")
|
| print(f" Log file : {log}")
|
| print(f" Tail lines : {_DEFAULT_TAIL_LINES}")
|
| print(f" Redis avail : {_REDIS_AVAILABLE}")
|
| if _REDIS_AVAILABLE:
|
| print(f" Redis chan : {ABLY_SIGNAL_CHANNEL}")
|
| print(f"{'=' * 62}\n")
|
|
|
| raw = extract_metrics_from_log(log_path=log)
|
|
|
| if raw is None:
|
| print("❌ No metrics found.")
|
| print(" Log file missing or no regex patterns matched.")
|
| print(f"\n Expected patterns in: {log}")
|
| print(" avn_training_steps: <int>")
|
| print(" Actor Loss: <float> | 'actor_loss': <float>")
|
| print(" Critic Loss: <float> | 'critic_loss': <float>")
|
| print(" Avg Loss: <float> | 'avn_loss': <float>")
|
| print(" AVN Accuracy: <float>% | Avg Accuracy: <float>%")
|
| print(" Dominant Signal: BUY|SELL|NEUTRAL")
|
| print(" buy_count: <int> | Buy Votes: <int>")
|
| print(" sell_count: <int> | Sell Votes: <int>")
|
| sys.exit(1)
|
|
|
| print("✅ Raw extracted values (from log):")
|
| print(json.dumps({
|
| "training_steps": raw.training_steps,
|
| "actor_loss": raw.actor_loss,
|
| "critic_loss": raw.critic_loss,
|
| "avn_loss": raw.avn_loss,
|
| "avn_accuracy_pct": raw.avn_accuracy,
|
| "dominant_signal": raw.dominant_signal,
|
| "buy_count": raw.buy_count,
|
| "sell_count": raw.sell_count,
|
| }, indent=2))
|
|
|
| if raw.has_training():
|
| tm = raw.to_training()
|
| print("\n📡 TrainingMetrics (as sent to hub):")
|
| print(f" training_steps = {tm.training_steps}")
|
| print(f" actor_loss = {tm.actor_loss}")
|
| print(f" critic_loss = {tm.critic_loss}")
|
| print(f" avn_loss = {tm.avn_loss}")
|
| print(f" avn_accuracy = {tm.avn_accuracy:.6f} ← 0-1 float, not %")
|
|
|
| if raw.has_voting():
|
| vm = raw.to_voting()
|
| print("\n🗳️ VotingMetrics (log-based — shown as fallback reference):")
|
| print(f" dominant_signal = {vm.dominant_signal}")
|
| print(f" buy_count = {vm.buy_count}")
|
| print(f" sell_count = {vm.sell_count}")
|
| if _REDIS_AVAILABLE:
|
| print(f"\n ℹ️ Redis is available — in production, these log-based voting values")
|
| print(f" will be REPLACED by live Redis signals from {ABLY_SIGNAL_CHANNEL}")
|
| print(f" as soon as the first BUY/SELL signal is received.")
|
| elif _REDIS_AVAILABLE:
|
| print(f"\n ℹ️ No voting data in log. Redis ({ABLY_SIGNAL_CHANNEL}) will supply")
|
| print(f" voting metrics in production.")
|
|
|
| print(f"\n{'=' * 62}")
|
| print(" Log parsing OK — safe to deploy as log_metrics_reader.py")
|
| print(f"{'=' * 62}\n") |