Files
DOLPHIN/Observability/esof_advisor.py
hjnormey af5156f52d feat(esof): rename NEUTRAL→UNKNOWN + backward-compat alias
The mid-band advisory label (constituent signals in conflict) was called
NEUTRAL, implying "benign middle" — but retrospective data (637 trades)
shows it is empirically the worst-ROI regime. Renaming to UNKNOWN makes
the semantics explicit for regime-gate consumers.

- esof_advisor.py: emits UNKNOWN; LABEL_COLOR keeps NEUTRAL alias for
  historical CH rows / stale HZ snapshots
- esof_gate.py: S6_MULT, IRP_PARAMS, Strategy A mult_map all keyed on
  UNKNOWN with NEUTRAL alias (values identical → replays unaffected)
- prod/docs/ESOF_LABEL_MIGRATION.md: migration note, CH/HZ impact,
  rollback procedure

Plan ref: Task 4 — NEUTRAL→UNKNOWN is load-bearing for the EsoF gate
in the orchestrator (0.25× sizing vs 1.0× under old label semantics).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:07:30 +02:00

483 lines
22 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
DOLPHIN EsoF Advisory — v2.0 (2026-04-19)
==========================================
Advisory-only (NOT wired into BLUE engine).
Computes esoteric/calendar/session factors every 15s and:
- Writes to HZ DOLPHIN_FEATURES['esof_advisor_latest']
- Writes to CH dolphin.esof_advisory (fire-and-forget)
- Stdout: live display (run standalone or import get_advisory())
Expectancy tables derived from 637 live trades (2026-03-31 → 2026-04-19).
Update these tables periodically as more data accumulates.
Weighted hours: uses MarketIndicators.get_weighted_times() from
external_factors/esoteric_factors_service.py (requires astropy).
Falls back to UTC-based approximation if astropy not available.
Run: source /home/dolphin/siloqy_env/bin/activate && python esof_advisor.py
"""
import json
import math
import sys
import time
import threading
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
# ── MarketIndicators integration (real weighted hours) ────────────────────────
_EF_PATH = Path(__file__).parent.parent / "external_factors"
if str(_EF_PATH) not in sys.path:
sys.path.insert(0, str(_EF_PATH))
try:
from esoteric_factors_service import MarketIndicators as _MI
_market_indicators = _MI()
_WEIGHTED_HOURS_AVAILABLE = True
except Exception:
_market_indicators = None
_WEIGHTED_HOURS_AVAILABLE = False
def _get_weighted_hours(now: datetime):
"""Returns (pop_hour, liq_hour). Falls back to UTC approximation."""
if _WEIGHTED_HOURS_AVAILABLE:
return _market_indicators.get_weighted_times(now)
# Fallback: pop≈UTC+4.2, liq≈UTC+1.0 (empirical offsets)
h = now.hour + now.minute / 60.0
return ((h + 4.21) % 24), ((h + 0.98) % 24)
# ── Expectancy tables (from CH analysis, 637 trades) ──────────────────────────
# Key: liq_hour 3h bucket start → (trades, wr_pct, net_pnl, avg_pnl)
# liq_hour ≈ UTC + 0.98h (liquidity-weighted centroid: Americas 35%, EMEA 30%,
# East_Asia 20%, Oceania_SEA 10%, South_Asia 5%)
LIQ_HOUR_STATS = {
0: ( 70, 51.4, +1466, +20.9), # liq 0-3h ≈ UTC 23-2h (Asia open)
3: ( 73, 46.6, -1166, -16.0), # liq 3-6h ≈ UTC 2-5h (deep Asia)
6: ( 62, 41.9, +1026, +16.5), # liq 6-9h ≈ UTC 5-8h (Asia/EMEA handoff)
9: ( 65, 43.1, +476, +7.3), # liq 9-12h ≈ UTC 8-11h (EMEA morning)
12: ( 84, 52.4, +3532, +42.0), # liq 12-15h ≈ UTC 11-14h (EMEA pm + US open) ★ BEST
15: (113, 43.4, -770, -6.8), # liq 15-18h ≈ UTC 14-17h (US morning)
18: ( 99, 35.4, -2846, -28.8), # liq 18-21h ≈ UTC 17-20h (US afternoon) ✗ WORST
21: ( 72, 36.1, -1545, -21.5), # liq 21-24h ≈ UTC 20-23h (US close/late)
}
# Key: session name → (trades, wr_pct, net_pnl, avg_pnl)
SESSION_STATS = {
"LONDON_MORNING": (111, 47.7, +4132.94, +37.23),
"ASIA_PACIFIC": (182, 46.7, +1600.04, +8.79),
"LOW_LIQUIDITY": ( 71, 39.4, -809.19, -11.40),
"LN_NY_OVERLAP": (147, 45.6, -894.86, -6.09),
"NY_AFTERNOON": (127, 35.4, -3857.09, -30.37),
}
# Key: dow (0=Mon) → (trades, wr_pct, net_pnl, avg_pnl)
DOW_STATS = {
0: (81, 27.2, -1053.91, -13.01), # Mon — worst
1: (77, 54.5, +3823.81, +49.66), # Tue — best
2: (98, 43.9, -385.08, -3.93), # Wed
3: (115, 44.3, -4017.06, -34.93), # Thu — 2nd worst
4: (106, 39.6, -1968.41, -18.57), # Fri
5: (82, 43.9, +43.37, +0.53), # Sat
6: (78, 53.8, +3729.73, +47.82), # Sun — 2nd best
}
DOW_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
# DoW × Session notable extremes (subset — key cells only)
# Format: (dow, session) → (trades, wr_pct, net_pnl)
DOW_SESSION_STATS = {
(6, "LONDON_MORNING"): (13, 85.0, +2153), # Sun LDN — best cell
(6, "LN_NY_OVERLAP"): (24, 75.0, +2110), # Sun OVLP — 2nd best
(1, "ASIA_PACIFIC"): (27, 67.0, +2522), # Tue ASIA — 3rd
(1, "LN_NY_OVERLAP"): (18, 56.0, +2260), # Tue OVLP — 4th
(6, "NY_AFTERNOON"): (17, 6.0, -1025), # Sun NY — worst cell
(0, "ASIA_PACIFIC"): (21, 19.0, -411), # Mon ASIA — bad
(3, "LN_NY_OVERLAP"): (27, 41.0, -3310), # Thu OVLP — catastrophic
}
# 15m slot stats: slot_key → (trades, wr_pct, net_pnl, avg_pnl)
# Only slots with n >= 5 included
SLOT_STATS = {
"0:00": (7, 57.1, +32.52, +4.65),
"0:15": (5, 80.0,+103.19, +20.64),
"0:30": (6, 33.3,-203.12, -33.85),
"1:00": (7, 42.9,-271.32, -38.76),
"1:30": (10, 50.0,+1606.66,+160.67),
"1:45": (5, 80.0, +458.74, +91.75),
"2:00": (8, 62.5,-214.45, -26.81),
"2:15": (5, 0.0, -851.56,-170.31),
"2:30": (7, 57.1,-157.04, -22.43),
"2:45": (7, 57.1, +83.24, +11.89),
"3:00": (8, 50.0, +65.0, +8.13),
"3:30": (7, 14.3,-230.05, -32.86),
"4:00": (8, 37.5, +38.73, +4.84),
"4:15": (5, 60.0, +525.75,+105.15),
"4:30": (6, 50.0, +221.14, +36.86),
"4:45": (7, 28.6,-777.03,-111.00),
"5:00": (5, 40.0,-120.47, -24.09),
"5:15": (4, 50.0, +559.32,+139.83),
"5:30": (5, 40.0, +345.88, +69.18),
"5:45": (5, 40.0,-1665.24,-333.05),
"6:00": (5, 80.0, +635.74,+127.15),
"6:30": (5, 60.0,-191.66, -38.33),
"6:45": (8, 37.5, +325.97, +40.75),
"7:15": (7, 42.9, +763.60,+109.09),
"7:30": (5, 20.0,-162.27, -32.45),
"7:45": (6, 66.7, -18.42, -3.07),
"8:00": (5, 40.0, +10.23, +2.05),
"8:15": (5, 20.0, -31.26, -6.25),
"8:30": (5, 40.0, -69.76, -13.95),
"8:45": (6, 50.0, +302.53, +50.42),
"9:00": (5, 60.0, -62.44, -12.49),
"9:15": (6, 66.7, +81.85, +13.64),
"9:30": (5, 20.0, -23.36, -4.67),
"9:45": (7, 42.9, -8.20, -1.17),
"10:15": (8, 62.5, +542.20, +67.77),
"10:30": (5, 80.0, +37.19, +7.44),
"10:45": (6, 0.0,-223.62, -37.27),
"11:00": (9, 44.4, +737.87, +81.99),
"11:30": (8, 87.5,+1074.52,+134.32),
"11:45": (5, 60.0, +558.01,+111.60),
"12:00": (5, 60.0, +660.08,+132.02),
"12:15": (6, 66.7, +705.15,+117.53),
"12:30": (6, 33.3, +513.91, +85.65),
"12:45": (6, 16.7,-1178.07,-196.35),
"13:00": (7, 14.3, -878.41,-125.49),
"13:15": (10, 60.0, +419.31, +41.93),
"13:30": (9, 44.4, -699.33, -77.70),
"13:45": (10, 70.0,+1082.10,+108.21),
"14:00": (7, 42.9,-388.03, -55.43),
"14:15": (9, 55.6, +215.29, +23.92),
"14:30": (7, 28.6, +413.16, +59.02),
"14:45": (11, 27.3, -65.79, -5.98),
"15:00": (10, 70.0,+2265.83,+226.58),
"15:15": (9, 55.6,-1225.87,-136.21),
"15:30": (11, 63.6, -65.03, -5.91),
"15:45": (10, 30.0, +81.01, +8.10),
"16:00": (5, 60.0, +691.34,+138.27),
"16:15": (9, 22.2, -78.42, -8.71),
"16:30": (4, 25.0,-2024.04,-506.01),
"16:45": (19, 42.1,-637.98, -33.58),
"17:00": (13, 38.5, +410.17, +31.55),
"17:15": (15, 46.7,-439.31, -29.29),
"17:30": (10, 60.0,-157.24, -15.72),
"18:00": (6, 16.7,-1595.60,-265.93),
"18:15": (17, 17.6, +60.98, +3.59),
"18:30": (9, 22.2,-317.64, -35.29),
"19:00": (8, 50.0,-157.93, -19.74),
"19:15": (5, 60.0, -95.94, -19.19),
"19:45": (7, 28.6,-392.53, -56.08),
"20:00": (5, 60.0, +409.41, +81.88),
"20:15": (8, 12.5,-1116.49,-139.56),
"20:45": (9, 44.4,-173.96, -19.33),
"21:15": (8, 50.0,-653.67, -81.71),
"21:30": (6, 33.3, +338.33, +56.39),
"22:00": (8, 25.0,-360.17, -45.02),
"22:15": (5, 60.0, +73.44, +14.69),
"22:30": (7, 28.6,-248.96, -35.57),
"23:00": (8, 62.5, +476.83, +59.60),
"23:15": (7, 71.4, +82.51, +11.79),
"23:30": (7, 42.9, -69.24, -9.89),
}
# Baseline: overall WR 43.7% — score is deviation from baseline
BASELINE_WR = 43.7
# ── Session classification ─────────────────────────────────────────────────────
def get_session(hour_utc: float) -> str:
if hour_utc < 8: return "ASIA_PACIFIC"
elif hour_utc < 13: return "LONDON_MORNING"
elif hour_utc < 17: return "LN_NY_OVERLAP"
elif hour_utc < 21: return "NY_AFTERNOON"
else: return "LOW_LIQUIDITY"
# ── EsoF computation ───────────────────────────────────────────────────────────
def compute_esof(now: datetime = None) -> dict:
"""Compute all EsoF advisory signals for a given UTC datetime."""
if now is None:
now = datetime.now(timezone.utc)
dow = now.weekday() # 0=Mon
hour_utc = now.hour + now.minute / 60.0
min_bucket = (now.minute // 15) * 15
slot_key = f"{now.hour}:{min_bucket:02d}"
session = get_session(hour_utc)
# ── Weighted hours (real computation via MarketIndicators) ─────────────────
pop_hour, liq_hour = _get_weighted_hours(now)
liq_bkt = int(liq_hour // 3) * 3
# ── Session expectancy ─────────────────────────────────────────────────────
sess_data = SESSION_STATS.get(session, (0, BASELINE_WR, 0, 0))
sess_wr, sess_net = sess_data[1], sess_data[2]
# ── Liq_hour expectancy (more granular than session) ───────────────────────
liq_data = LIQ_HOUR_STATS.get(liq_bkt, (0, BASELINE_WR, 0, 0))
liq_wr, liq_net = liq_data[1], liq_data[2]
# ── DoW expectancy ─────────────────────────────────────────────────────────
dow_data = DOW_STATS.get(dow, (0, BASELINE_WR, 0, 0))
dow_wr, dow_net = dow_data[1], dow_data[2]
# ── Slot expectancy ────────────────────────────────────────────────────────
slot_data = SLOT_STATS.get(slot_key, None)
if slot_data:
slot_wr, slot_net, slot_avg = slot_data[1], slot_data[2], slot_data[3]
else:
slot_wr, slot_net, slot_avg = BASELINE_WR, 0.0, 0.0
# ── DoW × Session notable cell ─────────────────────────────────────────────
cell_data = DOW_SESSION_STATS.get((dow, session), None)
cell_bonus = 0.0
if cell_data:
cell_trades, cell_wr, cell_net = cell_data
# bonus/penalty proportional to deviation from baseline
cell_bonus = (cell_wr - BASELINE_WR) / 100.0 * 0.3 # ±0.3 max contribution
# ── Fibonacci time ─────────────────────────────────────────────────────────
mins_passed = now.hour * 60 + now.minute
fib_seq = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1440]
closest_fib = min(fib_seq, key=lambda x: abs(x - mins_passed))
fib_dist = abs(mins_passed - closest_fib)
fib_strength = 1.0 - min(fib_dist / 30.0, 1.0)
# ── Market cycle position (BTC halving: Apr 19 2024) ──────────────────────
halving = datetime(2024, 4, 19, tzinfo=timezone.utc)
days_since = (now - halving).days
cycle_pos = (days_since % 1461) / 1461.0 # 4yr cycle
# ── Moon (simple approximation without astropy dependency) ────────────────
# Synodic period 29.53d; reference new moon 2024-01-11
ref_new_moon = datetime(2024, 1, 11, tzinfo=timezone.utc)
days_since_ref = (now - ref_new_moon).days + (now - ref_new_moon).seconds / 86400
moon_age = days_since_ref % 29.53059
moon_illumination = (1 - math.cos(2 * math.pi * moon_age / 29.53059)) / 2.0
if moon_illumination < 0.03: moon_phase = "NEW_MOON"
elif moon_illumination > 0.97: moon_phase = "FULL_MOON"
elif moon_age < 14.77:
moon_phase = "WAXING_CRESCENT" if moon_illumination < 0.5 else "WAXING_GIBBOUS"
else:
moon_phase = "WANING_GIBBOUS" if moon_illumination > 0.5 else "WANING_CRESCENT"
# Mercury retrograde periods (2025-2026 known dates)
retro_periods = [
(datetime(2025, 3, 15, tzinfo=timezone.utc), datetime(2025, 4, 7, tzinfo=timezone.utc)),
(datetime(2025, 7, 18, tzinfo=timezone.utc), datetime(2025, 8, 11, tzinfo=timezone.utc)),
(datetime(2025, 11, 10, tzinfo=timezone.utc), datetime(2025, 12, 1, tzinfo=timezone.utc)),
(datetime(2026, 3, 7, tzinfo=timezone.utc), datetime(2026, 3, 30, tzinfo=timezone.utc)),
(datetime(2026, 6, 29, tzinfo=timezone.utc), datetime(2026, 7, 23, tzinfo=timezone.utc)),
]
mercury_retrograde = any(s <= now <= e for s, e in retro_periods)
# ── Composite advisory score ───────────────────────────────────────────────
# Normalize each component to [-1, +1] relative to baseline WR=43.7%
# Range ≈ ±20 WR points across all factors
sess_score = (sess_wr - BASELINE_WR) / 20.0
liq_score = (liq_wr - BASELINE_WR) / 20.0
dow_score = (dow_wr - BASELINE_WR) / 20.0
slot_score = (slot_wr - BASELINE_WR) / 20.0 if slot_data else 0.0
# Weights: liq_hour 30%, session 25%, dow 30%, slot 10%, cell 5%
# liq_hour replaces pure session — it's strictly more granular (continuous)
advisory_score = (
liq_score * 0.30 +
sess_score * 0.25 +
dow_score * 0.30 +
slot_score * 0.10 +
cell_bonus * 0.05
)
advisory_score = max(-1.0, min(1.0, advisory_score))
if advisory_score > 0.25: advisory_label = "FAVORABLE"
elif advisory_score > 0.05: advisory_label = "MILD_POSITIVE"
# UNKNOWN (was NEUTRAL): constituent signals in conflict. Empirically the worst
# ROI bucket, not a benign mid-state — naming is load-bearing for consumers
# making "stand aside vs size-down" decisions.
elif advisory_score > -0.05: advisory_label = "UNKNOWN"
elif advisory_score > -0.25: advisory_label = "MILD_NEGATIVE"
else: advisory_label = "UNFAVORABLE"
# Mercury retrograde: small penalty
if mercury_retrograde:
advisory_score = max(-1.0, advisory_score - 0.05)
return {
"ts": now.isoformat(),
"_ts": now.timestamp(),
# Calendar
"dow": dow,
"dow_name": DOW_NAMES[dow],
"hour_utc": now.hour,
"slot_15m": slot_key,
"session": session,
# Weighted hours (real MarketIndicators computation)
"pop_weighted_hour": round(pop_hour, 2),
"liq_weighted_hour": round(liq_hour, 2),
"liq_bucket_3h": liq_bkt,
# Astro
"moon_illumination": round(moon_illumination, 3),
"moon_phase": moon_phase,
"mercury_retrograde": mercury_retrograde,
# Cycle / harmonic
"market_cycle_pos": round(cycle_pos, 4),
"fib_strength": round(fib_strength, 3),
# Expectancy (from live trade history)
"liq_wr_pct": round(liq_wr, 1),
"liq_net_pnl": round(liq_net, 2),
"slot_wr_pct": round(slot_wr, 1),
"slot_net_pnl": round(slot_net, 2),
"session_wr_pct": round(sess_wr, 1),
"session_net_pnl": round(sess_net, 2),
"dow_wr_pct": round(dow_wr, 1),
"dow_net_pnl": round(dow_net, 2),
# Composite
"advisory_score": round(advisory_score, 4),
"advisory_label": advisory_label,
# Meta
"_weighted_hours_real": _WEIGHTED_HOURS_AVAILABLE,
}
# ── CH writer (fire-and-forget) ───────────────────────────────────────────────
CH_URL = "http://localhost:8123"
CH_USER = "dolphin"
CH_PASS = "dolphin_ch_2026"
def _ch_write(row: dict):
ch_row = {
"ts": row["_ts"] * 1000, # ms for DateTime64(3)
"dow": row["dow"],
"dow_name": row["dow_name"],
"hour_utc": row["hour_utc"],
"slot_15m": row["slot_15m"],
"session": row["session"],
"moon_illumination": row["moon_illumination"],
"moon_phase": row["moon_phase"],
"mercury_retrograde": int(row["mercury_retrograde"]),
"pop_weighted_hour": row.get("pop_weighted_hour", 0.0),
"liq_weighted_hour": row.get("liq_weighted_hour", 0.0),
"market_cycle_pos": row["market_cycle_pos"],
"fib_strength": row["fib_strength"],
"slot_wr_pct": row["slot_wr_pct"],
"slot_net_pnl": row["slot_net_pnl"],
"session_wr_pct": row["session_wr_pct"],
"session_net_pnl": row["session_net_pnl"],
"dow_wr_pct": row["dow_wr_pct"],
"dow_net_pnl": row["dow_net_pnl"],
"advisory_score": row["advisory_score"],
"advisory_label": row["advisory_label"],
}
body = json.dumps(ch_row).encode()
url = f"{CH_URL}/?database=dolphin&query=INSERT+INTO+esof_advisory+FORMAT+JSONEachRow"
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("X-ClickHouse-User", CH_USER)
req.add_header("X-ClickHouse-Key", CH_PASS)
try:
urllib.request.urlopen(req, timeout=3)
except Exception:
pass
# ── HZ writer ─────────────────────────────────────────────────────────────────
def _hz_write(data: dict):
try:
import hazelcast
hz = hazelcast.HazelcastClient(
cluster_name="dolphin", cluster_members=["localhost:5701"],
connection_timeout=3.0)
hz.get_map("DOLPHIN_FEATURES").blocking().put(
"esof_advisor_latest", json.dumps(data))
hz.shutdown()
except Exception:
pass
# ── Display ───────────────────────────────────────────────────────────────────
GREEN = "\033[32m"; RED = "\033[31m"; YELLOW = "\033[33m"
CYAN = "\033[36m"; BOLD = "\033[1m"; DIM = "\033[2m"; RST = "\033[0m"
LABEL_COLOR = {
"FAVORABLE": GREEN,
"MILD_POSITIVE":"\033[92m",
"UNKNOWN": YELLOW, # renamed from NEUTRAL — signals-in-conflict
"NEUTRAL": YELLOW, # backward-compat for historical CH rows / stale HZ snapshots
"MILD_NEGATIVE":"\033[91m",
"UNFAVORABLE": RED,
}
def display(d: dict):
sc = d["advisory_score"]
lbl = d["advisory_label"]
col = LABEL_COLOR.get(lbl, RST)
bar_len = int(abs(sc) * 20)
bar = ("" * bar_len).ljust(20)
sign = "+" if sc >= 0 else "-"
print(f"\n{BOLD}{CYAN}🐬 DOLPHIN EsoF Advisory{RST} "
f"{DIM}{d['ts'][:19]} UTC{RST}")
_lc = GREEN if d['liq_wr_pct'] > BASELINE_WR else RED
print(f" {BOLD}Liq hour{RST} : {_lc}liq={d['liq_weighted_hour']:.2f}h (pop={d['pop_weighted_hour']:.2f}h){RST} "
f"bkt:{d['liq_bucket_3h']}-{d['liq_bucket_3h']+3}h "
f"WR={_lc}{d['liq_wr_pct']:.1f}%{RST} net={d['liq_net_pnl']:+,.0f}"
+ (f" {DIM}[real]{RST}" if d.get('_weighted_hours_real') else f" {YELLOW}[approx]{RST}"))
print(f" {BOLD}Session{RST} : {col}{d['session']:<22}{RST} "
f"WR={col}{d['session_wr_pct']:.1f}%{RST} net={d['session_net_pnl']:+,.0f}")
print(f" {BOLD}DoW{RST} : {col}{d['dow_name']:<22}{RST} "
f"WR={col}{d['dow_wr_pct']:.1f}%{RST} net={d['dow_net_pnl']:+,.0f}")
print(f" {BOLD}Slot 15m{RST} : {d['slot_15m']:<22} "
f"WR={d['slot_wr_pct']:.1f}% net={d['slot_net_pnl']:+,.0f}")
print(f" {BOLD}Moon{RST} : {d['moon_phase']:<18} {d['moon_illumination']*100:.0f}% illum")
print(f" {BOLD}Mercury{RST} : {'⚠ RETROGRADE' if d['mercury_retrograde'] else 'direct'}")
print(f" {BOLD}Fib{RST} : strength {d['fib_strength']:.2f} "
f"cycle_pos {d['market_cycle_pos']:.4f}")
print(f" {BOLD}Advisory{RST} : {col}{bar}{RST} "
f"{col}{sign}{abs(sc):.3f} {BOLD}{lbl}{RST}")
print()
# ── Daemon ────────────────────────────────────────────────────────────────────
def run_daemon(interval_s: float = 15.0, write_hz: bool = True,
write_ch: bool = True, verbose: bool = True):
"""Loop: compute → HZ → CH → display every interval_s."""
print(f"{BOLD}EsoF Advisory daemon started (interval={interval_s}s){RST}\n"
f" HZ={write_hz} CH={write_ch} display={verbose}\n"
f" Advisory-only — NOT wired into BLUE engine\n")
last_ch_write = 0.0 # write CH every 5 min, not every 15s
while True:
try:
d = compute_esof()
if verbose:
display(d)
if write_hz:
threading.Thread(target=_hz_write, args=(d,), daemon=True).start()
if write_ch and time.time() - last_ch_write > 300:
threading.Thread(target=_ch_write, args=(d,), daemon=True).start()
last_ch_write = time.time()
except Exception as e:
print(f"[EsoF] error: {e}")
time.sleep(interval_s)
# ── Public API for dolphin_status.py import ───────────────────────────────────
def get_advisory(now: datetime = None) -> dict:
"""Single-shot advisory computation. Import this into dolphin_status.py."""
return compute_esof(now)
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--once", action="store_true", help="Compute once and exit")
p.add_argument("--interval", type=float, default=15.0)
p.add_argument("--no-hz", action="store_true")
p.add_argument("--no-ch", action="store_true")
args = p.parse_args()
if args.once:
d = compute_esof()
display(d)
sys.exit(0)
run_daemon(
interval_s=args.interval,
write_hz=not args.no_hz,
write_ch=not args.no_ch,
)