Files
DOLPHIN/external_factors/esoteric_factors_service.py

300 lines
12 KiB
Python
Raw Normal View History

import asyncio
import datetime
import json
import logging
import math
import threading
import time
import zoneinfo
from pathlib import Path
from typing import Dict, Any, Optional
import numpy as np
from astropy.time import Time
import astropy.coordinates as coord
import astropy.units as u
from astropy.coordinates import solar_system_ephemeris, get_body, EarthLocation
logger = logging.getLogger(__name__)
class MarketIndicators:
"""
Mathematical and astronomical calculations for the Esoteric Factors mapping.
Evaluates completely locally without external API dependencies.
"""
def __init__(self):
# Regions defined by NON-OVERLAPPING population clusters for accurate global weighting.
# Population in Millions (approximate). Liquidity weight is estimated crypto volume share.
self.regions = [
{'name': 'Americas', 'tz': 'America/New_York', 'pop': 1000, 'liq_weight': 0.35},
{'name': 'EMEA', 'tz': 'Europe/London', 'pop': 2200, 'liq_weight': 0.30},
{'name': 'South_Asia', 'tz': 'Asia/Kolkata', 'pop': 1400, 'liq_weight': 0.05},
{'name': 'East_Asia', 'tz': 'Asia/Shanghai', 'pop': 1600, 'liq_weight': 0.20},
{'name': 'Oceania_SEA', 'tz': 'Asia/Singapore', 'pop': 800, 'liq_weight': 0.10}
]
# Market cycle: Bitcoin halving based, ~4 years
self.cycle_length_days = 1460
self.last_halving = datetime.datetime(2024, 4, 20, tzinfo=datetime.timezone.utc)
# Cache for expensive ASTRO calculations
self._cache = {
'moon': {'val': None, 'ts': 0},
'mercury': {'val': None, 'ts': 0}
}
self.cache_ttl_seconds = 3600 * 6 # Update astro every 6 hours
def get_calendar_items(self, now: datetime.datetime) -> Dict[str, int]:
return {
'year': now.year,
'month': now.month,
'day_of_month': now.day,
'hour': now.hour,
'minute': now.minute,
'day_of_week': now.weekday(), # 0=Monday
'week_of_year': now.isocalendar().week
}
def is_tradfi_open(self, region_name: str, local_time: datetime.datetime) -> bool:
day = local_time.weekday()
if day >= 5: return False
hour_dec = local_time.hour + local_time.minute / 60.0
if 'Americas' in region_name:
return 9.5 <= hour_dec < 16.0
elif 'EMEA' in region_name:
return 8.0 <= hour_dec < 16.5
elif 'Asia' in region_name:
return 9.0 <= hour_dec < 15.0
return False
def get_regional_times(self, now_utc: datetime.datetime) -> Dict[str, Any]:
times = {}
for region in self.regions:
tz = zoneinfo.ZoneInfo(region['tz'])
local_time = now_utc.astimezone(tz)
times[region['name']] = {
'hour': local_time.hour + local_time.minute / 60.0,
'is_tradfi_open': self.is_tradfi_open(region['name'], local_time)
}
return times
def get_liquidity_session(self, now_utc: datetime.datetime) -> str:
utc_hour = now_utc.hour + now_utc.minute / 60.0
if 13 <= utc_hour < 17:
return "LONDON_NEW_YORK_OVERLAP"
elif 8 <= utc_hour < 13:
return "LONDON_MORNING"
elif 0 <= utc_hour < 8:
return "ASIA_PACIFIC"
elif 17 <= utc_hour < 21:
return "NEW_YORK_AFTERNOON"
else:
return "LOW_LIQUIDITY"
def get_weighted_times(self, now_utc: datetime.datetime) -> tuple[float, float]:
pop_sin, pop_cos = 0.0, 0.0
liq_sin, liq_cos = 0.0, 0.0
total_pop = sum(r['pop'] for r in self.regions)
for region in self.regions:
tz = zoneinfo.ZoneInfo(region['tz'])
local_time = now_utc.astimezone(tz)
hour_frac = (local_time.hour + local_time.minute / 60.0) / 24.0
angle = 2 * math.pi * hour_frac
w_pop = region['pop'] / total_pop
pop_sin += math.sin(angle) * w_pop
pop_cos += math.cos(angle) * w_pop
w_liq = region['liq_weight']
liq_sin += math.sin(angle) * w_liq
liq_cos += math.cos(angle) * w_liq
pop_angle = math.atan2(pop_sin, pop_cos)
if pop_angle < 0: pop_angle += 2 * math.pi
pop_hour = (pop_angle / (2 * math.pi)) * 24
liq_angle = math.atan2(liq_sin, liq_cos)
if liq_angle < 0: liq_angle += 2 * math.pi
liq_hour = (liq_angle / (2 * math.pi)) * 24
return round(pop_hour, 2), round(liq_hour, 2)
def get_market_cycle_position(self, now_utc: datetime.datetime) -> float:
days_since_halving = (now_utc - self.last_halving).days
position = (days_since_halving % self.cycle_length_days) / self.cycle_length_days
return position
def get_fibonacci_time(self, now_utc: datetime.datetime) -> Dict[str, Any]:
mins_passed = now_utc.hour * 60 + now_utc.minute
fib_seq = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597]
closest = min(fib_seq, key=lambda x: abs(x - mins_passed))
distance = abs(mins_passed - closest)
strength = 1.0 - min(distance / 30.0, 1.0)
return {'closest_fib_minute': closest, 'harmonic_strength': round(strength, 3)}
def get_moon_phase(self, now_utc: datetime.datetime) -> Dict[str, Any]:
now_ts = now_utc.timestamp()
if self._cache['moon']['val'] and (now_ts - self._cache['moon']['ts'] < self.cache_ttl_seconds):
return self._cache['moon']['val']
t = Time(now_utc)
with solar_system_ephemeris.set('builtin'):
moon = get_body('moon', t)
sun = get_body('sun', t)
elongation = sun.separation(moon)
phase_angle = np.arctan2(sun.distance * np.sin(elongation),
moon.distance - sun.distance * np.cos(elongation))
illumination = (1 + np.cos(phase_angle)) / 2.0
phase_name = "WAXING"
if illumination < 0.03: phase_name = "NEW_MOON"
elif illumination > 0.97: phase_name = "FULL_MOON"
elif illumination < 0.5: phase_name = "WAXING_CRESCENT" if moon.dec.deg > sun.dec.deg else "WANING_CRESCENT"
else: phase_name = "WAXING_GIBBOUS" if moon.dec.deg > sun.dec.deg else "WANING_GIBBOUS"
result = {'illumination': float(illumination), 'phase_name': phase_name}
self._cache['moon'] = {'val': result, 'ts': now_ts}
return result
def is_mercury_retrograde(self, now_utc: datetime.datetime) -> bool:
now_ts = now_utc.timestamp()
if self._cache['mercury']['val'] is not None and (now_ts - self._cache['mercury']['ts'] < self.cache_ttl_seconds):
return self._cache['mercury']['val']
t = Time(now_utc)
is_retro = False
try:
with solar_system_ephemeris.set('builtin'):
loc = EarthLocation.of_site('greenwich')
merc_now = get_body('mercury', t, loc)
merc_later = get_body('mercury', t + 1 * u.day, loc)
lon_now = merc_now.transform_to('geocentrictrueecliptic').lon.deg
lon_later = merc_later.transform_to('geocentrictrueecliptic').lon.deg
diff = (lon_later - lon_now) % 360
is_retro = diff > 180
except Exception as e:
logger.error(f"Astro calc error: {e}")
self._cache['mercury'] = {'val': is_retro, 'ts': now_ts}
return is_retro
def get_indicators(self, custom_now: Optional[datetime.datetime] = None) -> Dict[str, Any]:
"""Generate full suite of Esoteric Matrix factors."""
now_utc = custom_now if custom_now else datetime.datetime.now(datetime.timezone.utc)
pop_hour, liq_hour = self.get_weighted_times(now_utc)
moon_data = self.get_moon_phase(now_utc)
calendar = self.get_calendar_items(now_utc)
return {
'timestamp': now_utc.isoformat(),
'unix': int(now_utc.timestamp()),
'calendar': calendar,
'fibonacci_time': self.get_fibonacci_time(now_utc),
'regional_times': self.get_regional_times(now_utc),
'population_weighted_hour': pop_hour,
'liquidity_weighted_hour': liq_hour,
'liquidity_session': self.get_liquidity_session(now_utc),
'market_cycle_position': round(self.get_market_cycle_position(now_utc), 4),
'moon_illumination': moon_data['illumination'],
'moon_phase_name': moon_data['phase_name'],
'mercury_retrograde': int(self.is_mercury_retrograde(now_utc)),
}
class EsotericFactorsService:
"""
Continuous evaluation service for Esoteric Factors.
Dumps state deterministically to be consumed by the live trading orchestrator/Forewarning layers.
"""
def __init__(self, output_dir: str = "", poll_interval_s: float = 60.0):
# Default to same structure as external factors
if not output_dir:
self.output_dir = Path(__file__).parent / "eso_cache"
else:
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.poll_interval_s = poll_interval_s
self.engine = MarketIndicators()
self._latest_data = {}
self._running = False
self._task = None
self._lock = threading.Lock()
async def _update_loop(self):
logger.info(f"EsotericFactorsService starting. Polling every {self.poll_interval_s}s.")
while self._running:
try:
# 1. Compute Matrix
data = self.engine.get_indicators()
# 2. Store in memory
with self._lock:
self._latest_data = data
# 3. Dump purely to fast JSON
self._write_to_disk(data)
except Exception as e:
logger.error(f"Error in Esoteric update loop: {e}", exc_info=True)
await asyncio.sleep(self.poll_interval_s)
def _write_to_disk(self, data: dict):
# Fast write pattern via atomic tmp rename strategy
target_path = self.output_dir / "latest_esoteric_factors.json"
tmp_path = self.output_dir / "latest_esoteric_factors.tmp"
try:
with open(tmp_path, 'w') as f:
json.dump(data, f, indent=2)
tmp_path.replace(target_path)
except Exception as e:
logger.error(f"Failed to write Esoteric factors to disk: {e}")
def get_latest(self) -> dict:
"""Non-blocking sub-millisecond retrieval of the latest internal state."""
with self._lock:
return self._latest_data.copy()
def start(self):
"""Starts the background calculation loop (Threaded/Async wrapper)."""
if self._running: return
self._running = True
def run_async():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self._update_loop())
self._thread = threading.Thread(target=run_async, daemon=True)
self._thread.start()
def stop(self):
self._running = False
if hasattr(self, '_thread'):
self._thread.join(timeout=2.0)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
svc = EsotericFactorsService(poll_interval_s=5.0)
print("Starting Esoteric Factors Service test run for 15 seconds...")
svc.start()
for _ in range(3):
time.sleep(5)
latest = svc.get_latest()
print(f"Update: Moon Illumination={latest.get('moon_illumination'):.3f} | Liquid Session={latest.get('liquidity_session')} | PopHour={latest.get('population_weighted_hour')}")
svc.stop()
print("Stopped successfully.")