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.")