300 lines
12 KiB
Python
300 lines
12 KiB
Python
|
|
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.")
|