Phase 2: persistence + safety controls

Aggiunge la persistenza SQLite, l'audit log a hash chain, il kill
switch coordinato e i CLI di gestione documentati in
docs/05-data-model.md e docs/07-risk-controls.md. 197 test pass,
1 skipped (sqlite3 CLI mancante), copertura totale 97%.

State (`state/`):
- 0001_init.sql con positions, instructions, decisions, dvol_history,
  manual_actions, system_state.
- db.py: connect con WAL + foreign_keys + transaction ctx, runner
  forward-only basato su PRAGMA user_version.
- models.py: record Pydantic, Decimal preservato come TEXT.
- repository.py: CRUD typed con singola connessione passata, cache
  aware, posizioni concorrenti.

Safety (`safety/`):
- audit_log.py: AuditLog append-only con SHA-256 chain e fsync,
  verify_chain riconosce ogni manomissione (payload, prev_hash,
  hash, JSON, separatori).
- kill_switch.py: arm/disarm transazionali, idempotenti, accoppiati
  all'audit chain.

Config (`config/loader.py` + `strategy.yaml`):
- Loader YAML con deep-merge di strategy.local.yaml.
- Verifica config_hash SHA-256 (riga config_hash esclusa).
- File golden strategy.yaml + esempio override.

Scripts:
- dead_man.sh: watchdog shell indipendente da Python.
- backup.py: VACUUM INTO orario con retention 30 giorni.

CLI:
- audit verify (exit 2 su tampering).
- kill-switch arm/disarm/status su SQLite reale.
- state inspect con tabella posizioni aperte.
- config hash, config validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 13:35:35 +02:00
parent fbb7753cc6
commit 263470786d
25 changed files with 3669 additions and 14 deletions
+10
View File
@@ -1,5 +1,11 @@
"""Strategy configuration: schema, loader, validation."""
from cerbero_bite.config.loader import (
ConfigHashError,
LoadedConfig,
compute_config_hash,
load_strategy,
)
from cerbero_bite.config.schema import (
AssetConfig,
DvolAdjustmentBand,
@@ -23,12 +29,14 @@ from cerbero_bite.config.schema import (
__all__ = [
"AssetConfig",
"ConfigHashError",
"DvolAdjustmentBand",
"EntryConfig",
"ExecutionConfig",
"ExitConfig",
"KellyConfig",
"LiquidityConfig",
"LoadedConfig",
"McpConfig",
"MonitoringConfig",
"ShortStrikeSpec",
@@ -39,5 +47,7 @@ __all__ = [
"StrategyConfig",
"StructureConfig",
"TelegramConfig",
"compute_config_hash",
"golden_config",
"load_strategy",
]
+141
View File
@@ -0,0 +1,141 @@
"""YAML loader for ``strategy.yaml`` with optional local override.
* Reads ``strategy.yaml`` (golden config).
* If ``strategy.local.yaml`` exists alongside, deep-merges its keys on
top — that file is ``.gitignore``'d and used by Adriano for emergency
overrides.
* Verifies ``config_hash`` matches the SHA-256 of the YAML *minus* the
``config_hash`` line itself. A mismatch is reported via
:class:`ConfigHashError` and the orchestrator must arm the kill switch
per ``docs/07-risk-controls.md``.
The loader does *not* depend on the runtime: it returns a validated
:class:`StrategyConfig` plus the computed hash; nothing else.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from cerbero_bite.config.schema import StrategyConfig
__all__ = [
"ConfigHashError",
"LoadedConfig",
"compute_config_hash",
"load_strategy",
]
_HASH_KEY = "config_hash"
class ConfigHashError(RuntimeError):
"""Raised when the recorded ``config_hash`` does not match the file."""
@dataclass(frozen=True)
class LoadedConfig:
"""Result of :func:`load_strategy`."""
config: StrategyConfig
computed_hash: str
sources: tuple[Path, ...]
def _strip_hash_line(text: str) -> str:
"""Return *text* with the ``config_hash:`` line replaced by an empty string.
We deliberately keep the surrounding whitespace so that any other
line numbers stay stable; only the value of the hash is removed.
"""
out: list[str] = []
for line in text.splitlines(keepends=True):
stripped = line.lstrip()
if stripped.startswith(f"{_HASH_KEY}:"):
# keep the key but strip the value, so identical files with
# different hashes still hash the same way
indent = line[: len(line) - len(stripped)]
out.append(f"{indent}{_HASH_KEY}:\n")
else:
out.append(line)
return "".join(out)
def compute_config_hash(text: str) -> str:
"""SHA-256 of the YAML text after stripping the ``config_hash`` value."""
canonical = _strip_hash_line(text).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Return ``base`` with values from ``override`` recursively merged in."""
out = dict(base)
for key, value in override.items():
existing = out.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
out[key] = _deep_merge(existing, value)
else:
out[key] = value
return out
def _load_yaml(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(f"{path}: expected a top-level mapping")
return data
def load_strategy(
yaml_path: Path | str,
*,
local_override_path: Path | str | None = None,
enforce_hash: bool = True,
) -> LoadedConfig:
"""Load and validate a strategy YAML, optionally merging a local file.
Args:
yaml_path: path to ``strategy.yaml``.
local_override_path: when ``None`` (default), use
``<yaml_path>.local.yaml`` if present. Pass ``False`` /
non-existent path to disable.
enforce_hash: when ``True``, raise :class:`ConfigHashError` if
the recorded hash does not match the file. Set to ``False``
in test fixtures or right after a manual edit.
"""
main_path = Path(yaml_path)
text = main_path.read_text(encoding="utf-8")
raw = _load_yaml(main_path)
sources: list[Path] = [main_path]
computed_hash = compute_config_hash(text)
declared_hash = raw.get(_HASH_KEY)
if enforce_hash and declared_hash != computed_hash:
raise ConfigHashError(
f"config_hash mismatch in {main_path}: "
f"declared={declared_hash}, computed={computed_hash}"
)
if local_override_path is None:
local_override_path = main_path.with_name(
main_path.stem + ".local" + main_path.suffix
)
override_path = Path(local_override_path)
if override_path.is_file():
override = _load_yaml(override_path)
raw = _deep_merge(raw, override)
sources.append(override_path)
return LoadedConfig(
config=StrategyConfig(**raw),
computed_hash=computed_hash,
sources=tuple(sources),
)