| """Bridge between the async Telegram bot and the synchronous polling core. |
| |
| The platform loop (build_runtime/run_once) runs in a daemon thread; the bot |
| reads state snapshots and flips thread-safe flags (pause, mute, interval). |
| Consistent with the rest of the POC, subscriber chats and pause state are |
| in-memory only and reset on restart. |
| """ |
|
|
| import logging |
| import threading |
| import time |
| from collections import deque |
| from typing import Dict, List, Optional |
|
|
| from src.core.config import POLL_INTERVAL_SEC, RISK_ENVELOPE_POLICY |
| from src.core.main import Runtime, build_runtime, run_once |
| from src.core.topics import TOPICS |
|
|
|
|
| logger = logging.getLogger(__name__) |
|
|
| MIN_POLL_INTERVAL_SEC = 15 |
| MAX_POLL_INTERVAL_SEC = 3600 |
|
|
|
|
| class PlatformController: |
| def __init__(self) -> None: |
| self.runtime: Optional[Runtime] = None |
| self.poll_interval = POLL_INTERVAL_SEC |
| self.started_at = time.time() |
| self.startup_error: Optional[str] = None |
| self.last_cycle_ts: Optional[float] = None |
| self.last_cycle_duration: Optional[float] = None |
|
|
| self.recent_risk_events: deque = deque(maxlen=50) |
| self.recent_regime_changes: deque = deque(maxlen=20) |
|
|
| self._subscribers: set[str] = set() |
| self._subscribers_lock = threading.Lock() |
|
|
| self._paused = threading.Event() |
| self._stop = threading.Event() |
| self._thread: Optional[threading.Thread] = None |
|
|
| |
|
|
| def start(self) -> None: |
| if self._thread is not None: |
| return |
| self._thread = threading.Thread(target=self._run_loop, name="platform-loop", daemon=True) |
| self._thread.start() |
|
|
| def stop(self) -> None: |
| self._stop.set() |
|
|
| def _run_loop(self) -> None: |
| try: |
| |
| runtime = build_runtime() |
| except Exception as exc: |
| logger.exception("platform runtime failed to build") |
| self.startup_error = str(exc) |
| return |
|
|
| runtime.pubsub.subscribe(TOPICS["risk_event"], self.recent_risk_events.append) |
| runtime.pubsub.subscribe(TOPICS["risk_regime_change"], self.recent_regime_changes.append) |
| runtime.notifier.chat_ids_provider = self.subscribers |
| self.runtime = runtime |
| logger.info("platform runtime ready, entering poll loop") |
|
|
| while not self._stop.is_set(): |
| if not self._paused.is_set(): |
| started = time.time() |
| try: |
| run_once(runtime) |
| except Exception: |
| logger.exception("poll cycle failed") |
| self.last_cycle_ts = time.time() |
| self.last_cycle_duration = self.last_cycle_ts - started |
| |
| deadline = time.time() + self.poll_interval |
| while time.time() < deadline and not self._stop.is_set(): |
| time.sleep(1) |
|
|
| |
|
|
| def pause(self) -> bool: |
| """Returns False if already paused.""" |
| if self._paused.is_set(): |
| return False |
| self._paused.set() |
| return True |
|
|
| def resume(self) -> bool: |
| if not self._paused.is_set(): |
| return False |
| self._paused.clear() |
| return True |
|
|
| @property |
| def paused(self) -> bool: |
| return self._paused.is_set() |
|
|
| def set_poll_interval(self, seconds: int) -> int: |
| seconds = max(MIN_POLL_INTERVAL_SEC, min(MAX_POLL_INTERVAL_SEC, seconds)) |
| self.poll_interval = seconds |
| return seconds |
|
|
| def set_muted(self, muted: bool) -> bool: |
| """Returns False if the runtime is not ready yet.""" |
| if self.runtime is None: |
| return False |
| self.runtime.notifier.muted = muted |
| return True |
|
|
| @property |
| def muted(self) -> bool: |
| return bool(self.runtime and self.runtime.notifier.muted) |
|
|
| |
|
|
| def subscribe(self, chat_id: int | str) -> bool: |
| with self._subscribers_lock: |
| before = len(self._subscribers) |
| self._subscribers.add(str(chat_id)) |
| return len(self._subscribers) > before |
|
|
| def unsubscribe(self, chat_id: int | str) -> bool: |
| with self._subscribers_lock: |
| try: |
| self._subscribers.remove(str(chat_id)) |
| return True |
| except KeyError: |
| return False |
|
|
| def subscribers(self) -> List[str]: |
| with self._subscribers_lock: |
| return list(self._subscribers) |
|
|
| |
|
|
| def status(self) -> Dict: |
| if self.startup_error: |
| state = "failed" |
| elif self.runtime is None: |
| state = "starting" |
| elif self.paused: |
| state = "paused" |
| else: |
| state = "running" |
|
|
| info: Dict = { |
| "state": state, |
| "startup_error": self.startup_error, |
| "uptime_sec": int(time.time() - self.started_at), |
| "poll_interval_sec": self.poll_interval, |
| "muted": self.muted, |
| "subscribers": len(self.subscribers()), |
| } |
| if self.runtime is not None: |
| info.update( |
| { |
| "cycle": self.runtime.cycle, |
| "regime": self.runtime.risk.regime, |
| "universe": {venue: len(tickers) for venue, tickers in self.runtime.universe.items()}, |
| "last_cycle_duration_sec": ( |
| round(self.last_cycle_duration, 1) if self.last_cycle_duration else None |
| ), |
| } |
| ) |
| return info |
|
|
| def regime_info(self) -> Optional[Dict]: |
| if self.runtime is None: |
| return None |
| regime = self.runtime.risk.regime |
| envelope = self.runtime.envelope_store.get() |
| policy = RISK_ENVELOPE_POLICY.get(regime, {}) |
| return {"regime": regime, "envelope": envelope, "policy": policy} |
|
|
| def universe(self) -> Optional[Dict[str, List[str]]]: |
| if self.runtime is None: |
| return None |
| return self.runtime.universe |
|
|
| def telemetry(self) -> Optional[Dict[str, int]]: |
| if self.runtime is None: |
| return None |
| return self.runtime.telemetry.snapshot() |
|
|
| def events(self, limit: int = 10) -> List[Dict]: |
| return list(self.recent_risk_events)[-limit:] |
|
|
| def regime_changes(self, limit: int = 5) -> List[Dict]: |
| return list(self.recent_regime_changes)[-limit:] |
|
|