abf5a140e2
Each bot now manages its own notification + portfolio aggregation: * TelegramClient calls the public Bot API directly via httpx, reading CERBERO_BITE_TELEGRAM_BOT_TOKEN / CERBERO_BITE_TELEGRAM_CHAT_ID from env. No credentials → silent disabled mode. * PortfolioClient composes DeribitClient + HyperliquidClient + the new MacroClient.get_asset_price/eur_usd_rate to expose equity (EUR) and per-asset exposure as the bot's own slice (no cross-bot view). * mcp-telegram and mcp-portfolio removed from MCP_SERVICES / McpEndpoints and the cerbero-bite ping CLI; health_check no longer probes portfolio. Docs (02/04/06/07) and docker-compose updated to reflect the new architecture. 353/353 tests pass; ruff clean; mypy src clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
18 KiB
Markdown
309 lines
18 KiB
Markdown
# 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 + 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
|
|
│ │ ├── 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 loader
|
|
│ ├── 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 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 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.
|