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
+152 -10
View File
@@ -11,8 +11,11 @@ from __future__ import annotations
import asyncio
import sys
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import click
from rich.console import Console
@@ -33,6 +36,7 @@ from cerbero_bite.config.mcp_endpoints import (
)
from cerbero_bite.logging import configure as configure_logging
from cerbero_bite.logging import get_logger
from cerbero_bite.runtime.orchestrator import Orchestrator, make_orchestrator
from cerbero_bite.safety.audit_log import AuditChainError, AuditLog
from cerbero_bite.safety.audit_log import verify_chain as verify_audit_chain
from cerbero_bite.safety.kill_switch import KillSwitch
@@ -87,22 +91,160 @@ def status() -> None:
)
def _engine_options(func: Callable[..., Any]) -> Callable[..., Any]:
"""Common options for the engine commands."""
decorators = [
click.option(
"--strategy",
"strategy_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=_DEFAULT_STRATEGY_PATH,
show_default=True,
),
click.option(
"--token-file",
type=click.Path(dir_okay=False, path_type=Path),
default=None,
),
click.option(
"--db",
type=click.Path(dir_okay=False, path_type=Path),
default=_DEFAULT_DB_PATH,
show_default=True,
),
click.option(
"--audit",
type=click.Path(dir_okay=False, path_type=Path),
default=_DEFAULT_AUDIT_PATH,
show_default=True,
),
click.option(
"--environment",
type=click.Choice(["testnet", "mainnet"]),
default="testnet",
show_default=True,
),
click.option(
"--eur-to-usd",
type=float,
default=1.075,
show_default=True,
help="EUR→USD conversion rate used by the sizing engine.",
),
]
for d in reversed(decorators):
func = d(func)
return func
def _build_orchestrator(
*,
strategy_path: Path,
token_file: Path | None,
db: Path,
audit: Path,
environment: str,
eur_to_usd: float,
) -> Orchestrator:
loaded = load_strategy(strategy_path, enforce_hash=False)
token = load_token(path=token_file)
return make_orchestrator(
cfg=loaded.config,
endpoints=load_endpoints(),
token=token,
db_path=db,
audit_path=audit,
expected_environment=environment, # type: ignore[arg-type]
eur_to_usd=Decimal(str(eur_to_usd)),
)
@main.command()
def start() -> None:
@_engine_options
def start(
strategy_path: Path,
token_file: Path | None,
db: Path,
audit: Path,
environment: str,
eur_to_usd: float,
) -> None:
"""Start the engine main loop (scheduler + monitoring)."""
_phase0_notice("start command not yet implemented; engine remains idle.")
orch = _build_orchestrator(
strategy_path=strategy_path,
token_file=token_file,
db=db,
audit=audit,
environment=environment,
eur_to_usd=eur_to_usd,
)
console.print(
f"[bold cyan]Cerbero Bite[/bold cyan] starting "
f"(env={environment}, db={db}, audit={audit})"
)
try:
asyncio.run(orch.run_forever())
except KeyboardInterrupt:
console.print("[yellow]engine interrupted[/yellow]")
@main.command()
@_engine_options
@click.option(
"--cycle",
type=click.Choice(["entry", "monitor", "health"]),
default="entry",
show_default=True,
)
def dry_run(
strategy_path: Path,
token_file: Path | None,
db: Path,
audit: Path,
environment: str,
eur_to_usd: float,
cycle: str,
) -> None:
"""Execute one cycle without starting the scheduler."""
orch = _build_orchestrator(
strategy_path=strategy_path,
token_file=token_file,
db=db,
audit=audit,
environment=environment,
eur_to_usd=eur_to_usd,
)
async def _go() -> None:
await orch.boot()
if cycle == "entry":
entry = await orch.run_entry()
console.print(
f"[green]entry[/green] {entry.status} reason={entry.reason}"
)
elif cycle == "monitor":
mon = await orch.run_monitor()
console.print(f"[green]monitor[/green] outcomes={len(mon.outcomes)}")
for outcome in mon.outcomes:
console.print(
f"{outcome.proposal_id[:8]} {outcome.action} "
f"closed={outcome.closed}"
)
else:
health = await orch.run_health()
console.print(f"[green]health[/green] state={health.state}")
asyncio.run(_go())
@main.command()
def stop() -> None:
"""Gracefully stop a running engine."""
_phase0_notice("stop command not yet implemented.")
@main.command(name="dry-run")
def dry_run() -> None:
"""Run the decision loop once in dry-run mode (no MCP writes)."""
_phase0_notice("dry-run command not yet implemented.")
"""Gracefully stop a running engine (manual: send SIGTERM)."""
console.print(
"Use [bold]docker compose stop cerbero-bite[/bold] (or send SIGTERM "
"to the engine PID). The container traps the signal and shuts down "
"the scheduler before exit."
)
@main.group(name="kill-switch")