2655 lines
99 KiB
Python
2655 lines
99 KiB
Python
|
|
"""
|
|||
|
|
DOLPHIN-NAUTILUS Real-Time TUI Monitor
|
|||
|
|
Observability/TUI/dolphin_tui.py
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python Observability/TUI/dolphin_tui.py [--hz-host HOST] [--hz-port PORT] [--log-path PATH]
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Standard library
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import asyncio
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Third-party
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
import httpx
|
|||
|
|
from textual.app import App, ComposeResult
|
|||
|
|
from textual.containers import Horizontal
|
|||
|
|
from textual.widgets import Static
|
|||
|
|
try:
|
|||
|
|
from textual.widgets import VerticalScroll
|
|||
|
|
except ImportError:
|
|||
|
|
from textual.containers import VerticalScroll # textual >= 0.47
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import hazelcast
|
|||
|
|
HAZELCAST_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
HAZELCAST_AVAILABLE = False
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Color / theme constants
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
POSTURE_COLORS: dict[str, str] = {
|
|||
|
|
"APEX": "green",
|
|||
|
|
"STALKER": "yellow",
|
|||
|
|
"TURTLE": "dark_orange",
|
|||
|
|
"HIBERNATE": "red",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
STATUS_COLORS: dict[str, str] = {
|
|||
|
|
"GREEN": "green",
|
|||
|
|
"DEGRADED": "yellow",
|
|||
|
|
"CRITICAL": "dark_orange",
|
|||
|
|
"DEAD": "red",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Staleness thresholds (seconds)
|
|||
|
|
STALE_WARN: int = 15 # yellow
|
|||
|
|
STALE_DEAD: int = 60 # red
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Hazelcast map names
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
HZ_MAP_FEATURES = "DOLPHIN_FEATURES"
|
|||
|
|
HZ_MAP_SAFETY = "DOLPHIN_SAFETY"
|
|||
|
|
HZ_MAP_STATE_BLUE = "DOLPHIN_STATE_BLUE"
|
|||
|
|
HZ_MAP_HEARTBEAT = "DOLPHIN_HEARTBEAT"
|
|||
|
|
HZ_MAP_META_HEALTH = "DOLPHIN_META_HEALTH"
|
|||
|
|
HZ_MAP_PNL_BLUE = "DOLPHIN_PNL_BLUE"
|
|||
|
|
|
|||
|
|
# Shard map name template — format with shard index 00-09
|
|||
|
|
HZ_MAP_SHARD_TMPL = "DOLPHIN_FEATURES_SHARD_{:02d}"
|
|||
|
|
HZ_SHARD_COUNT = 10 # shards 00-09
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Hazelcast key names
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
HZ_KEY_EIGEN_SCAN = "latest_eigen_scan"
|
|||
|
|
HZ_KEY_ACB_BOOST = "acb_boost"
|
|||
|
|
HZ_KEY_EXF_LATEST = "exf_latest"
|
|||
|
|
HZ_KEY_ESOF_LATEST = "esof_latest"
|
|||
|
|
HZ_KEY_SAFETY_LATEST = "latest"
|
|||
|
|
HZ_KEY_STATE_LATEST = "latest"
|
|||
|
|
HZ_KEY_STATE_NAUT = "latest_nautilus"
|
|||
|
|
HZ_KEY_HEARTBEAT = "nautilus_flow_heartbeat"
|
|||
|
|
HZ_KEY_META_LATEST = "latest"
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Prefect API
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
PREFECT_BASE_URL = "http://dolphin.taile8ad92.ts.net:4200"
|
|||
|
|
PREFECT_TIMEOUT_S = 2.0
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Poll / reconnect settings
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
POLL_INTERVAL_S = 2.0
|
|||
|
|
RECONNECT_INIT_S = 5.0
|
|||
|
|
RECONNECT_MULT = 1.5
|
|||
|
|
RECONNECT_MAX_S = 60.0
|
|||
|
|
HZ_CONNECT_TIMEOUT_S = 2.0
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Log tail defaults
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
LOG_DEFAULT_PATH = "run_logs/meta_health.log"
|
|||
|
|
LOG_TAIL_LINES = 50
|
|||
|
|
LOG_TAIL_CHUNK_BYTES = 32_768 # 32 KB — enough for 50 lines in most cases
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Terminal size requirements
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
MIN_TERM_WIDTH = 120
|
|||
|
|
MIN_TERM_HEIGHT = 30
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Helper Utilities
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def color_age(age_s: float | None) -> tuple[str, str]:
|
|||
|
|
"""Return (color_class, display_text) for a data-age value in seconds.
|
|||
|
|
|
|||
|
|
Postconditions:
|
|||
|
|
age_s is None → ("dim", "N/A")
|
|||
|
|
age_s < STALE_WARN → ("green", f"{age_s:.1f}s")
|
|||
|
|
age_s < STALE_DEAD → ("yellow", f"{age_s:.1f}s")
|
|||
|
|
age_s >= STALE_DEAD → ("red", f"{age_s:.1f}s")
|
|||
|
|
"""
|
|||
|
|
if age_s is None:
|
|||
|
|
return ("dim", "N/A")
|
|||
|
|
if age_s < STALE_WARN:
|
|||
|
|
return ("green", f"{age_s:.1f}s")
|
|||
|
|
if age_s < STALE_DEAD:
|
|||
|
|
return ("yellow", f"{age_s:.1f}s")
|
|||
|
|
return ("red", f"{age_s:.1f}s")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def rm_bar(rm: float | None, width: int = 20) -> str:
|
|||
|
|
"""Return an ASCII progress bar string representing a risk-management ratio.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
rm: Ratio in [0.0, 1.0], or None.
|
|||
|
|
width: Total number of bar characters (default 20).
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
``"--"`` when *rm* is None, otherwise a string of the form::
|
|||
|
|
|
|||
|
|
[████████░░░░░░░░░░░░] 0.40
|
|||
|
|
|
|||
|
|
Postconditions:
|
|||
|
|
rm is None → "--"
|
|||
|
|
rm in [0.0, 1.0] → len(result) == width + 4
|
|||
|
|
filled = int(rm * width) → first *filled* chars inside brackets are "█"
|
|||
|
|
remaining chars → "░" * (width - filled)
|
|||
|
|
suffix → f"] {rm:.2f}"
|
|||
|
|
"""
|
|||
|
|
if rm is None:
|
|||
|
|
return "--"
|
|||
|
|
filled = int(rm * width)
|
|||
|
|
return "[" + "█" * filled + "░" * (width - filled) + f"] {rm:.2f}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fmt_float(v: float | None, decimals: int = 4) -> str:
|
|||
|
|
"""Format a float value for display, returning ``"--"`` when *v* is None.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
v: The value to format, or None.
|
|||
|
|
decimals: Number of decimal places (default 4).
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
``"--"`` when *v* is None, otherwise ``f"{v:.{decimals}f}"``.
|
|||
|
|
"""
|
|||
|
|
if v is None:
|
|||
|
|
return "--"
|
|||
|
|
return f"{v:.{decimals}f}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fmt_pnl(v: float | None) -> tuple[str, str]:
|
|||
|
|
"""Format a PnL value for display with sign and currency notation.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
v: The PnL value in dollars, or None.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
A ``(color, text)`` tuple where *color* is ``"green"``, ``"red"``, or
|
|||
|
|
``"white"`` and *text* is ``"--"`` when *v* is None, otherwise
|
|||
|
|
``"+$X,XXX.XX"`` for positive values or ``"-$X,XXX.XX"`` for negative.
|
|||
|
|
"""
|
|||
|
|
if v is None:
|
|||
|
|
return ("white", "--")
|
|||
|
|
if v > 0:
|
|||
|
|
return ("green", f"+${v:,.2f}")
|
|||
|
|
if v < 0:
|
|||
|
|
return ("red", f"-${abs(v):,.2f}")
|
|||
|
|
return ("white", f"${v:,.2f}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def posture_color(posture: str | None) -> str:
|
|||
|
|
"""Return the color class for a posture string.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
posture: One of ``"APEX"``, ``"STALKER"``, ``"TURTLE"``,
|
|||
|
|
``"HIBERNATE"``, or any unknown/None value.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
A color class string from :data:`POSTURE_COLORS`, or ``"dim"`` if
|
|||
|
|
*posture* is None or not found in the map.
|
|||
|
|
"""
|
|||
|
|
if posture is None:
|
|||
|
|
return "dim"
|
|||
|
|
return POSTURE_COLORS.get(posture, "dim")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def status_color(status: str | None) -> str:
|
|||
|
|
"""Return the color class for a meta-health status string.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
status: One of ``"GREEN"``, ``"DEGRADED"``, ``"CRITICAL"``,
|
|||
|
|
``"DEAD"``, or any unknown/None value.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
A color class string from :data:`STATUS_COLORS`, or ``"dim"`` if
|
|||
|
|
*status* is None or not found in the map.
|
|||
|
|
"""
|
|||
|
|
if status is None:
|
|||
|
|
return "dim"
|
|||
|
|
return STATUS_COLORS.get(status, "dim")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# CLI argument parsing
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def parse_args() -> argparse.Namespace:
|
|||
|
|
"""Parse command-line arguments for the TUI monitor."""
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
description="DOLPHIN-NAUTILUS Real-Time TUI Monitor",
|
|||
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--hz-host",
|
|||
|
|
default="dolphin.taile8ad92.ts.net",
|
|||
|
|
metavar="HOST",
|
|||
|
|
help="Hazelcast cluster host",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--hz-port",
|
|||
|
|
type=int,
|
|||
|
|
default=5701,
|
|||
|
|
metavar="PORT",
|
|||
|
|
help="Hazelcast cluster port",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--log-path",
|
|||
|
|
default=LOG_DEFAULT_PATH,
|
|||
|
|
metavar="PATH",
|
|||
|
|
help="Path to log file for the log tail panel",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--mock",
|
|||
|
|
action="store_true",
|
|||
|
|
default=False,
|
|||
|
|
help="Populate all panels with mock data (no HZ needed — for UI testing)",
|
|||
|
|
)
|
|||
|
|
return parser.parse_args()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# DataSnapshot — immutable snapshot of all polled data for one render cycle
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class DataSnapshot:
|
|||
|
|
"""Immutable snapshot of all data sources for a single TUI render cycle.
|
|||
|
|
|
|||
|
|
All fields default to None / empty so that a partially-populated snapshot
|
|||
|
|
never causes a KeyError or AttributeError in the UI layer.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# Wall-clock time this snapshot was assembled
|
|||
|
|
ts: float = field(default_factory=time.time)
|
|||
|
|
|
|||
|
|
# Connectivity flags
|
|||
|
|
hz_connected: bool = False
|
|||
|
|
prefect_connected: bool = False
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_FEATURES["latest_eigen_scan"]
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
scan_number: int | None = None
|
|||
|
|
vel_div: float | None = None
|
|||
|
|
w50_velocity: float | None = None
|
|||
|
|
w750_velocity: float | None = None
|
|||
|
|
instability_50: float | None = None
|
|||
|
|
asset_prices: dict[str, float] = field(default_factory=dict)
|
|||
|
|
scan_bridge_ts: str | None = None # ISO-8601 timestamp string
|
|||
|
|
scan_age_s: float | None = None # seconds since bridge_ts
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_FEATURES["acb_boost"]
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
acb_boost: float | None = None
|
|||
|
|
acb_beta: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_FEATURES["exf_latest"] (ExtF)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
funding_btc: float | None = None
|
|||
|
|
dvol_btc: float | None = None
|
|||
|
|
fng: float | None = None
|
|||
|
|
taker: float | None = None
|
|||
|
|
vix: float | None = None
|
|||
|
|
ls_btc: float | None = None
|
|||
|
|
acb_ready: bool | None = None
|
|||
|
|
acb_present: str | None = None # e.g. "9/9"
|
|||
|
|
exf_age_s: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_FEATURES["esof_latest"] (EsoF)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
moon_phase: str | None = None
|
|||
|
|
mercury_retro: bool | None = None
|
|||
|
|
liquidity_session: str | None = None
|
|||
|
|
market_cycle_pos: float | None = None
|
|||
|
|
esof_age_s: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_SAFETY["latest"]
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
posture: str | None = None # APEX / STALKER / TURTLE / HIBERNATE
|
|||
|
|
rm: float | None = None # 0.0 – 1.0
|
|||
|
|
cat1: float | None = None
|
|||
|
|
cat2: float | None = None
|
|||
|
|
cat3: float | None = None
|
|||
|
|
cat4: float | None = None
|
|||
|
|
cat5: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_STATE_BLUE["latest"] (Blue strategy)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
capital: float | None = None
|
|||
|
|
drawdown: float | None = None
|
|||
|
|
peak_capital: float | None = None
|
|||
|
|
pnl: float | None = None
|
|||
|
|
trades: int | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_STATE_BLUE["latest_nautilus"] (Nautilus engine)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
nautilus_capital: float | None = None
|
|||
|
|
nautilus_pnl: float | None = None
|
|||
|
|
nautilus_trades: int | None = None
|
|||
|
|
nautilus_posture: str | None = None
|
|||
|
|
nautilus_param_hash: str | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_HEARTBEAT["nautilus_flow_heartbeat"]
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
heartbeat_ts: float | None = None
|
|||
|
|
heartbeat_phase: str | None = None
|
|||
|
|
heartbeat_flow: str | None = None
|
|||
|
|
heartbeat_age_s: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# DOLPHIN_META_HEALTH["latest"]
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
meta_rm: float | None = None
|
|||
|
|
meta_status: str | None = None # GREEN / DEGRADED / CRITICAL / DEAD
|
|||
|
|
m1_proc: float | None = None
|
|||
|
|
m2_heartbeat: float | None = None
|
|||
|
|
m3_data: float | None = None
|
|||
|
|
m4_cp: float | None = None
|
|||
|
|
m5_coh: float | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# OBF shards (top-5 assets by absolute imbalance)
|
|||
|
|
# Each entry: {asset, imbalance, fill_prob, depth_quality}
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
obf_top: list[dict[str, Any]] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Prefect
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
prefect_healthy: bool = False
|
|||
|
|
prefect_flows: list[dict[str, Any]] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Log tail (last N lines from meta_health.log)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
log_lines: list[str] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# DolphinDataFetcher — async data retrieval from Hazelcast and Prefect
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class DolphinDataFetcher:
|
|||
|
|
"""Encapsulates all data retrieval from Hazelcast and Prefect.
|
|||
|
|
|
|||
|
|
Runs in a background async worker. Returns a DataSnapshot on each poll cycle.
|
|||
|
|
The __init__ only sets up state — actual connection happens in connect_hz().
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
hz_host: str = "dolphin.taile8ad92.ts.net",
|
|||
|
|
hz_port: int = 5701,
|
|||
|
|
log_path: str = "run_logs/meta_health.log",
|
|||
|
|
) -> None:
|
|||
|
|
# Hazelcast connection config
|
|||
|
|
self.hz_host = hz_host
|
|||
|
|
self.hz_port = hz_port
|
|||
|
|
|
|||
|
|
# Hazelcast client state
|
|||
|
|
self.hz_client = None # HazelcastClient instance after connect
|
|||
|
|
self.hz_connected: bool = False
|
|||
|
|
|
|||
|
|
# Reconnect state tracking
|
|||
|
|
self._running: bool = True # set False on shutdown to stop reconnect loop
|
|||
|
|
self._reconnect_task = None # asyncio Task handle
|
|||
|
|
self._reconnect_backoff: float = 5.0 # current backoff delay (s)
|
|||
|
|
self._reconnect_backoff_initial: float = 5.0 # reset value on success
|
|||
|
|
self._reconnect_backoff_max: float = 60.0 # cap
|
|||
|
|
self._reconnect_backoff_multiplier: float = 1.5
|
|||
|
|
|
|||
|
|
# Log path
|
|||
|
|
self.log_path = log_path
|
|||
|
|
|
|||
|
|
async def connect_hz(self) -> bool:
|
|||
|
|
"""Attempt to connect to Hazelcast with a 2-second timeout.
|
|||
|
|
|
|||
|
|
Returns True on success, False on failure. Never raises.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
client = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hazelcast.HazelcastClient(
|
|||
|
|
cluster_name="dolphin",
|
|||
|
|
cluster_members=[f"{self.hz_host}:{self.hz_port}"],
|
|||
|
|
connection_timeout=HZ_CONNECT_TIMEOUT_S,
|
|||
|
|
async_start=False,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
self.hz_client = client
|
|||
|
|
self.hz_connected = True
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
self.hz_connected = False
|
|||
|
|
self._start_reconnect()
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
async def _get_hz_map(self, map_name: str):
|
|||
|
|
"""Safe async map getter — returns a blocking IMap proxy or None on any exception."""
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
hz_map = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: self.hz_client.get_map(map_name).blocking(),
|
|||
|
|
)
|
|||
|
|
return hz_map
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _parse_scan(self, raw_json: str | None) -> dict:
|
|||
|
|
"""Extract all latest_eigen_scan fields from raw JSON string.
|
|||
|
|
|
|||
|
|
Returns a dict with scan fields, all None on failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
scan_number=None,
|
|||
|
|
vel_div=None,
|
|||
|
|
w50_velocity=None,
|
|||
|
|
w750_velocity=None,
|
|||
|
|
instability_50=None,
|
|||
|
|
asset_prices={},
|
|||
|
|
scan_bridge_ts=None,
|
|||
|
|
scan_age_s=None,
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return _none
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_scan: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
print(f"[WARN] _parse_scan: expected JSON object, got {type(data).__name__}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
bridge_ts: str | None = data.get("bridge_ts")
|
|||
|
|
scan_age_s: float | None = None
|
|||
|
|
if bridge_ts is not None:
|
|||
|
|
try:
|
|||
|
|
scan_age_s = time.time() - datetime.fromisoformat(bridge_ts).timestamp()
|
|||
|
|
except (ValueError, OSError):
|
|||
|
|
scan_age_s = None
|
|||
|
|
|
|||
|
|
# Support both flat schema (NG8 direct write) and nested NG7 schema
|
|||
|
|
# NG7: fields are under data["result"], NG8: fields are at top level
|
|||
|
|
result = data.get("result", {}) if isinstance(data.get("result"), dict) else {}
|
|||
|
|
flat = data # NG8 direct write puts vel_div etc. at top level
|
|||
|
|
|
|||
|
|
def _get(key: str, default=None):
|
|||
|
|
"""Try flat first, then nested result."""
|
|||
|
|
v = flat.get(key)
|
|||
|
|
if v is not None:
|
|||
|
|
return v
|
|||
|
|
return result.get(key, default)
|
|||
|
|
|
|||
|
|
# vel_div: flat in NG8, or compute from multi_window_results in NG7
|
|||
|
|
vel_div = _get("vel_div")
|
|||
|
|
if vel_div is None:
|
|||
|
|
mwr = result.get("multi_window_results", {})
|
|||
|
|
w50 = mwr.get("50", {}).get("tracking_data", {})
|
|||
|
|
w150 = mwr.get("150", {}).get("tracking_data", {})
|
|||
|
|
v50 = w50.get("lambda_max_velocity")
|
|||
|
|
v150 = w150.get("lambda_max_velocity")
|
|||
|
|
if v50 is not None and v150 is not None:
|
|||
|
|
vel_div = float(v50) - float(v150)
|
|||
|
|
|
|||
|
|
# w50/w750 velocity
|
|||
|
|
w50_velocity = _get("w50_velocity")
|
|||
|
|
if w50_velocity is None:
|
|||
|
|
mwr = result.get("multi_window_results", {})
|
|||
|
|
w50_velocity = mwr.get("50", {}).get("tracking_data", {}).get("lambda_max_velocity")
|
|||
|
|
|
|||
|
|
w750_velocity = _get("w750_velocity")
|
|||
|
|
if w750_velocity is None:
|
|||
|
|
mwr = result.get("multi_window_results", {})
|
|||
|
|
w750_velocity = mwr.get("750", {}).get("tracking_data", {}).get("lambda_max_velocity")
|
|||
|
|
|
|||
|
|
# instability_50 — from regime_prediction or multi_window_results
|
|||
|
|
instability_50 = _get("instability_50")
|
|||
|
|
if instability_50 is None:
|
|||
|
|
rp = result.get("regime_prediction", {})
|
|||
|
|
mwi = rp.get("multi_window_instabilities", {})
|
|||
|
|
instability_50 = mwi.get("50") or mwi.get(50)
|
|||
|
|
|
|||
|
|
# bridge_ts — try top level then result
|
|||
|
|
if bridge_ts is None:
|
|||
|
|
bridge_ts = result.get("bridge_ts") or result.get("timestamp")
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
scan_number=_get("scan_number"),
|
|||
|
|
vel_div=vel_div,
|
|||
|
|
w50_velocity=w50_velocity,
|
|||
|
|
w750_velocity=w750_velocity,
|
|||
|
|
instability_50=instability_50,
|
|||
|
|
asset_prices=_get("asset_prices") or {},
|
|||
|
|
scan_bridge_ts=bridge_ts,
|
|||
|
|
scan_age_s=scan_age_s,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _parse_safety(self, raw_json: str | None) -> dict:
|
|||
|
|
"""Extract posture, Rm, and Cat1-Cat5 from DOLPHIN_SAFETY["latest"].
|
|||
|
|
|
|||
|
|
Returns a dict with safety fields, all None on failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
posture=None,
|
|||
|
|
rm=None,
|
|||
|
|
cat1=None,
|
|||
|
|
cat2=None,
|
|||
|
|
cat3=None,
|
|||
|
|
cat4=None,
|
|||
|
|
cat5=None,
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return _none
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_safety: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
print(f"[WARN] _parse_safety: expected JSON object, got {type(data).__name__}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
posture=data.get("posture"),
|
|||
|
|
rm=data.get("Rm"),
|
|||
|
|
cat1=data.get("Cat1"),
|
|||
|
|
cat2=data.get("Cat2"),
|
|||
|
|
cat3=data.get("Cat3"),
|
|||
|
|
cat4=data.get("Cat4"),
|
|||
|
|
cat5=data.get("Cat5"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _parse_state(self) -> dict:
|
|||
|
|
"""Extract capital/pnl/trades from DOLPHIN_STATE_BLUE["latest"] and ["latest_nautilus"].
|
|||
|
|
|
|||
|
|
Reads both keys from Hazelcast, parses JSON, and returns a flat dict.
|
|||
|
|
Any missing key or parse error returns None for that field — never raises.
|
|||
|
|
"""
|
|||
|
|
result = dict(
|
|||
|
|
capital=None,
|
|||
|
|
drawdown=None,
|
|||
|
|
peak_capital=None,
|
|||
|
|
pnl=None,
|
|||
|
|
trades=None,
|
|||
|
|
nautilus_capital=None,
|
|||
|
|
nautilus_pnl=None,
|
|||
|
|
nautilus_trades=None,
|
|||
|
|
nautilus_posture=None,
|
|||
|
|
nautilus_param_hash=None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_STATE_BLUE)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# --- DOLPHIN_STATE_BLUE["capital_checkpoint"] or ["latest"] ---
|
|||
|
|
# Live system uses "capital_checkpoint"; legacy used "latest"
|
|||
|
|
for key_latest in ("latest", "capital_checkpoint"):
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_latest = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda k=key_latest: hz_map.get(k),
|
|||
|
|
)
|
|||
|
|
if raw_latest is not None:
|
|||
|
|
data = json.loads(raw_latest)
|
|||
|
|
result["capital"] = data.get("capital")
|
|||
|
|
result["drawdown"] = data.get("drawdown")
|
|||
|
|
result["peak_capital"] = data.get("peak_capital")
|
|||
|
|
result["pnl"] = data.get("pnl")
|
|||
|
|
result["trades"] = data.get("trades")
|
|||
|
|
break # use first key that has data
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_state {key_latest}: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_state {key_latest}: {exc}", file=__import__("sys").stderr)
|
|||
|
|
|
|||
|
|
# --- DOLPHIN_STATE_BLUE["engine_snapshot"] or ["latest_nautilus"] ---
|
|||
|
|
# Live system uses "engine_snapshot"; legacy used "latest_nautilus"
|
|||
|
|
for key_naut in ("latest_nautilus", "engine_snapshot"):
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_naut = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda k=key_naut: hz_map.get(k),
|
|||
|
|
)
|
|||
|
|
if raw_naut is not None:
|
|||
|
|
data = json.loads(raw_naut)
|
|||
|
|
result["nautilus_capital"] = data.get("capital")
|
|||
|
|
result["nautilus_pnl"] = data.get("pnl")
|
|||
|
|
result["nautilus_trades"] = data.get("trades") or data.get("trades_executed")
|
|||
|
|
result["nautilus_posture"] = data.get("posture")
|
|||
|
|
result["nautilus_param_hash"] = data.get("param_hash")
|
|||
|
|
break
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_state {key_naut}: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_state {key_naut}: {exc}", file=__import__("sys").stderr)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
async def _parse_extf(self) -> dict:
|
|||
|
|
"""Extract all ExtF fields + age from DOLPHIN_FEATURES["exf_latest"].
|
|||
|
|
|
|||
|
|
Returns a dict with extf fields, all None on any failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
funding_btc=None,
|
|||
|
|
dvol_btc=None,
|
|||
|
|
fng=None,
|
|||
|
|
taker=None,
|
|||
|
|
vix=None,
|
|||
|
|
ls_btc=None,
|
|||
|
|
acb_ready=None,
|
|||
|
|
acb_present=None,
|
|||
|
|
exf_age_s=None,
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_FEATURES)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_json = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_map.get(HZ_KEY_EXF_LATEST),
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
|
|||
|
|
pushed_at: str | None = data.get("_pushed_at")
|
|||
|
|
exf_age_s: float | None = None
|
|||
|
|
if pushed_at is not None:
|
|||
|
|
try:
|
|||
|
|
ts_str = pushed_at.replace("Z", "+00:00")
|
|||
|
|
exf_age_s = time.time() - datetime.fromisoformat(ts_str).timestamp()
|
|||
|
|
except (ValueError, OSError):
|
|||
|
|
exf_age_s = None
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
funding_btc=data.get("funding_btc"),
|
|||
|
|
dvol_btc=data.get("dvol_btc"),
|
|||
|
|
fng=data.get("fng"),
|
|||
|
|
taker=data.get("taker"),
|
|||
|
|
vix=data.get("vix"),
|
|||
|
|
ls_btc=data.get("ls_btc"),
|
|||
|
|
acb_ready=data.get("_acb_ready"),
|
|||
|
|
acb_present=data.get("_acb_present"),
|
|||
|
|
exf_age_s=exf_age_s,
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_extf: {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
async def _parse_esof(self) -> dict:
|
|||
|
|
"""Extract all EsoF fields + age from DOLPHIN_FEATURES["esof_latest"].
|
|||
|
|
|
|||
|
|
Returns a dict with esof fields, all None on any failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
moon_phase=None,
|
|||
|
|
mercury_retro=None,
|
|||
|
|
liquidity_session=None,
|
|||
|
|
market_cycle_pos=None,
|
|||
|
|
esof_age_s=None,
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_FEATURES)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_json = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_map.get(HZ_KEY_ESOF_LATEST),
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_esof: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
pushed_at: str | None = data.get("_pushed_at")
|
|||
|
|
esof_age_s: float | None = None
|
|||
|
|
if pushed_at is not None:
|
|||
|
|
try:
|
|||
|
|
ts_str = pushed_at.replace("Z", "+00:00")
|
|||
|
|
esof_age_s = time.time() - datetime.fromisoformat(ts_str).timestamp()
|
|||
|
|
except (ValueError, OSError):
|
|||
|
|
esof_age_s = None
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
moon_phase=data.get("moon_phase_name"),
|
|||
|
|
mercury_retro=data.get("mercury_retrograde"),
|
|||
|
|
liquidity_session=data.get("liquidity_session"),
|
|||
|
|
market_cycle_pos=data.get("market_cycle_position"),
|
|||
|
|
esof_age_s=esof_age_s,
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_esof: {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
async def _parse_meta_health(self) -> dict:
|
|||
|
|
"""Extract rm_meta, status, and M1-M5 scores from DOLPHIN_META_HEALTH["latest"].
|
|||
|
|
|
|||
|
|
Returns a dict with meta health fields, all None on any failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
meta_rm=None,
|
|||
|
|
meta_status=None,
|
|||
|
|
m1_proc=None,
|
|||
|
|
m2_heartbeat=None,
|
|||
|
|
m3_data=None,
|
|||
|
|
m4_cp=None,
|
|||
|
|
m5_coh=None,
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_META_HEALTH)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_json = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_map.get(HZ_KEY_META_LATEST),
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_meta_health: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
meta_rm=data.get("rm_meta"),
|
|||
|
|
meta_status=data.get("status"),
|
|||
|
|
m1_proc=data.get("m1_proc"),
|
|||
|
|
m2_heartbeat=data.get("m2_heartbeat"),
|
|||
|
|
m3_data=data.get("m3_data_freshness"),
|
|||
|
|
m4_cp=data.get("m4_control_plane"),
|
|||
|
|
m5_coh=data.get("m5_coherence"),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_meta_health: {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
async def _parse_daily_pnl(self) -> dict | None:
|
|||
|
|
"""Extract pnl, capital, trades, boost, mc_status from DOLPHIN_PNL_BLUE[YYYY-MM-DD].
|
|||
|
|
|
|||
|
|
Returns a dict with daily pnl fields, or None if the key is absent or on any failure.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_PNL_BLUE)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
date_key = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_json = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_map.get(date_key),
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as exc:
|
|||
|
|
print(f"[WARN] _parse_daily_pnl: malformed JSON — {exc}", file=__import__("sys").stderr)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return dict(
|
|||
|
|
pnl=data.get("pnl"),
|
|||
|
|
capital=data.get("capital"),
|
|||
|
|
trades=data.get("trades"),
|
|||
|
|
boost=data.get("boost"),
|
|||
|
|
mc_status=data.get("mc_status"),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_daily_pnl: {exc}", file=__import__("sys").stderr)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _parse_heartbeat(self, raw: str | None) -> dict:
|
|||
|
|
"""Extract ts, phase, flow and compute heartbeat_age_s from DOLPHIN_HEARTBEAT["nautilus_flow_heartbeat"].
|
|||
|
|
|
|||
|
|
Returns a dict with heartbeat fields, all None on failure.
|
|||
|
|
"""
|
|||
|
|
_none = dict(
|
|||
|
|
heartbeat_ts=None,
|
|||
|
|
heartbeat_phase=None,
|
|||
|
|
heartbeat_flow=None,
|
|||
|
|
heartbeat_age_s=None,
|
|||
|
|
)
|
|||
|
|
if raw is None:
|
|||
|
|
return _none
|
|||
|
|
try:
|
|||
|
|
data = json.loads(raw)
|
|||
|
|
ts: float = data["ts"]
|
|||
|
|
return dict(
|
|||
|
|
heartbeat_ts=ts,
|
|||
|
|
heartbeat_phase=data.get("phase"),
|
|||
|
|
heartbeat_flow=data.get("flow"),
|
|||
|
|
heartbeat_age_s=time.time() - ts,
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _parse_heartbeat: {exc}", file=__import__("sys").stderr)
|
|||
|
|
return _none
|
|||
|
|
|
|||
|
|
async def _sample_obf_shards(self) -> list[dict]:
|
|||
|
|
"""Read OBF data from DOLPHIN_FEATURES asset_*_ob keys.
|
|||
|
|
|
|||
|
|
Live system stores OBF as asset_BTCUSDT_ob etc. in DOLPHIN_FEATURES,
|
|||
|
|
not in DOLPHIN_FEATURES_SHARD_* maps (which are empty).
|
|||
|
|
Returns top 5 assets by abs(imbalance).
|
|||
|
|
"""
|
|||
|
|
if not self.hz_connected:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
assets: list[dict] = []
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
hz_map = await self._get_hz_map(HZ_MAP_FEATURES)
|
|||
|
|
if hz_map is None:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
all_keys = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda m=hz_map: m.key_set(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
ob_keys = [k for k in all_keys if k.startswith("asset_") and k.endswith("_ob")]
|
|||
|
|
|
|||
|
|
for key in ob_keys[:50]: # cap at 50 to avoid slow scans
|
|||
|
|
try:
|
|||
|
|
raw_json = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda m=hz_map, k=key: m.get(k),
|
|||
|
|
)
|
|||
|
|
if raw_json is None:
|
|||
|
|
continue
|
|||
|
|
data = json.loads(raw_json)
|
|||
|
|
imbalance = data.get("imbalance")
|
|||
|
|
if imbalance is None:
|
|||
|
|
continue
|
|||
|
|
asset_name = key[len("asset_"):-len("_ob")]
|
|||
|
|
fill_prob = data.get("fill_prob") or data.get("fill_probability")
|
|||
|
|
depth_quality = data.get("depth_quality")
|
|||
|
|
assets.append(dict(
|
|||
|
|
asset=asset_name,
|
|||
|
|
imbalance=imbalance,
|
|||
|
|
fill_prob=fill_prob,
|
|||
|
|
depth_quality=depth_quality,
|
|||
|
|
))
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _sample_obf key {key}: {exc}", file=__import__("sys").stderr)
|
|||
|
|
continue
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] _sample_obf_shards: {exc}", file=__import__("sys").stderr)
|
|||
|
|
|
|||
|
|
if not assets:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
assets.sort(key=lambda x: abs(x["imbalance"]), reverse=True)
|
|||
|
|
return assets[:5]
|
|||
|
|
|
|||
|
|
async def _reconnect_loop(self) -> None:
|
|||
|
|
"""Background task: retry HZ connection with exponential backoff.
|
|||
|
|
|
|||
|
|
Initial delay = RECONNECT_INIT_S (5s), multiplier = RECONNECT_MULT (1.5x),
|
|||
|
|
cap = RECONNECT_MAX_S (60s). Resets backoff to initial on success.
|
|||
|
|
Exits once connected — the main fetch() will restart it if needed.
|
|||
|
|
"""
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
self._reconnect_backoff = self._reconnect_backoff_initial
|
|||
|
|
|
|||
|
|
while self.hz_client is None and self._running:
|
|||
|
|
await asyncio.sleep(self._reconnect_backoff)
|
|||
|
|
if not self._running:
|
|||
|
|
break
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
client = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hazelcast.HazelcastClient(
|
|||
|
|
cluster_name="dolphin",
|
|||
|
|
cluster_members=[f"{self.hz_host}:{self.hz_port}"],
|
|||
|
|
connection_timeout=HZ_CONNECT_TIMEOUT_S,
|
|||
|
|
async_start=False,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
self.hz_client = client
|
|||
|
|
self.hz_connected = True
|
|||
|
|
self._reconnect_backoff = self._reconnect_backoff_initial # reset on success
|
|||
|
|
print("[INFO] _reconnect_loop: reconnected to Hazelcast", file=sys.stderr)
|
|||
|
|
except Exception:
|
|||
|
|
self._reconnect_backoff = min(
|
|||
|
|
self._reconnect_backoff * self._reconnect_backoff_multiplier,
|
|||
|
|
self._reconnect_backoff_max,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _start_reconnect(self) -> None:
|
|||
|
|
"""Start the reconnect background task if not already running."""
|
|||
|
|
if not HAZELCAST_AVAILABLE:
|
|||
|
|
return
|
|||
|
|
# Only start a new task if there isn't one already running
|
|||
|
|
if self._reconnect_task is None or self._reconnect_task.done():
|
|||
|
|
try:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
|||
|
|
except RuntimeError:
|
|||
|
|
pass # No running event loop — will be started later
|
|||
|
|
|
|||
|
|
def start_reconnect_task(self) -> None:
|
|||
|
|
"""Public method to launch _reconnect_loop() as a background asyncio task.
|
|||
|
|
|
|||
|
|
Stores the task reference in self._reconnect_task for cleanup.
|
|||
|
|
No-op if Hazelcast is not available or a task is already running.
|
|||
|
|
"""
|
|||
|
|
self._start_reconnect()
|
|||
|
|
|
|||
|
|
async def disconnect_hz(self) -> None:
|
|||
|
|
"""Shut down the Hazelcast client cleanly. Never raises."""
|
|||
|
|
self._running = False
|
|||
|
|
# Cancel the reconnect background task if it's still running
|
|||
|
|
if self._reconnect_task is not None and not self._reconnect_task.done():
|
|||
|
|
self._reconnect_task.cancel()
|
|||
|
|
try:
|
|||
|
|
await self._reconnect_task
|
|||
|
|
except (asyncio.CancelledError, Exception):
|
|||
|
|
pass
|
|||
|
|
try:
|
|||
|
|
if self.hz_client is not None:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
await loop.run_in_executor(None, self.hz_client.shutdown)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
finally:
|
|||
|
|
self.hz_client = None
|
|||
|
|
self.hz_connected = False
|
|||
|
|
|
|||
|
|
async def fetch(self) -> DataSnapshot:
|
|||
|
|
"""Orchestrate all _parse_* calls and assemble a DataSnapshot.
|
|||
|
|
|
|||
|
|
Always returns a DataSnapshot — never raises. If hz_client is None,
|
|||
|
|
all HZ-derived fields are None and hz_connected=False.
|
|||
|
|
"""
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
ts = time.time()
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Short-circuit: no HZ client available
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
if self.hz_client is None or not self.hz_connected:
|
|||
|
|
# Ensure reconnect loop is running
|
|||
|
|
self._start_reconnect()
|
|||
|
|
prefect_healthy, prefect_flows = await self.fetch_prefect()
|
|||
|
|
log_lines = self.tail_log(self.log_path, LOG_TAIL_LINES)
|
|||
|
|
return DataSnapshot(
|
|||
|
|
ts=ts,
|
|||
|
|
hz_connected=False,
|
|||
|
|
prefect_connected=prefect_healthy,
|
|||
|
|
prefect_healthy=prefect_healthy,
|
|||
|
|
prefect_flows=prefect_flows,
|
|||
|
|
log_lines=log_lines,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Fetch raw values from HZ maps that need raw JSON passed to sync parsers
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# --- DOLPHIN_FEATURES["latest_eigen_scan"] ---
|
|||
|
|
raw_scan: str | None = None
|
|||
|
|
try:
|
|||
|
|
hz_features = await self._get_hz_map(HZ_MAP_FEATURES)
|
|||
|
|
if hz_features is not None:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_scan = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_features.get(HZ_KEY_EIGEN_SCAN),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: get latest_eigen_scan: {exc}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
# --- DOLPHIN_SAFETY["latest"] ---
|
|||
|
|
raw_safety: str | None = None
|
|||
|
|
try:
|
|||
|
|
hz_safety = await self._get_hz_map(HZ_MAP_SAFETY)
|
|||
|
|
if hz_safety is not None:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_safety = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_safety.get(HZ_KEY_SAFETY_LATEST),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: get safety latest: {exc}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
# --- DOLPHIN_HEARTBEAT["nautilus_flow_heartbeat"] ---
|
|||
|
|
raw_heartbeat: str | None = None
|
|||
|
|
try:
|
|||
|
|
hz_heartbeat = await self._get_hz_map(HZ_MAP_HEARTBEAT)
|
|||
|
|
if hz_heartbeat is not None:
|
|||
|
|
loop = asyncio.get_event_loop()
|
|||
|
|
raw_heartbeat = await loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: hz_heartbeat.get(HZ_KEY_HEARTBEAT),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: get heartbeat: {exc}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Run all async parsers + Prefect concurrently
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
(
|
|||
|
|
state_d,
|
|||
|
|
extf_d,
|
|||
|
|
esof_d,
|
|||
|
|
meta_d,
|
|||
|
|
obf_top,
|
|||
|
|
(prefect_healthy, prefect_flows),
|
|||
|
|
) = await asyncio.gather(
|
|||
|
|
self._parse_state(),
|
|||
|
|
self._parse_extf(),
|
|||
|
|
self._parse_esof(),
|
|||
|
|
self._parse_meta_health(),
|
|||
|
|
self._sample_obf_shards(),
|
|||
|
|
self.fetch_prefect(),
|
|||
|
|
return_exceptions=False,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Run sync parsers (they only parse already-fetched raw strings)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
scan_d: dict = {}
|
|||
|
|
try:
|
|||
|
|
scan_d = self._parse_scan(raw_scan)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: _parse_scan: {exc}", file=sys.stderr)
|
|||
|
|
scan_d = dict(
|
|||
|
|
scan_number=None, vel_div=None, w50_velocity=None,
|
|||
|
|
w750_velocity=None, instability_50=None, asset_prices={},
|
|||
|
|
scan_bridge_ts=None, scan_age_s=None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
safety_d: dict = {}
|
|||
|
|
try:
|
|||
|
|
safety_d = self._parse_safety(raw_safety)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: _parse_safety: {exc}", file=sys.stderr)
|
|||
|
|
safety_d = dict(posture=None, rm=None, cat1=None, cat2=None,
|
|||
|
|
cat3=None, cat4=None, cat5=None)
|
|||
|
|
|
|||
|
|
heartbeat_d: dict = {}
|
|||
|
|
try:
|
|||
|
|
heartbeat_d = self._parse_heartbeat(raw_heartbeat)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: _parse_heartbeat: {exc}", file=sys.stderr)
|
|||
|
|
heartbeat_d = dict(heartbeat_ts=None, heartbeat_phase=None,
|
|||
|
|
heartbeat_flow=None, heartbeat_age_s=None)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Log tail (sync, fast)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
log_lines: list[str] = []
|
|||
|
|
try:
|
|||
|
|
log_lines = self.tail_log(self.log_path, LOG_TAIL_LINES)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[WARN] fetch: tail_log: {exc}", file=sys.stderr)
|
|||
|
|
log_lines = [f"Log error: {exc}"]
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Assemble and return DataSnapshot
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
return DataSnapshot(
|
|||
|
|
ts=ts,
|
|||
|
|
hz_connected=True,
|
|||
|
|
prefect_connected=prefect_healthy,
|
|||
|
|
# scan
|
|||
|
|
scan_number=scan_d.get("scan_number"),
|
|||
|
|
vel_div=scan_d.get("vel_div"),
|
|||
|
|
w50_velocity=scan_d.get("w50_velocity"),
|
|||
|
|
w750_velocity=scan_d.get("w750_velocity"),
|
|||
|
|
instability_50=scan_d.get("instability_50"),
|
|||
|
|
asset_prices=scan_d.get("asset_prices") or {},
|
|||
|
|
scan_bridge_ts=scan_d.get("scan_bridge_ts"),
|
|||
|
|
scan_age_s=scan_d.get("scan_age_s"),
|
|||
|
|
# safety
|
|||
|
|
posture=safety_d.get("posture"),
|
|||
|
|
rm=safety_d.get("rm"),
|
|||
|
|
cat1=safety_d.get("cat1"),
|
|||
|
|
cat2=safety_d.get("cat2"),
|
|||
|
|
cat3=safety_d.get("cat3"),
|
|||
|
|
cat4=safety_d.get("cat4"),
|
|||
|
|
cat5=safety_d.get("cat5"),
|
|||
|
|
# state (blue + nautilus)
|
|||
|
|
capital=state_d.get("capital"),
|
|||
|
|
drawdown=state_d.get("drawdown"),
|
|||
|
|
peak_capital=state_d.get("peak_capital"),
|
|||
|
|
pnl=state_d.get("pnl"),
|
|||
|
|
trades=state_d.get("trades"),
|
|||
|
|
nautilus_capital=state_d.get("nautilus_capital"),
|
|||
|
|
nautilus_pnl=state_d.get("nautilus_pnl"),
|
|||
|
|
nautilus_trades=state_d.get("nautilus_trades"),
|
|||
|
|
nautilus_posture=state_d.get("nautilus_posture"),
|
|||
|
|
nautilus_param_hash=state_d.get("nautilus_param_hash"),
|
|||
|
|
# extf
|
|||
|
|
funding_btc=extf_d.get("funding_btc"),
|
|||
|
|
dvol_btc=extf_d.get("dvol_btc"),
|
|||
|
|
fng=extf_d.get("fng"),
|
|||
|
|
taker=extf_d.get("taker"),
|
|||
|
|
vix=extf_d.get("vix"),
|
|||
|
|
ls_btc=extf_d.get("ls_btc"),
|
|||
|
|
acb_ready=extf_d.get("acb_ready"),
|
|||
|
|
acb_present=extf_d.get("acb_present"),
|
|||
|
|
exf_age_s=extf_d.get("exf_age_s"),
|
|||
|
|
# esof
|
|||
|
|
moon_phase=esof_d.get("moon_phase"),
|
|||
|
|
mercury_retro=esof_d.get("mercury_retro"),
|
|||
|
|
liquidity_session=esof_d.get("liquidity_session"),
|
|||
|
|
market_cycle_pos=esof_d.get("market_cycle_pos"),
|
|||
|
|
esof_age_s=esof_d.get("esof_age_s"),
|
|||
|
|
# heartbeat
|
|||
|
|
heartbeat_ts=heartbeat_d.get("heartbeat_ts"),
|
|||
|
|
heartbeat_phase=heartbeat_d.get("heartbeat_phase"),
|
|||
|
|
heartbeat_flow=heartbeat_d.get("heartbeat_flow"),
|
|||
|
|
heartbeat_age_s=heartbeat_d.get("heartbeat_age_s"),
|
|||
|
|
# meta health
|
|||
|
|
meta_rm=meta_d.get("meta_rm"),
|
|||
|
|
meta_status=meta_d.get("meta_status"),
|
|||
|
|
m1_proc=meta_d.get("m1_proc"),
|
|||
|
|
m2_heartbeat=meta_d.get("m2_heartbeat"),
|
|||
|
|
m3_data=meta_d.get("m3_data"),
|
|||
|
|
m4_cp=meta_d.get("m4_cp"),
|
|||
|
|
m5_coh=meta_d.get("m5_coh"),
|
|||
|
|
# obf
|
|||
|
|
obf_top=obf_top if isinstance(obf_top, list) else [],
|
|||
|
|
# prefect
|
|||
|
|
prefect_healthy=prefect_healthy,
|
|||
|
|
prefect_flows=prefect_flows if isinstance(prefect_flows, list) else [],
|
|||
|
|
# log
|
|||
|
|
log_lines=log_lines,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def fetch_prefect(self) -> tuple[bool, list[dict]]:
|
|||
|
|
"""Fetch Prefect API health and last 5 flow runs.
|
|||
|
|
|
|||
|
|
Makes async httpx GETs to /api/health and /api/flow-runs with a 2s timeout.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(healthy, flows_list) where:
|
|||
|
|
- healthy: True if /api/health returned HTTP 200, False otherwise
|
|||
|
|
- flows_list: list of dicts with keys name, status, start_time, duration
|
|||
|
|
Empty list if Prefect is unreachable or returns no data.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
async with httpx.AsyncClient(timeout=PREFECT_TIMEOUT_S) as client:
|
|||
|
|
# Check health
|
|||
|
|
try:
|
|||
|
|
health_resp = await client.get(f"{PREFECT_BASE_URL}/api/health")
|
|||
|
|
healthy = health_resp.status_code == 200
|
|||
|
|
except Exception:
|
|||
|
|
return (False, [])
|
|||
|
|
|
|||
|
|
# Fetch last 5 flow runs
|
|||
|
|
flows_list: list[dict] = []
|
|||
|
|
try:
|
|||
|
|
flows_resp = await client.get(
|
|||
|
|
f"{PREFECT_BASE_URL}/api/flow-runs",
|
|||
|
|
params={"limit": 5, "sort": "START_TIME_DESC"},
|
|||
|
|
)
|
|||
|
|
if flows_resp.status_code == 200:
|
|||
|
|
raw_flows = flows_resp.json()
|
|||
|
|
for run in raw_flows:
|
|||
|
|
state = run.get("state") or {}
|
|||
|
|
flows_list.append(dict(
|
|||
|
|
name=run.get("name"),
|
|||
|
|
status=state.get("type"),
|
|||
|
|
start_time=run.get("start_time"),
|
|||
|
|
duration=run.get("total_run_time"),
|
|||
|
|
))
|
|||
|
|
except Exception:
|
|||
|
|
pass # flows_list stays empty; healthy is still valid
|
|||
|
|
|
|||
|
|
return (healthy, flows_list)
|
|||
|
|
except Exception:
|
|||
|
|
return (False, [])
|
|||
|
|
|
|||
|
|
def tail_log(self, path: str, n: int = 50) -> list[str]:
|
|||
|
|
"""Read the last n lines of a log file efficiently using seek(-chunk, 2).
|
|||
|
|
|
|||
|
|
Uses a chunk-based approach to avoid reading the full file (REQ 13.3).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
path: Path to the log file.
|
|||
|
|
n: Number of lines to return (default 50, per REQ 9.1).
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
List of the last n non-empty lines, or an error/not-found message list.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
chunk_size = max(8192, n * 200)
|
|||
|
|
with open(path, "rb") as f:
|
|||
|
|
try:
|
|||
|
|
f.seek(-chunk_size, 2)
|
|||
|
|
except OSError:
|
|||
|
|
# File is smaller than chunk_size — read from start
|
|||
|
|
f.seek(0)
|
|||
|
|
data = f.read()
|
|||
|
|
|
|||
|
|
lines = [ln for ln in data.decode("utf-8", errors="replace").splitlines() if ln.strip()]
|
|||
|
|
return lines[-n:]
|
|||
|
|
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
return [f"Log not found: {path}"]
|
|||
|
|
except Exception as exc:
|
|||
|
|
return [f"Log error: {exc}"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# HeaderBar — top bar widget (Req 11.1, 11.2, 11.3)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
TUI_VERSION = "1.1.0"
|
|||
|
|
TUI_BUILD_DATE = "2026-04-03"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class HeaderBar(Static):
|
|||
|
|
"""Top-bar widget: app name, UTC clock (1s updates), HZ badge, meta status badge.
|
|||
|
|
|
|||
|
|
Updated by:
|
|||
|
|
- App's 1s timer → calls update_clock()
|
|||
|
|
- _apply_snapshot() → calls update_status(hz_connected, meta_status)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, hz_host: str = "?", **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._hz_connected: bool = False
|
|||
|
|
self._meta_status: str | None = None
|
|||
|
|
self._hz_host: str = hz_host
|
|||
|
|
self._start_time: datetime = datetime.now(timezone.utc)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full header markup string from current state."""
|
|||
|
|
clock_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|||
|
|
|
|||
|
|
uptime_s = int((datetime.now(timezone.utc) - self._start_time).total_seconds())
|
|||
|
|
h, rem = divmod(uptime_s, 3600)
|
|||
|
|
m, s = divmod(rem, 60)
|
|||
|
|
uptime_str = f"{h:02d}:{m:02d}:{s:02d}"
|
|||
|
|
|
|||
|
|
hz_badge = "[green][HZ ✓][/green]" if self._hz_connected else "[red][HZ ✗][/red]"
|
|||
|
|
|
|||
|
|
if self._meta_status is not None:
|
|||
|
|
color = STATUS_COLORS.get(self._meta_status, "dim")
|
|||
|
|
status_badge = f"[{color}]● {self._meta_status}[/{color}]"
|
|||
|
|
else:
|
|||
|
|
status_badge = "[dim]● --[/dim]"
|
|||
|
|
|
|||
|
|
# Line 1: identity + clock
|
|||
|
|
line1 = (
|
|||
|
|
f"[bold cyan]🐬 DOLPHIN-NAUTILUS MONITOR[/bold cyan]"
|
|||
|
|
f" v{TUI_VERSION} ({TUI_BUILD_DATE})"
|
|||
|
|
f" │ {clock_str}"
|
|||
|
|
f" │ up {uptime_str}"
|
|||
|
|
f" │ {status_badge} {hz_badge}"
|
|||
|
|
)
|
|||
|
|
# Line 2: static startup info — always visible, no HZ needed
|
|||
|
|
line2 = (
|
|||
|
|
f"[dim] HZ: {self._hz_host}"
|
|||
|
|
f" │ Python TUI │ q=quit r=refresh l=log ↑↓=scroll[/dim]"
|
|||
|
|
)
|
|||
|
|
return f"{line1}\n{line2}"
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update methods
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_clock(self) -> None:
|
|||
|
|
"""Refresh the clock display. Called by the app's 1s timer (Req 11.1)."""
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
def update_status(self, hz_connected: bool, meta_status: str | None) -> None:
|
|||
|
|
"""Update HZ badge and meta status badge. Called from _apply_snapshot() (Req 11.2, 11.3)."""
|
|||
|
|
self._hz_connected = hz_connected
|
|||
|
|
self._meta_status = meta_status
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# SystemHealthPanel — system health widget (Req 6.1, 6.2, 6.3)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class SystemHealthPanel(Static):
|
|||
|
|
"""System health panel: rm_meta gauge, M1-M5 scores, status with color dot.
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# rm_meta — 3 decimal places
|
|||
|
|
if snap is not None and snap.meta_rm is not None:
|
|||
|
|
rm_meta_str = fmt_float(snap.meta_rm, decimals=3)
|
|||
|
|
else:
|
|||
|
|
rm_meta_str = "--"
|
|||
|
|
|
|||
|
|
# M1-M5 — 1 decimal place each
|
|||
|
|
def _m(v: float | None) -> str:
|
|||
|
|
return fmt_float(v, decimals=1) if v is not None else "--"
|
|||
|
|
|
|||
|
|
m1 = _m(snap.m1_proc if snap else None)
|
|||
|
|
m2 = _m(snap.m2_heartbeat if snap else None)
|
|||
|
|
m3 = _m(snap.m3_data if snap else None)
|
|||
|
|
m4 = _m(snap.m4_cp if snap else None)
|
|||
|
|
m5 = _m(snap.m5_coh if snap else None)
|
|||
|
|
|
|||
|
|
# Status line with colored dot
|
|||
|
|
meta_status = snap.meta_status if snap is not None else None
|
|||
|
|
if meta_status is not None:
|
|||
|
|
color = STATUS_COLORS.get(meta_status, "dim")
|
|||
|
|
status_line = f"[{color}]● {meta_status}[/{color}]"
|
|||
|
|
else:
|
|||
|
|
status_line = "[dim]● --[/dim]"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]SYSTEM HEALTH[/bold]\n"
|
|||
|
|
f"rm_meta: {rm_meta_str}\n"
|
|||
|
|
f"M1: {m1} M2: {m2}\n"
|
|||
|
|
f"M3: {m3} M4: {m4}\n"
|
|||
|
|
f"M5: {m5}\n"
|
|||
|
|
f"Status: {status_line}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# AlphaEnginePanel — alpha engine widget (Req 3.1, 3.2, 3.3, 3.4)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class AlphaEnginePanel(Static):
|
|||
|
|
"""Alpha engine panel: posture (colored), Rm bar, ACB boost/beta, Cat1-Cat5.
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data (Req 12.3).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Posture with color (Req 3.1) ---
|
|||
|
|
posture = snap.posture if snap is not None else None
|
|||
|
|
if posture is not None:
|
|||
|
|
color = posture_color(posture)
|
|||
|
|
posture_str = f"[{color}]{posture}[/{color}]"
|
|||
|
|
else:
|
|||
|
|
posture_str = "[dim]--[/dim]"
|
|||
|
|
|
|||
|
|
# --- Rm bar (Req 3.2) ---
|
|||
|
|
rm = snap.rm if snap is not None else None
|
|||
|
|
rm_str = rm_bar(rm) # returns "--" when rm is None
|
|||
|
|
|
|||
|
|
# --- ACB boost / beta (Req 3.3) ---
|
|||
|
|
boost = snap.acb_boost if snap is not None else None
|
|||
|
|
beta = snap.acb_beta if snap is not None else None
|
|||
|
|
boost_str = fmt_float(boost, decimals=2) if boost is not None else "--"
|
|||
|
|
beta_str = fmt_float(beta, decimals=2) if beta is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Cat1-Cat5 (Req 3.4) — 2 decimal places, right-aligned in 4 chars (Req 15.3) ---
|
|||
|
|
def _cat(v: float | None) -> str:
|
|||
|
|
return f"{v:>6.2f}" if v is not None else " --"
|
|||
|
|
|
|||
|
|
c1 = _cat(snap.cat1 if snap else None)
|
|||
|
|
c2 = _cat(snap.cat2 if snap else None)
|
|||
|
|
c3 = _cat(snap.cat3 if snap else None)
|
|||
|
|
c4 = _cat(snap.cat4 if snap else None)
|
|||
|
|
c5 = _cat(snap.cat5 if snap else None)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]ALPHA ENGINE[/bold]\n"
|
|||
|
|
f"Posture: {posture_str}\n"
|
|||
|
|
f"Rm: {rm_str}\n"
|
|||
|
|
f"ACB: {boost_str}x β={beta_str}\n"
|
|||
|
|
f"Cat1:{c1} Cat2:{c2}\n"
|
|||
|
|
f"Cat3:{c3} Cat4:{c4}\n"
|
|||
|
|
f"Cat5:{c5}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# ScanBridgePanel — scan bridge / NG7 widget (Req 2.1, 2.2, 2.3, 2.4)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class ScanBridgePanel(Static):
|
|||
|
|
"""Scan bridge / NG7 panel: scan#, vel_div, w50/w750 velocity, instability_50,
|
|||
|
|
bridge_ts, and scan age (color-coded by staleness).
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data (Req 12.3).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Scan number (Req 2.1) ---
|
|||
|
|
scan_num = snap.scan_number if snap is not None else None
|
|||
|
|
scan_str = str(scan_num) if scan_num is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Scan age with color coding (Req 2.2, 2.4) ---
|
|||
|
|
age_s = snap.scan_age_s if snap is not None else None
|
|||
|
|
age_color, age_text = color_age(age_s)
|
|||
|
|
age_str = f"[{age_color}]{age_text} ●[/{age_color}]"
|
|||
|
|
|
|||
|
|
# --- Numeric fields (Req 2.3) — 4 decimal places ---
|
|||
|
|
vel_div_str = fmt_float(snap.vel_div if snap else None, decimals=4)
|
|||
|
|
w50_vel_str = fmt_float(snap.w50_velocity if snap else None, decimals=4)
|
|||
|
|
w750_vel_str = fmt_float(snap.w750_velocity if snap else None, decimals=4)
|
|||
|
|
instab50_str = fmt_float(snap.instability_50 if snap else None, decimals=4)
|
|||
|
|
|
|||
|
|
# --- bridge_ts — format as HH:MM:SS UTC when present ---
|
|||
|
|
bridge_ts_raw = snap.scan_bridge_ts if snap is not None else None
|
|||
|
|
if bridge_ts_raw is not None:
|
|||
|
|
try:
|
|||
|
|
dt = datetime.fromisoformat(bridge_ts_raw.replace("Z", "+00:00"))
|
|||
|
|
bridge_ts_str = dt.strftime("%H:%M:%S UTC")
|
|||
|
|
except (ValueError, AttributeError):
|
|||
|
|
bridge_ts_str = bridge_ts_raw
|
|||
|
|
else:
|
|||
|
|
bridge_ts_str = "--"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]SCAN BRIDGE / NG7[/bold]\n"
|
|||
|
|
f"Scan #{scan_str} Age: {age_str}\n"
|
|||
|
|
f"vel_div: {vel_div_str}\n"
|
|||
|
|
f"w50_vel: {w50_vel_str}\n"
|
|||
|
|
f"w750_vel: {w750_vel_str}\n"
|
|||
|
|
f"instab50: {instab50_str}\n"
|
|||
|
|
f"bridge_ts: {bridge_ts_str}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# ExtFPanel — external features widget (Req 4.1, 4.2, 4.3)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class ExtFPanel(Static):
|
|||
|
|
"""ExtF panel: funding_btc, dvol_btc, fng, taker, vix, ls_btc,
|
|||
|
|
ACB readiness (boolean ✓/✗) and present count, and data age (color-coded).
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data (Req 12.3).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Numeric fields (Req 4.1) — 4 decimal places ---
|
|||
|
|
funding_str = fmt_float(snap.funding_btc if snap else None, decimals=4)
|
|||
|
|
dvol_str = fmt_float(snap.dvol_btc if snap else None, decimals=4)
|
|||
|
|
fng_str = fmt_float(snap.fng if snap else None, decimals=4)
|
|||
|
|
taker_str = fmt_float(snap.taker if snap else None, decimals=4)
|
|||
|
|
vix_str = fmt_float(snap.vix if snap else None, decimals=4)
|
|||
|
|
ls_btc_str = fmt_float(snap.ls_btc if snap else None, decimals=4)
|
|||
|
|
|
|||
|
|
# --- ACB readiness (Req 4.2) — boolean → ✓ (green) or ✗ (red) ---
|
|||
|
|
acb_ready = snap.acb_ready if snap is not None else None
|
|||
|
|
if acb_ready is True:
|
|||
|
|
acb_ready_str = "[green]✓[/green]"
|
|||
|
|
elif acb_ready is False:
|
|||
|
|
acb_ready_str = "[red]✗[/red]"
|
|||
|
|
else:
|
|||
|
|
acb_ready_str = "[dim]--[/dim]"
|
|||
|
|
|
|||
|
|
# --- ACB present (Req 4.2) — string like "9/9" ---
|
|||
|
|
acb_present = snap.acb_present if snap is not None else None
|
|||
|
|
acb_present_str = acb_present if acb_present is not None else "--"
|
|||
|
|
|
|||
|
|
# --- ExtF data age with color coding (Req 4.3) ---
|
|||
|
|
age_s = snap.exf_age_s if snap is not None else None
|
|||
|
|
age_color, age_text = color_age(age_s)
|
|||
|
|
age_str = f"[{age_color}]{age_text} ●[/{age_color}]"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]ExtF[/bold]\n"
|
|||
|
|
f"funding: {funding_str}\n"
|
|||
|
|
f"dvol: {dvol_str}\n"
|
|||
|
|
f"fng: {fng_str}\n"
|
|||
|
|
f"taker: {taker_str}\n"
|
|||
|
|
f"vix: {vix_str}\n"
|
|||
|
|
f"ls_btc: {ls_btc_str}\n"
|
|||
|
|
f"ACB: {acb_present_str} {acb_ready_str}\n"
|
|||
|
|
f"Age: {age_str}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# EsoFPanel — esoteric features widget (Req 5.1, 5.2)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class EsoFPanel(Static):
|
|||
|
|
"""EsoF panel: moon_phase, mercury_retro, liquidity_session, market_cycle_pos,
|
|||
|
|
and data age (color-coded by staleness).
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data (Req 12.3).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Moon phase (Req 5.1) — plain text or "--" ---
|
|||
|
|
moon = snap.moon_phase if snap is not None else None
|
|||
|
|
moon_str = moon if moon is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Mercury retrograde (Req 5.1) — colored indicator ---
|
|||
|
|
mercury = snap.mercury_retro if snap is not None else None
|
|||
|
|
if mercury is True:
|
|||
|
|
mercury_str = "[yellow]Retro ⚠[/yellow]"
|
|||
|
|
elif mercury is False:
|
|||
|
|
mercury_str = "[green]Normal[/green]"
|
|||
|
|
else:
|
|||
|
|
mercury_str = "[dim]--[/dim]"
|
|||
|
|
|
|||
|
|
# --- Liquidity session (Req 5.1) — plain text or "--" ---
|
|||
|
|
session = snap.liquidity_session if snap is not None else None
|
|||
|
|
session_str = session if session is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Market cycle position (Req 5.1) — 2 decimal places ---
|
|||
|
|
mc_pos = snap.market_cycle_pos if snap is not None else None
|
|||
|
|
mc_str = fmt_float(mc_pos, decimals=2) if mc_pos is not None else "--"
|
|||
|
|
|
|||
|
|
# --- EsoF data age with color coding (Req 5.2) ---
|
|||
|
|
age_s = snap.esof_age_s if snap is not None else None
|
|||
|
|
age_color, age_text = color_age(age_s)
|
|||
|
|
age_str = f"[{age_color}]{age_text} ●[/{age_color}]"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]EsoF[/bold]\n"
|
|||
|
|
f"Moon: {moon_str}\n"
|
|||
|
|
f"Mercury: {mercury_str}\n"
|
|||
|
|
f"Session: {session_str}\n"
|
|||
|
|
f"MC pos: {mc_str}\n"
|
|||
|
|
f"Age: {age_str}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# CapitalPanel — capital / PnL widget (Req 7.1, 7.2, 7.3)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class CapitalPanel(Static):
|
|||
|
|
"""Capital / PnL panel: Blue strategy capital, drawdown, peak, pnl, trades;
|
|||
|
|
Nautilus capital, pnl, trades, and posture.
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
None fields render as "--" — never crashes on missing data (Req 12.3).
|
|||
|
|
PnL values are color-coded: positive → green, negative → red, zero → white (Req 7.3).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Blue capital (Req 7.1) — $X,XXX.XX format ---
|
|||
|
|
capital = snap.capital if snap is not None else None
|
|||
|
|
capital_str = f"${capital:,.2f}" if capital is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Drawdown (Req 7.1) — stored as ratio, displayed as percentage ---
|
|||
|
|
drawdown = snap.drawdown if snap is not None else None
|
|||
|
|
if drawdown is not None:
|
|||
|
|
drawdown_str = f"{drawdown * 100:.2f}%"
|
|||
|
|
else:
|
|||
|
|
drawdown_str = "--"
|
|||
|
|
|
|||
|
|
# --- Peak capital (Req 7.1) — $X,XXX.XX format ---
|
|||
|
|
peak = snap.peak_capital if snap is not None else None
|
|||
|
|
peak_str = f"${peak:,.2f}" if peak is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Blue PnL (Req 7.1, 7.3) — color-coded via fmt_pnl ---
|
|||
|
|
pnl_color, pnl_text = fmt_pnl(snap.pnl if snap is not None else None)
|
|||
|
|
pnl_str = f"[{pnl_color}]{pnl_text}[/{pnl_color}]"
|
|||
|
|
|
|||
|
|
# --- Blue trades (Req 7.1) ---
|
|||
|
|
trades = snap.trades if snap is not None else None
|
|||
|
|
trades_str = str(trades) if trades is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Nautilus capital (Req 7.2) — $X,XXX.XX format ---
|
|||
|
|
naut_capital = snap.nautilus_capital if snap is not None else None
|
|||
|
|
naut_capital_str = f"${naut_capital:,.2f}" if naut_capital is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Nautilus PnL (Req 7.2, 7.3) — color-coded via fmt_pnl ---
|
|||
|
|
naut_pnl_color, naut_pnl_text = fmt_pnl(snap.nautilus_pnl if snap is not None else None)
|
|||
|
|
naut_pnl_str = f"[{naut_pnl_color}]{naut_pnl_text}[/{naut_pnl_color}]"
|
|||
|
|
|
|||
|
|
# --- Nautilus trades (Req 7.2) ---
|
|||
|
|
naut_trades = snap.nautilus_trades if snap is not None else None
|
|||
|
|
naut_trades_str = str(naut_trades) if naut_trades is not None else "--"
|
|||
|
|
|
|||
|
|
# --- Nautilus posture (Req 7.2) — color-coded via posture_color ---
|
|||
|
|
naut_posture = snap.nautilus_posture if snap is not None else None
|
|||
|
|
if naut_posture is not None:
|
|||
|
|
color = posture_color(naut_posture)
|
|||
|
|
naut_posture_str = f"[{color}]{naut_posture}[/{color}]"
|
|||
|
|
else:
|
|||
|
|
naut_posture_str = "[dim]--[/dim]"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]CAPITAL / PnL[/bold]\n"
|
|||
|
|
f"Blue capital: {capital_str}\n"
|
|||
|
|
f"Drawdown: {drawdown_str}\n"
|
|||
|
|
f"Peak: {peak_str}\n"
|
|||
|
|
f"PnL today: {pnl_str}\n"
|
|||
|
|
f"Trades: {trades_str}\n"
|
|||
|
|
f"Nautilus cap: {naut_capital_str}\n"
|
|||
|
|
f"Nautilus PnL: {naut_pnl_str}\n"
|
|||
|
|
f"Naut trades: {naut_trades_str}\n"
|
|||
|
|
f"Naut posture: {naut_posture_str}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# PrefectPanel — Prefect flow orchestrator status (Req 8.1–8.4)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# Flow run status → Textual color class (Req 8.3) — public constant
|
|||
|
|
PREFECT_FLOW_STATUS_COLORS: dict[str, str] = {
|
|||
|
|
"COMPLETED": "green",
|
|||
|
|
"RUNNING": "cyan",
|
|||
|
|
"FAILED": "red",
|
|||
|
|
"CRASHED": "red",
|
|||
|
|
"PENDING": "yellow",
|
|||
|
|
"LATE": "yellow",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Internal alias (includes extra statuses for display)
|
|||
|
|
_FLOW_STATUS_COLORS: dict[str, str] = {
|
|||
|
|
**PREFECT_FLOW_STATUS_COLORS,
|
|||
|
|
"CANCELLED": "dim",
|
|||
|
|
"CANCELLING": "dim",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_flow_duration(seconds: float | None) -> str:
|
|||
|
|
"""Format a flow run duration (total_run_time in seconds) as a human string.
|
|||
|
|
|
|||
|
|
Examples: 45s → "45s", 135s → "2m 15s", 3600s → "60m 0s".
|
|||
|
|
Returns "--" for None.
|
|||
|
|
"""
|
|||
|
|
if seconds is None:
|
|||
|
|
return "--"
|
|||
|
|
s = int(seconds)
|
|||
|
|
if s < 60:
|
|||
|
|
return f"{s}s"
|
|||
|
|
m = s // 60
|
|||
|
|
rem_s = s % 60
|
|||
|
|
return f"{m}m {rem_s}s"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_flow_start(iso: str | None) -> str:
|
|||
|
|
"""Format an ISO-8601 start_time string as HH:MM UTC.
|
|||
|
|
|
|||
|
|
Returns "--" for None or unparseable values.
|
|||
|
|
"""
|
|||
|
|
if iso is None:
|
|||
|
|
return "--"
|
|||
|
|
try:
|
|||
|
|
# Truncate sub-second precision and timezone suffix for fromisoformat
|
|||
|
|
ts = iso[:19].replace("T", " ")
|
|||
|
|
dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
|
|||
|
|
return dt.strftime("%H:%M UTC")
|
|||
|
|
except Exception:
|
|||
|
|
return iso[:5] if len(iso) >= 5 else "--"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PrefectPanel(Static):
|
|||
|
|
"""Prefect orchestration panel.
|
|||
|
|
|
|||
|
|
Displays:
|
|||
|
|
- Health badge: [PREFECT ✓] (green) or [PREFECT OFFLINE] (red) (Req 8.1, 8.4)
|
|||
|
|
- Last 5 flow runs: name, status (color-coded), start time, duration (Req 8.2, 8.3)
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
Never crashes when Prefect is unreachable (Req 12.2).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
|
|||
|
|
# --- Health badge (Req 8.1, 8.4) ---
|
|||
|
|
if snap is None or not snap.prefect_healthy:
|
|||
|
|
health_str = "[red][PREFECT OFFLINE][/red]"
|
|||
|
|
else:
|
|||
|
|
health_str = "[green][PREFECT ✓][/green]"
|
|||
|
|
|
|||
|
|
flows = snap.prefect_flows if snap is not None else []
|
|||
|
|
|
|||
|
|
# --- Flow run rows (Req 8.2, 8.3) ---
|
|||
|
|
rows: list[str] = []
|
|||
|
|
for run in flows[:5]:
|
|||
|
|
name = run.get("name") or "--"
|
|||
|
|
status = (run.get("status") or "").upper()
|
|||
|
|
start = _fmt_flow_start(run.get("start_time"))
|
|||
|
|
duration = _fmt_flow_duration(run.get("duration"))
|
|||
|
|
|
|||
|
|
color = _FLOW_STATUS_COLORS.get(status, "dim")
|
|||
|
|
# Truncate long flow names to keep layout tidy
|
|||
|
|
name_trunc = name[:22] if len(name) > 22 else name
|
|||
|
|
status_str = f"[{color}]{status or '--'}[/{color}]"
|
|||
|
|
rows.append(f" {status_str:<30} {name_trunc:<24} {start} {duration}")
|
|||
|
|
|
|||
|
|
# Pad to 5 rows so the panel height stays stable
|
|||
|
|
while len(rows) < 5:
|
|||
|
|
rows.append(" [dim]--[/dim]")
|
|||
|
|
|
|||
|
|
flow_block = "\n".join(rows)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
f"[bold]PREFECT FLOWS[/bold] {health_str}\n"
|
|||
|
|
f" {'STATUS':<20} {'NAME':<24} {'START':>5} {'DUR':>4}\n"
|
|||
|
|
f"{flow_block}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# OBFPanel — top-5 assets by absolute imbalance
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
class OBFPanel(Static):
|
|||
|
|
"""Order Book Features panel.
|
|||
|
|
|
|||
|
|
Displays a table of the top-5 assets ranked by absolute imbalance value.
|
|||
|
|
Columns: asset, imbalance (color-coded), fill_prob, depth_quality.
|
|||
|
|
|
|||
|
|
Updated by _apply_snapshot() via update_data(snap).
|
|||
|
|
Shows "No OBF data" when obf_top is empty or None.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _render_markup(self) -> str:
|
|||
|
|
"""Build the full panel markup string from current snapshot."""
|
|||
|
|
snap = self._snap
|
|||
|
|
obf_top: list[dict] = (snap.obf_top if snap is not None else None) or []
|
|||
|
|
|
|||
|
|
if not obf_top:
|
|||
|
|
return "[bold]OBF TOP ASSETS[/bold]\n [dim]No OBF data[/dim]"
|
|||
|
|
|
|||
|
|
header = (
|
|||
|
|
f"[bold]OBF TOP ASSETS[/bold]\n"
|
|||
|
|
f" {'ASSET':<8} {'IMBALANCE':>8} {'FILL_PROB':>9} {'DEPTH_QUAL':>13}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
rows: list[str] = []
|
|||
|
|
for entry in obf_top[:5]:
|
|||
|
|
asset = str(entry.get("asset") or "--")
|
|||
|
|
imbalance = entry.get("imbalance")
|
|||
|
|
fill_prob = entry.get("fill_prob")
|
|||
|
|
depth_quality = entry.get("depth_quality")
|
|||
|
|
|
|||
|
|
# Imbalance: right-aligned 8 chars, +/- sign, 2 decimals (Req 1.3)
|
|||
|
|
if imbalance is None:
|
|||
|
|
imb_str = f"{'--':>8}"
|
|||
|
|
else:
|
|||
|
|
imb_str = f"{imbalance:>+8.2f}"
|
|||
|
|
|
|||
|
|
fp_str = f"{fill_prob:>9.2f}" if fill_prob is not None else f"{'--':>9}"
|
|||
|
|
dq_str = f"{depth_quality:>13.2f}" if depth_quality is not None else f"{'--':>13}"
|
|||
|
|
|
|||
|
|
rows.append(f" {asset:<8} {imb_str} {fp_str} {dq_str}")
|
|||
|
|
|
|||
|
|
# Pad to 5 rows so panel height stays stable
|
|||
|
|
while len(rows) < 5:
|
|||
|
|
rows.append(f" [dim]{'--':<8} {'--':>8} {'--':>9} {'--':>13}[/dim]")
|
|||
|
|
|
|||
|
|
return header + "\n" + "\n".join(rows)
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update method
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the widget display.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self.update(self._render_markup())
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# LogPanel
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LogPanel(VerticalScroll):
|
|||
|
|
"""Scrollable log tail panel.
|
|||
|
|
|
|||
|
|
Displays the last N lines from a log file (default: run_logs/meta_health.log).
|
|||
|
|
Visibility is toggled by the ``l`` key in DolphinTUIApp.
|
|||
|
|
|
|||
|
|
When the log file does not exist or cannot be read, shows a
|
|||
|
|
``"Log not found: <path>"`` fallback message.
|
|||
|
|
|
|||
|
|
The panel is hidden by default; DolphinTUIApp calls
|
|||
|
|
``display = True/False`` to show/hide it.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
DEFAULT_CSS = """
|
|||
|
|
LogPanel {
|
|||
|
|
height: 8;
|
|||
|
|
border: solid $border;
|
|||
|
|
background: $surface;
|
|||
|
|
padding: 0 1;
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, log_path: str = LOG_DEFAULT_PATH, **kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._log_path: str = log_path
|
|||
|
|
self._lines: list[str] = []
|
|||
|
|
self._snap: DataSnapshot | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Compose
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def compose(self) -> ComposeResult:
|
|||
|
|
"""Yield the inner Static child that holds the log text."""
|
|||
|
|
yield Static(self._build_content(), id="log_inner")
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Internal rendering
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _build_content(self) -> str:
|
|||
|
|
"""Build the markup string from the current log lines."""
|
|||
|
|
if not self._lines:
|
|||
|
|
return (
|
|||
|
|
f"[bold]LOG TAIL [meta_health.log] (l=toggle, ↑↓=scroll)[/bold]\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
header = "[bold]LOG TAIL [meta_health.log] (l=toggle, ↑↓=scroll)[/bold]"
|
|||
|
|
body = "\n".join(self._lines)
|
|||
|
|
return header + "\n" + body
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Public update methods
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def update_data(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Store snapshot and refresh the inner Static widget.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
self._snap = snap
|
|||
|
|
self._lines = list(snap.log_lines) if snap is not None else []
|
|||
|
|
content = self._build_content()
|
|||
|
|
try:
|
|||
|
|
inner = self.query_one("#log_inner", Static)
|
|||
|
|
inner.update(content)
|
|||
|
|
except Exception:
|
|||
|
|
pass # Not yet mounted — content will be set in compose()
|
|||
|
|
|
|||
|
|
def update_lines(self, lines: list[str]) -> None:
|
|||
|
|
"""Replace displayed log lines and refresh the widget.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
lines: New list of log lines (may be empty to show fallback).
|
|||
|
|
"""
|
|||
|
|
self._lines = lines or []
|
|||
|
|
content = self._build_content()
|
|||
|
|
try:
|
|||
|
|
inner = self.query_one("#log_inner", Static)
|
|||
|
|
inner.update(content)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def set_log_path(self, path: str) -> None:
|
|||
|
|
"""Update the log file path label (does not re-read the file).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
path: New log file path string.
|
|||
|
|
"""
|
|||
|
|
self._log_path = path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# DolphinTUIApp — main Textual application (Req 11, 12, 13, 14, 15)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DolphinTUIApp(App):
|
|||
|
|
"""Top-level Textual App for the DOLPHIN-NAUTILUS real-time TUI monitor.
|
|||
|
|
|
|||
|
|
Owns the layout, keyboard bindings, and the 2s poll timer.
|
|||
|
|
Instantiates DolphinDataFetcher in on_mount() and drives all panel updates
|
|||
|
|
via _apply_snapshot() on each poll cycle.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
CSS = """
|
|||
|
|
Screen {
|
|||
|
|
background: #0d0d0d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Color utility classes — used by panel markup via [@class] tags */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
.green {
|
|||
|
|
color: #00ff00;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.yellow {
|
|||
|
|
color: #ffff00;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.red {
|
|||
|
|
color: #ff0000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dim {
|
|||
|
|
color: #666666;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dark_orange {
|
|||
|
|
color: #ff8c00;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cyan {
|
|||
|
|
color: #00ffff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Header bar */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
HeaderBar {
|
|||
|
|
height: 2;
|
|||
|
|
background: #1a1a1a;
|
|||
|
|
color: #cccccc;
|
|||
|
|
padding: 0 1;
|
|||
|
|
border-bottom: solid #333333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Panel widgets — shared border / padding */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
SystemHealthPanel,
|
|||
|
|
AlphaEnginePanel,
|
|||
|
|
ScanBridgePanel,
|
|||
|
|
ExtFPanel,
|
|||
|
|
EsoFPanel,
|
|||
|
|
CapitalPanel,
|
|||
|
|
PrefectPanel,
|
|||
|
|
OBFPanel {
|
|||
|
|
border: solid #333333;
|
|||
|
|
padding: 0 1;
|
|||
|
|
background: #111111;
|
|||
|
|
color: #cccccc;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Layout rows */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
#top_row {
|
|||
|
|
height: 9;
|
|||
|
|
layout: horizontal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#mid_row {
|
|||
|
|
height: 11;
|
|||
|
|
layout: horizontal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#bottom_row {
|
|||
|
|
height: 9;
|
|||
|
|
layout: horizontal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Top row — equal thirds */
|
|||
|
|
#top_row SystemHealthPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#top_row AlphaEnginePanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#top_row ScanBridgePanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mid row — equal thirds */
|
|||
|
|
#mid_row ExtFPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#mid_row EsoFPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#mid_row CapitalPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Bottom row — two halves */
|
|||
|
|
#bottom_row PrefectPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#bottom_row OBFPanel {
|
|||
|
|
width: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Log panel */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
LogPanel {
|
|||
|
|
height: 8;
|
|||
|
|
border: solid #333333;
|
|||
|
|
background: #0a0a0a;
|
|||
|
|
padding: 0 1;
|
|||
|
|
color: #888888;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
/* Terminal size warning overlay (Req 14.4) */
|
|||
|
|
/* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
#size_warning {
|
|||
|
|
layer: overlay;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
background: #1a0000 80%;
|
|||
|
|
border: double #ff0000;
|
|||
|
|
color: #ff4444;
|
|||
|
|
content-align: center middle;
|
|||
|
|
text-align: center;
|
|||
|
|
text-style: bold;
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
BINDINGS = [
|
|||
|
|
("q", "quit", "Quit"),
|
|||
|
|
("r", "force_refresh", "Refresh"),
|
|||
|
|
("l", "toggle_log", "Log Panel"),
|
|||
|
|
("up", "scroll_up", "Scroll Up"),
|
|||
|
|
("down", "scroll_down", "Scroll Down"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Instance state
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_log_visible: bool = False
|
|||
|
|
|
|||
|
|
def __init__(self, hz_host: str = "dolphin.taile8ad92.ts.net",
|
|||
|
|
hz_port: int = 5701,
|
|||
|
|
log_path: str = LOG_DEFAULT_PATH,
|
|||
|
|
mock_mode: bool = False,
|
|||
|
|
**kwargs) -> None:
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self.hz_host = hz_host
|
|||
|
|
self.hz_port = hz_port
|
|||
|
|
self.log_path = log_path
|
|||
|
|
self.mock_mode = mock_mode
|
|||
|
|
self.fetcher: DolphinDataFetcher | None = None
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Compose — widget tree
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def compose(self) -> ComposeResult:
|
|||
|
|
"""Build the full widget tree.
|
|||
|
|
|
|||
|
|
Layout:
|
|||
|
|
- HeaderBar (full width, 1 row)
|
|||
|
|
- #top_row: SystemHealthPanel | AlphaEnginePanel | ScanBridgePanel
|
|||
|
|
- #mid_row: ExtFPanel | EsoFPanel | CapitalPanel
|
|||
|
|
- #bottom_row: PrefectPanel | OBFPanel
|
|||
|
|
- LogPanel (full width, toggleable)
|
|||
|
|
"""
|
|||
|
|
yield HeaderBar(hz_host=f"{self.hz_host}:{self.hz_port}", id="header")
|
|||
|
|
|
|||
|
|
with Horizontal(id="top_row"):
|
|||
|
|
yield SystemHealthPanel(id="panel_health")
|
|||
|
|
yield AlphaEnginePanel(id="panel_alpha")
|
|||
|
|
yield ScanBridgePanel(id="panel_scan")
|
|||
|
|
|
|||
|
|
with Horizontal(id="mid_row"):
|
|||
|
|
yield ExtFPanel(id="panel_extf")
|
|||
|
|
yield EsoFPanel(id="panel_esof")
|
|||
|
|
yield CapitalPanel(id="panel_capital")
|
|||
|
|
|
|||
|
|
with Horizontal(id="bottom_row"):
|
|||
|
|
yield PrefectPanel(id="panel_prefect")
|
|||
|
|
yield OBFPanel(id="panel_obf")
|
|||
|
|
|
|||
|
|
yield LogPanel(log_path=self.log_path, id="panel_log")
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Lifecycle
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async def on_mount(self) -> None:
|
|||
|
|
"""Instantiate fetcher, connect to HZ, start poll and clock timers."""
|
|||
|
|
# 1. Instantiate fetcher (skip in mock mode)
|
|||
|
|
if not self.mock_mode:
|
|||
|
|
self.fetcher = DolphinDataFetcher(
|
|||
|
|
hz_host=self.hz_host,
|
|||
|
|
hz_port=self.hz_port,
|
|||
|
|
log_path=self.log_path,
|
|||
|
|
)
|
|||
|
|
await self.fetcher.connect_hz()
|
|||
|
|
|
|||
|
|
# 2. Start timers
|
|||
|
|
self._poll_timer = self.set_interval(2, self._poll)
|
|||
|
|
self.set_interval(1, self._update_clock)
|
|||
|
|
|
|||
|
|
# 3. Terminal size check
|
|||
|
|
self._check_terminal_size()
|
|||
|
|
|
|||
|
|
# 4. Hide LogPanel initially
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_log", LogPanel).display = False
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 5. Fire first poll immediately so panels aren't blank on startup
|
|||
|
|
await self._poll()
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Clock update
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _update_clock(self) -> None:
|
|||
|
|
"""Called by the 1s timer — refreshes the HeaderBar UTC clock."""
|
|||
|
|
try:
|
|||
|
|
self.query_one(HeaderBar).update_clock()
|
|||
|
|
except Exception:
|
|||
|
|
pass # Widget not yet mounted or already unmounted
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Terminal size check (Req 14.4)
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _check_terminal_size(self) -> None:
|
|||
|
|
"""Show or hide the terminal-too-small warning overlay.
|
|||
|
|
|
|||
|
|
Mounts the overlay on first call when the terminal is too small.
|
|||
|
|
Toggles display=True/False on subsequent calls (e.g. from on_resize).
|
|||
|
|
"""
|
|||
|
|
size = self.size
|
|||
|
|
too_small = size.width < MIN_TERM_WIDTH or size.height < MIN_TERM_HEIGHT
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
warning = self.query_one("#size_warning", Static)
|
|||
|
|
# Already mounted — just toggle visibility and update text
|
|||
|
|
if too_small:
|
|||
|
|
warning.update(
|
|||
|
|
f"⚠ Terminal too small — resize to "
|
|||
|
|
f"{MIN_TERM_WIDTH}×{MIN_TERM_HEIGHT} minimum\n"
|
|||
|
|
f"(current: {size.width}×{size.height})"
|
|||
|
|
)
|
|||
|
|
warning.display = True
|
|||
|
|
else:
|
|||
|
|
warning.display = False
|
|||
|
|
except Exception:
|
|||
|
|
# Not yet mounted — create it if needed
|
|||
|
|
if too_small:
|
|||
|
|
try:
|
|||
|
|
overlay = Static(
|
|||
|
|
f"⚠ Terminal too small — resize to "
|
|||
|
|
f"{MIN_TERM_WIDTH}×{MIN_TERM_HEIGHT} minimum\n"
|
|||
|
|
f"(current: {size.width}×{size.height})",
|
|||
|
|
id="size_warning",
|
|||
|
|
)
|
|||
|
|
self.mount(overlay)
|
|||
|
|
except Exception:
|
|||
|
|
pass # Non-fatal — app still runs
|
|||
|
|
|
|||
|
|
def on_resize(self, event) -> None:
|
|||
|
|
"""Re-check terminal size whenever the terminal is resized (Req 14.4)."""
|
|||
|
|
self._check_terminal_size()
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Poll / snapshot
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _make_mock_snapshot() -> "DataSnapshot":
|
|||
|
|
"""Return a fully-populated DataSnapshot with realistic mock values."""
|
|||
|
|
import time as _time
|
|||
|
|
return DataSnapshot(
|
|||
|
|
ts=_time.time(),
|
|||
|
|
hz_connected=True,
|
|||
|
|
prefect_connected=True,
|
|||
|
|
prefect_healthy=True,
|
|||
|
|
# scan
|
|||
|
|
scan_number=59001,
|
|||
|
|
vel_div=-0.0312,
|
|||
|
|
w50_velocity=-0.0421,
|
|||
|
|
w750_velocity=-0.0109,
|
|||
|
|
instability_50=0.0234,
|
|||
|
|
asset_prices={"BTCUSDT": 83420.5, "ETHUSDT": 1612.3},
|
|||
|
|
scan_bridge_ts="2026-04-04T18:45:00+00:00",
|
|||
|
|
scan_age_s=2.1,
|
|||
|
|
# safety
|
|||
|
|
posture="APEX",
|
|||
|
|
rm=0.82,
|
|||
|
|
cat1=0.9, cat2=0.8, cat3=0.7, cat4=1.0, cat5=0.9,
|
|||
|
|
# state
|
|||
|
|
capital=124532.10,
|
|||
|
|
drawdown=-3.21,
|
|||
|
|
peak_capital=128750.00,
|
|||
|
|
pnl=1240.50,
|
|||
|
|
trades=12,
|
|||
|
|
nautilus_capital=124532.10,
|
|||
|
|
nautilus_pnl=1240.50,
|
|||
|
|
nautilus_trades=12,
|
|||
|
|
nautilus_posture="APEX",
|
|||
|
|
nautilus_param_hash="abc123",
|
|||
|
|
# extf
|
|||
|
|
funding_btc=-0.012,
|
|||
|
|
dvol_btc=62.4,
|
|||
|
|
fng=28.0,
|
|||
|
|
taker=0.81,
|
|||
|
|
vix=18.2,
|
|||
|
|
ls_btc=0.48,
|
|||
|
|
acb_ready=True,
|
|||
|
|
acb_present="9/9",
|
|||
|
|
exf_age_s=4.2,
|
|||
|
|
# esof
|
|||
|
|
moon_phase="Waxing Gibbous",
|
|||
|
|
mercury_retro=False,
|
|||
|
|
liquidity_session="London",
|
|||
|
|
market_cycle_pos=0.42,
|
|||
|
|
esof_age_s=3.8,
|
|||
|
|
# heartbeat
|
|||
|
|
heartbeat_ts=_time.time() - 5,
|
|||
|
|
heartbeat_phase="trading",
|
|||
|
|
heartbeat_flow="nautilus_event_trader",
|
|||
|
|
heartbeat_age_s=5.1,
|
|||
|
|
# meta health
|
|||
|
|
meta_rm=0.923,
|
|||
|
|
meta_status="GREEN",
|
|||
|
|
m1_proc=1.0,
|
|||
|
|
m2_heartbeat=1.0,
|
|||
|
|
m3_data=1.0,
|
|||
|
|
m4_cp=1.0,
|
|||
|
|
m5_coh=1.0,
|
|||
|
|
# obf
|
|||
|
|
obf_top=[
|
|||
|
|
{"asset": "BTCUSDT", "imbalance": 0.18, "fill_prob": 0.72, "depth_quality": 1.2},
|
|||
|
|
{"asset": "ETHUSDT", "imbalance": 0.12, "fill_prob": 0.68, "depth_quality": 1.1},
|
|||
|
|
{"asset": "SOLUSDT", "imbalance": 0.09, "fill_prob": 0.61, "depth_quality": 1.0},
|
|||
|
|
{"asset": "BNBUSDT", "imbalance": -0.05, "fill_prob": 0.51, "depth_quality": 0.9},
|
|||
|
|
{"asset": "XRPUSDT", "imbalance": -0.11, "fill_prob": 0.44, "depth_quality": 0.8},
|
|||
|
|
],
|
|||
|
|
# prefect
|
|||
|
|
prefect_flows=[
|
|||
|
|
{"name": "paper_trade_flow", "status": "COMPLETED", "start_time": "10:30:01", "duration": "2m"},
|
|||
|
|
{"name": "nautilus_prefect", "status": "COMPLETED", "start_time": "10:22:00", "duration": "8m"},
|
|||
|
|
{"name": "obf_prefect_flow", "status": "RUNNING", "start_time": "10:30:00", "duration": "0m"},
|
|||
|
|
{"name": "exf_fetcher_flow", "status": "COMPLETED", "start_time": "10:15:00", "duration": "15m"},
|
|||
|
|
{"name": "mc_forewarner_flow", "status": "COMPLETED", "start_time": "09:30:00", "duration": "1h"},
|
|||
|
|
],
|
|||
|
|
# log
|
|||
|
|
log_lines=[
|
|||
|
|
"10:30:01 [INFO] RM_META=0.923 [GREEN] M1=1.0 M2=1.0 M3=1.0 M4=1.0 M5=1.0",
|
|||
|
|
"10:29:56 [INFO] RM_META=0.921 [GREEN] M1=1.0 M2=1.0 M3=0.98 M4=1.0 M5=1.0",
|
|||
|
|
"10:29:51 [INFO] HEARTBEAT phase=trading flow=nautilus_event_trader",
|
|||
|
|
"10:29:46 [INFO] SCAN #59000 vel_div=-0.031 posture=APEX",
|
|||
|
|
"10:29:41 [INFO] ACB boost=1.55x beta=0.80 cut=0.0",
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def _poll(self) -> None:
|
|||
|
|
"""Called by set_interval(2) — fetches data and updates all panels."""
|
|||
|
|
import dataclasses, sys
|
|||
|
|
|
|||
|
|
if self.mock_mode:
|
|||
|
|
self._apply_snapshot(self._make_mock_snapshot())
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
snap, (prefect_healthy, prefect_flows) = await asyncio.gather(
|
|||
|
|
self.fetcher.fetch(),
|
|||
|
|
self.fetcher.fetch_prefect(),
|
|||
|
|
)
|
|||
|
|
snap = dataclasses.replace(
|
|||
|
|
snap,
|
|||
|
|
prefect_healthy=prefect_healthy,
|
|||
|
|
prefect_connected=prefect_healthy,
|
|||
|
|
prefect_flows=prefect_flows if isinstance(prefect_flows, list) else [],
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[POLL ERROR] {exc}", file=sys.stderr)
|
|||
|
|
import traceback; traceback.print_exc(file=sys.stderr)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self._apply_snapshot(snap)
|
|||
|
|
|
|||
|
|
def _apply_snapshot(self, snap: DataSnapshot) -> None:
|
|||
|
|
"""Update all panel widgets from a DataSnapshot.
|
|||
|
|
|
|||
|
|
None fields render as '--'; color classes applied per thresholds.
|
|||
|
|
Log panel updated only if self._log_visible.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
snap: Latest DataSnapshot from the poll cycle.
|
|||
|
|
"""
|
|||
|
|
# --- HeaderBar: HZ badge + meta status badge ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#header", HeaderBar).update_status(
|
|||
|
|
snap.hz_connected, snap.meta_status
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- SystemHealthPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_health", SystemHealthPanel).update_data(snap)
|
|||
|
|
except Exception as exc:
|
|||
|
|
import sys; print(f"[APPLY panel_health] {exc}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
# --- AlphaEnginePanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_alpha", AlphaEnginePanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- ScanBridgePanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_scan", ScanBridgePanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- ExtFPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_extf", ExtFPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- EsoFPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_esof", EsoFPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- CapitalPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_capital", CapitalPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- PrefectPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_prefect", PrefectPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- OBFPanel ---
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_obf", OBFPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- LogPanel — only if visible (Req 9.2) ---
|
|||
|
|
if self._log_visible:
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_log", LogPanel).update_data(snap)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Actions
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async def action_quit(self) -> None:
|
|||
|
|
"""Shut down HZ client cleanly, then exit."""
|
|||
|
|
if self.fetcher is not None:
|
|||
|
|
await self.fetcher.disconnect_hz()
|
|||
|
|
self.exit()
|
|||
|
|
|
|||
|
|
async def action_force_refresh(self) -> None:
|
|||
|
|
"""Cancel the current poll timer, immediately run a poll cycle, then restart the timer."""
|
|||
|
|
try:
|
|||
|
|
self._poll_timer.stop()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
await self._poll()
|
|||
|
|
self._poll_timer = self.set_interval(2, self._poll)
|
|||
|
|
|
|||
|
|
def action_toggle_log(self) -> None:
|
|||
|
|
"""Toggle LogPanel visibility and update _log_visible flag."""
|
|||
|
|
self._log_visible = not self._log_visible
|
|||
|
|
try:
|
|||
|
|
self.query_one("#panel_log", LogPanel).display = self._log_visible
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
args = parse_args()
|
|||
|
|
app = DolphinTUIApp(
|
|||
|
|
hz_host=args.hz_host,
|
|||
|
|
hz_port=args.hz_port,
|
|||
|
|
log_path=args.log_path,
|
|||
|
|
mock_mode=args.mock,
|
|||
|
|
)
|
|||
|
|
app.run()
|