# 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                 # daily entry auto-execute (crypto 24/7)
│   │   ├── 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, asset="ETH", dvol=dvol, 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.
