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