#!/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, )