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:
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user