docs: align 02/05/07 with autonomous notify-only architecture

Conclude il doc drift residuo dei tre documenti che ancora
descrivevano il modello di esercizio pre-Fase 4 (memory/brain-bridge,
push_user_instruction, conferma manuale). Aggiornati per riflettere
l'engine autonomo notify-only attuale, con tutti gli ultimi
hardening integrati.

docs/02-architecture.md:
- Diagramma a blocchi: rimosso cerbero-memory ↔ Cerbero core,
  aggiunto annotation sull'audit chain con anchor SQLite.
- Tabella stack: httpx pooling al posto dell'SDK mcp, hash chain
  con anchor in system_state.
- Layout cartelle: aggiunte runtime/lockfile.py,
  runtime/orchestrator.py, runtime/recovery.py, scripts/dead_man.sh,
  state/migrations/0002_audit_anchor.sql.
- Sequenze entry/monitor riscritte all'auto-execute via
  place_combo_order, niente attesa conferma utente.
- Nuova sezione "Lifecycle del container" con boot order, scheduler,
  SIGTERM clean shutdown, lock release.
- Failure modes aggiornati: environment mismatch, audit anchor
  mismatch, lock occupato.

docs/05-data-model.md:
- Filosofia estesa con la regola dell'audit chain e l'anchor.
- Schema instructions: payload_json riferito ai response Deribit
  (combo_instrument, order_id, state) invece di
  push_user_instruction.
- Aggiunta migration 0002_audit_anchor.sql con last_audit_hash.
- Schema log JSONL: campi cycle e cycle_id propagati da
  structlog.contextvars.
- Sezione "Audit log" descrive il formato concretamente in uso
  (separatori | con prev_hash/hash) ed elenco eventi reali
  (ENGINE_START, RECOVERY_DONE, ENTRY_PLACED, HOLD, EXIT_FILLED,
  KILL_SWITCH_*, ALERT, KELLY_RECALIBRATED).
- Sezione backup riferita allo job APScheduler ora schedulato
  (0 * * * *).

docs/07-risk-controls.md:
- Nuova tabella trigger automatici allineata al codice (column
  "Implementato" punta ai moduli runtime/safety reali).
- Sezione "Single-instance lock" introdotta (fcntl.flock,
  EngineLock, caveat multi-host).
- Sezione "Anti-truncation" che descrive il flusso anchor: callback
  on_append → SQLite → check al boot.
- "Cap di rischio" estesa con i due nuovi filter dealer-gamma e
  liquidation-heatmap (§2.8).
- Sezione "Versionamento config" cita execution.environment,
  execution.eur_to_usd, dealer_gamma_min, dealer_gamma_filter_enabled,
  liquidation_filter_enabled.
- Escalation tree concretizzata sull'AlertManager con i metodi
  reali (low/medium/high/critical).

Test: 335 pass, 1 skip (sqlite3 CLI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:04:30 +02:00
parent f4faef6fd1
commit 067f74bc89
3 changed files with 482 additions and 306 deletions
+183 -139
View File
@@ -5,15 +5,17 @@
``` ```
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ ADRIANO (utente) │ │ ADRIANO (utente) │
└────────────────────────────────┘ └────────────────────────────────┘
│ Telegram (report + conferma) │ Telegram (notifiche post-fact)
┌─────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────┐
│ CERBERO BITE (rule engine) │ │ CERBERO BITE (rule engine) │
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ scheduler │ │ state store │ │ audit logger │ │ │ │ scheduler │ │ state store │ │ audit logger │ │
│ │ (APScheduler)│ │ (SQLite) │ │ (append-only) │ │ │ │ (APScheduler)│ │ (SQLite) │ │ (append-only, │ │
│ │ │ │ │ │ hash chain + │ │
│ │ │ │ │ │ anchor SQLite) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │
│ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
@@ -21,7 +23,7 @@
│ │ decision orchestrator (core/) │ │ │ │ decision orchestrator (core/) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ entry │ │ sizing │ │ exit │ │ greeks │ │ │ │ │ │ entry │ │ sizing │ │ exit │ │ greeks │ │ │
│ │ │validator │ │ engine │ │ decision │ │ aggregator│ │ │ │ │ │validator │ │ engine │ │ decision │ │aggregator│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │liquidity │ │ combo │ │ kelly │ │ │ │ │ │liquidity │ │ combo │ │ kelly │ │ │
@@ -31,42 +33,50 @@
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ MCP client wrappers (clients/) │ │ │ │ MCP HTTP client wrappers (clients/) │ │
│ │ Deribit │ Hyperliquid │ Macro │ Sentiment │ Portfolio │ │ │ │ Deribit │ Hyperliquid │ Macro │ Sentiment │ Portfolio │ │
│ │ Memory │ Telegram │ Brain-Bridge │ │ │ │ Telegram │ │
│ └─────────────────────────────────────────────────────────┘ │ │ └─────────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────────┘ └────────────────────────┬────────────────────────────────────────┘
MCP JSON-RPC HTTP (Bearer token, rete Docker
│ `cerbero-suite`)
┌───────────────────────────────┐ ┌───────────────────┐ ┌──────────────────────────────────┐
│ MCP servers │ │ Cerbero core MCP servers │
│ (esistenti in CerberoSuite) │───▶│ (esecuzione) │ (FastAPI, repo Cerbero_mcp)
└───────────────────────────────┘ └───────────────────┘ └─────────────────────────────────┘
┌────────────────┐ ┌────────────────┐
│ Deribit │ │ Deribit │
API │ (testnet o
│ mainnet) │
└────────────────┘ └────────────────┘
``` ```
Cerbero Bite è completamente autonomo: l'esecuzione degli ordini
combo passa direttamente per `mcp-deribit.place_combo_order`, senza
intermediazioni. Telegram serve esclusivamente per notificare ad
Adriano gli eventi post-fact (entry placed, exit filled, alert).
## Stack tecnologico ## Stack tecnologico
| Strato | Scelta | Motivazione | | Strato | Scelta | Motivazione |
|---|---|---| |---|---|---|
| Linguaggio | Python 3.12+ | Coerente con `mcp_cerbero_brain` e l'ecosistema Cerbero | | Linguaggio | Python 3.13 | Allineato a `Cerbero_mcp` |
| Async runtime | `asyncio` | Necessario per i client MCP | | Async runtime | `asyncio` | Necessario per i client HTTP MCP e lo scheduler |
| Scheduler | `APScheduler` | Cron-like + interval-based, in-process | | Scheduler | `APScheduler` `AsyncIOScheduler` | Cron-like + interval-based, in-process |
| Persistenza stato | SQLite (file singolo) | Zero deploy overhead, transazionale, sufficiente per ≤ 4 posizioni | | Persistenza stato | SQLite (file singolo) con WAL + `synchronous=NORMAL` | Zero deploy overhead, transazionale, sufficiente per ≤ 4 posizioni |
| Persistenza log | File `.jsonl` rotativi (gzip > 30g) | Audit-friendly, append-only, parseable | | Persistenza log | File `.jsonl` rotativi giornalieri (gzip > 30 g) | Audit-friendly, append-only, parseable |
| Audit immutabile | `data/audit.log` con hash chain SHA-256 + anchor in `system_state.last_audit_hash` | Anti-tampering + anti-truncation |
| Validazione config | `pydantic` v2 | Schema a runtime su `strategy.yaml` | | Validazione config | `pydantic` v2 | Schema a runtime su `strategy.yaml` |
| Test | `pytest` + `pytest-asyncio` + `hypothesis` (property-based) | TDD imposto | | Test | `pytest` + `pytest-asyncio` + `pytest-httpx` + `hypothesis` | TDD, integration con MCP fake |
| Type checking | `mypy --strict` | Disciplina, niente sorprese a runtime | | Type checking | `mypy --strict` | Disciplina, niente sorprese a runtime |
| Format/lint | `ruff` | Standard del progetto | | Format/lint | `ruff` | Standard del progetto |
| Dependency manager | `uv` | Coerente con `mcp_cerbero_brain` | | Dependency manager | `uv` | Coerente con `Cerbero_mcp` |
| MCP client | SDK ufficiale `mcp` | Connessione ai server già configurati in `.mcp.json` | | Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `mcp` |
| Notifiche | MCP `cerbero-telegram` | Riusa canale esistente | | Notifiche | MCP `cerbero-telegram` (notify-only) | Riusa il canale esistente |
| GUI | `streamlit` ≥ 1.40 + `plotly` | Dashboard locale, processo separato (vedi `11-gui-streamlit.md`) | | GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato |
## Layout cartelle ## Layout cartelle
@@ -75,12 +85,18 @@ Cerbero_Bite/
├── README.md ├── README.md
├── pyproject.toml ├── pyproject.toml
├── uv.lock ├── uv.lock
├── strategy.yaml # config golden ├── strategy.yaml # config golden + execution.environment
├── strategy.local.yaml.example # override locale (gitignored) ├── strategy.local.yaml.example # override locale (gitignored)
├── Dockerfile # image runtime + HEALTHCHECK
├── docker-compose.yml # rete external cerbero-suite + secrets
├── docs/ # questa documentazione ├── docs/ # questa documentazione
├── secrets/ # gitignored (solo .gitkeep + README)
├── src/cerbero_bite/ ├── src/cerbero_bite/
│ ├── __init__.py │ ├── __init__.py
│ ├── __main__.py # entry point CLI │ ├── __main__.py # entry point CLI
│ ├── cli.py # status, start, dry-run, stop,
│ │ # ping, healthcheck, kill-switch,
│ │ # state, audit, config
│ ├── core/ # algoritmi puri (no I/O) │ ├── core/ # algoritmi puri (no I/O)
│ │ ├── entry_validator.py │ │ ├── entry_validator.py
│ │ ├── sizing_engine.py │ │ ├── sizing_engine.py
@@ -88,53 +104,57 @@ Cerbero_Bite/
│ │ ├── greeks_aggregator.py │ │ ├── greeks_aggregator.py
│ │ ├── liquidity_gate.py │ │ ├── liquidity_gate.py
│ │ ├── combo_builder.py │ │ ├── combo_builder.py
│ │ ── kelly_recalibration.py │ │ ── kelly_recalibration.py
├── clients/ # wrapper MCP │ └── types.py # OptionQuote, OptionLeg
│ ├── clients/ # wrapper HTTP sui MCP
│ │ ├── _base.py # HttpToolClient + retry/timeout
│ │ ├── _exceptions.py # McpError gerarchia
│ │ ├── deribit.py │ │ ├── deribit.py
│ │ ├── hyperliquid.py │ │ ├── hyperliquid.py
│ │ ├── macro.py │ │ ├── macro.py
│ │ ├── sentiment.py │ │ ├── sentiment.py
│ │ ├── portfolio.py │ │ ├── portfolio.py
│ │ ── memory.py │ │ ── telegram.py
│ │ ├── telegram.py
│ │ └── brain_bridge.py
│ ├── runtime/ # I/O, scheduling, orchestrazione │ ├── runtime/ # I/O, scheduling, orchestrazione
│ │ ├── scheduler.py │ │ ├── orchestrator.py # façade boot/run_*
│ │ ├── orchestrator.py │ │ ├── dependencies.py # RuntimeContext factory
│ │ ├── monitoring.py │ │ ├── scheduler.py # APScheduler builder
│ │ ── alert_manager.py │ │ ── lockfile.py # fcntl.flock single-instance
│ │ ├── alert_manager.py # severity routing
│ │ ├── health_check.py # ping + 3-strikes kill switch
│ │ ├── entry_cycle.py # weekly entry auto-execute
│ │ ├── monitor_cycle.py # 12h exit auto-execute
│ │ └── recovery.py # state reconcile al boot
│ ├── state/ # persistenza │ ├── state/ # persistenza
│ │ ├── repository.py │ │ ├── repository.py
│ │ ├── models.py # SQLAlchemy o dataclasses │ │ ├── models.py
│ │ ├── db.py # connect, transaction, run_migrations
│ │ └── migrations/ │ │ └── migrations/
│ │ ├── 0001_init.sql
│ │ └── 0002_audit_anchor.sql
│ ├── config/ # caricamento e validazione yaml │ ├── config/ # caricamento e validazione yaml
│ │ ├── schema.py │ │ ├── schema.py
│ │ ── loader.py │ │ ── loader.py
├── reporting/ # report umani │ └── mcp_endpoints.py # URL + token loader
│ ├── pre_trade.py │ ├── reporting/ # report umani (Fase 5)
│ ├── post_trade.py │ ├── gui/ # Streamlit dashboard (Fase 4.5)
│ │ └── daily_digest.py
│ ├── gui/ # Streamlit dashboard (vedi 11-gui-streamlit.md)
│ │ ├── main.py
│ │ ├── pages/
│ │ ├── components/
│ │ └── data_layer.py
│ └── safety/ # kill switch, dead man, audit │ └── safety/ # kill switch, dead man, audit
│ ├── kill_switch.py │ ├── kill_switch.py
│ ├── dead_man.py │ ├── audit_log.py # hash chain + on_append callback
│ └── audit_log.py │ └── __init__.py
├── tests/ ├── tests/
│ ├── unit/ # test puri sui moduli core/ │ ├── unit/ # test puri sui moduli core/
│ ├── integration/ # test con MCP fake │ ├── integration/ # test con MCP fake (httpx mock)
│ ├── golden/ # scenari deterministici di riferimento │ ├── golden/ # scenari deterministici (Fase 6)
│ └── fixtures/ │ └── fixtures/
├── scripts/ ├── scripts/
│ ├── reset_state.py │ ├── backup.py # VACUUM INTO orario
── replay_day.py # replay forensico di una giornata ── dead_man.sh # watchdog shell indipendente
│ └── recalibrate_kelly.py └── data/ # gitignored
└── data/ ├── state.sqlite
├── state.sqlite # gitignored ├── audit.log
── log/ # gitignored ── log/
└── backups/
``` ```
## Principio di separazione ## Principio di separazione
@@ -142,123 +162,147 @@ Cerbero_Bite/
- **`core/`** contiene **funzioni pure**: input → output, niente I/O, - **`core/`** contiene **funzioni pure**: input → output, niente I/O,
niente MCP, niente time. Testabili con dati statici. Sono il cuore niente MCP, niente time. Testabili con dati statici. Sono il cuore
della strategia e devono restare riproducibili al byte. della strategia e devono restare riproducibili al byte.
- **`clients/`** contiene wrapper sui server MCP. Ogni client espone - **`clients/`** contiene wrapper HTTP sui server MCP. Ogni client
una API tipizzata che usa internamente JSON-RPC. Mai logica di espone una API tipizzata che usa internamente `httpx.AsyncClient`.
business qui. Mai logica di business qui.
- **`runtime/`** orchestrazione: composizione di `clients/` + `core/` + - **`runtime/`** orchestrazione: composizione di `clients/` + `core/`
`state/`. È l'unico strato che può fare I/O e ha effetti collaterali. + `state/` + `safety/`. È l'unico strato che può fare I/O e ha
effetti collaterali. Espone `Orchestrator` come façade per il CLI.
- **`state/`** persistenza. Mai logica di business. Solo CRUD. - **`state/`** persistenza. Mai logica di business. Solo CRUD.
- **`config/`** caricamento di `strategy.yaml`, validazione, esposizione - **`config/`** caricamento di `strategy.yaml`, validazione,
immutabile dei parametri. esposizione immutabile dei parametri. Risolve gli URL MCP e legge
- **`reporting/`** generazione di stringhe per Telegram. Niente logica il bearer token al boot.
di trading, solo formatting.
- **`safety/`** controlli trasversali (vedere `07-risk-controls.md`). - **`safety/`** controlli trasversali (vedere `07-risk-controls.md`).
- **`reporting/`** generazione di stringhe per Telegram. Niente
logica di trading, solo formatting.
## Decision orchestrator — sequenza tipo per "Lunedì 14:00 UTC" ## Decision orchestrator — sequenza tipo per "Lunedì 14:00 UTC"
```python ```python
async def evaluate_entry(): async def run_entry_cycle(ctx: RuntimeContext, *, eur_to_usd_rate, now):
# 1. Stato sistema if ctx.kill_switch.is_armed():
if not safety.system_healthy(): return return EntryCycleResult(status="kill_switch_armed", ...)
if state.has_open_position(): return if ctx.repository.count_concurrent_positions() > 0:
return EntryCycleResult(status="has_open_position", ...)
# 2. Dati di mercato # 1. Snapshot dati di mercato in parallelo (asyncio.gather)
spot = await deribit.get_index_price("ETH") snap = await _gather_snapshot(
dvol = await deribit.get_dvol() deribit, hyperliquid, sentiment, macro, portfolio, cfg, now
funding = await sentiment.get_funding_cross_exchange("ETH")
macro = await macro.get_calendar(days=18)
holdings = await portfolio.get_holdings()
# 3. Algoritmi puri
bias = entry_validator.compute_bias(spot, trend_30d, funding)
if bias == "no_entry": return log_and_skip()
capital = await portfolio.get_capital()
chain = await deribit.get_options_chain("ETH", dte_target=18)
short_strike, long_strike = combo_builder.select_strikes(
chain, bias, spot, config
) )
if short_strike is None: return log_and_skip("no_strike")
if not liquidity_gate.passes(short_strike, long_strike, deribit_book): # 2. Algoritmi puri
return log_and_skip("illiquid") entry_decision = validate_entry(EntryContext(...), cfg)
if not entry_decision.accepted:
return EntryCycleResult(status="no_entry", reason=entry_decision.reasons)
n = sizing_engine.compute_contracts( bias = compute_bias(TrendContext(...), cfg)
capital, max_loss_per_contract, dvol, config if bias is None:
) return EntryCycleResult(status="no_entry", reason="no_bias")
if n < 1: return log_and_skip("undersize")
proposal = combo_builder.build(short_strike, long_strike, n, mid_price) chain = await deribit.options_chain(currency="ETH", expiry_from=..., expiry_to=...)
quotes = await _build_quotes(deribit, chain)
selection = select_strikes(chain=quotes, bias=bias, spot=spot, now=now, cfg=cfg)
if selection is None:
return EntryCycleResult(status="no_entry", reason="no_strike")
# 4. Conferma short, long_ = selection
await telegram.send(reporting.pre_trade(proposal)) sizing = compute_contracts(SizingContext(...), cfg)
confirmation = await wait_user_confirmation(timeout="60min") if sizing.n_contracts < 1:
if not confirmation.accepted: return log_and_skip("rejected") return EntryCycleResult(status="no_entry", reason="undersize")
# 5. Esecuzione via Cerbero core if not check(short_leg=..., long_leg=..., credit=..., n_contracts=...).accepted:
instruction = combo_builder.to_cerbero_instruction(proposal) return EntryCycleResult(status="no_entry", reason="illiquid")
await memory.push_user_instruction(instruction, source="cerbero-bite")
# 6. Stato proposal = build(short=short, long_=long_, n_contracts=..., spot=spot, ...)
state.create_position(proposal, dvol_at_entry=dvol)
audit_logger.log("ENTRY_PROPOSED", proposal) # 3. Persistenza + auto-execute
repo.create_position(proposal, status="proposed")
order = await deribit.place_combo_order(legs=[short, long_], side="sell", ...)
if order.state in {"filled", "open"}:
repo.update_position_status(proposal_id, status="open" if filled else "awaiting_fill")
repo.create_instruction(InstructionRecord(...))
await telegram.notify_position_opened(...)
audit.append("ENTRY_PLACED", {...})
else:
repo.update_position_status(proposal_id, status="cancelled")
await alert_manager.high(source="entry_cycle", message="broker_reject")
``` ```
## Sequenza tipo per "monitoring 12h" ## Sequenza tipo per "monitoring 12h"
```python ```python
async def evaluate_open_positions(): async def run_monitor_cycle(ctx: RuntimeContext, *, now):
if not safety.system_healthy(): return if ctx.kill_switch.is_armed():
for position in state.list_open_positions(): return MonitorCycleResult(outcomes=[])
spot = await deribit.get_index_price("ETH")
dvol = await deribit.get_dvol()
mark = await deribit.get_combo_mark(position.legs)
delta_short = await deribit.get_instrument(position.short_leg).delta
decision = exit_decision.evaluate( spot = await deribit.index_price_eth()
position=position, dvol = await deribit.latest_dvol(currency="ETH", now=now)
spot=spot, return_4h = await _fetch_return_4h(ctx, now=now) # usa dvol_history o
dvol_now=dvol, # fallback get_historical
mark_now=mark, repo.record_dvol_snapshot(DvolSnapshot(timestamp=now, dvol=dvol, eth_spot=spot))
delta_short_now=delta_short,
now=datetime.now(timezone.utc),
config=config,
)
for record in repo.list_positions(status="open"):
snapshot = await _build_position_snapshot(...)
decision = evaluate(snapshot, cfg)
if decision.action == "HOLD": if decision.action == "HOLD":
audit_logger.log("HOLD", position.id, decision.reason) audit.append("HOLD", {...}); continue
continue
await telegram.send(reporting.exit_proposal(position, decision)) repo.update_position_status(record.proposal_id, status="closing")
confirmation = await wait_user_confirmation(timeout="30min") order = await deribit.place_combo_order(
if not confirmation.accepted: legs=[short_buy, long_sell], side="buy", ...
audit_logger.log("EXIT_DEFERRED", position.id)
continue
await memory.push_user_instruction(
combo_builder.close_instruction(position),
source="cerbero-bite"
) )
state.mark_closing(position.id) if order.state in {"filled", "open"}:
repo.update_position_status(record.proposal_id, status="closed", ...)
audit.append("EXIT_FILLED", {...})
await telegram.notify_position_closed(...)
else:
repo.update_position_status(record.proposal_id, status="open")
await alert_manager.critical(source="monitor_cycle", ...)
``` ```
## Failure modes e retry ## Failure modes e retry
| Modalità | Risposta | | Modalità | Risposta |
|---|---| |---|---|
| MCP non risponde (timeout) | Retry esponenziale 3 tentativi (1s, 5s, 30s); poi alert + skip ciclo | | MCP non risponde (timeout) | Retry esponenziale 3 tentativi (1 s, 5 s, 30 s); poi `McpTimeoutError` propagato e tradotto in alert HIGH dal modulo che ha originato la chiamata |
| MCP risponde con dato palesemente rotto (es. orderbook tutti a 0) | Skip ciclo, alert | | MCP risponde con dato palesemente rotto (es. `mark_iv == 7%`) | Wrapper solleva `McpDataAnomalyError`; alert HIGH e ciclo saltato |
| Confidence Adriano scaduta | Skip apertura; le chiusure restano in coda con alta priorità | | Stato SQLite corrotto al boot | Recovery riconcilia con il broker; se non possibile arma kill switch CRITICAL |
| Stato SQLite corrotto | Kill switch attivato, alert manuale richiesto | | Mismatch testnet/mainnet rispetto a `strategy.execution.environment` | Kill switch CRITICAL al boot, prima di qualsiasi ciclo trading |
| Cerbero core non riceve l'istruzione | Engine si aspetta ack via `cerbero-memory.get_pending`; se entro 5 min ack assente, alert e blocco apertura | | Mismatch hash anchor tra `system_state.last_audit_hash` e tail del file `audit.log` | Kill switch CRITICAL al boot (truncation o tampering del log) |
| `place_combo_order` respinto dal broker | Posizione marcata `cancelled`, alert HIGH; in monitor la posizione torna a `open` per ritentare al ciclo successivo |
| Lock file occupato | Boot fallisce con `LockError`, exit immediato (un'altra istanza è già attiva) |
Vedi `07-risk-controls.md` per il dettaglio completo dei kill switch. Vedi `07-risk-controls.md` per il dettaglio completo dei kill switch.
## Concorrenza e idempotenza ## Concorrenza e idempotenza
- Una sola istanza dell'engine alla volta (file lock su `data/.lockfile`). - Una sola istanza dell'engine alla volta (`fcntl.flock` esclusivo su
- Tutti i ticker di scheduler sono **idempotenti**: se il sistema crasha `data/.lockfile`).
durante un'apertura, al riavvio il monitoring ricostruisce lo stato - Lo scheduler è in-process e i job sono coalesce + misfire grace
da SQLite + Cerbero core (`cerbero-memory.get_state`) e prosegue. 300 s, quindi un riavvio del container non duplica l'ultimo
- Ogni proposta ha un `proposal_id` UUID. Cerbero core ignora trigger perso.
duplicati con lo stesso id. - Tutti i ticker dello scheduler sono **idempotenti**: se il sistema
crasha durante un'apertura, al riavvio il `recover_state` ricostruisce
lo stato confrontando SQLite con `deribit.get_positions()` e prosegue.
- Ogni proposta ha un `proposal_id` UUID; il `label` inviato a Deribit
contiene questo id, così posso correlare gli ordini sul broker con
la riga `positions` in caso di indagine forense.
## Lifecycle del container
1. `docker compose up cerbero-bite``cerbero-bite start` con
`enforce_hash=True` su `strategy.yaml`.
2. `Orchestrator.run_forever` acquisisce `data/.lockfile`.
3. `boot()` esegue, in ordine: verifica anchor audit chain → recover
state → environment check → primo health probe → log
`ENGINE_START`.
4. Lo scheduler arma i 4 job documentati (entry, monitor, health,
backup).
5. `asyncio.Event` blocca il main task fino a SIGTERM/SIGINT.
6. Alla ricezione del segnale: `scheduler.shutdown(wait=False)`
`RuntimeContext.aclose()` (chiude `httpx.AsyncClient` condiviso) →
uscita pulita; il lock file viene rilasciato dal context manager.
7. Il container Docker, in modalità `restart: unless-stopped`,
resta down se l'engine ha ricevuto SIGTERM dal compose `stop`;
riparte automaticamente solo se il processo è morto per crash.
+119 -68
View File
@@ -6,21 +6,30 @@ file `.jsonl`. Niente database remoti, niente broker esterni.
## Filosofia ## Filosofia
- **Lo stato è la verità per ciò che è in volo.** Posizioni aperte, - **Lo stato è la verità per ciò che è in volo.** Posizioni aperte,
proposte in attesa di conferma, ack di Cerbero core. proposte in attesa di fill, snapshot DVOL.
- **Il log è la verità per ciò che è successo.** Ogni evento (entry, - **Il log è la verità per ciò che è successo.** Ogni evento (entry,
exit, errore, decisione di hold) viene scritto in modo immutabile exit, health, decisione di hold, alert) viene scritto in modo
prima di toccare lo stato. immutabile prima di toccare lo stato.
- Lo stato si può sempre **ricostruire dal log** in caso di corruzione. - **L'audit chain è la verità per ciò che era stato registrato.**
Hash chain SHA-256 su `data/audit.log`, ancorata in
`system_state.last_audit_hash`: una manomissione del file viene
rilevata al boot successivo dall'orchestrator.
- Lo stato si può sempre **ricostruire dal broker** in caso di
corruzione tramite `runtime/recovery.py`.
## Schema SQLite ## Schema SQLite
Migrazione gestita con file SQL semplici sotto `state/migrations/0001_init.sql`, Migrazione gestita con file SQL semplici sotto
applicati in sequenza. No ORM heavy: si usa `sqlalchemy.core` o `src/cerbero_bite/state/migrations/`, applicati in sequenza dal runner
addirittura `sqlite3` nativo con piccole utility. in `state/db.py` che incrementa `PRAGMA user_version`. Forward-only.
Tutti i pragmi runtime (`foreign_keys = ON`, `journal_mode = WAL`,
`synchronous = NORMAL`) vengono applicati a ogni connessione aperta da
`state.db.connect`.
### `positions` ### `positions`
Posizione Cerberus Bite (aperta, in chiusura, chiusa). Posizione Cerberus Bite (proposta, in attesa di fill, aperta, in
chiusura, chiusa, cancellata).
```sql ```sql
CREATE TABLE positions ( CREATE TABLE positions (
@@ -30,7 +39,7 @@ CREATE TABLE positions (
expiry TEXT NOT NULL, -- ISO8601 UTC expiry TEXT NOT NULL, -- ISO8601 UTC
short_strike NUMERIC NOT NULL, short_strike NUMERIC NOT NULL,
long_strike NUMERIC NOT NULL, long_strike NUMERIC NOT NULL,
short_instrument TEXT NOT NULL, short_instrument TEXT NOT NULL, -- ETH-15MAY26-2475-P
long_instrument TEXT NOT NULL, long_instrument TEXT NOT NULL,
n_contracts INTEGER NOT NULL, n_contracts INTEGER NOT NULL,
spread_width_usd NUMERIC NOT NULL, spread_width_usd NUMERIC NOT NULL,
@@ -61,27 +70,36 @@ CREATE INDEX idx_positions_closed_at ON positions(closed_at);
Stati validi: Stati validi:
``` ```
proposed → proposta inviata a Adriano, in attesa di conferma proposed → riga creata, place_combo_order in volo
awaiting_fill → istruzione push_user_instruction inviata, in attesa fill awaiting_fill → ordine accettato da Deribit, in attesa di fill
open → fill confermato, monitoring attivo open → fill confermato, monitoring attivo
closing → istruzione close inviata, in attesa fill closing → close combo inviata, in attesa di fill di chiusura
closed → close fill confermato, P&L calcolato closed → close fill confermato, P&L calcolato
cancelled → proposta rifiutata da Adriano o no fill cancelled → ordine respinto dal broker o nessun fill nella finestra
``` ```
I `Decimal` documentati come `NUMERIC` vengono persistiti come `TEXT`
con `str(Decimal)`: la conversione avviene nel layer `Repository` per
preservare la precisione e non scivolare mai su `float`.
### `instructions` ### `instructions`
Tracciamento di ogni `push_user_instruction` inviata a Cerbero core. Tracciamento di ogni ordine inviato a Deribit tramite
`mcp-deribit.place_combo_order` o `cancel_order`. La tabella esiste
per audit forense: a fronte di una posizione chiusa serve poter
ricostruire la sequenza di ordini broker che hanno portato al fill.
```sql ```sql
CREATE TABLE instructions ( CREATE TABLE instructions (
instruction_id TEXT PRIMARY KEY, -- UUID dato a Cerbero core instruction_id TEXT PRIMARY KEY, -- UUID Bite-side
proposal_id TEXT NOT NULL REFERENCES positions(proposal_id), proposal_id TEXT NOT NULL REFERENCES positions(proposal_id),
kind TEXT NOT NULL, -- open_combo, close_combo kind TEXT NOT NULL, -- open_combo, close_combo
payload_json TEXT NOT NULL, -- JSON dell'istruzione completa payload_json TEXT NOT NULL, -- JSON della response Deribit
-- (combo_instrument, order_id, state, ...)
sent_at TEXT NOT NULL, sent_at TEXT NOT NULL,
acknowledged_at TEXT, acknowledged_at TEXT, -- riservato a un futuro hook ack
filled_at TEXT, filled_at TEXT, -- compilato dal recovery o da un
-- hook che monitora i fill
cancelled_at TEXT, cancelled_at TEXT,
actual_fill_eth NUMERIC, -- prezzo medio fill effettivo actual_fill_eth NUMERIC, -- prezzo medio fill effettivo
actual_fees_eth NUMERIC actual_fees_eth NUMERIC
@@ -90,6 +108,12 @@ CREATE TABLE instructions (
CREATE INDEX idx_instructions_proposal ON instructions(proposal_id); CREATE INDEX idx_instructions_proposal ON instructions(proposal_id);
``` ```
Cerbero Bite invia direttamente l'ordine al broker: il payload qui
salvato è la response di `mcp-deribit.place_combo_order` (che a sua
volta wrappa `private/create_combo` + `private/buy|sell`). Niente
relazioni con un eventuale `cerbero-memory`: quel servizio non è
parte del decision loop.
### `decisions` ### `decisions`
Storia delle valutazioni del decision loop (anche quando l'esito è Storia delle valutazioni del decision loop (anche quando l'esito è
@@ -103,7 +127,7 @@ CREATE TABLE decisions (
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
inputs_json TEXT NOT NULL, -- snapshot input al modulo core inputs_json TEXT NOT NULL, -- snapshot input al modulo core
outputs_json TEXT NOT NULL, -- output del modulo core outputs_json TEXT NOT NULL, -- output del modulo core
action_taken TEXT, -- HOLD, no_entry, propose_open, propose_close action_taken TEXT, -- HOLD, no_entry, propose_open, broker_error, ...
notes TEXT notes TEXT
); );
@@ -113,7 +137,9 @@ CREATE INDEX idx_decisions_proposal ON decisions(proposal_id);
### `dvol_history` ### `dvol_history`
Snapshot DVOL ad ogni evaluation (utile per analisi di lungo periodo). Snapshot DVOL + ETH spot ad ogni evaluation. Utile per il calcolo di
`return_4h` durante il monitor (vedi `runtime/monitor_cycle.py
_fetch_return_4h`) e per analisi post-mortem.
```sql ```sql
CREATE TABLE dvol_history ( CREATE TABLE dvol_history (
@@ -125,13 +151,15 @@ CREATE TABLE dvol_history (
### `manual_actions` ### `manual_actions`
Coda di azioni manuali generate dalla GUI Streamlit (vedi `11-gui-streamlit.md`). Coda di azioni manuali generate dalla GUI Streamlit (vedi
L'engine consuma le righe non processate via job APScheduler ogni 30s. `11-gui-streamlit.md`). Schema previsto in vista della Fase 4.5; al
momento la GUI non è implementata e la tabella resta vuota.
```sql ```sql
CREATE TABLE manual_actions ( CREATE TABLE manual_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- approve_proposal, reject_proposal, force_close, arm_kill, disarm_kill kind TEXT NOT NULL, -- approve_proposal, reject_proposal,
-- force_close, arm_kill, disarm_kill
proposal_id TEXT, -- NULL se l'azione non è legata a una proposta proposal_id TEXT, -- NULL se l'azione non è legata a una proposta
payload_json TEXT, -- JSON con motivo, conferma typed, ecc. payload_json TEXT, -- JSON con motivo, conferma typed, ecc.
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -142,12 +170,14 @@ CREATE TABLE manual_actions (
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at); CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
``` ```
Le `manual_actions` non bypassano i risk control: il consumer applica Le `manual_actions` non bypassano i risk control: il consumer
gli stessi check di `safety.system_healthy()` prima di eseguire. (quando esisterà) applicherà gli stessi check di
`safety.system_healthy()` prima di eseguire.
### `system_state` ### `system_state`
Singleton di stato globale (kill switch, last health check). Singleton di stato globale (kill switch, last health check, anchor
audit chain).
```sql ```sql
CREATE TABLE system_state ( CREATE TABLE system_state (
@@ -158,54 +188,66 @@ CREATE TABLE system_state (
last_health_check TEXT NOT NULL, last_health_check TEXT NOT NULL,
last_kelly_calib TEXT, last_kelly_calib TEXT,
config_version TEXT NOT NULL, config_version TEXT NOT NULL,
started_at TEXT NOT NULL started_at TEXT NOT NULL,
last_audit_hash TEXT -- aggiunto dalla migration 0002
); );
``` ```
Il campo `last_audit_hash` viene aggiornato dal callback
`AuditLog.on_append` registrato in `runtime/dependencies.build_runtime`.
Al boot l'orchestrator confronta questo valore con il tail del file
`audit.log`: discrepanza → kill switch CRITICAL, vedi
`07-risk-controls.md`.
## Log file ## Log file
Sotto `data/log/` un file per giorno: `cerbero-bite-YYYY-MM-DD.jsonl`. Sotto `data/log/` un file per giorno: `cerbero-bite-YYYY-MM-DD.jsonl`.
Ogni linea è un evento JSON. Ogni linea è un evento JSON arricchito automaticamente da
`structlog.contextvars` con `cycle` e `cycle_id` quando il record è
emesso dentro un ciclo (entry/monitor/health), in modo da poter
filtrare tutti i log relativi a una specifica esecuzione.
### Schema evento ### Schema evento
```json ```json
{ {
"ts": "2026-04-27T14:00:01.234Z", "ts": "2026-04-27T14:00:01.234Z",
"event": "ENTRY_PROPOSED", "event": "entry placed",
"level": "INFO", "level": "info",
"module": "runtime.orchestrator", "logger": "cerbero_bite.runtime.entry",
"cycle": "entry",
"cycle_id": "9d2a8a6a-7f58-4f10-8b33-...",
"proposal_id": "uuid-...", "proposal_id": "uuid-...",
"data": { ... }, "data": { ... }
"decision_id": 12345
} }
``` ```
### Audit log
Il file `data/audit.log` è separato dai log JSONL ed è alimentato
esclusivamente da `safety.audit_log.AuditLog`. Ogni riga ha la forma:
```
<iso-ts>|<event>|<json-payload>|prev_hash=<hex>|hash=<hex>
```
con `hash = SHA256("<iso-ts>|<event>|<json-payload>|<prev_hash>")`.
La chain è ancorata in `system_state.last_audit_hash`. Il file è
fsync'd ad ogni append.
### Eventi previsti ### Eventi previsti
| Event | Quando | | Event | Quando |
|---|---| |---|---|
| `ENGINE_START` | Avvio engine | | `ENGINE_START` | Avvio engine completato (boot OK) |
| `ENGINE_STOP` | Stop pulito | | `RECOVERY_DONE` | Fine del recovery loop |
| `HEALTH_OK` / `HEALTH_DEGRADED` | Health check periodico | | `HEALTH_OK` / `HEALTH_DEGRADED` | Health check periodico |
| `MCP_CALL` | Ogni chiamata MCP (livello DEBUG) | | `ENTRY_PLACED` | Combo aperto e fill (o awaiting_fill) confermato |
| `MCP_FAIL` | Fallimento MCP (livello WARN/ERROR) | | `HOLD` | Monitor ha valutato una posizione e nessun trigger ha fired |
| `ENTRY_EVALUATED` | Ciclo settimanale completato | | `EXIT_FILLED` | Close confermato dal broker |
| `ENTRY_REJECTED` | Filtri non passati | | `KILL_SWITCH_ARMED` / `KILL_SWITCH_DISARMED` | Transizioni manuali o automatiche |
| `ENTRY_PROPOSED` | Proposta inviata a Telegram | | `ALERT` | Routing severity-based dell'alert manager |
| `ENTRY_CONFIRMED` | Adriano conferma | | `KELLY_RECALIBRATED` | Output del job mensile (Fase 5) |
| `ENTRY_REJECTED_BY_USER` | Adriano nega |
| `ENTRY_TIMEOUT` | Adriano non risponde nei 60 min |
| `INSTRUCTION_SENT` | push_user_instruction completato |
| `INSTRUCTION_ACK` | Cerbero core ack |
| `FILL_CONFIRMED` | Posizione effettivamente aperta |
| `EXIT_EVALUATED` | Ciclo monitor completato (anche HOLD) |
| `EXIT_PROPOSED` | Proposta chiusura |
| `EXIT_CONFIRMED` | Adriano conferma chiusura |
| `EXIT_FILLED` | Fill chiusura confermato |
| `KILL_SWITCH_ARMED` | Disarm manuale richiesto |
| `KILL_SWITCH_DISARMED` | Manuale via CLI |
| `KELLY_RECALIBRATED` | Output report mensile |
### Rotation e ritenzione ### Rotation e ritenzione
@@ -216,26 +258,35 @@ Ogni linea è un evento JSON.
## Stato in memoria ## Stato in memoria
L'engine mantiene una cache delle posizioni aperte in RAM, ricaricata L'engine non mantiene una cache delle posizioni aperte: ogni ciclo
da SQLite all'avvio e refreshed dopo ogni transizione di stato. legge fresco da SQLite. Lo scheduler APScheduler è in-process e i job
Ogni mutazione segue il pattern: ricevono il `RuntimeContext` (con `Repository`, `AuditLog`,
`KillSwitch`, alert manager, client MCP) dalla closure di
`Orchestrator.install_scheduler`. Ogni mutazione dei `positions`
segue il pattern:
1. Append evento al log file (fsync). 1. (Opzionale) Audit append per gli eventi tracciati nella chain.
2. Begin transaction SQLite. 2. `with transaction(conn):``repository.update_position_status(...)`
3. Update tabelle. o `repository.create_*(...)`.
4. Commit. 3. Notifica Telegram post-fact, dove pertinente.
5. Update cache RAM.
Se il sistema crasha tra (1) e (5), al restart un riconciliatore confronta Il connessione SQLite è aperta e chiusa dentro la singola operazione,
log vs SQLite e ripristina la consistenza. mai mantenuta long-lived: WAL e auto-commit garantiscono che letture
da altri processi (es. CLI `state inspect`) non vedano stati parziali.
## Migrations ## Migrations
Lo schema viene tracciato con un counter `pragma user_version`. La Lo schema viene tracciato con il counter `PRAGMA user_version`. La
prima volta `0001_init.sql` viene applicato e versione → 1. Aggiunte prima volta `0001_init.sql` viene applicato e versione → 1; alla
future incrementano. Nessun rollback supportato (migrations forward-only). seconda esecuzione (o su DB già a versione 1) `0002_audit_anchor.sql`
viene applicato e versione → 2. `state.db.run_migrations` è
idempotente. Nessun rollback supportato (migrations forward-only).
## Backup ## Backup
Backup orari di `state.sqlite` in `data/backups/state-YYYYMMDD-HH.sqlite`, Backup orari di `state.sqlite` in
ritenuti per 30 giorni. Operazione idempotente con SQLite `VACUUM INTO`. `data/backups/state-YYYYMMDD-HH.sqlite`, ritenuti per 30 giorni.
Operazione idempotente con SQLite `VACUUM INTO`. Lo job è registrato
nello scheduler dell'orchestrator (`backup_cron = "0 * * * *"`); è
disponibile anche come CLI `python scripts/backup.py` per backup
ad-hoc.
+166 -85
View File
@@ -8,7 +8,7 @@ infrastrutturali o decisioni umane fuori posto.
- **Default deny**: in caso di dubbio, il sistema non fa nulla. - **Default deny**: in caso di dubbio, il sistema non fa nulla.
- **Disarm manuale**: ogni kill switch viene disarmato esplicitamente - **Disarm manuale**: ogni kill switch viene disarmato esplicitamente
da Adriano via CLI o comando Telegram, mai automaticamente. da Adriano via CLI, mai automaticamente.
- **Visibilità**: ogni evento di sicurezza viene loggato e notificato - **Visibilità**: ogni evento di sicurezza viene loggato e notificato
immediatamente. immediatamente.
- **No silent close**: una posizione viene chiusa solo a seguito di - **No silent close**: una posizione viene chiusa solo a seguito di
@@ -19,45 +19,43 @@ infrastrutturali o decisioni umane fuori posto.
### Stato ### Stato
`system_state.kill_switch ∈ {0, 1}`. Quando = 1, l'engine: `system_state.kill_switch ∈ {0, 1}`. Quando `= 1`, l'engine:
- continua i flussi di **sola lettura** (health check, monitoring di - continua i flussi di **sola lettura** (health check, monitoring di
stato, log) stato, log)
- **non** invia istruzioni di apertura - **non** invia istruzioni di apertura
- **non** invia istruzioni di chiusura **automatiche** (richiede - **non** invia istruzioni di chiusura **automatiche** (il monitor
intervento manuale via CLI con flag `--force-close-position`) cycle salta quando il kill switch è armato)
- continua a notificare i trigger di uscita ad Adriano via Telegram - continua a notificare via Telegram gli alert con la severity
con escalation manuale appropriata (vedi escalation tree)
### Trigger automatici ### Trigger automatici
| Causa | Auto-arm | Note | | Causa | Auto-arm | Implementato | Note |
|---|---|---| |---|---|---|---|
| MCP `cerbero-deribit` versione mismatch | Sì | Non procede senza schema atteso | | MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| MCP `cerbero-macro` non risponde per >30 min | Sì | No entry possibile | | MCP `cerbero-macro` / `cerbero-portfolio` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
| MCP `cerbero-memory` non risponde per >5 min | Sì | No esecuzione possibile | | `mcp-deribit.environment_info.environment``strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime |
| Stato SQLite incoerente con broker | Sì | Diff tra DB e Deribit positions | | Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot |
| 3 health check consecutivi falliti | Sì | Engine instabile | | Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
| Perdita giornaliera > 3% equity | Sì | Hard prohibition Cerbero v4 | | `place_combo_order` di chiusura respinto dal broker | Sì | `runtime/monitor_cycle.py` | Severity CRITICAL; la posizione torna in `open` per ritentare |
| Numero posizioni concorrenti > cap | Sì | Bug del sizing engine | | `place_combo_order` di apertura respinto dal broker | Sì | `runtime/entry_cycle.py` | Severity HIGH; la posizione viene marcata `cancelled` |
| Push instruction respinta da Cerbero core | Sì | Dopo 2 retry | | Hash chain audit non verifica (`audit verify` fallisce) | Manuale per ora; CLI `audit verify` segnala l'anomalia con exit 2 | `cli.py audit verify` + `safety/audit_log.verify_chain` | Severity CRITICAL quando integrata nel boot |
| Schema config invalido al reload | Sì | Mai partire con config rotta | | Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) |
| Comando Adriano `/kill` su Telegram | Sì | Trigger volontario |
### Disarm ### Disarm
```bash ```bash
cerbero-bite kill-switch --disarm --reason "<motivo>" cerbero-bite kill-switch disarm --reason "<motivo>" \
--db data/state.sqlite \
--audit data/audit.log
``` ```
oppure via Telegram: L'operazione è transazionale: SQLite `system_state.kill_switch = 0` +
una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il
``` disarm non riavvia automaticamente lo scheduler; è il prossimo tick
/disarm <motivo> naturale (entry settimanale o monitor 12h) a far ripartire la
``` decisione.
In entrambi i casi il sistema chiede una conferma esplicita ("yes I am
sure").
## Cap di rischio (oltre alle regole di strategia) ## Cap di rischio (oltre alle regole di strategia)
@@ -66,61 +64,113 @@ ma sono applicati in modo difensivo come **ultima linea**:
| Misura | Limite | Comportamento se superato | | Misura | Limite | Comportamento se superato |
|---|---|---| |---|---|---|
| Notional combo singolo | 200 EUR | Sizing engine reject | | Notional combo singolo | 200 EUR | Sizing engine reject (`undersize`) |
| Engagement totale aperto | 1.000 EUR | Sizing engine reject | | Engagement totale aperto | 1.000 EUR | Sizing engine reject |
| Posizioni concorrenti CB | 4 (default 1 per strategia) | Reject pre-push | | Posizioni concorrenti CB | 1 (default per la strategia ETH) | Entry cycle reject (`has_open_position`) |
| Trade aperti per giorno | 6 (intera Cerbero suite) | Lookup `cerbero-memory.daily_trade_count` | | Trade aperti per giorno | 6 (intera Cerbero suite) | Non implementato — richiede integrazione cross-suite, lasciato a Cerbero core |
| Perdita giornaliera | 3% equity | Kill switch | | Distance short strike | < 15 % spot OTM | Combo builder reject (`no_strike`) |
| Distance short strike | < 15% spot OTM | Combo builder reject | | Credit / width | < 0.30 | Combo builder reject (`no_strike`) |
| Credit / width | < 0.30 | Combo builder reject | | Slippage ≥ 8 % credito | Hard | Liquidity gate reject (`illiquid`) |
| DVOL fuori `[35, 90]` | Hard | Entry validator reject (`dvol`) |
| Funding ETH-PERP `|·| > 80%` annualizzato | Hard | Entry validator reject (`funding`) |
| ETH holdings > 30 % portfolio | Hard | Entry validator reject (`holdings`) |
| Macro evento `high` entro DTE | Hard | Entry validator reject (`macro`) |
| Dealer net gamma < `dealer_gamma_min` | Soft (gate disabilitabile) | Entry validator reject (`dealer short-gamma regime`) — vedi `01-strategy-rules.md §2.8` |
| `liquidation_squeeze_risk == "high"` | Soft (gate disabilitabile) | Entry validator reject (`imminent liquidation squeeze risk`) |
Doppia verifica: il sizing_engine applica il cap, **e** il watchdog I primi sei cap sono applicati direttamente dai moduli `core/`; gli
runtime ricontrolla il payload prima di chiamare `push_user_instruction`. altri tre filtri quant-grade (DVOL, holdings, dealer-gamma,
Se le due misure divergono → kill switch + alert. liquidation) sono applicati da `entry_validator.validate_entry` con
soglie esplicite in `strategy.yaml`.
## Single-instance lock
Cerbero Bite acquisisce `data/.lockfile` con `fcntl.flock(LOCK_EX |
LOCK_NB)` all'avvio dell'engine (`runtime/lockfile.EngineLock`). Un
secondo container che provasse a partire sullo stesso file di stato
fallirebbe immediatamente con `LockError`, prima di toccare SQLite o i
client MCP. Il lock viene rilasciato in modo automatico dal kernel
quando il processo termina, anche su crash, quindi non rimane mai
"appeso".
Caveat: `flock` è efficace solo all'interno dello stesso host
(filesystem locale del container o bind mount). Per uno scenario
distribuito multi-host servirebbe un lock service esterno (es. Redis
SET NX); al momento Cerbero Bite gira in un singolo container e il
lock locale è sufficiente.
## Dead-man switch ## Dead-man switch
Se l'engine non scrive un evento `HEALTH_OK` per **15 minuti** consecutivi: Se l'engine non scrive un evento `HEALTH_OK` per **15 minuti**
consecutivi:
1. Un processo separato `data/dead-man.sh` (cron in user crontab, 1. Il processo separato `scripts/dead_man.sh` (cron in user crontab,
indipendente dall'engine) rileva il silenzio. indipendente dall'engine) rileva il silenzio cercando l'ultimo
2. Invia alert su canale Telegram di backup. `HEALTH_OK` nel JSONL del giorno.
3. Marca SQLite con `system_state.kill_switch=1`. 2. Invia un alert al canale Telegram di backup (variabile
`DEAD_MAN_ALERT_CMD` o file `data/log/dead-man-alert.txt`).
3. Marca SQLite con `system_state.kill_switch=1` direttamente via
sqlite3 CLI.
4. Adriano interviene manualmente. 4. Adriano interviene manualmente.
Il dead-man è scritto in shell minimale (no dipendenze Python) per Lo script è scritto in shell minimale (no dipendenze Python) per
sopravvivere a corruzioni dell'env Python. sopravvivere a corruzioni dell'env Python. La presenza del binario
`sqlite3` è opzionale: in sua assenza il dead-man genera comunque
l'alert ma salta lo step di arming SQLite.
## Audit log immutabile ## Audit log immutabile
Oltre al log JSONL standard, ogni decisione di trading produce una Oltre al log JSONL standard, ogni decisione di trading produce una
linea append-only in `data/audit.log` con il **digest SHA-256** della linea append-only in `data/audit.log` con il **digest SHA-256** della
linea precedente (chain di hash, stile blockchain semplificato). linea precedente (chain di hash, stile blockchain semplificato). Il
file viene `flush + os.fsync` a ogni append.
Esempio: Esempio:
``` ```
2026-04-27T14:00:01Z|ENTRY_PROPOSED|{...full payload...}|prev_hash=abc123...|hash=def456... 2026-04-27T14:00:01+00:00|ENTRY_PLACED|{"proposal_id":"...","spread_type":"bull_put"}|prev_hash=abc123...|hash=def456...
``` ```
Verificabile retroattivamente con `cerbero-bite audit verify`. Una Verificabile retroattivamente con `cerbero-bite audit verify`. La
discrepanza dell'hash chain è trattata come tampering e arma il kill verifica controlla:
switch.
* parsing della struttura (`<ts>|<event>|<json>|prev_hash=...|hash=...`);
* consistenza del JSON payload (oggetto, non lista o scalare);
* `prev_hash` di ogni linea uguale all'`hash` della precedente;
* `hash` ricalcolato uguale a quello memorizzato.
Una discrepanza è trattata come tampering e produce `exit 2` dal
comando CLI; in regime servirà che lo stesso check, integrato nel
ciclo di health, armi il kill switch CRITICAL.
### Anti-truncation
La chain così com'è descritta resta valida anche se il file viene
**troncato** alla fine: i restanti record verificano l'uno con
l'altro. Per coprire questo caso Cerbero Bite mantiene un *anchor*:
ogni `AuditLog.append` invoca un callback registrato in
`runtime/dependencies.build_runtime` che persiste l'hash appena
scritto in `system_state.last_audit_hash`. Al boot
`Orchestrator._verify_audit_anchor` confronta il valore persistito con
il tail del file: in caso di mismatch (truncation, sostituzione, file
mancante) viene armato il kill switch CRITICAL prima che qualsiasi
ciclo trading parta.
## Dry-run mode ## Dry-run mode
Tutti i flussi possono essere eseguiti in modalità `--dry-run`: Il comando `cerbero-bite dry-run --cycle entry|monitor|health` esegue
**un singolo ciclo** senza avviare lo scheduler. Il ciclo usa lo
stesso codice di produzione (snapshot reali, `place_combo_order` reale
sul testnet), quindi non è "lettura sola" — è un'esecuzione one-shot.
Per testare flussi senza toccare il broker si usa il
`Cerbero_mcp` con `DERIBIT_TESTNET=true` (default), così
`mcp-deribit.environment_info` riporta `testnet` e gli ordini vanno
sul paper book.
- Tutti i tool MCP **read-only** vengono chiamati normalmente. `enforce_hash` è disattivato in dry-run per agevolare il debug; il
- Tool MCP **write** (`push_user_instruction`, `kb_write`, `send`) vengono comando `start` invece carica `strategy.yaml` con
loggati ma **non** chiamati. `enforce_hash=True`, quindi mismatch dell'hash producono exit 1
- `state.create_position` viene scritto in tabella `dry_positions` prima che l'engine tocchi qualsiasi stato.
(schema identico a `positions` ma separata).
Usato per:
- Testing in produzione prima di andare live.
- Replay di giornate storiche per validazione.
- Test di nuove versioni di config o algoritmi.
## Versionamento config ## Versionamento config
@@ -128,14 +178,33 @@ Ogni `strategy.yaml` ha:
```yaml ```yaml
config_version: "1.0.0" config_version: "1.0.0"
config_hash: "<sha256 del file senza questa riga>" config_hash: "<sha256 del file con il valore di config_hash sostituito da vuoto>"
last_review: "2026-04-26" last_review: "2026-04-26"
last_reviewer: "Adriano" last_reviewer: "Adriano"
``` ```
All'avvio l'engine verifica che `config_hash` corrisponda al contenuto. All'avvio di `cerbero-bite start` l'engine verifica che `config_hash`
Mismatch → kill switch (qualcuno ha modificato la config senza corrisponda al contenuto del file (il calcolo esclude il valore stesso
aggiornare l'hash, possibile tampering o errore umano). del campo `config_hash`, vedi `config/loader.compute_config_hash`).
Mismatch → exit 1 prima del boot. La verifica protegge da modifiche
silenziose alla config, accidentali o malevole.
Nuovi campi proposti dalla migration di Fase 4 hardening:
```yaml
execution:
environment: "testnet" # testnet|mainnet — kill switch su mismatch broker
eur_to_usd: "1.075" # FX di sizing, override-able via CLI flag
entry:
dealer_gamma_min: "0" # filtro §2.8
dealer_gamma_filter_enabled: true
liquidation_filter_enabled: true
```
Ogni cambio richiede una nuova versione di `config_version`,
ricalcolo dell'hash via `cerbero-bite config hash` e commit con
giustificazione testuale nel messaggio.
## Escalation tree ## Escalation tree
@@ -143,39 +212,51 @@ aggiornare l'hash, possibile tampering o errore umano).
Evento anomalo Evento anomalo
├── Severity LOW (es. 1 health check fallito) ├── Severity LOW (es. 1 health check fallito)
│ └── Log WARNING, continua │ └── Append in audit chain (event=ALERT severity=low),
│ continua
├── Severity MEDIUM (es. MCP timeout occasionale) ├── Severity MEDIUM (es. snapshot dato mancante non bloccante)
│ ├── Log WARNING + Telegram digest giornaliero │ ├── Append in audit chain
│ └── Continua, retry next cycle │ └── Telegram notify (priority=high), continua
├── Severity HIGH (es. 3 health check consecutivi falliti) ├── Severity HIGH (es. 3 health check consecutivi falliti,
├── Kill switch ARM entry rejected dal broker)
│ ├── Telegram alert immediato │ ├── Append in audit chain
── Adriano interviene ── Telegram notify_alert (priority=high)
│ ├── Kill switch ARM (idempotente)
│ └── Adriano interviene per disarmare
└── Severity CRITICAL (es. stato incoerente, hash chain rotto) └── Severity CRITICAL (es. mismatch environment al boot,
├── Kill switch ARM hash chain rotta, close fallito su monitor)
├── Telegram + canale backup BotPapà ├── Append in audit chain
├── Engine si mette in idle (no decisioni, solo monitoring) ├── Telegram notify_system_error (priority=critical)
── Richiede intervento umano per disarmo ── Kill switch ARM (idempotente)
└── Engine resta in idle finché Adriano non disarma
``` ```
L'implementazione vive in `runtime/alert_manager.AlertManager`;
ciascun modulo runtime accede al manager tramite
`RuntimeContext.alert_manager` e chiama
`am.low(...)` / `am.medium(...)` / `am.high(...)` / `am.critical(...)`
con `source` (modulo emittente) e `message` (descrizione human-friendly).
## Test di resilienza obbligatori ## Test di resilienza obbligatori
Prima del go-live e ad ogni release minor: Prima del go-live e ad ogni release minor:
1. **Chaos test MCP**: simula timeout/errori su ogni MCP, verifica 1. **Chaos test MCP**: simula timeout/errori su ogni MCP, verifica
che il comportamento documentato in `04-mcp-integration.md` sia che il comportamento documentato in `04-mcp-integration.md` sia
rispettato. rispettato (retry, fallback, kill switch).
2. **State corruption test**: corrompi una riga `positions` e verifica 2. **State corruption test**: corrompi una riga `positions` e
che il riconciliatore lo rilevi. verifica che il `recover_state` lo rilevi.
3. **Hash chain test**: modifica una linea audit e verifica che 3. **Hash chain test**: modifica una linea audit e verifica che
`audit verify` fallisca. `audit verify` fallisca; tronca il file e verifica che il check
4. **Replay test**: rigioca una giornata storica in dry-run, confronta anchor al boot armi il kill switch.
le decisioni con un set golden. 4. **Replay test**: rigioca una giornata storica via
5. **Cap saturation test**: simula 4 posizioni concorrenti, verifica `cerbero-bite replay` (Fase 5/6), confronta le decisioni con un
che il quinto trade venga rifiutato. set golden.
5. **Cap saturation test**: simula posizioni concorrenti, verifica
che il sizing engine rifiuti.
I risultati sono documentati in `tests/golden/results-YYYY-MM-DD.md`. I risultati sono documentati in `tests/golden/results-YYYY-MM-DD.md`.
@@ -184,7 +265,7 @@ I risultati sono documentati in `tests/golden/results-YYYY-MM-DD.md`.
Per chiarezza, queste cose **non** sono cap né kill switch — sono Per chiarezza, queste cose **non** sono cap né kill switch — sono
parte della strategia, gestite altrove: parte della strategia, gestite altrove:
- Profit take 50%: regola di strategia. - Profit take 50 %: regola di strategia.
- Stop loss 1.5×: regola di strategia. - Stop loss 1.5×: regola di strategia.
- Vol stop +10 DVOL: regola di strategia. - Vol stop +10 DVOL: regola di strategia.
- Time stop 7 DTE: regola di strategia. - Time stop 7 DTE: regola di strategia.