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:
@@ -94,6 +94,16 @@ def _wire_market_snapshot(
|
||||
json={"currency": "ETH", "latest": dvol, "candles": []},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/get_historical",
|
||||
json={"candles": [{"close": spot * 0.95}]},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/get_technical_indicators",
|
||||
json={"adx": [{"value": 22.0}]},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
|
||||
json={"asset": "ETH", "current_funding_rate": funding_perp_hourly},
|
||||
|
||||
@@ -125,4 +125,4 @@ def test_install_scheduler_registers_canonical_jobs(tmp_path: Path) -> None:
|
||||
orch = _build_orch(tmp_path)
|
||||
sched = orch.install_scheduler()
|
||||
job_ids = {j.id for j in sched.get_jobs()}
|
||||
assert job_ids == {"entry", "monitor", "health"}
|
||||
assert job_ids == {"entry", "monitor", "health", "backup"}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for the single-instance EngineLock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.runtime.lockfile import EngineLock, LockError
|
||||
|
||||
|
||||
def test_acquire_writes_pid(tmp_path: Path) -> None:
|
||||
target = tmp_path / "lockfile"
|
||||
with EngineLock(target):
|
||||
assert target.exists()
|
||||
content = target.read_text(encoding="utf-8").strip()
|
||||
assert int(content) == os.getpid()
|
||||
|
||||
|
||||
def test_release_after_with_block(tmp_path: Path) -> None:
|
||||
target = tmp_path / "lockfile"
|
||||
lock = EngineLock(target)
|
||||
with lock:
|
||||
pass
|
||||
# second acquire must succeed because the previous one was released
|
||||
with EngineLock(target):
|
||||
pass
|
||||
|
||||
|
||||
def test_second_acquire_blocks(tmp_path: Path) -> None:
|
||||
target = tmp_path / "lockfile"
|
||||
first = EngineLock(target)
|
||||
first.acquire()
|
||||
try:
|
||||
second = EngineLock(target)
|
||||
with pytest.raises(LockError, match="another Cerbero Bite instance"):
|
||||
second.acquire()
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
|
||||
def test_lockfile_directory_is_created(tmp_path: Path) -> None:
|
||||
nested = tmp_path / "data" / "nested" / "lockfile"
|
||||
with EngineLock(nested):
|
||||
assert nested.exists()
|
||||
|
||||
|
||||
def test_release_is_idempotent(tmp_path: Path) -> None:
|
||||
target = tmp_path / "lockfile"
|
||||
lock = EngineLock(target)
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
lock.release() # must be a no-op
|
||||
@@ -27,15 +27,41 @@ def test_cli_help_lists_status_command() -> None:
|
||||
assert "status" in result.output
|
||||
|
||||
|
||||
def test_cli_status_runs(tmp_data_dir: Path) -> None:
|
||||
def test_cli_status_when_state_missing(tmp_data_dir: Path) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
["--log-dir", str(tmp_data_dir / "log"), "status"],
|
||||
[
|
||||
"--log-dir",
|
||||
str(tmp_data_dir / "log"),
|
||||
"status",
|
||||
"--db",
|
||||
str(tmp_data_dir / "missing.sqlite"),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Cerbero Bite" in result.output
|
||||
assert "phase: 0" in result.output
|
||||
assert "never started" in result.output
|
||||
|
||||
|
||||
def test_cli_status_after_kill_switch_arm(tmp_data_dir: Path) -> None:
|
||||
runner = CliRunner()
|
||||
db_path = tmp_data_dir / "state.sqlite"
|
||||
audit_path = tmp_data_dir / "audit.log"
|
||||
runner.invoke(
|
||||
cli_main,
|
||||
[
|
||||
"--log-dir", str(tmp_data_dir / "log"),
|
||||
"kill-switch", "arm",
|
||||
"--reason", "smoke",
|
||||
"--db", str(db_path),
|
||||
"--audit", str(audit_path),
|
||||
],
|
||||
)
|
||||
result = runner.invoke(cli_main, ["status", "--db", str(db_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "ARMED" in result.output
|
||||
assert "open positions: 0" in result.output
|
||||
|
||||
|
||||
def test_cli_kill_switch_arm_persists_state(tmp_data_dir: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user