Files
Cerbero-Bite/docs/02-architecture.md
T
Adriano abf5a140e2 refactor: telegram + portfolio in-process (drop shared MCP)
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>
2026-04-30 00:31:20 +02:00

18 KiB

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"

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"

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-bitecerbero-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.