Phase 4: orchestrator + cycles auto-execute

Componente runtime/ che cabla core+clients+state+safety in un engine
autonomo notify-only: nessuna conferma manuale, ordini combo
piazzati direttamente quando le regole passano. 311 test pass,
copertura totale 94%, runtime/ 90%, mypy strict pulito, ruff clean.

Moduli:
- runtime/alert_manager.py: escalation tree
  LOW/MEDIUM/HIGH/CRITICAL → audit + Telegram + kill switch.
- runtime/dependencies.py: build_runtime() costruisce
  RuntimeContext con tutti i client MCP, repository, audit log,
  kill switch, alert manager.
- runtime/entry_cycle.py: flusso settimanale (snapshot parallelo
  spot/dvol/funding/macro/holdings/equity → validate_entry →
  compute_bias → options_chain → select_strikes →
  liquidity_gate → sizing_engine → combo_builder.build →
  place_combo_order → notify_position_opened).
- runtime/monitor_cycle.py: loop 12h con dvol_history per il
  return_4h, exit_decision.evaluate, close auto-execute.
- runtime/health_check.py: probe parallelo MCP + SQLite +
  environment match; 3 strikes consecutivi → kill switch HIGH.
- runtime/recovery.py: riconciliazione SQLite vs broker
  all'avvio; mismatch → kill switch CRITICAL.
- runtime/scheduler.py: AsyncIOScheduler builder con cron entry
  (lun 14:00), monitor (02/14), health (5min).
- runtime/orchestrator.py: façade boot() + run_entry/monitor/health
  + install_scheduler + run_forever, con env check vs strategy.

CLI:
- start: avvia engine bloccante (asyncio.run + scheduler).
- dry-run --cycle entry|monitor|health: esegue un singolo ciclo
  per debug/test in produzione.
- stop: documenta lo shutdown via SIGTERM al container.

Documentazione:
- docs/06-operational-flow.md riscritto per il modello
  notify-only auto-execute (no conferma manuale, no memory,
  no brain-bridge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:03:45 +02:00
parent 466e63dc19
commit 42b0fbe1ab
20 changed files with 3715 additions and 131 deletions
+208
View File
@@ -0,0 +1,208 @@
"""Façade that ties the runtime modules into a runnable engine.
The :class:`Orchestrator` is the single entry point for the CLI: it
holds the :class:`RuntimeContext`, the :class:`HealthCheck` state, and
the boot procedure (recover + boot environment check + scheduler
arming). Every concrete cycle is delegated to its own module so each
piece stays independently testable.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Literal
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from cerbero_bite.config.mcp_endpoints import McpEndpoints
from cerbero_bite.config.schema import StrategyConfig
from cerbero_bite.runtime.dependencies import RuntimeContext, build_runtime
from cerbero_bite.runtime.entry_cycle import EntryCycleResult, run_entry_cycle
from cerbero_bite.runtime.health_check import HealthCheck, HealthCheckResult
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
from cerbero_bite.runtime.recovery import recover_state
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
__all__ = ["Orchestrator"]
_log = logging.getLogger("cerbero_bite.runtime.orchestrator")
Environment = Literal["testnet", "mainnet"]
# Default cron schedule (matches docs/06-operational-flow.md table).
_CRON_ENTRY = "0 14 * * MON"
_CRON_MONITOR = "0 2,14 * * *"
_CRON_HEALTH = "*/5 * * * *"
@dataclass(frozen=True)
class _BootResult:
environment: Environment
health: HealthCheckResult
class Orchestrator:
"""Engine façade — boot, scheduler, manual cycle invocation."""
def __init__(
self,
ctx: RuntimeContext,
*,
expected_environment: Environment,
eur_to_usd: Decimal,
) -> None:
self._ctx = ctx
self._expected_env = expected_environment
self._eur_to_usd = eur_to_usd
self._health = HealthCheck(ctx, expected_environment=expected_environment)
self._scheduler: AsyncIOScheduler | None = None
@property
def context(self) -> RuntimeContext:
return self._ctx
@property
def expected_environment(self) -> Environment:
return self._expected_env
# ------------------------------------------------------------------
# Boot
# ------------------------------------------------------------------
async def boot(self) -> _BootResult:
"""Reconcile state, verify environment, run a first health probe."""
when = self._ctx.clock()
await recover_state(self._ctx, now=when)
info = await self._ctx.deribit.environment_info()
if info.environment != self._expected_env:
await self._ctx.alert_manager.critical(
source="orchestrator.boot",
message=(
f"Deribit environment mismatch at boot: expected "
f"{self._expected_env}, got {info.environment}"
),
component="runtime.orchestrator",
)
health = await self._health.run(now=when)
self._ctx.audit_log.append(
event="ENGINE_START",
payload={
"environment": info.environment,
"health": health.state,
"config_version": self._ctx.cfg.config_version,
},
now=when,
)
return _BootResult(environment=info.environment, health=health)
# ------------------------------------------------------------------
# Cycle invocations (used by scheduler jobs and CLI dry-run)
# ------------------------------------------------------------------
async def run_entry(
self, *, now: datetime | None = None
) -> EntryCycleResult:
return await run_entry_cycle(
self._ctx, eur_to_usd_rate=self._eur_to_usd, now=now
)
async def run_monitor(
self, *, now: datetime | None = None
) -> MonitorCycleResult:
return await run_monitor_cycle(self._ctx, now=now)
async def run_health(
self, *, now: datetime | None = None
) -> HealthCheckResult:
return await self._health.run(now=now)
# ------------------------------------------------------------------
# Scheduler lifecycle
# ------------------------------------------------------------------
def install_scheduler(
self,
*,
entry_cron: str = _CRON_ENTRY,
monitor_cron: str = _CRON_MONITOR,
health_cron: str = _CRON_HEALTH,
) -> AsyncIOScheduler:
"""Build the scheduler with the canonical job set, ready to start."""
async def _safe(name: str, coro_factory: Callable[[], Awaitable[object]]) -> None:
try:
await coro_factory()
except Exception as exc: # never let a tick kill the scheduler
_log.exception("scheduler tick %s raised", name)
await self._ctx.alert_manager.critical(
source=f"scheduler.{name}",
message=f"{type(exc).__name__}: {exc}",
component=f"runtime.{name}",
)
async def _entry() -> None:
await _safe("entry", self.run_entry)
async def _monitor() -> None:
await _safe("monitor", self.run_monitor)
async def _health() -> None:
await _safe("health", self.run_health)
self._scheduler = build_scheduler(
[
JobSpec(name="entry", cron=entry_cron, coro_factory=_entry),
JobSpec(name="monitor", cron=monitor_cron, coro_factory=_monitor),
JobSpec(name="health", cron=health_cron, coro_factory=_health),
]
)
return self._scheduler
async def run_forever(self) -> None:
"""Boot, install the scheduler, and block forever (until cancelled)."""
await self.boot()
scheduler = self.install_scheduler()
scheduler.start()
try:
await asyncio.Event().wait()
finally:
scheduler.shutdown(wait=False)
# ---------------------------------------------------------------------------
# Convenience builder for the CLI
# ---------------------------------------------------------------------------
def make_orchestrator(
*,
cfg: StrategyConfig,
endpoints: McpEndpoints,
token: str,
db_path: Path,
audit_path: Path,
expected_environment: Environment,
eur_to_usd: Decimal,
clock: Callable[[], datetime] | None = None,
) -> Orchestrator:
"""Build a fresh :class:`Orchestrator` ready for ``boot``/``run_*``."""
ctx = build_runtime(
cfg=cfg,
endpoints=endpoints,
token=token,
db_path=db_path,
audit_path=audit_path,
clock=clock or (lambda: datetime.now(UTC)),
)
return Orchestrator(
ctx, expected_environment=expected_environment, eur_to_usd=eur_to_usd
)