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:
+183
-139
@@ -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) │
|
||||
└───────────────────────────────┘ └───────────────────┘
|
||||
┌──────────────────────────────────┐
|
||||
│ MCP servers │
|
||||
│ (FastAPI, repo Cerbero_mcp) │
|
||||
└────────────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 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
|
||||
|
||||
| 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,12 +85,18 @@ Cerbero_Bite/
|
||||
├── README.md
|
||||
├── pyproject.toml
|
||||
├── uv.lock
|
||||
├── strategy.yaml # config golden
|
||||
├── 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
|
||||
│ ├── 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
|
||||
@@ -88,53 +104,57 @@ Cerbero_Bite/
|
||||
│ │ ├── 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
|
||||
│ │ └── telegram.py
|
||||
│ ├── runtime/ # I/O, scheduling, orchestrazione
|
||||
│ │ ├── scheduler.py
|
||||
│ │ ├── orchestrator.py
|
||||
│ │ ├── monitoring.py
|
||||
│ │ └── alert_manager.py
|
||||
│ │ ├── 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/
|
||||
│ │ ├── 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
|
||||
│ │ ├── 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
|
||||
│ ├── 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.
|
||||
|
||||
+119
-68
@@ -6,21 +6,30 @@ file `.jsonl`. Niente database remoti, niente broker esterni.
|
||||
## Filosofia
|
||||
|
||||
- **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,
|
||||
exit, errore, decisione di hold) viene scritto in modo immutabile
|
||||
prima di toccare lo stato.
|
||||
- Lo stato si può sempre **ricostruire dal log** in caso di corruzione.
|
||||
exit, health, decisione di hold, alert) viene scritto in modo
|
||||
immutabile prima di toccare lo stato.
|
||||
- **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
|
||||
|
||||
Migrazione gestita con file SQL semplici sotto `state/migrations/0001_init.sql`,
|
||||
applicati in sequenza. No ORM heavy: si usa `sqlalchemy.core` o
|
||||
addirittura `sqlite3` nativo con piccole utility.
|
||||
Migrazione gestita con file SQL semplici sotto
|
||||
`src/cerbero_bite/state/migrations/`, applicati in sequenza dal runner
|
||||
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`
|
||||
|
||||
Posizione Cerberus Bite (aperta, in chiusura, chiusa).
|
||||
Posizione Cerberus Bite (proposta, in attesa di fill, aperta, in
|
||||
chiusura, chiusa, cancellata).
|
||||
|
||||
```sql
|
||||
CREATE TABLE positions (
|
||||
@@ -30,7 +39,7 @@ CREATE TABLE positions (
|
||||
expiry TEXT NOT NULL, -- ISO8601 UTC
|
||||
short_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,
|
||||
n_contracts INTEGER NOT NULL,
|
||||
spread_width_usd NUMERIC NOT NULL,
|
||||
@@ -61,27 +70,36 @@ CREATE INDEX idx_positions_closed_at ON positions(closed_at);
|
||||
Stati validi:
|
||||
|
||||
```
|
||||
proposed → proposta inviata a Adriano, in attesa di conferma
|
||||
awaiting_fill → istruzione push_user_instruction inviata, in attesa fill
|
||||
proposed → riga creata, place_combo_order in volo
|
||||
awaiting_fill → ordine accettato da Deribit, in attesa di fill
|
||||
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
|
||||
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`
|
||||
|
||||
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
|
||||
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),
|
||||
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,
|
||||
acknowledged_at TEXT,
|
||||
filled_at TEXT,
|
||||
acknowledged_at TEXT, -- riservato a un futuro hook ack
|
||||
filled_at TEXT, -- compilato dal recovery o da un
|
||||
-- hook che monitora i fill
|
||||
cancelled_at TEXT,
|
||||
actual_fill_eth NUMERIC, -- prezzo medio fill effettivo
|
||||
actual_fees_eth NUMERIC
|
||||
@@ -90,6 +108,12 @@ CREATE TABLE instructions (
|
||||
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`
|
||||
|
||||
Storia delle valutazioni del decision loop (anche quando l'esito è
|
||||
@@ -103,7 +127,7 @@ CREATE TABLE decisions (
|
||||
timestamp TEXT NOT NULL,
|
||||
inputs_json TEXT NOT NULL, -- snapshot input al 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
|
||||
);
|
||||
|
||||
@@ -113,7 +137,9 @@ CREATE INDEX idx_decisions_proposal ON decisions(proposal_id);
|
||||
|
||||
### `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
|
||||
CREATE TABLE dvol_history (
|
||||
@@ -125,13 +151,15 @@ CREATE TABLE dvol_history (
|
||||
|
||||
### `manual_actions`
|
||||
|
||||
Coda di azioni manuali generate dalla GUI Streamlit (vedi `11-gui-streamlit.md`).
|
||||
L'engine consuma le righe non processate via job APScheduler ogni 30s.
|
||||
Coda di azioni manuali generate dalla GUI Streamlit (vedi
|
||||
`11-gui-streamlit.md`). Schema previsto in vista della Fase 4.5; al
|
||||
momento la GUI non è implementata e la tabella resta vuota.
|
||||
|
||||
```sql
|
||||
CREATE TABLE manual_actions (
|
||||
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
|
||||
payload_json TEXT, -- JSON con motivo, conferma typed, ecc.
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -142,12 +170,14 @@ CREATE TABLE manual_actions (
|
||||
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
|
||||
```
|
||||
|
||||
Le `manual_actions` non bypassano i risk control: il consumer applica
|
||||
gli stessi check di `safety.system_healthy()` prima di eseguire.
|
||||
Le `manual_actions` non bypassano i risk control: il consumer
|
||||
(quando esisterà) applicherà gli stessi check di
|
||||
`safety.system_healthy()` prima di eseguire.
|
||||
|
||||
### `system_state`
|
||||
|
||||
Singleton di stato globale (kill switch, last health check).
|
||||
Singleton di stato globale (kill switch, last health check, anchor
|
||||
audit chain).
|
||||
|
||||
```sql
|
||||
CREATE TABLE system_state (
|
||||
@@ -158,54 +188,66 @@ CREATE TABLE system_state (
|
||||
last_health_check TEXT NOT NULL,
|
||||
last_kelly_calib TEXT,
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-04-27T14:00:01.234Z",
|
||||
"event": "ENTRY_PROPOSED",
|
||||
"level": "INFO",
|
||||
"module": "runtime.orchestrator",
|
||||
"event": "entry placed",
|
||||
"level": "info",
|
||||
"logger": "cerbero_bite.runtime.entry",
|
||||
"cycle": "entry",
|
||||
"cycle_id": "9d2a8a6a-7f58-4f10-8b33-...",
|
||||
"proposal_id": "uuid-...",
|
||||
"data": { ... },
|
||||
"decision_id": 12345
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
| Event | Quando |
|
||||
|---|---|
|
||||
| `ENGINE_START` | Avvio engine |
|
||||
| `ENGINE_STOP` | Stop pulito |
|
||||
| `ENGINE_START` | Avvio engine completato (boot OK) |
|
||||
| `RECOVERY_DONE` | Fine del recovery loop |
|
||||
| `HEALTH_OK` / `HEALTH_DEGRADED` | Health check periodico |
|
||||
| `MCP_CALL` | Ogni chiamata MCP (livello DEBUG) |
|
||||
| `MCP_FAIL` | Fallimento MCP (livello WARN/ERROR) |
|
||||
| `ENTRY_EVALUATED` | Ciclo settimanale completato |
|
||||
| `ENTRY_REJECTED` | Filtri non passati |
|
||||
| `ENTRY_PROPOSED` | Proposta inviata a Telegram |
|
||||
| `ENTRY_CONFIRMED` | Adriano conferma |
|
||||
| `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 |
|
||||
| `ENTRY_PLACED` | Combo aperto e fill (o awaiting_fill) confermato |
|
||||
| `HOLD` | Monitor ha valutato una posizione e nessun trigger ha fired |
|
||||
| `EXIT_FILLED` | Close confermato dal broker |
|
||||
| `KILL_SWITCH_ARMED` / `KILL_SWITCH_DISARMED` | Transizioni manuali o automatiche |
|
||||
| `ALERT` | Routing severity-based dell'alert manager |
|
||||
| `KELLY_RECALIBRATED` | Output del job mensile (Fase 5) |
|
||||
|
||||
### Rotation e ritenzione
|
||||
|
||||
@@ -216,26 +258,35 @@ Ogni linea è un evento JSON.
|
||||
|
||||
## Stato in memoria
|
||||
|
||||
L'engine mantiene una cache delle posizioni aperte in RAM, ricaricata
|
||||
da SQLite all'avvio e refreshed dopo ogni transizione di stato.
|
||||
Ogni mutazione segue il pattern:
|
||||
L'engine non mantiene una cache delle posizioni aperte: ogni ciclo
|
||||
legge fresco da SQLite. Lo scheduler APScheduler è in-process e i job
|
||||
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).
|
||||
2. Begin transaction SQLite.
|
||||
3. Update tabelle.
|
||||
4. Commit.
|
||||
5. Update cache RAM.
|
||||
1. (Opzionale) Audit append per gli eventi tracciati nella chain.
|
||||
2. `with transaction(conn):` → `repository.update_position_status(...)`
|
||||
o `repository.create_*(...)`.
|
||||
3. Notifica Telegram post-fact, dove pertinente.
|
||||
|
||||
Se il sistema crasha tra (1) e (5), al restart un riconciliatore confronta
|
||||
log vs SQLite e ripristina la consistenza.
|
||||
Il connessione SQLite è aperta e chiusa dentro la singola operazione,
|
||||
mai mantenuta long-lived: WAL e auto-commit garantiscono che letture
|
||||
da altri processi (es. CLI `state inspect`) non vedano stati parziali.
|
||||
|
||||
## Migrations
|
||||
|
||||
Lo schema viene tracciato con un counter `pragma user_version`. La
|
||||
prima volta `0001_init.sql` viene applicato e versione → 1. Aggiunte
|
||||
future incrementano. Nessun rollback supportato (migrations forward-only).
|
||||
Lo schema viene tracciato con il counter `PRAGMA user_version`. La
|
||||
prima volta `0001_init.sql` viene applicato e versione → 1; alla
|
||||
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 orari di `state.sqlite` in `data/backups/state-YYYYMMDD-HH.sqlite`,
|
||||
ritenuti per 30 giorni. Operazione idempotente con SQLite `VACUUM INTO`.
|
||||
Backup orari di `state.sqlite` in
|
||||
`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
@@ -8,7 +8,7 @@ infrastrutturali o decisioni umane fuori posto.
|
||||
|
||||
- **Default deny**: in caso di dubbio, il sistema non fa nulla.
|
||||
- **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
|
||||
immediatamente.
|
||||
- **No silent close**: una posizione viene chiusa solo a seguito di
|
||||
@@ -19,45 +19,43 @@ infrastrutturali o decisioni umane fuori posto.
|
||||
|
||||
### 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
|
||||
stato, log)
|
||||
- **non** invia istruzioni di apertura
|
||||
- **non** invia istruzioni di chiusura **automatiche** (richiede
|
||||
intervento manuale via CLI con flag `--force-close-position`)
|
||||
- continua a notificare i trigger di uscita ad Adriano via Telegram
|
||||
con escalation manuale
|
||||
- **non** invia istruzioni di chiusura **automatiche** (il monitor
|
||||
cycle salta quando il kill switch è armato)
|
||||
- continua a notificare via Telegram gli alert con la severity
|
||||
appropriata (vedi escalation tree)
|
||||
|
||||
### Trigger automatici
|
||||
|
||||
| Causa | Auto-arm | Note |
|
||||
|---|---|---|
|
||||
| MCP `cerbero-deribit` versione mismatch | Sì | Non procede senza schema atteso |
|
||||
| MCP `cerbero-macro` non risponde per >30 min | Sì | No entry possibile |
|
||||
| MCP `cerbero-memory` non risponde per >5 min | Sì | No esecuzione possibile |
|
||||
| Stato SQLite incoerente con broker | Sì | Diff tra DB e Deribit positions |
|
||||
| 3 health check consecutivi falliti | Sì | Engine instabile |
|
||||
| Perdita giornaliera > 3% equity | Sì | Hard prohibition Cerbero v4 |
|
||||
| Numero posizioni concorrenti > cap | Sì | Bug del sizing engine |
|
||||
| Push instruction respinta da Cerbero core | Sì | Dopo 2 retry |
|
||||
| Schema config invalido al reload | Sì | Mai partire con config rotta |
|
||||
| Comando Adriano `/kill` su Telegram | Sì | Trigger volontario |
|
||||
| Causa | Auto-arm | Implementato | Note |
|
||||
|---|---|---|---|
|
||||
| MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
|
||||
| MCP `cerbero-macro` / `cerbero-portfolio` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
|
||||
| `mcp-deribit.environment_info.environment` ≠ `strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime |
|
||||
| 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 |
|
||||
| Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
|
||||
| `place_combo_order` di chiusura respinto dal broker | Sì | `runtime/monitor_cycle.py` | Severity CRITICAL; la posizione torna in `open` per ritentare |
|
||||
| `place_combo_order` di apertura respinto dal broker | Sì | `runtime/entry_cycle.py` | Severity HIGH; la posizione viene marcata `cancelled` |
|
||||
| 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 |
|
||||
| Comando manuale via `cerbero-bite kill-switch arm` | Sì | `cli.py kill_switch_arm` | Severity HIGH (operator-initiated) |
|
||||
|
||||
### Disarm
|
||||
|
||||
```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:
|
||||
|
||||
```
|
||||
/disarm <motivo>
|
||||
```
|
||||
|
||||
In entrambi i casi il sistema chiede una conferma esplicita ("yes I am
|
||||
sure").
|
||||
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
|
||||
naturale (entry settimanale o monitor 12h) a far ripartire la
|
||||
decisione.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| Posizioni concorrenti CB | 4 (default 1 per strategia) | Reject pre-push |
|
||||
| Trade aperti per giorno | 6 (intera Cerbero suite) | Lookup `cerbero-memory.daily_trade_count` |
|
||||
| Perdita giornaliera | 3% equity | Kill switch |
|
||||
| Distance short strike | < 15% spot OTM | Combo builder reject |
|
||||
| Credit / width | < 0.30 | Combo builder reject |
|
||||
| Posizioni concorrenti CB | 1 (default per la strategia ETH) | Entry cycle reject (`has_open_position`) |
|
||||
| Trade aperti per giorno | 6 (intera Cerbero suite) | Non implementato — richiede integrazione cross-suite, lasciato a Cerbero core |
|
||||
| Distance short strike | < 15 % spot OTM | Combo builder reject (`no_strike`) |
|
||||
| Credit / width | < 0.30 | Combo builder reject (`no_strike`) |
|
||||
| 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
|
||||
runtime ricontrolla il payload prima di chiamare `push_user_instruction`.
|
||||
Se le due misure divergono → kill switch + alert.
|
||||
I primi sei cap sono applicati direttamente dai moduli `core/`; gli
|
||||
altri tre filtri quant-grade (DVOL, holdings, dealer-gamma,
|
||||
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
|
||||
|
||||
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,
|
||||
indipendente dall'engine) rileva il silenzio.
|
||||
2. Invia alert su canale Telegram di backup.
|
||||
3. Marca SQLite con `system_state.kill_switch=1`.
|
||||
1. Il processo separato `scripts/dead_man.sh` (cron in user crontab,
|
||||
indipendente dall'engine) rileva il silenzio cercando l'ultimo
|
||||
`HEALTH_OK` nel JSONL del giorno.
|
||||
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.
|
||||
|
||||
Il dead-man è scritto in shell minimale (no dipendenze Python) per
|
||||
sopravvivere a corruzioni dell'env Python.
|
||||
Lo script è scritto in shell minimale (no dipendenze Python) per
|
||||
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
|
||||
|
||||
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 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:
|
||||
|
||||
```
|
||||
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
|
||||
discrepanza dell'hash chain è trattata come tampering e arma il kill
|
||||
switch.
|
||||
Verificabile retroattivamente con `cerbero-bite audit verify`. La
|
||||
verifica controlla:
|
||||
|
||||
* 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
|
||||
|
||||
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.
|
||||
- Tool MCP **write** (`push_user_instruction`, `kb_write`, `send`) vengono
|
||||
loggati ma **non** chiamati.
|
||||
- `state.create_position` viene scritto in tabella `dry_positions`
|
||||
(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.
|
||||
`enforce_hash` è disattivato in dry-run per agevolare il debug; il
|
||||
comando `start` invece carica `strategy.yaml` con
|
||||
`enforce_hash=True`, quindi mismatch dell'hash producono exit 1
|
||||
prima che l'engine tocchi qualsiasi stato.
|
||||
|
||||
## Versionamento config
|
||||
|
||||
@@ -128,14 +178,33 @@ Ogni `strategy.yaml` ha:
|
||||
|
||||
```yaml
|
||||
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_reviewer: "Adriano"
|
||||
```
|
||||
|
||||
All'avvio l'engine verifica che `config_hash` corrisponda al contenuto.
|
||||
Mismatch → kill switch (qualcuno ha modificato la config senza
|
||||
aggiornare l'hash, possibile tampering o errore umano).
|
||||
All'avvio di `cerbero-bite start` l'engine verifica che `config_hash`
|
||||
corrisponda al contenuto del file (il calcolo esclude il valore stesso
|
||||
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
|
||||
|
||||
@@ -143,39 +212,51 @@ aggiornare l'hash, possibile tampering o errore umano).
|
||||
Evento anomalo
|
||||
│
|
||||
├── 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)
|
||||
│ ├── Log WARNING + Telegram digest giornaliero
|
||||
│ └── Continua, retry next cycle
|
||||
├── Severity MEDIUM (es. snapshot dato mancante non bloccante)
|
||||
│ ├── Append in audit chain
|
||||
│ └── Telegram notify (priority=high), continua
|
||||
│
|
||||
├── Severity HIGH (es. 3 health check consecutivi falliti)
|
||||
│ ├── Kill switch ARM
|
||||
│ ├── Telegram alert immediato
|
||||
│ └── Adriano interviene
|
||||
├── Severity HIGH (es. 3 health check consecutivi falliti,
|
||||
│ entry rejected dal broker)
|
||||
│ ├── Append in audit chain
|
||||
│ ├── Telegram notify_alert (priority=high)
|
||||
│ ├── Kill switch ARM (idempotente)
|
||||
│ └── Adriano interviene per disarmare
|
||||
│
|
||||
└── Severity CRITICAL (es. stato incoerente, hash chain rotto)
|
||||
├── Kill switch ARM
|
||||
├── Telegram + canale backup BotPapà
|
||||
├── Engine si mette in idle (no decisioni, solo monitoring)
|
||||
└── Richiede intervento umano per disarmo
|
||||
└── Severity CRITICAL (es. mismatch environment al boot,
|
||||
hash chain rotta, close fallito su monitor)
|
||||
├── Append in audit chain
|
||||
├── Telegram notify_system_error (priority=critical)
|
||||
├── 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
|
||||
|
||||
Prima del go-live e ad ogni release minor:
|
||||
|
||||
1. **Chaos test MCP**: simula timeout/errori su ogni MCP, verifica
|
||||
che il comportamento documentato in `04-mcp-integration.md` sia
|
||||
rispettato.
|
||||
2. **State corruption test**: corrompi una riga `positions` e verifica
|
||||
che il riconciliatore lo rilevi.
|
||||
rispettato (retry, fallback, kill switch).
|
||||
2. **State corruption test**: corrompi una riga `positions` e
|
||||
verifica che il `recover_state` lo rilevi.
|
||||
3. **Hash chain test**: modifica una linea audit e verifica che
|
||||
`audit verify` fallisca.
|
||||
4. **Replay test**: rigioca una giornata storica in dry-run, confronta
|
||||
le decisioni con un set golden.
|
||||
5. **Cap saturation test**: simula 4 posizioni concorrenti, verifica
|
||||
che il quinto trade venga rifiutato.
|
||||
`audit verify` fallisca; tronca il file e verifica che il check
|
||||
anchor al boot armi il kill switch.
|
||||
4. **Replay test**: rigioca una giornata storica via
|
||||
`cerbero-bite replay` (Fase 5/6), confronta le decisioni con un
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
parte della strategia, gestite altrove:
|
||||
|
||||
- Profit take 50%: regola di strategia.
|
||||
- Profit take 50 %: regola di strategia.
|
||||
- Stop loss 1.5×: regola di strategia.
|
||||
- Vol stop +10 DVOL: regola di strategia.
|
||||
- Time stop 7 DTE: regola di strategia.
|
||||
|
||||
Reference in New Issue
Block a user