# 02 — Architettura ## Vista a blocchi ``` ┌─────────────────────────────────┐ │ ADRIANO (utente) │ └──────▲──────────────────────────┘ │ Telegram (notifiche post-fact) │ ┌─────────────────────────────────────────────────────────────────┐ │ CERBERO BITE (rule engine) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ scheduler │ │ state store │ │ audit logger │ │ │ │ (APScheduler)│ │ (SQLite) │ │ (append-only, │ │ │ │ │ │ │ │ hash chain + │ │ │ │ │ │ │ │ anchor SQLite) │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ decision orchestrator (core/) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ entry │ │ sizing │ │ exit │ │ greeks │ │ │ │ │ │validator │ │ engine │ │ decision │ │aggregator│ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │liquidity │ │ combo │ │ kelly │ │ │ │ │ │ gate │ │ builder │ │ recalib │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ MCP HTTP client wrappers (clients/) │ │ │ │ Deribit │ Hyperliquid │ Macro │ Sentiment │ Portfolio │ │ │ │ Telegram │ │ │ └─────────────────────────────────────────────────────────┘ │ └────────────────────────┬────────────────────────────────────────┘ │ HTTP (Bearer token, rete Docker │ `cerbero-suite`) ▼ ┌──────────────────────────────────┐ │ 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.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` + `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 `Cerbero_mcp` | | Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `mcp` | | Notifiche | Bot API Telegram in-process (notify-only) | Token e chat-id da env, no-op se non configurati | | GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato | ## Layout cartelle ``` Cerbero_Bite/ ├── README.md ├── pyproject.toml ├── uv.lock ├── strategy.yaml # config golden + execution.environment ├── strategy.local.yaml.example # override locale (gitignored) ├── Dockerfile # image runtime + HEALTHCHECK ├── docker-compose.yml # rete external cerbero-suite, env passthrough ├── .env.example # template variabili (token MCP, bot tag, modalità) ├── docs/ # questa documentazione ├── 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 │ │ ├── exit_decision.py │ │ ├── greeks_aggregator.py │ │ ├── liquidity_gate.py │ │ ├── combo_builder.py │ │ ├── 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 │ │ └── 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 │ │ ├── db.py # connect, transaction, run_migrations │ │ └── migrations/ │ │ ├── 0001_init.sql │ │ └── 0002_audit_anchor.sql │ ├── config/ # caricamento e validazione yaml │ │ ├── schema.py │ │ ├── loader.py │ │ ├── mcp_endpoints.py # URL + token + bot tag (da .env) │ │ └── runtime_flags.py # ENABLE_DATA_ANALYSIS / ENABLE_STRATEGY │ ├── reporting/ # report umani (Fase 5) │ ├── gui/ # Streamlit dashboard (Fase 4.5) │ └── safety/ # kill switch, dead man, audit │ ├── kill_switch.py │ ├── audit_log.py # hash chain + on_append callback │ └── __init__.py ├── tests/ │ ├── unit/ # test puri sui moduli core/ │ ├── integration/ # test con MCP fake (httpx mock) │ ├── golden/ # scenari deterministici (Fase 6) │ └── fixtures/ ├── scripts/ │ ├── backup.py # VACUUM INTO orario │ └── dead_man.sh # watchdog shell indipendente └── data/ # gitignored ├── state.sqlite ├── audit.log ├── log/ └── backups/ ``` ## Principio di separazione - **`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 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. Risolve gli URL MCP, legge il bearer token + il bot tag al boot ed espone i due interruttori operativi `RuntimeFlags(data_analysis_enabled, strategy_enabled)`. - **`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 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", ...) # 1. Snapshot dati di mercato in parallelo (asyncio.gather) snap = await _gather_snapshot( deribit, hyperliquid, sentiment, macro, portfolio, cfg, now ) # 2. Algoritmi puri entry_decision = validate_entry(EntryContext(...), cfg) if not entry_decision.accepted: return EntryCycleResult(status="no_entry", reason=entry_decision.reasons) bias = compute_bias(TrendContext(...), cfg) if bias is None: return EntryCycleResult(status="no_entry", reason="no_bias") 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") short, long_ = selection sizing = compute_contracts(SizingContext(...), cfg) if sizing.n_contracts < 1: return EntryCycleResult(status="no_entry", reason="undersize") if not check(short_leg=..., long_leg=..., credit=..., n_contracts=...).accepted: return EntryCycleResult(status="no_entry", reason="illiquid") 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 run_monitor_cycle(ctx: RuntimeContext, *, now): if ctx.kill_switch.is_armed(): return MonitorCycleResult(outcomes=[]) 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.append("HOLD", {...}); continue repo.update_position_status(record.proposal_id, status="closing") order = await deribit.place_combo_order( legs=[short_buy, long_sell], side="buy", ... ) 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 (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 (`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.