Files
Cerbero-Bite/docs/02-architecture.md
root 6ff021fbf4 feat(strategy): abbandono gating settimanale — entry daily 24/7
Crypto opera 24/7: la cadenza settimanale lunedì-only era un retaggio
TradFi senza giustificazione. La nuova cadenza è giornaliera (cron
0 14 * * *), con i gate quantitativi a decidere se entrare o saltare.

Cambiamenti principali:

* runtime/orchestrator.py — _CRON_ENTRY 0 14 * * * (era MON)
* runtime/auto_pause.py — pause_until(days=) (era weeks=); minimo
  clamp 1 giorno (era 1 settimana)
* core/backtest.py — MondayPick→DailyPick, monday_picks→daily_picks
  (1 pick per calendar-day all'ora target); Sharpe annualization su
  ~120 trade/anno (era 52)
* config/schema.py — default cron daily; max_concurrent_positions 1→5;
  AutoPauseConfig.pause_weeks→pause_days, default 14
* runtime/option_chain_snapshot_cycle.py + orchestrator — cron */15
  per accumulo continuo dataset di backtest empirico

Strategy yamls (config_version 1.3.0 → 1.4.0, hash rigenerati):

* strategy.yaml — max_concurrent 1→5, cap_aggregate coerente
* strategy.aggressiva.yaml — max_concurrent 2→8, cap_aggregate
  3200→6400, max_contracts_per_trade invariato a 16
* strategy.conservativa.yaml — max_concurrent 1→3
* tutti — pause_weeks→pause_days: 14

GUI (pages/7_📚_Strategia.py):

* slider Trade/anno: range 20-200 (era 8-30), default 110, help
  riallineato sulla math 365 candidature × pass-rate 30-40%
* card profili: versione letta dinamicamente da config_version invece
  che hard-coded "v1.2.0"
* warning "entrambi perdono soldi" ora valuta i P/L effettivi
  (cons['annual_pl'], aggr['annual_pl']) invece del win_rate grezzo;
  aggiunto stato intermedio quando solo conservativo è in perdita

Tests (450/450 passati):

* test_auto_pause: pause_days, clamp ≥1 giorno
* test_backtest: rinomina + ridisegno daily picks (assert su
  calendar-day dedupe e hour filter)
* test_sizing_engine: other_open_positions=5 per cap default
* test_config_loader: version 1.4.0

Docs (README + 9 file in docs/) — tutti i riferimenti weekly/lunedì
allineati a daily/24-7, volume option_chain ricalcolato per cron
*/15 (~1.1 MB/giorno, ~400 MB/anno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:21:16 +00: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, 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"

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.