152 lines
5.5 KiB
Python
152 lines
5.5 KiB
Python
|
|
"""DOLPHIN — EsoF (Esoteric Factors) Live Daemon
|
||
|
|
================================================
|
||
|
|
Long-running process that wraps EsotericFactorsService and streams its
|
||
|
|
5-second state snapshot to Hazelcast every `HZ_PUSH_INTERVAL_S` seconds.
|
||
|
|
|
||
|
|
Architecture (from ExF_EsoF_Complete_Specification.md):
|
||
|
|
- Local astropy / math calculations — NO external APIs, NO rate limits
|
||
|
|
- EsotericFactorsService polls at 5s; <1ms retrieval via get_latest()
|
||
|
|
- 6-hour TTL cache for expensive astro computations (moon, mercury)
|
||
|
|
- HZ key: DOLPHIN_FEATURES['esof_latest']
|
||
|
|
- Local JSON cache: external_factors/eso_cache/latest_esoteric_factors.json
|
||
|
|
(atomic tmp→rename write; legacy fallback for disk-readers)
|
||
|
|
|
||
|
|
Indicators pushed:
|
||
|
|
moon_illumination, moon_phase_name, mercury_retrograde,
|
||
|
|
population_weighted_hour, liquidity_weighted_hour, liquidity_session,
|
||
|
|
market_cycle_position, fibonacci_time, calendar, regional_times,
|
||
|
|
timestamp, unix
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python prod/esof_update_flow.py # run live
|
||
|
|
python prod/esof_update_flow.py --poll 5 # override poll interval
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import signal
|
||
|
|
import time
|
||
|
|
import argparse
|
||
|
|
from pathlib import Path
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
HCM_DIR = Path(__file__).parent.parent
|
||
|
|
sys.path.insert(0, str(HCM_DIR / "external_factors"))
|
||
|
|
sys.path.insert(0, str(HCM_DIR))
|
||
|
|
|
||
|
|
logging.basicConfig(
|
||
|
|
level=logging.INFO,
|
||
|
|
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||
|
|
)
|
||
|
|
logger = logging.getLogger("esof_daemon")
|
||
|
|
|
||
|
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||
|
|
HZ_PUSH_INTERVAL_S = 5 # push to HZ every 5 s (≤ scan resolution)
|
||
|
|
POLL_INTERVAL_S = 5 # EsotericFactorsService internal poll
|
||
|
|
WARMUP_S = 6 # wait for first compute cycle
|
||
|
|
HZ_KEY = "esof_latest"
|
||
|
|
LOCAL_CACHE_DIR = HCM_DIR / "external_factors" / "eso_cache"
|
||
|
|
LOCAL_CACHE_FILE = LOCAL_CACHE_DIR / "latest_esoteric_factors.json"
|
||
|
|
|
||
|
|
|
||
|
|
def _write_local_cache(data: dict):
|
||
|
|
"""Atomic tmp→rename write to local JSON (legacy fallback)."""
|
||
|
|
LOCAL_CACHE_DIR.mkdir(exist_ok=True)
|
||
|
|
tmp = LOCAL_CACHE_DIR / "latest_esoteric_factors.tmp"
|
||
|
|
try:
|
||
|
|
with open(tmp, "w") as f:
|
||
|
|
json.dump(data, f, indent=2)
|
||
|
|
tmp.replace(LOCAL_CACHE_FILE)
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning(f"Local cache write failed: {e}")
|
||
|
|
|
||
|
|
|
||
|
|
# ── Main daemon ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def run(poll_interval_s: float = POLL_INTERVAL_S):
|
||
|
|
from esoteric_factors_service import EsotericFactorsService
|
||
|
|
from prod._hz_push import make_hz_client, hz_push
|
||
|
|
|
||
|
|
svc = EsotericFactorsService(poll_interval_s=poll_interval_s)
|
||
|
|
svc.start()
|
||
|
|
logger.info(f"EsotericFactorsService started (poll={poll_interval_s}s) — warmup {WARMUP_S}s …")
|
||
|
|
time.sleep(WARMUP_S)
|
||
|
|
|
||
|
|
# Connect HZ
|
||
|
|
client = None
|
||
|
|
def _connect():
|
||
|
|
nonlocal client
|
||
|
|
try:
|
||
|
|
if client:
|
||
|
|
try: client.shutdown()
|
||
|
|
except: pass
|
||
|
|
client = make_hz_client()
|
||
|
|
logger.info("Hazelcast connected")
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning(f"HZ connect failed: {e}")
|
||
|
|
client = None
|
||
|
|
|
||
|
|
_connect()
|
||
|
|
|
||
|
|
# Graceful shutdown
|
||
|
|
_running = [True]
|
||
|
|
def _stop(signum, frame):
|
||
|
|
logger.info("Shutdown signal — stopping EsoF daemon")
|
||
|
|
_running[0] = False
|
||
|
|
signal.signal(signal.SIGINT, _stop)
|
||
|
|
signal.signal(signal.SIGTERM, _stop)
|
||
|
|
|
||
|
|
push_count = 0
|
||
|
|
fail_count = 0
|
||
|
|
|
||
|
|
logger.info(f"EsoF daemon live — pushing to HZ['{HZ_KEY}'] every {HZ_PUSH_INTERVAL_S}s")
|
||
|
|
|
||
|
|
while _running[0]:
|
||
|
|
t0 = time.monotonic()
|
||
|
|
|
||
|
|
# Non-blocking <1ms read from in-memory state
|
||
|
|
data = svc.get_latest()
|
||
|
|
|
||
|
|
if data:
|
||
|
|
# Write local JSON cache (legacy fallback for disk-readers)
|
||
|
|
_write_local_cache(data)
|
||
|
|
|
||
|
|
# Push to HZ
|
||
|
|
if client is None:
|
||
|
|
_connect()
|
||
|
|
|
||
|
|
if client and hz_push(HZ_KEY, data, client):
|
||
|
|
push_count += 1
|
||
|
|
if push_count % 12 == 1: # log every minute
|
||
|
|
logger.info(
|
||
|
|
f"EsoF push#{push_count} "
|
||
|
|
f"moon={data.get('moon_phase_name')} "
|
||
|
|
f"retro={data.get('mercury_retrograde')} "
|
||
|
|
f"session={data.get('liquidity_session')} "
|
||
|
|
f"cycle={data.get('market_cycle_position')}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
fail_count += 1
|
||
|
|
logger.warning(f"HZ push failed (total fails={fail_count}) — reconnecting")
|
||
|
|
_connect()
|
||
|
|
else:
|
||
|
|
logger.debug("EsoF state empty — service still warming up")
|
||
|
|
|
||
|
|
elapsed = time.monotonic() - t0
|
||
|
|
time.sleep(max(0.0, HZ_PUSH_INTERVAL_S - elapsed))
|
||
|
|
|
||
|
|
svc.stop()
|
||
|
|
if client:
|
||
|
|
try: client.shutdown()
|
||
|
|
except: pass
|
||
|
|
logger.info(f"EsoF daemon stopped. pushes={push_count} fails={fail_count}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
parser = argparse.ArgumentParser(description="DOLPHIN EsoF live daemon")
|
||
|
|
parser.add_argument("--poll", type=float, default=POLL_INTERVAL_S,
|
||
|
|
help=f"Internal poll interval for EsotericFactorsService (default {POLL_INTERVAL_S}s)")
|
||
|
|
args = parser.parse_args()
|
||
|
|
run(poll_interval_s=args.poll)
|