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
+197 -153
View File
@@ -5,15 +5,17 @@
```
┌─────────────────────────────────┐
│ ADRIANO (utente) │
└────────────────────────────────┘
│ Telegram (report + conferma)
└────────────────────────────────┘
│ Telegram (notifiche post-fact)
┌─────────────────────────────────────────────────────────────────┐
│ CERBERO BITE (rule engine) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ scheduler │ │ state store │ │ audit logger │ │
│ │ (APScheduler)│ │ (SQLite) │ │ (append-only) │ │
│ │ (APScheduler)│ │ (SQLite) │ │ (append-only, │ │
│ │ │ │ │ │ hash chain + │ │
│ │ │ │ │ │ anchor SQLite) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
@@ -21,7 +23,7 @@
│ │ decision orchestrator (core/) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ entry │ │ sizing │ │ exit │ │ greeks │ │ │
│ │ │validator │ │ engine │ │ decision │ │ aggregator│ │ │
│ │ │validator │ │ engine │ │ decision │ │aggregator│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │liquidity │ │ combo │ │ kelly │ │ │
@@ -31,42 +33,50 @@
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ MCP client wrappers (clients/) │ │
│ │ MCP HTTP client wrappers (clients/) │ │
│ │ Deribit │ Hyperliquid │ Macro │ Sentiment │ Portfolio │ │
│ │ Memory │ Telegram │ Brain-Bridge │ │
│ │ Telegram │ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────────┘
MCP JSON-RPC
HTTP (Bearer token, rete Docker
│ `cerbero-suite`)
┌───────────────────────────────┐ ┌───────────────────┐
│ MCP servers │ │ Cerbero core
│ (esistenti in CerberoSuite) │───▶│ (esecuzione)
└───────────────────────────────┘ └───────────────────┘
┌────────────────┐
│ Deribit │
API
└────────────────┘
┌──────────────────────────────────┐
MCP servers │
│ (FastAPI, repo Cerbero_mcp)
└─────────────────────────────────┘
┌────────────────┐
│ Deribit │
│ (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
| Strato | Scelta | Motivazione |
|---|---|---|
| Linguaggio | Python 3.12+ | Coerente con `mcp_cerbero_brain` e l'ecosistema Cerbero |
| Async runtime | `asyncio` | Necessario per i client MCP |
| Scheduler | `APScheduler` | Cron-like + interval-based, in-process |
| Persistenza stato | SQLite (file singolo) | Zero deploy overhead, transazionale, sufficiente per ≤ 4 posizioni |
| Persistenza log | File `.jsonl` rotativi (gzip > 30g) | Audit-friendly, append-only, parseable |
| Linguaggio | Python 3.13 | Allineato a `Cerbero_mcp` |
| Async runtime | `asyncio` | Necessario per i client HTTP MCP e lo scheduler |
| Scheduler | `APScheduler` `AsyncIOScheduler` | Cron-like + interval-based, in-process |
| Persistenza stato | SQLite (file singolo) con WAL + `synchronous=NORMAL` | Zero deploy overhead, transazionale, sufficiente per ≤ 4 posizioni |
| 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` |
| 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 |
| Format/lint | `ruff` | Standard del progetto |
| Dependency manager | `uv` | Coerente con `mcp_cerbero_brain` |
| MCP client | SDK ufficiale `mcp` | Connessione ai server già configurati in `.mcp.json` |
| Notifiche | MCP `cerbero-telegram` | Riusa canale esistente |
| GUI | `streamlit` ≥ 1.40 + `plotly` | Dashboard locale, processo separato (vedi `11-gui-streamlit.md`) |
| Dependency manager | `uv` | Coerente con `Cerbero_mcp` |
| Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `mcp` |
| Notifiche | MCP `cerbero-telegram` (notify-only) | Riusa il canale esistente |
| GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato |
## Layout cartelle
@@ -75,66 +85,76 @@ Cerbero_Bite/
├── README.md
├── pyproject.toml
├── uv.lock
├── strategy.yaml # config golden
├── strategy.local.yaml.example # override locale (gitignored)
├── docs/ # questa documentazione
├── strategy.yaml # config golden + execution.environment
├── strategy.local.yaml.example # override locale (gitignored)
├── Dockerfile # image runtime + HEALTHCHECK
├── docker-compose.yml # rete external cerbero-suite + secrets
├── docs/ # questa documentazione
├── secrets/ # gitignored (solo .gitkeep + README)
├── src/cerbero_bite/
│ ├── __init__.py
│ ├── __main__.py # entry point CLI
│ ├── core/ # algoritmi puri (no I/O)
│ ├── __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)
│ │ ├── entry_validator.py
│ │ ├── sizing_engine.py
│ │ ├── exit_decision.py
│ │ ├── greeks_aggregator.py
│ │ ├── liquidity_gate.py
│ │ ├── combo_builder.py
│ │ ── kelly_recalibration.py
├── clients/ # wrapper MCP
│ │ ── kelly_recalibration.py
│ └── types.py # OptionQuote, OptionLeg
│ ├── clients/ # wrapper HTTP sui MCP
│ │ ├── _base.py # HttpToolClient + retry/timeout
│ │ ├── _exceptions.py # McpError gerarchia
│ │ ├── deribit.py
│ │ ├── hyperliquid.py
│ │ ├── macro.py
│ │ ├── sentiment.py
│ │ ├── portfolio.py
│ │ ── memory.py
│ ├── telegram.py
│ │ ── brain_bridge.py
│ ├── runtime/ # I/O, scheduling, orchestrazione
│ │ ├── scheduler.py
│ │ ├── orchestrator.py
│ │ ├── monitoring.py
│ │ ── alert_manager.py
│ ├── state/ # persistenza
│ │ ── telegram.py
│ ├── runtime/ # I/O, scheduling, orchestrazione
│ │ ── orchestrator.py # façade boot/run_*
│ ├── dependencies.py # RuntimeContext factory
│ │ ├── scheduler.py # APScheduler builder
│ │ ├── 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
│ │ ├── repository.py
│ │ ├── models.py # SQLAlchemy o dataclasses
│ │ ├── models.py
│ │ ├── db.py # connect, transaction, run_migrations
│ │ └── migrations/
├── config/ # caricamento e validazione yaml
│ ├── 0001_init.sql
│ │ └── 0002_audit_anchor.sql
│ ├── config/ # caricamento e validazione yaml
│ │ ├── schema.py
│ │ ── loader.py
├── reporting/ # report umani
│ ├── pre_trade.py
│ ├── post_trade.py
│ └── daily_digest.py
│ ├── gui/ # Streamlit dashboard (vedi 11-gui-streamlit.md)
│ │ ├── main.py
│ │ ├── pages/
│ │ ├── components/
│ │ └── data_layer.py
│ └── safety/ # kill switch, dead man, audit
│ │ ── loader.py
│ └── mcp_endpoints.py # URL + token loader
│ ├── reporting/ # report umani (Fase 5)
│ ├── gui/ # Streamlit dashboard (Fase 4.5)
│ └── safety/ # kill switch, dead man, audit
│ ├── kill_switch.py
│ ├── dead_man.py
│ └── audit_log.py
│ ├── audit_log.py # hash chain + on_append callback
│ └── __init__.py
├── tests/
│ ├── unit/ # test puri sui moduli core/
│ ├── integration/ # test con MCP fake
│ ├── golden/ # scenari deterministici di riferimento
│ ├── unit/ # test puri sui moduli core/
│ ├── integration/ # test con MCP fake (httpx mock)
│ ├── golden/ # scenari deterministici (Fase 6)
│ └── fixtures/
├── scripts/
│ ├── reset_state.py
── replay_day.py # replay forensico di una giornata
│ └── recalibrate_kelly.py
└── data/
├── state.sqlite # gitignored
── log/ # gitignored
│ ├── backup.py # VACUUM INTO orario
── dead_man.sh # watchdog shell indipendente
└── data/ # gitignored
├── state.sqlite
├── audit.log
── log/
└── backups/
```
## Principio di separazione
@@ -142,123 +162,147 @@ Cerbero_Bite/
- **`core/`** contiene **funzioni pure**: input → output, niente I/O,
niente MCP, niente time. Testabili con dati statici. Sono il cuore
della strategia e devono restare riproducibili al byte.
- **`clients/`** contiene wrapper sui server MCP. Ogni client espone
una API tipizzata che usa internamente JSON-RPC. Mai logica di
business qui.
- **`runtime/`** orchestrazione: composizione di `clients/` + `core/` +
`state/`. È l'unico strato che può fare I/O e ha effetti collaterali.
- **`clients/`** contiene wrapper HTTP sui server MCP. Ogni client
espone una API tipizzata che usa internamente `httpx.AsyncClient`.
Mai logica di business qui.
- **`runtime/`** orchestrazione: composizione di `clients/` + `core/`
+ `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.
- **`config/`** caricamento di `strategy.yaml`, validazione, esposizione
immutabile dei parametri.
- **`reporting/`** generazione di stringhe per Telegram. Niente logica
di trading, solo formatting.
- **`config/`** caricamento di `strategy.yaml`, validazione,
esposizione immutabile dei parametri. Risolve gli URL MCP e legge
il bearer token al boot.
- **`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"
```python
async def evaluate_entry():
# 1. Stato sistema
if not safety.system_healthy(): return
if state.has_open_position(): return
async def run_entry_cycle(ctx: RuntimeContext, *, eur_to_usd_rate, now):
if ctx.kill_switch.is_armed():
return EntryCycleResult(status="kill_switch_armed", ...)
if ctx.repository.count_concurrent_positions() > 0:
return EntryCycleResult(status="has_open_position", ...)
# 2. Dati di mercato
spot = await deribit.get_index_price("ETH")
dvol = await deribit.get_dvol()
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
# 1. Snapshot dati di mercato in parallelo (asyncio.gather)
snap = await _gather_snapshot(
deribit, hyperliquid, sentiment, macro, portfolio, cfg, now
)
if short_strike is None: return log_and_skip("no_strike")
if not liquidity_gate.passes(short_strike, long_strike, deribit_book):
return log_and_skip("illiquid")
# 2. Algoritmi puri
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(
capital, max_loss_per_contract, dvol, config
)
if n < 1: return log_and_skip("undersize")
bias = compute_bias(TrendContext(...), cfg)
if bias is None:
return EntryCycleResult(status="no_entry", reason="no_bias")
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
await telegram.send(reporting.pre_trade(proposal))
confirmation = await wait_user_confirmation(timeout="60min")
if not confirmation.accepted: return log_and_skip("rejected")
short, long_ = selection
sizing = compute_contracts(SizingContext(...), cfg)
if sizing.n_contracts < 1:
return EntryCycleResult(status="no_entry", reason="undersize")
# 5. Esecuzione via Cerbero core
instruction = combo_builder.to_cerbero_instruction(proposal)
await memory.push_user_instruction(instruction, source="cerbero-bite")
if not check(short_leg=..., long_leg=..., credit=..., n_contracts=...).accepted:
return EntryCycleResult(status="no_entry", reason="illiquid")
# 6. Stato
state.create_position(proposal, dvol_at_entry=dvol)
audit_logger.log("ENTRY_PROPOSED", proposal)
proposal = build(short=short, long_=long_, n_contracts=..., spot=spot, ...)
# 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"
```python
async def evaluate_open_positions():
if not safety.system_healthy(): return
for position in state.list_open_positions():
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
async def run_monitor_cycle(ctx: RuntimeContext, *, now):
if ctx.kill_switch.is_armed():
return MonitorCycleResult(outcomes=[])
decision = exit_decision.evaluate(
position=position,
spot=spot,
dvol_now=dvol,
mark_now=mark,
delta_short_now=delta_short,
now=datetime.now(timezone.utc),
config=config,
)
spot = await deribit.index_price_eth()
dvol = await deribit.latest_dvol(currency="ETH", now=now)
return_4h = await _fetch_return_4h(ctx, now=now) # usa dvol_history o
# fallback get_historical
repo.record_dvol_snapshot(DvolSnapshot(timestamp=now, dvol=dvol, eth_spot=spot))
for record in repo.list_positions(status="open"):
snapshot = await _build_position_snapshot(...)
decision = evaluate(snapshot, cfg)
if decision.action == "HOLD":
audit_logger.log("HOLD", position.id, decision.reason)
continue
audit.append("HOLD", {...}); continue
await telegram.send(reporting.exit_proposal(position, decision))
confirmation = await wait_user_confirmation(timeout="30min")
if not confirmation.accepted:
audit_logger.log("EXIT_DEFERRED", position.id)
continue
await memory.push_user_instruction(
combo_builder.close_instruction(position),
source="cerbero-bite"
repo.update_position_status(record.proposal_id, status="closing")
order = await deribit.place_combo_order(
legs=[short_buy, long_sell], side="buy", ...
)
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
| Modalità | Risposta |
|---|---|
| MCP non risponde (timeout) | Retry esponenziale 3 tentativi (1s, 5s, 30s); poi alert + skip ciclo |
| MCP risponde con dato palesemente rotto (es. orderbook tutti a 0) | Skip ciclo, alert |
| Confidence Adriano scaduta | Skip apertura; le chiusure restano in coda con alta priorità |
| Stato SQLite corrotto | Kill switch attivato, alert manuale richiesto |
| 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 |
| 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. `mark_iv == 7%`) | Wrapper solleva `McpDataAnomalyError`; alert HIGH e ciclo saltato |
| Stato SQLite corrotto al boot | Recovery riconcilia con il broker; se non possibile arma kill switch CRITICAL |
| Mismatch testnet/mainnet rispetto a `strategy.execution.environment` | Kill switch CRITICAL al boot, prima di qualsiasi ciclo trading |
| 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.
## Concorrenza e idempotenza
- Una sola istanza dell'engine alla volta (file lock su `data/.lockfile`).
- Tutti i ticker di scheduler sono **idempotenti**: se il sistema crasha
durante un'apertura, al riavvio il monitoring ricostruisce lo stato
da SQLite + Cerbero core (`cerbero-memory.get_state`) e prosegue.
- Ogni proposta ha un `proposal_id` UUID. Cerbero core ignora
duplicati con lo stesso id.
- Una sola istanza dell'engine alla volta (`fcntl.flock` esclusivo su
`data/.lockfile`).
- Lo scheduler è in-process e i job sono coalesce + misfire grace
300 s, quindi un riavvio del container non duplica l'ultimo
trigger perso.
- 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.