Phase 4 hardening: status CLI, lock file, backup job, hash enforce, pooling, real bias

Sei interventi mirati sui rischi operativi rilevati nell'audit
post-Fase 4. 317 test pass, mypy strict pulito, ruff clean.

1. status CLI: legge SQLite reale e mostra kill_switch, posizioni
   aperte, environment, config_version, last_health_check, started_at.
   Sostituisce il placeholder "phase 0 skeleton".

2. Lock file single-instance: runtime/lockfile.py acquisisce
   data/.lockfile via fcntl.flock al boot di run_forever; un secondo
   container fallisce subito con LockError.

3. Backup orario nello scheduler: nuovo job APScheduler 0 * * * *
   chiama scripts.backup.backup_database + prune_backups.

4. config_hash enforce su start: il CLI start verifica l'integrità
   del file (enforce_hash=True). Mismatch → exit 1 prima di toccare
   stato. dry-run resta enforce_hash=False per debug.

5. Connection pooling MCP: RuntimeContext espone un httpx.AsyncClient
   long-lived condiviso da tutti i wrapper (limits 20/10
   connections/keepalive). aclose() chiamato in run_forever finale.

6. Bias direzionale reale: deribit.historical_close +
   deribit.adx_14 popolano TrendContext con spot a 30 giorni e
   ADX(14) effettivi. Sblocca bull_put e bear_call. Quando i dati
   storici mancano l'engine emette alert MEDIUM e cade su no_entry
   in modo deterministico.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:15:28 +02:00
parent ca1e6379df
commit 411b747e93
11 changed files with 439 additions and 36 deletions
+68 -9
View File
@@ -25,6 +25,7 @@ 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.lockfile import EngineLock
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
@@ -40,6 +41,8 @@ Environment = Literal["testnet", "mainnet"]
_CRON_ENTRY = "0 14 * * MON"
_CRON_MONITOR = "0 2,14 * * *"
_CRON_HEALTH = "*/5 * * * *"
_CRON_BACKUP = "0 * * * *"
_BACKUP_RETENTION_DAYS = 30
@dataclass(frozen=True)
@@ -135,6 +138,9 @@ class Orchestrator:
entry_cron: str = _CRON_ENTRY,
monitor_cron: str = _CRON_MONITOR,
health_cron: str = _CRON_HEALTH,
backup_cron: str = _CRON_BACKUP,
backup_dir: Path | None = None,
backup_retention_days: int = _BACKUP_RETENTION_DAYS,
) -> AsyncIOScheduler:
"""Build the scheduler with the canonical job set, ready to start."""
@@ -158,24 +164,77 @@ class Orchestrator:
async def _health() -> None:
await _safe("health", self.run_health)
backups_target = backup_dir or self._ctx.db_path.parent / "backups"
async def _backup() -> None:
async def _do() -> None:
await asyncio.to_thread(
_run_backup,
db_path=self._ctx.db_path,
backup_dir=backups_target,
retention_days=backup_retention_days,
)
await _safe("backup", _do)
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),
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
]
)
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)
async def run_forever(self, *, lock_path: Path | None = None) -> None:
"""Boot, acquire the single-instance lock, install the scheduler.
``lock_path`` defaults to ``<db_path.parent>/.lockfile`` so two
containers cannot trade against the same SQLite file.
"""
lock = EngineLock(
lock_path or self._ctx.db_path.parent / ".lockfile"
)
with lock:
try:
await self.boot()
scheduler = self.install_scheduler()
scheduler.start()
try:
await asyncio.Event().wait()
finally:
scheduler.shutdown(wait=False)
finally:
await self._ctx.aclose()
def _run_backup(
*, db_path: Path, backup_dir: Path, retention_days: int
) -> None:
"""Synchronous helper invoked from the scheduler via ``asyncio.to_thread``.
Keeps the import of ``scripts.backup`` lazy: importing the module
eagerly at orchestrator load time would mean the scheduler depends
on a script that lives outside the ``cerbero_bite`` package, which
breaks ``importlib.util.spec_from_file_location`` if the cwd shifts
at runtime.
"""
import sys # noqa: PLC0415 — kept lazy to keep module load cheap
from importlib.util import ( # noqa: PLC0415
module_from_spec,
spec_from_file_location,
)
backup_py = Path(__file__).resolve().parents[3] / "scripts" / "backup.py"
spec = spec_from_file_location("_cerbero_bite_backup", backup_py)
if spec is None or spec.loader is None: # pragma: no cover — only on broken installs
raise RuntimeError(f"cannot load scripts/backup.py from {backup_py}")
module = module_from_spec(spec)
sys.modules.setdefault(spec.name, module)
spec.loader.exec_module(module)
module.backup_database(db_path=db_path, backup_dir=backup_dir)
module.prune_backups(backup_dir, retention_days=retention_days)
# ---------------------------------------------------------------------------