Dmitry Beresnev
Add Telegram bot control interface for HuggingFace Spaces deployment
820c4b2
Raw
History Blame Contribute Delete
6.78 kB
"""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
# -- lifecycle ---------------------------------------------------------
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:
# Universe load hits GitHub/Wikipedia/Binance; may take a while
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
# Sleep in small steps so pause/interval changes apply quickly
deadline = time.time() + self.poll_interval
while time.time() < deadline and not self._stop.is_set():
time.sleep(1)
# -- control commands --------------------------------------------------
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)
# -- alert subscriptions ----------------------------------------------
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)
# -- state snapshots ---------------------------------------------------
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:]