Phase 0: project skeleton
- pyproject.toml with uv, deps for runtime + gui + backtest + dev - ruff/mypy strict config, pre-commit hooks for ruff/mypy/pytest - src/cerbero_bite/ layout with empty modules ready for Phase 1+ - structlog JSONL logger with daily rotation - click CLI with placeholder subcommands (status, start, kill-switch, gui, replay, config hash, audit verify) - 6 smoke tests passing, mypy --strict clean, ruff clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.cache
|
||||
.hypothesis/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# venv
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# uv
|
||||
# (uv.lock is checked in; ignore only the venv it creates)
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Cerbero Bite — runtime data
|
||||
data/
|
||||
*.local.yaml
|
||||
.lockfile
|
||||
.gui-lockfile
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,43 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.7.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=500"]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: mypy --strict
|
||||
entry: uv run mypy --strict src/cerbero_bite
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- id: pytest-fast
|
||||
name: pytest unit
|
||||
entry: uv run pytest tests/unit -x -q
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-golden
|
||||
name: pytest golden
|
||||
entry: uv run pytest tests/golden tests/integration -x -q
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
@@ -0,0 +1,72 @@
|
||||
# Cerbero Bite
|
||||
|
||||
Sistema deterministico rule-based per l'esecuzione sistematica della strategia
|
||||
**Cerberus Bite**: credit spread su opzioni Ethereum (Deribit) con gestione
|
||||
attiva, sizing Quarter Kelly e disciplina di uscita rigida.
|
||||
|
||||
## Sintesi della strategia
|
||||
|
||||
- **Sottostante:** ETH/USD su Deribit (opzioni europee).
|
||||
- **Struttura:** Bull Put Spread (modalità principale) o Bear Call Spread,
|
||||
vendita di credito a delta corto **0.10–0.15** (≈ 18% OTM).
|
||||
- **DTE:** 14–21 giorni, sweet spot a 18 DTE.
|
||||
- **Sizing:** Quarter Kelly = **13% del capitale corrente**, con cap
|
||||
hard 200 EUR per trade e 1.000 EUR di engagement aperto totale.
|
||||
- **Gestione attiva:** profit take 50% credito, stop loss 1.5× credito,
|
||||
vol stop +10 punti DVOL, time stop 7 DTE, exit su short strike testato
|
||||
(|delta| ≥ 0.30). Su ETH **non si difende rollando**: si esce.
|
||||
- **Frequenza:** apertura ogni 7 giorni, una posizione alla volta.
|
||||
|
||||
Il sistema è **deterministico**: nessun LLM partecipa al decision loop.
|
||||
Le regole sono codificate, le soglie sono parametri di configurazione,
|
||||
i tool MCP sono usati esclusivamente come fonti di dati e canali di
|
||||
esecuzione (proposta verso Cerbero core, notifiche verso Adriano).
|
||||
|
||||
## Catena di responsabilità
|
||||
|
||||
```
|
||||
Cerbero Bite (rule engine) → Adriano (decisione finale) → Cerbero core (esecuzione)
|
||||
```
|
||||
|
||||
- Cerbero Bite **propone** trade e segnala uscite. Non esegue mai
|
||||
direttamente sul broker.
|
||||
- Adriano riceve un report strutturato e dà conferma esplicita.
|
||||
- Cerbero core riceve l'istruzione tramite `cerbero-memory.push_user_instruction`
|
||||
con `source="cerbero-bite"` e ne pianifica l'apertura/chiusura sul
|
||||
proprio motore di esecuzione.
|
||||
|
||||
## Documentazione
|
||||
|
||||
I documenti di progetto si trovano sotto `docs/`. Sono numerati e da
|
||||
leggere in ordine per chi implementa.
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `docs/00-overview.md` | Visione del sistema, perimetro, non-obiettivi |
|
||||
| `docs/01-strategy-rules.md` | Regole della strategia (entry/manage/exit) |
|
||||
| `docs/02-architecture.md` | Architettura software, componenti, interfacce |
|
||||
| `docs/03-algorithms.md` | Specifiche dettagliate dei sette algoritmi core |
|
||||
| `docs/04-mcp-integration.md` | Mappa dei tool MCP usati e contratti |
|
||||
| `docs/05-data-model.md` | Schema persistenza posizioni, log, KB |
|
||||
| `docs/06-operational-flow.md` | Flussi operativi: avvio, settimanale, monitoring |
|
||||
| `docs/07-risk-controls.md` | Kill switch, cap, dead-man, audit |
|
||||
| `docs/08-testing-validation.md` | TDD, paper trading, golden tests |
|
||||
| `docs/09-development-roadmap.md` | Fasi di sviluppo e milestone |
|
||||
| `docs/10-config-spec.md` | Schema di `strategy.yaml` con soglie |
|
||||
| `docs/11-gui-streamlit.md` | Dashboard Streamlit locale per osservazione e azioni manuali |
|
||||
|
||||
## Stato attuale
|
||||
|
||||
Il progetto è in fase **specifica**. Nessun codice è stato ancora
|
||||
prodotto; tutta la documentazione di design è quella presente nella
|
||||
cartella `docs/`. La prima fase di implementazione è dettagliata in
|
||||
`docs/09-development-roadmap.md`.
|
||||
|
||||
## Avvertenza
|
||||
|
||||
Questo sistema gestisce capitale reale ed è soggetto alle Hard
|
||||
Prohibitions di Cerbero (vedi `Cerbero_Office/CLAUDE.md`). Le opzioni
|
||||
su criptovaluta sono strumenti complessi con rischio di perdita totale.
|
||||
La validazione statistica via Monte Carlo (`Cerbero_Office/NewStrategy/
|
||||
analisi_dai_storici/`) è uno stimatore, non una garanzia. Le regole di
|
||||
stop loss e i cap di sizing sono **non negoziabili**.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 00 — Overview
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eseguire in modo sistematico, deterministico e disciplinato la strategia
|
||||
**Cerberus Bite** — credit spread su opzioni Ethereum su Deribit con
|
||||
gestione attiva — minimizzando l'errore umano e rispettando i cap di
|
||||
rischio definiti.
|
||||
|
||||
## Perché senza LLM
|
||||
|
||||
La strategia è **completamente codificabile in regole**. Non c'è alcuna
|
||||
componente di interpretazione semantica del mercato che richieda un
|
||||
modello linguistico. Tutte le decisioni del decision loop sono
|
||||
matematiche o booleane:
|
||||
|
||||
- "il mark price dello spread è ≤ 50% del credito iniziale?" — confronto numerico
|
||||
- "DVOL corrente è ≥ DVOL all'apertura + 10?" — confronto numerico
|
||||
- "delta della short put ha superato 0.30 in valore assoluto?" — confronto numerico
|
||||
- "sono trascorse 11 giorni da apertura?" — confronto numerico
|
||||
|
||||
L'introduzione di un LLM in questo loop introdurrebbe:
|
||||
|
||||
- non determinismo (stesso input → output diversi)
|
||||
- latenza non controllata
|
||||
- costi ricorrenti senza valore aggiunto
|
||||
- difficoltà di test e di audit
|
||||
- rischio di hallucination su dati di mercato
|
||||
|
||||
Il LLM (Milito) resta nel ruolo di **consulente offline**: aggiorna le
|
||||
regole quando l'evidenza statistica lo richiede, riceve dati post-trade
|
||||
per imparare, ma **non sta nel loop di esecuzione**.
|
||||
|
||||
## Confine del sistema
|
||||
|
||||
### Cosa fa Cerbero Bite
|
||||
|
||||
1. Legge dati di mercato dagli MCP (Deribit, Hyperliquid, sentiment, macro).
|
||||
2. Valuta condizioni di entrata su finestra temporale fissa (settimanale).
|
||||
3. Calcola la struttura ottimale dello spread secondo le regole.
|
||||
4. Verifica liquidità, cap di rischio, calendar macro.
|
||||
5. Calcola sizing in contratti.
|
||||
6. Costruisce la proposta di trade e la inoltra ad Adriano (Telegram).
|
||||
7. Su conferma di Adriano, invia istruzione a Cerbero core.
|
||||
8. Persiste lo stato della posizione (entry DVOL, credit, strikes, ecc.).
|
||||
9. Monitora ogni 12 ore le posizioni aperte e applica le regole di uscita.
|
||||
10. Su trigger di uscita, propone la chiusura ad Adriano e poi a Cerbero core.
|
||||
11. Logga ogni evento per audit e per il successivo aggiornamento Kelly.
|
||||
|
||||
### Cosa NON fa
|
||||
|
||||
- Non esegue ordini direttamente sul broker.
|
||||
- Non aggiorna autonomamente le proprie regole o i parametri.
|
||||
- Non interpreta news o sentiment in modo qualitativo.
|
||||
- Non sceglie un sottostante diverso da ETH (per ora).
|
||||
- Non applica strategie di rolling (regola hard della strategia).
|
||||
- Non considera setup intraday o eventi a frequenza inferiore alle 12 ore.
|
||||
|
||||
## Stakeholder
|
||||
|
||||
| Attore | Ruolo |
|
||||
|---|---|
|
||||
| Adriano | Owner del capitale, decisore finale, riceve report |
|
||||
| Cerbero Bite | Rule engine deterministico (questo progetto) |
|
||||
| Cerbero core | Motore di esecuzione ordini sul broker |
|
||||
| Milito | Consulente offline (LLM), aggiorna regole su evidenza |
|
||||
| Cerbero_Brain | Wiki tecnica condivisa con Milito (ricerca lezioni) |
|
||||
|
||||
## Vincoli ereditati
|
||||
|
||||
- **Cap Cerbero v4** (vedi `Cerbero_Office/CLAUDE.md`):
|
||||
- Max 200 EUR per singolo trade
|
||||
- Max 1.000 EUR di impegno totale aperto
|
||||
- Max 4 posizioni concorrenti
|
||||
- Max 6 trade per giorno
|
||||
- Stop trading se perdita giornaliera > 3% equity
|
||||
- No vendita vega 24h prima di FOMC, CPI, NFP
|
||||
|
||||
- **Hard Prohibitions** (non aggirabili nemmeno con override Adriano):
|
||||
- Mai opzioni nude
|
||||
- Mai leva > 3x sui perp (irrilevante per Bite, ma resta valido)
|
||||
- Mai short premium senza SL on-exchange dove tecnicamente possibile
|
||||
|
||||
## Metriche di successo a 12 mesi
|
||||
|
||||
Soglie minime sotto le quali il sistema va riesaminato:
|
||||
|
||||
| Metrica | Target | Soglia di allarme |
|
||||
|---|---|---|
|
||||
| Win rate | ≥ 75% | < 65% |
|
||||
| CAGR (capitale operativo) | ≥ 30% | < 10% |
|
||||
| Drawdown massimo | ≤ 25% | > 35% |
|
||||
| Sharpe ratio (annualizzato) | ≥ 0.8 | < 0.4 |
|
||||
| Numero di violazioni di regole | 0 | ≥ 1 (incident review) |
|
||||
| Errori di stato (posizioni desincronizzate) | 0 | ≥ 1 (kill switch + fix) |
|
||||
|
||||
I risultati sono confrontati a fine anno con la simulazione Monte Carlo
|
||||
del documento `Cerbero_Office/NewStrategy/analisi_dai_storici/
|
||||
analisi-montecarlo-kelly.md`.
|
||||
|
||||
## Glossario rapido
|
||||
|
||||
- **DVOL**: indice di volatilità implicita ETH calcolato da Deribit, analogo al VIX per S&P 500.
|
||||
- **Bull Put Spread**: vendita di una put OTM e acquisto di una put più OTM come protezione, su stesso sottostante e stessa scadenza. Incassi credito netto.
|
||||
- **DTE**: Days To Expiration, giorni alla scadenza dell'opzione.
|
||||
- **Quarter Kelly**: 25% della frazione di Kelly piena, sizing professionale standard. In questa strategia coincide con il 13% del capitale corrente.
|
||||
- **Vol stop**: regola di uscita che chiude la posizione se la volatilità implicita esplode oltre una soglia rispetto all'entry.
|
||||
- **Combo order**: ordine atomico su Deribit che apre/chiude entrambe le gambe dello spread simultaneamente, eliminando il leg risk.
|
||||
@@ -0,0 +1,225 @@
|
||||
# 01 — Regole della strategia (Cerberus Bite)
|
||||
|
||||
Le regole qui formalizzate sono la versione **canonica e immutabile** che
|
||||
il rule engine deve applicare. Modifiche richiedono una review esplicita
|
||||
con Adriano (e una nuova versione di questo documento).
|
||||
|
||||
Sorgente teorica: `Cerbero_Office/NewStrategy/strategia-credit-spread-eth.md`
|
||||
+ analisi Monte Carlo + cap operativi Cerbero v4.
|
||||
|
||||
## 1. Universo e strumenti
|
||||
|
||||
| Parametro | Valore |
|
||||
|---|---|
|
||||
| Sottostante | ETH/USD |
|
||||
| Exchange | Deribit |
|
||||
| Tipo opzioni | Europee, cash-settled, USDC-margined |
|
||||
| Strutture ammesse | Bull Put Spread, Bear Call Spread, (Iron Condor solo in regime laterale, vedere §6) |
|
||||
| Strutture vietate | Naked options, calendar spread, ratio spread, condor sbilanciati |
|
||||
|
||||
## 2. Trigger di apertura (entry)
|
||||
|
||||
Il rule engine valuta l'apertura di un nuovo trade **una sola volta al
|
||||
giorno**, alle **14:00 UTC** del lunedì (orario UE pomeridiano stabile,
|
||||
fuori dai picchi di funding statunitensi). Se il lunedì è festività
|
||||
italiana, l'engine ignora la regola e attende il lunedì successivo.
|
||||
|
||||
Una nuova posizione viene aperta **solo se tutte** le seguenti condizioni
|
||||
sono vere:
|
||||
|
||||
1. **Nessuna posizione Cerberus Bite già aperta.**
|
||||
2. **Capitale corrente ≥ 720 USD.** Sotto questa soglia il sizing in
|
||||
contratti non può raggiungere 1 unità Deribit.
|
||||
3. **DVOL corrente ≥ 35.** Sotto 35 il premio è troppo magro per
|
||||
mantenere il rapporto fees/credit accettabile.
|
||||
4. **DVOL corrente ≤ 90.** Sopra 90 si è in regime di stress, no entry.
|
||||
5. **Nessun evento macro** (FOMC, CPI USA, NFP, ECB, Powell speech)
|
||||
nei prossimi DTE giorni dall'apertura.
|
||||
6. **Funding rate ETH-PERP** in valore assoluto annualizzato ≤ 80%
|
||||
(filtra regimi di liquidazioni a cascata imminenti).
|
||||
7. **Holdings ETH** in `cerbero-portfolio` non superiori al 30% del
|
||||
patrimonio totale (correlazione direzionale già alta).
|
||||
8. **Liquidità degli strike candidati** entro le soglie del §3.
|
||||
|
||||
Se anche **una sola** condizione fallisce → **no entry**, log con motivo,
|
||||
ritento la settimana successiva.
|
||||
|
||||
## 3. Selezione struttura
|
||||
|
||||
### 3.1 Bias direzionale
|
||||
|
||||
Il bias è dedotto deterministicamente da due indicatori, mai in modo
|
||||
discrezionale:
|
||||
|
||||
| Indicatore | Calcolo | Soglia bull | Soglia bear |
|
||||
|---|---|---|---|
|
||||
| Trend ETH 30g | (ETH_now / ETH_30g_ago - 1) × 100 | ≥ +5% | ≤ -5% |
|
||||
| Funding cross-exchange | mediana funding annualizzato 4 maggiori exchange | ≥ +20% | ≤ -20% |
|
||||
|
||||
Risultato:
|
||||
|
||||
- Entrambi bull → **Bull Put Spread**.
|
||||
- Entrambi bear → **Bear Call Spread**.
|
||||
- Discordanti → **no entry** (mercato indeciso, edge incerto).
|
||||
- Entrambi in range neutro `(-5%, +5%)` E DVOL ≥ 55 E ADX(14) < 20 →
|
||||
**Iron Condor stretto** ammesso (vedi §6).
|
||||
- Tutti gli altri casi neutri → **no entry**.
|
||||
|
||||
### 3.2 Strike short
|
||||
|
||||
Si seleziona lo strike short più vicino a delta target, in valore
|
||||
assoluto.
|
||||
|
||||
| Parametro | Valore |
|
||||
|---|---|
|
||||
| Delta short target | 0.12 |
|
||||
| Tolleranza delta | [0.10, 0.15] |
|
||||
| Distanza minima OTM | 15% (anche se delta è dentro tolleranza) |
|
||||
| Distanza massima OTM | 25% |
|
||||
|
||||
Se nessuno strike disponibile rientra in entrambe le tolleranze → no entry.
|
||||
|
||||
### 3.3 Strike long (protezione)
|
||||
|
||||
Si seleziona lo strike a distanza fissa dallo short:
|
||||
|
||||
| Parametro | Valore |
|
||||
|---|---|
|
||||
| Larghezza spread (in % di spot) | 4% |
|
||||
| Larghezza minima accettabile | 3% |
|
||||
| Larghezza massima accettabile | 5% |
|
||||
|
||||
Se non esiste lo strike esatto a 4%, si prende il più vicino entro
|
||||
[3%, 5%]; altrimenti no entry.
|
||||
|
||||
### 3.4 Rapporto credito/larghezza
|
||||
|
||||
Il credito netto incassato (mid-price del combo) deve essere ≥ **30%**
|
||||
della larghezza dello spread. Sotto questa soglia il rapporto
|
||||
rischio/rendimento è inferiore al minimo accettabile. → no entry.
|
||||
|
||||
## 4. Liquidità (filtro hard pre-entry)
|
||||
|
||||
Per ciascuno strumento delle due gambe, **tutte** le condizioni:
|
||||
|
||||
| Misura | Soglia |
|
||||
|---|---|
|
||||
| Open interest | ≥ 100 contratti |
|
||||
| Volume 24h sullo strumento | ≥ 20 contratti |
|
||||
| Spread bid-ask | ≤ 15% del mid (≤ 10% per ATM, irrilevante qui) |
|
||||
| Profondità book ai primi 3 livelli | ≥ 5 contratti aggregati |
|
||||
|
||||
Calcoliamo inoltre uno **slippage stimato** moltiplicando la larghezza
|
||||
del bid-ask di entrambe le gambe per la size proposta. Se lo slippage
|
||||
totale > 8% del credito atteso → no entry.
|
||||
|
||||
## 5. Sizing
|
||||
|
||||
### 5.1 Calcolo numero contratti
|
||||
|
||||
```
|
||||
risk_target_usd = capitale_corrente * 0.13 # Quarter Kelly
|
||||
risk_target_usd = min(risk_target_usd, 200_eur_in_usd) # cap Cerbero
|
||||
n_contracts = floor(risk_target_usd / max_loss_per_contract)
|
||||
n_contracts = min(n_contracts, 4) # cap aggregato implicito
|
||||
```
|
||||
|
||||
`max_loss_per_contract` = larghezza spread × multiplier ETH (1 ETH).
|
||||
|
||||
### 5.2 Vincolo aggregato
|
||||
|
||||
Se `n_contracts × max_loss_per_contract + engagement_aperto_totale > 1.000_eur` →
|
||||
riduci `n_contracts` finché il vincolo è rispettato. Se risulta < 1 → no entry.
|
||||
|
||||
### 5.3 Volatilità di volatilità
|
||||
|
||||
Sizing dinamico per regime DVOL:
|
||||
|
||||
| DVOL corrente | Aggiustamento `n_contracts` |
|
||||
|---|---|
|
||||
| < 45 | × 1.0 (base) |
|
||||
| 45–60 | × 0.85 |
|
||||
| 60–80 | × 0.65 |
|
||||
| > 80 | (no entry, vedi §2) |
|
||||
|
||||
Arrotondamento sempre per difetto a intero, minimo 1.
|
||||
|
||||
## 6. Iron Condor (ammesso solo in regime laterale)
|
||||
|
||||
Se le condizioni del §3.1 attivano IC, struttura:
|
||||
|
||||
- Bull Put Spread alle stesse regole §3.2/§3.3
|
||||
- Bear Call Spread speculare (delta 0.12, 4% width)
|
||||
- Sizing: `n_contracts` calcolato come sopra ma **dimezzato** per gamba
|
||||
(rischio totale = somma max loss dei due lati, ma su ETH i due lati
|
||||
non possono essere entrambi violati, quindi rischio effettivo = max
|
||||
loss del lato peggiore + credito del lato vincente)
|
||||
- Profit take: 25% del credito totale (più aggressivo)
|
||||
- Tutto il resto delle regole §7-§9 si applica per lato.
|
||||
|
||||
## 7. Gestione attiva (decision loop ogni 12 ore)
|
||||
|
||||
Per ogni posizione aperta, il rule engine valuta in ordine:
|
||||
|
||||
1. **Profit take** — se mark price del combo ≤ 50% del credito incassato,
|
||||
`decision = CLOSE_PROFIT`.
|
||||
2. **Stop loss stretto** — se mark price del combo ≥ 250% del credito
|
||||
(perdita = 1.5× credito), `decision = CLOSE_STOP`.
|
||||
3. **Vol stop** — se DVOL corrente ≥ DVOL all'entry + 10,
|
||||
`decision = CLOSE_VOL`.
|
||||
4. **Time stop** — se `(scadenza - oggi) ≤ 7 giorni`,
|
||||
`decision = CLOSE_TIME` (a meno che non siamo già al 50% di profit:
|
||||
in quel caso teniamo per regola 1 in arrivo).
|
||||
5. **Strike testato** — se `|delta_short_corrente| ≥ 0.30`,
|
||||
`decision = CLOSE_DELTA`. Su ETH non si difende, si esce.
|
||||
6. **Movimento esplosivo** — se `|return_4h_ETH| ≥ 5%` direzione avversa,
|
||||
`decision = CLOSE_AVERSE` (azione conservativa, può essere finalizzata
|
||||
alla prossima fenestra).
|
||||
7. Altrimenti `decision = HOLD`.
|
||||
|
||||
L'ordine è importante: il primo trigger soddisfatto vince.
|
||||
|
||||
## 8. Esecuzione di apertura
|
||||
|
||||
1. Engine costruisce **combo order Deribit** (un solo ordine atomico
|
||||
con due gambe). Mai due ordini separati.
|
||||
2. Limit price = mid-price calcolato dall'engine.
|
||||
3. Ordine inviato come limit GTC con time-in-force che scade alle
|
||||
16:00 UTC dello stesso giorno.
|
||||
4. Se non riempito entro 30 min, engine alza limit di 1 tick verso
|
||||
ask combinato fino a un massimo di 3 step (poi cancella e log
|
||||
"no fill").
|
||||
5. Riempimento → engine persiste lo stato (vedi `05-data-model.md`)
|
||||
includendo: spot ETH, DVOL, strike, scadenza, credit incassato,
|
||||
timestamp.
|
||||
|
||||
## 9. Esecuzione di chiusura
|
||||
|
||||
1. Stessa logica di combo, in direzione inversa.
|
||||
2. Limit di partenza al mid-price.
|
||||
3. Se non riempito entro 30 min: alza limit fino a 3 step verso il
|
||||
bid combinato. Su trigger `CLOSE_STOP` o `CLOSE_VOL` o `CLOSE_DELTA`
|
||||
l'engine può accettare slippage maggiore (fino a 5 step) perché
|
||||
l'urgenza prevale sul prezzo.
|
||||
4. Riempimento → posizione marcata `CLOSED`, P&L calcolato e loggato,
|
||||
record archiviato per Kelly recalibration mensile.
|
||||
|
||||
## 10. Cosa NON fa il rule engine — esplicito
|
||||
|
||||
- Non rolla mai una posizione (regola §7.5: si esce e basta).
|
||||
- Non aggiunge gambe a una posizione esistente (no conversione in IC
|
||||
durante un trade aperto).
|
||||
- Non aggiusta strike o size dopo l'apertura.
|
||||
- Non apre nuovi trade per "compensare" perdite recenti.
|
||||
- Non opera fuori dalla finestra del lunedì 14:00 UTC, eccetto chiusure.
|
||||
- Non deroga ai cap nemmeno per "opportunità eccezionali".
|
||||
- Non si aggiorna automaticamente: nuovo set di regole = nuovo deploy
|
||||
con review esplicita.
|
||||
|
||||
## 11. Riepilogo soglie (parametri di config)
|
||||
|
||||
Tutti i numeri sopra sono **parametrizzati** in `strategy.yaml` (vedi
|
||||
`10-config-spec.md`). Modificarli è una decisione di Adriano con
|
||||
giustificazione scritta nel commit message. La regola di default
|
||||
fissata da questo documento è la **golden config** che il sistema parte
|
||||
con al primo deploy.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 02 — Architettura
|
||||
|
||||
## Vista a blocchi
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ADRIANO (utente) │
|
||||
└──────┬──────────────────────────┘
|
||||
│ Telegram (report + conferma)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CERBERO BITE (rule engine) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ scheduler │ │ state store │ │ audit logger │ │
|
||||
│ │ (APScheduler)│ │ (SQLite) │ │ (append-only) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ decision orchestrator (core/) │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ entry │ │ sizing │ │ exit │ │ greeks │ │ │
|
||||
│ │ │validator │ │ engine │ │ decision │ │ aggregator│ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │liquidity │ │ combo │ │ kelly │ │ │
|
||||
│ │ │ gate │ │ builder │ │ recalib │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ MCP client wrappers (clients/) │ │
|
||||
│ │ Deribit │ Hyperliquid │ Macro │ Sentiment │ Portfolio │ │
|
||||
│ │ Memory │ Telegram │ Brain-Bridge │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ MCP JSON-RPC
|
||||
▼
|
||||
┌───────────────────────────────┐ ┌───────────────────┐
|
||||
│ MCP servers │ │ Cerbero core │
|
||||
│ (esistenti in CerberoSuite) │───▶│ (esecuzione) │
|
||||
└───────────────────────────────┘ └───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Deribit │
|
||||
│ API │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
## Stack tecnologico
|
||||
|
||||
| Strato | Scelta | Motivazione |
|
||||
|---|---|---|
|
||||
| Linguaggio | Python 3.12+ | Coerente con `mcp_cerbero_brain` e l'ecosistema Cerbero |
|
||||
| Async runtime | `asyncio` | Necessario per i client MCP |
|
||||
| Scheduler | `APScheduler` | Cron-like + interval-based, in-process |
|
||||
| Persistenza stato | SQLite (file singolo) | Zero deploy overhead, transazionale, sufficiente per ≤ 4 posizioni |
|
||||
| Persistenza log | File `.jsonl` rotativi (gzip > 30g) | Audit-friendly, append-only, parseable |
|
||||
| Validazione config | `pydantic` v2 | Schema a runtime su `strategy.yaml` |
|
||||
| Test | `pytest` + `pytest-asyncio` + `hypothesis` (property-based) | TDD imposto |
|
||||
| Type checking | `mypy --strict` | Disciplina, niente sorprese a runtime |
|
||||
| Format/lint | `ruff` | Standard del progetto |
|
||||
| Dependency manager | `uv` | Coerente con `mcp_cerbero_brain` |
|
||||
| MCP client | SDK ufficiale `mcp` | Connessione ai server già configurati in `.mcp.json` |
|
||||
| Notifiche | MCP `cerbero-telegram` | Riusa canale esistente |
|
||||
| GUI | `streamlit` ≥ 1.40 + `plotly` | Dashboard locale, processo separato (vedi `11-gui-streamlit.md`) |
|
||||
|
||||
## Layout cartelle
|
||||
|
||||
```
|
||||
Cerbero_Bite/
|
||||
├── README.md
|
||||
├── pyproject.toml
|
||||
├── uv.lock
|
||||
├── strategy.yaml # config golden
|
||||
├── strategy.local.yaml.example # override locale (gitignored)
|
||||
├── docs/ # questa documentazione
|
||||
├── src/cerbero_bite/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # entry point CLI
|
||||
│ ├── 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
|
||||
│ ├── clients/ # wrapper MCP
|
||||
│ │ ├── deribit.py
|
||||
│ │ ├── hyperliquid.py
|
||||
│ │ ├── macro.py
|
||||
│ │ ├── sentiment.py
|
||||
│ │ ├── portfolio.py
|
||||
│ │ ├── memory.py
|
||||
│ │ ├── telegram.py
|
||||
│ │ └── brain_bridge.py
|
||||
│ ├── runtime/ # I/O, scheduling, orchestrazione
|
||||
│ │ ├── scheduler.py
|
||||
│ │ ├── orchestrator.py
|
||||
│ │ ├── monitoring.py
|
||||
│ │ └── alert_manager.py
|
||||
│ ├── state/ # persistenza
|
||||
│ │ ├── repository.py
|
||||
│ │ ├── models.py # SQLAlchemy o dataclasses
|
||||
│ │ └── migrations/
|
||||
│ ├── config/ # caricamento e validazione yaml
|
||||
│ │ ├── schema.py
|
||||
│ │ └── loader.py
|
||||
│ ├── reporting/ # report umani
|
||||
│ │ ├── pre_trade.py
|
||||
│ │ ├── post_trade.py
|
||||
│ │ └── daily_digest.py
|
||||
│ ├── gui/ # Streamlit dashboard (vedi 11-gui-streamlit.md)
|
||||
│ │ ├── main.py
|
||||
│ │ ├── pages/
|
||||
│ │ ├── components/
|
||||
│ │ └── data_layer.py
|
||||
│ └── safety/ # kill switch, dead man, audit
|
||||
│ ├── kill_switch.py
|
||||
│ ├── dead_man.py
|
||||
│ └── audit_log.py
|
||||
├── tests/
|
||||
│ ├── unit/ # test puri sui moduli core/
|
||||
│ ├── integration/ # test con MCP fake
|
||||
│ ├── golden/ # scenari deterministici di riferimento
|
||||
│ └── fixtures/
|
||||
├── scripts/
|
||||
│ ├── reset_state.py
|
||||
│ ├── replay_day.py # replay forensico di una giornata
|
||||
│ └── recalibrate_kelly.py
|
||||
└── data/
|
||||
├── state.sqlite # gitignored
|
||||
└── log/ # gitignored
|
||||
```
|
||||
|
||||
## 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 sui server MCP. Ogni client espone
|
||||
una API tipizzata che usa internamente JSON-RPC. Mai logica di
|
||||
business qui.
|
||||
- **`runtime/`** orchestrazione: composizione di `clients/` + `core/` +
|
||||
`state/`. È l'unico strato che può fare I/O e ha effetti collaterali.
|
||||
- **`state/`** persistenza. Mai logica di business. Solo CRUD.
|
||||
- **`config/`** caricamento di `strategy.yaml`, validazione, esposizione
|
||||
immutabile dei parametri.
|
||||
- **`reporting/`** generazione di stringhe per Telegram. Niente logica
|
||||
di trading, solo formatting.
|
||||
- **`safety/`** controlli trasversali (vedere `07-risk-controls.md`).
|
||||
|
||||
## Decision orchestrator — sequenza tipo per "Lunedì 14:00 UTC"
|
||||
|
||||
```python
|
||||
async def evaluate_entry():
|
||||
# 1. Stato sistema
|
||||
if not safety.system_healthy(): return
|
||||
if state.has_open_position(): return
|
||||
|
||||
# 2. Dati di mercato
|
||||
spot = await deribit.get_index_price("ETH")
|
||||
dvol = await deribit.get_dvol()
|
||||
funding = await sentiment.get_funding_cross_exchange("ETH")
|
||||
macro = await macro.get_calendar(days=18)
|
||||
holdings = await portfolio.get_holdings()
|
||||
|
||||
# 3. Algoritmi puri
|
||||
bias = entry_validator.compute_bias(spot, trend_30d, funding)
|
||||
if bias == "no_entry": return log_and_skip()
|
||||
|
||||
capital = await portfolio.get_capital()
|
||||
chain = await deribit.get_options_chain("ETH", dte_target=18)
|
||||
short_strike, long_strike = combo_builder.select_strikes(
|
||||
chain, bias, spot, config
|
||||
)
|
||||
if short_strike is None: return log_and_skip("no_strike")
|
||||
|
||||
if not liquidity_gate.passes(short_strike, long_strike, deribit_book):
|
||||
return log_and_skip("illiquid")
|
||||
|
||||
n = sizing_engine.compute_contracts(
|
||||
capital, max_loss_per_contract, dvol, config
|
||||
)
|
||||
if n < 1: return log_and_skip("undersize")
|
||||
|
||||
proposal = combo_builder.build(short_strike, long_strike, n, mid_price)
|
||||
|
||||
# 4. Conferma
|
||||
await telegram.send(reporting.pre_trade(proposal))
|
||||
confirmation = await wait_user_confirmation(timeout="60min")
|
||||
if not confirmation.accepted: return log_and_skip("rejected")
|
||||
|
||||
# 5. Esecuzione via Cerbero core
|
||||
instruction = combo_builder.to_cerbero_instruction(proposal)
|
||||
await memory.push_user_instruction(instruction, source="cerbero-bite")
|
||||
|
||||
# 6. Stato
|
||||
state.create_position(proposal, dvol_at_entry=dvol)
|
||||
audit_logger.log("ENTRY_PROPOSED", proposal)
|
||||
```
|
||||
|
||||
## Sequenza tipo per "monitoring 12h"
|
||||
|
||||
```python
|
||||
async def evaluate_open_positions():
|
||||
if not safety.system_healthy(): return
|
||||
for position in state.list_open_positions():
|
||||
spot = await deribit.get_index_price("ETH")
|
||||
dvol = await deribit.get_dvol()
|
||||
mark = await deribit.get_combo_mark(position.legs)
|
||||
delta_short = await deribit.get_instrument(position.short_leg).delta
|
||||
|
||||
decision = exit_decision.evaluate(
|
||||
position=position,
|
||||
spot=spot,
|
||||
dvol_now=dvol,
|
||||
mark_now=mark,
|
||||
delta_short_now=delta_short,
|
||||
now=datetime.now(timezone.utc),
|
||||
config=config,
|
||||
)
|
||||
|
||||
if decision.action == "HOLD":
|
||||
audit_logger.log("HOLD", position.id, decision.reason)
|
||||
continue
|
||||
|
||||
await telegram.send(reporting.exit_proposal(position, decision))
|
||||
confirmation = await wait_user_confirmation(timeout="30min")
|
||||
if not confirmation.accepted:
|
||||
audit_logger.log("EXIT_DEFERRED", position.id)
|
||||
continue
|
||||
|
||||
await memory.push_user_instruction(
|
||||
combo_builder.close_instruction(position),
|
||||
source="cerbero-bite"
|
||||
)
|
||||
state.mark_closing(position.id)
|
||||
```
|
||||
|
||||
## Failure modes e retry
|
||||
|
||||
| Modalità | Risposta |
|
||||
|---|---|
|
||||
| MCP non risponde (timeout) | Retry esponenziale 3 tentativi (1s, 5s, 30s); poi alert + skip ciclo |
|
||||
| MCP risponde con dato palesemente rotto (es. orderbook tutti a 0) | Skip ciclo, alert |
|
||||
| Confidence Adriano scaduta | Skip apertura; le chiusure restano in coda con alta priorità |
|
||||
| Stato SQLite corrotto | Kill switch attivato, alert manuale richiesto |
|
||||
| Cerbero core non riceve l'istruzione | Engine si aspetta ack via `cerbero-memory.get_pending`; se entro 5 min ack assente, alert e blocco apertura |
|
||||
|
||||
Vedi `07-risk-controls.md` per il dettaglio completo dei kill switch.
|
||||
|
||||
## Concorrenza e idempotenza
|
||||
|
||||
- Una sola istanza dell'engine alla volta (file lock su `data/.lockfile`).
|
||||
- Tutti i ticker di scheduler sono **idempotenti**: se il sistema crasha
|
||||
durante un'apertura, al riavvio il monitoring ricostruisce lo stato
|
||||
da SQLite + Cerbero core (`cerbero-memory.get_state`) e prosegue.
|
||||
- Ogni proposta ha un `proposal_id` UUID. Cerbero core ignora
|
||||
duplicati con lo stesso id.
|
||||
@@ -0,0 +1,460 @@
|
||||
# 03 — Algoritmi core
|
||||
|
||||
Specifica dettagliata dei sette algoritmi puri che vivono in `src/cerbero_bite/core/`.
|
||||
Sono **funzioni deterministiche**: stesso input → stesso output, niente I/O,
|
||||
niente LLM. Ogni modulo è testabile in isolamento con dati statici.
|
||||
|
||||
Convenzione di tipo: tutti i moduli usano `pydantic` v2 per i record di
|
||||
input/output, `Decimal` per i prezzi (mai `float` — vedi nota in §0.2).
|
||||
|
||||
## 0. Convenzioni
|
||||
|
||||
### 0.1 Tipi base
|
||||
|
||||
```python
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
PutOrCall = Literal["P", "C"]
|
||||
SpreadType = Literal["bull_put", "bear_call", "iron_condor"]
|
||||
|
||||
class OptionLeg(BaseModel):
|
||||
instrument: str # es. "ETH-13MAY26-1900-P"
|
||||
side: Literal["BUY", "SELL"]
|
||||
strike: Decimal
|
||||
expiry: datetime # UTC, scadenza Deribit (08:00 UTC)
|
||||
type: PutOrCall
|
||||
size: int # numero contratti
|
||||
mid_price_eth: Decimal # mid in ETH (Deribit prices in ETH per inverse)
|
||||
delta: Decimal
|
||||
gamma: Decimal
|
||||
theta: Decimal
|
||||
vega: Decimal
|
||||
```
|
||||
|
||||
### 0.2 Precisione numerica
|
||||
|
||||
I prezzi opzioni sono in ETH e si lavorano con `Decimal` ad alta
|
||||
precisione. Mai `float` per quantità monetarie. Le greche sono ricevute
|
||||
come float dal MCP Deribit; vengono convertite in `Decimal` con 6 cifre
|
||||
all'ingresso del modulo.
|
||||
|
||||
### 0.3 Deterministic clock
|
||||
|
||||
Ogni algoritmo che dipende dal tempo riceve `now: datetime` come
|
||||
parametro **esplicito**. Mai `datetime.now()` dentro `core/`. Questo
|
||||
rende i test riproducibili.
|
||||
|
||||
---
|
||||
|
||||
## 1. `entry_validator`
|
||||
|
||||
**Responsabilità:** verificare che tutte le condizioni di entrata
|
||||
elencate in `01-strategy-rules.md §2` siano soddisfatte. Restituisce
|
||||
`pass | fail(reason)`.
|
||||
|
||||
```python
|
||||
class EntryContext(BaseModel):
|
||||
capital_usd: Decimal
|
||||
dvol_now: Decimal
|
||||
funding_perp_annualized: Decimal # ETH-PERP, frazione annualizzata
|
||||
eth_holdings_pct_of_portfolio: Decimal
|
||||
next_macro_event_in_days: int | None # None se nessun evento entro DTE
|
||||
has_open_position: bool
|
||||
|
||||
class EntryDecision(BaseModel):
|
||||
accepted: bool
|
||||
reasons: list[str] # tutti i motivi di fallimento
|
||||
|
||||
def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision: ...
|
||||
```
|
||||
|
||||
**Logica** (in ordine, accumula tutti i motivi di fallimento):
|
||||
|
||||
1. `has_open_position` → fail.
|
||||
2. `capital_usd < 720` → fail.
|
||||
3. `dvol_now < 35 or dvol_now > 90` → fail.
|
||||
4. `next_macro_event_in_days is not None and next_macro_event_in_days <= dte_target` → fail.
|
||||
5. `abs(funding_perp_annualized) > 0.80` → fail.
|
||||
6. `eth_holdings_pct_of_portfolio > 0.30` → fail.
|
||||
|
||||
Restituisce `accepted = (len(reasons) == 0)`.
|
||||
|
||||
**Bias direzionale** (componente separato):
|
||||
|
||||
```python
|
||||
class TrendContext(BaseModel):
|
||||
eth_now: Decimal
|
||||
eth_30d_ago: Decimal
|
||||
funding_cross_annualized: Decimal # mediana 4 exchange
|
||||
dvol_now: Decimal
|
||||
adx_14: Decimal
|
||||
|
||||
def compute_bias(ctx: TrendContext, cfg: StrategyConfig) -> SpreadType | None: ...
|
||||
```
|
||||
|
||||
Restituisce `"bull_put"` / `"bear_call"` / `"iron_condor"` / `None`
|
||||
secondo le tabelle del §3.1 della strategia.
|
||||
|
||||
---
|
||||
|
||||
## 2. `liquidity_gate`
|
||||
|
||||
**Responsabilità:** validare la liquidità degli strumenti delle due
|
||||
gambe e stimare lo slippage atteso.
|
||||
|
||||
```python
|
||||
class InstrumentSnapshot(BaseModel):
|
||||
instrument: str
|
||||
bid: Decimal
|
||||
ask: Decimal
|
||||
mid: Decimal
|
||||
open_interest: int
|
||||
volume_24h: int
|
||||
book_depth_top3: int # somma size primi 3 livelli (lato vicino)
|
||||
|
||||
class LiquidityCheck(BaseModel):
|
||||
accepted: bool
|
||||
reasons: list[str]
|
||||
estimated_slippage_pct_of_credit: Decimal
|
||||
|
||||
def check(legs: list[InstrumentSnapshot],
|
||||
credit: Decimal,
|
||||
n_contracts: int,
|
||||
cfg: StrategyConfig) -> LiquidityCheck: ...
|
||||
```
|
||||
|
||||
**Soglie** (parametri da `cfg`, default in `01-strategy-rules.md §4`):
|
||||
|
||||
- Per ogni leg: `OI >= 100`, `volume_24h >= 20`,
|
||||
`(ask - bid) / mid <= 0.15`, `book_depth_top3 >= 5`.
|
||||
- `slippage_total_eth = (ask_short - mid_short) * n + (mid_long - bid_long) * n`
|
||||
- `estimated_slippage_pct_of_credit = slippage_total_eth / credit`
|
||||
- Reject se > 0.08 (8%).
|
||||
|
||||
---
|
||||
|
||||
## 3. `sizing_engine`
|
||||
|
||||
**Responsabilità:** calcolare il numero di contratti rispettando Quarter
|
||||
Kelly, cap Cerbero, vincolo aggregato e aggiustamento volatilità.
|
||||
|
||||
```python
|
||||
class SizingContext(BaseModel):
|
||||
capital_usd: Decimal
|
||||
max_loss_per_contract_usd: Decimal # = spread_width × 1 ETH
|
||||
dvol_now: Decimal
|
||||
open_engagement_usd: Decimal # somma max_loss aperti su CB
|
||||
eur_to_usd: Decimal # tasso live, per cap 200 EUR
|
||||
other_open_positions: int # da CB, escluso il nuovo
|
||||
|
||||
class SizingResult(BaseModel):
|
||||
n_contracts: int
|
||||
risk_dollars: Decimal
|
||||
reason_if_zero: str | None
|
||||
|
||||
def compute_contracts(ctx: SizingContext, cfg: StrategyConfig) -> SizingResult: ...
|
||||
```
|
||||
|
||||
**Logica:**
|
||||
|
||||
```
|
||||
risk_target = ctx.capital_usd * cfg.kelly_fraction # 0.13
|
||||
cap_per_trade_usd = 200_eur * ctx.eur_to_usd
|
||||
risk_target = min(risk_target, cap_per_trade_usd)
|
||||
|
||||
# aggiustamento DVOL
|
||||
if ctx.dvol_now < 45: adj = 1.00
|
||||
elif ctx.dvol_now < 60: adj = 0.85
|
||||
elif ctx.dvol_now < 80: adj = 0.65
|
||||
else: return zero("dvol > 80, no entry")
|
||||
risk_target *= adj
|
||||
|
||||
n = floor(risk_target / ctx.max_loss_per_contract_usd)
|
||||
n = min(n, cfg.max_contracts_per_trade) # default 4
|
||||
|
||||
# vincolo engagement aggregato
|
||||
cap_aggregate_usd = 1000_eur * ctx.eur_to_usd
|
||||
while n > 0 and (n * ctx.max_loss_per_contract + ctx.open_engagement) > cap_aggregate_usd:
|
||||
n -= 1
|
||||
|
||||
# vincolo numero posizioni concorrenti
|
||||
if ctx.other_open_positions >= cfg.max_concurrent_positions: return zero("too many positions")
|
||||
|
||||
if n < 1: return zero("undersize")
|
||||
return SizingResult(n_contracts=n, risk_dollars=n * max_loss_per_contract)
|
||||
```
|
||||
|
||||
**Test obbligatori (TDD):**
|
||||
|
||||
- Capitale 720, max_loss/contratto 93, dvol 40 → 1 contratto
|
||||
- Capitale 1500, dvol 50 → adj 0.85; 13% × 1500 = 195; 195 × 0.85 = 165; 165/93 ≈ 1
|
||||
- Capitale 1500, dvol 40 → 13% × 1500 = 195, cap 200 EUR; 195/93 ≈ 2
|
||||
- Capitale 5000, dvol 40 → 13% × 5000 = 650, cap 200 EUR ≈ 215 USD; 215/93 ≈ 2 (cap morde)
|
||||
- Capitale 100000, dvol 40 → cap 215 USD; 2 contratti (cap saturo)
|
||||
|
||||
---
|
||||
|
||||
## 4. `combo_builder`
|
||||
|
||||
**Responsabilità:** dato un bias e una option chain, selezionare i due
|
||||
strike e costruire il combo order ready-to-submit.
|
||||
|
||||
```python
|
||||
class StrikeCandidate(BaseModel):
|
||||
strike: Decimal
|
||||
delta: Decimal
|
||||
instrument: str
|
||||
|
||||
class ComboProposal(BaseModel):
|
||||
proposal_id: UUID
|
||||
spread_type: SpreadType
|
||||
legs: list[OptionLeg]
|
||||
credit_target_eth: Decimal
|
||||
credit_target_usd: Decimal
|
||||
max_loss_eth: Decimal
|
||||
max_loss_usd: Decimal
|
||||
breakeven: Decimal
|
||||
spot_at_proposal: Decimal
|
||||
dvol_at_proposal: Decimal
|
||||
expiry: datetime
|
||||
|
||||
def select_strikes(chain: list[InstrumentSnapshot],
|
||||
bias: SpreadType,
|
||||
spot: Decimal,
|
||||
dte_target: int,
|
||||
cfg: StrategyConfig) -> tuple[StrikeCandidate, StrikeCandidate] | None: ...
|
||||
|
||||
def build(short: InstrumentSnapshot,
|
||||
long: InstrumentSnapshot,
|
||||
n_contracts: int,
|
||||
spot: Decimal,
|
||||
dvol: Decimal,
|
||||
cfg: StrategyConfig) -> ComboProposal: ...
|
||||
|
||||
def to_cerbero_instruction(proposal: ComboProposal) -> CerberoInstruction: ...
|
||||
def close_instruction(position: OpenPosition, reason: str) -> CerberoInstruction: ...
|
||||
```
|
||||
|
||||
**Selezione short strike:**
|
||||
|
||||
1. Filtra chain per `expiry` con DTE compreso in `[14, 21]` (preferito 18).
|
||||
2. Filtra per tipo (`P` per bull_put, `C` per bear_call).
|
||||
3. Calcola distanza percentuale dallo spot e ritieni solo strike con
|
||||
distanza in `[15%, 25%]`.
|
||||
4. Tra questi, scegli quello con `|delta|` più vicino a 0.12 (range
|
||||
accettato `[0.10, 0.15]`).
|
||||
5. Se nessuno → `None`.
|
||||
|
||||
**Selezione long strike:**
|
||||
|
||||
1. Larghezza target: `spot * 0.04`. Range accettato `[0.03 × spot, 0.05 × spot]`.
|
||||
2. Per bull_put: `long_strike = short_strike - width`. Trova lo strike
|
||||
esistente più vicino.
|
||||
3. Verifica che la distanza effettiva sia nel range; altrimenti `None`.
|
||||
4. Per bear_call: simmetrico, `long_strike = short_strike + width`.
|
||||
|
||||
**Verifica credit ratio:**
|
||||
|
||||
- `credit = mid_short - mid_long` (ETH).
|
||||
- Se `credit / width < 0.30` → `None` (spec §3.4).
|
||||
|
||||
---
|
||||
|
||||
## 5. `greeks_aggregator`
|
||||
|
||||
**Responsabilità:** dato un set di legs, calcolare le greche aggregate
|
||||
nette dello spread.
|
||||
|
||||
```python
|
||||
class AggregateGreeks(BaseModel):
|
||||
delta_net: Decimal
|
||||
gamma_net: Decimal
|
||||
theta_net: Decimal # giornaliero, in USD
|
||||
vega_net: Decimal # per 1 punto IV
|
||||
|
||||
def aggregate(legs: list[OptionLeg], spot: Decimal, eth_price_usd: Decimal) -> AggregateGreeks: ...
|
||||
```
|
||||
|
||||
**Logica:**
|
||||
|
||||
```
|
||||
delta_net = sum( sign(side) * size * leg.delta for leg in legs )
|
||||
# sign(side): +1 per BUY, -1 per SELL
|
||||
# (analogo per gamma, theta, vega)
|
||||
```
|
||||
|
||||
Theta convertito in USD: `theta_eth × eth_price_usd × multiplier`.
|
||||
|
||||
**Uso:** alimentazione del report pre-trade e check di sanità in
|
||||
monitoring (vedi `01-strategy-rules.md §7.3` — il vol_stop usa DVOL,
|
||||
non vega aggregata, ma vega aggregata serve per validazione).
|
||||
|
||||
---
|
||||
|
||||
## 6. `exit_decision`
|
||||
|
||||
**Responsabilità:** dato lo stato di una posizione aperta + dati
|
||||
correnti, decidere se chiudere e perché.
|
||||
|
||||
```python
|
||||
class PositionSnapshot(BaseModel):
|
||||
proposal_id: UUID
|
||||
spread_type: SpreadType
|
||||
legs: list[OptionLeg]
|
||||
credit_received_eth: Decimal
|
||||
credit_received_usd: Decimal
|
||||
spot_at_entry: Decimal
|
||||
dvol_at_entry: Decimal
|
||||
expiry: datetime
|
||||
opened_at: datetime
|
||||
eth_price_usd_now: Decimal
|
||||
spot_now: Decimal
|
||||
dvol_now: Decimal
|
||||
mark_combo_now_eth: Decimal # mark price del combo (debito necessario per chiudere)
|
||||
delta_short_now: Decimal
|
||||
return_4h_now: Decimal # ritorno ETH a 4h
|
||||
now: datetime
|
||||
|
||||
ExitAction = Literal["HOLD", "CLOSE_PROFIT", "CLOSE_STOP", "CLOSE_VOL",
|
||||
"CLOSE_TIME", "CLOSE_DELTA", "CLOSE_AVERSE"]
|
||||
|
||||
class ExitDecisionResult(BaseModel):
|
||||
action: ExitAction
|
||||
reason: str
|
||||
pnl_estimate_eth: Decimal
|
||||
pnl_estimate_usd: Decimal
|
||||
|
||||
def evaluate(snapshot: PositionSnapshot, cfg: StrategyConfig) -> ExitDecisionResult: ...
|
||||
```
|
||||
|
||||
**Logica** (ordine vincolante, primo match vince):
|
||||
|
||||
```python
|
||||
days_to_expiry = (snapshot.expiry - snapshot.now).total_seconds() / 86400
|
||||
debit_needed = snapshot.mark_combo_now_eth # ETH
|
||||
profit_take_threshold = snapshot.credit_received_eth * 0.50
|
||||
|
||||
# 1. Profit take
|
||||
if debit_needed <= profit_take_threshold:
|
||||
return CLOSE_PROFIT
|
||||
|
||||
# 2. Stop loss stretto
|
||||
if debit_needed >= snapshot.credit_received_eth * 2.5: # spec: stop a 1.5× credito
|
||||
return CLOSE_STOP
|
||||
|
||||
# 3. Vol stop
|
||||
if snapshot.dvol_now >= snapshot.dvol_at_entry + 10:
|
||||
return CLOSE_VOL
|
||||
|
||||
# 4. Time stop (con eccezione "quasi a profit")
|
||||
if days_to_expiry <= 7:
|
||||
if debit_needed > profit_take_threshold * 0.7: # NON siamo prossimi al 50% di profit
|
||||
return CLOSE_TIME
|
||||
|
||||
# 5. Strike testato
|
||||
if abs(snapshot.delta_short_now) >= 0.30:
|
||||
return CLOSE_DELTA
|
||||
|
||||
# 6. Movimento esplosivo direzione avversa
|
||||
adverse_move = (snapshot.return_4h_now < -0.05 if spread_type == "bull_put"
|
||||
else snapshot.return_4h_now > 0.05)
|
||||
if adverse_move:
|
||||
return CLOSE_AVERSE
|
||||
|
||||
return HOLD
|
||||
```
|
||||
|
||||
**Calcolo P&L stimato:**
|
||||
|
||||
```
|
||||
realized_pnl_eth = (credit_received - debit_needed) * n_contracts
|
||||
realized_pnl_usd = realized_pnl_eth * eth_price_usd_now
|
||||
```
|
||||
|
||||
**Test obbligatori:**
|
||||
|
||||
- Posizione con mark = 50% credito → CLOSE_PROFIT
|
||||
- Posizione con mark = 250% credito → CLOSE_STOP
|
||||
- DVOL +12 dall'entry → CLOSE_VOL
|
||||
- 6 giorni a scadenza, mark = 80% credito → CLOSE_TIME
|
||||
- 6 giorni a scadenza, mark = 30% credito → HOLD (eccezione)
|
||||
- delta short = -0.32 → CLOSE_DELTA
|
||||
- Tutto OK ma return -7% in 4h → CLOSE_AVERSE
|
||||
- Tutto neutro → HOLD
|
||||
|
||||
---
|
||||
|
||||
## 7. `kelly_recalibration`
|
||||
|
||||
**Responsabilità:** ricalcolare il `kelly_fraction` ogni 30 giorni
|
||||
basandosi sui trade reali chiusi.
|
||||
|
||||
```python
|
||||
class TradeRecord(BaseModel):
|
||||
proposal_id: UUID
|
||||
pnl_usd: Decimal
|
||||
risk_usd: Decimal
|
||||
closed_at: datetime
|
||||
outcome: ExitAction
|
||||
|
||||
class KellyResult(BaseModel):
|
||||
win_rate: Decimal
|
||||
avg_win_pct_risk: Decimal
|
||||
avg_loss_pct_risk: Decimal
|
||||
full_kelly_pct: Decimal
|
||||
quarter_kelly_pct: Decimal
|
||||
sample_size: int
|
||||
recommended_fraction: Decimal
|
||||
confidence: Literal["low", "medium", "high"] # da sample size
|
||||
|
||||
def recalibrate(trades: list[TradeRecord], cfg: StrategyConfig) -> KellyResult: ...
|
||||
```
|
||||
|
||||
**Logica:**
|
||||
|
||||
- Ignora trade con `closed_at` più vecchi di 365 giorni.
|
||||
- Win rate = trade con pnl > 0 / totali.
|
||||
- Avg win = media dei pnl positivi normalizzata sul risk.
|
||||
- Avg loss = media dei pnl negativi normalizzata sul risk (in valore assoluto).
|
||||
- `b = avg_win / avg_loss`
|
||||
- `full_kelly = (win_rate × b - (1 − win_rate)) / b` (clip a 0 se negativo)
|
||||
- `quarter_kelly = full_kelly × 0.25`
|
||||
- `recommended_fraction`:
|
||||
- Se `sample_size < 30` → mantieni `cfg.kelly_fraction` (no update).
|
||||
- Se 30 ≤ sample < 100 → media pesata 50/50 con cfg corrente.
|
||||
- Se ≥ 100 → adotta `quarter_kelly`.
|
||||
- `confidence`: low (<30), medium (30-99), high (≥100).
|
||||
|
||||
Output non viene mai applicato automaticamente: il sistema produce un
|
||||
report mensile, Adriano decide se aggiornare `strategy.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting: ordine di chiamata
|
||||
|
||||
Il decision orchestrator chiama gli algoritmi in questa sequenza
|
||||
canonica per l'apertura:
|
||||
|
||||
```
|
||||
1. entry_validator.validate_entry (filtri base)
|
||||
2. entry_validator.compute_bias (direzione)
|
||||
3. combo_builder.select_strikes (struttura)
|
||||
4. liquidity_gate.check (eseguibilità)
|
||||
5. sizing_engine.compute_contracts (size)
|
||||
6. combo_builder.build (proposta finale)
|
||||
7. greeks_aggregator.aggregate (validazione e report)
|
||||
```
|
||||
|
||||
Per il monitoring (12h):
|
||||
|
||||
```
|
||||
1. exit_decision.evaluate (decisione)
|
||||
2. greeks_aggregator.aggregate (per il report di chiusura)
|
||||
```
|
||||
|
||||
Ogni passo ha precondizioni esplicite documentate nella docstring del
|
||||
modulo. Il loro ordine è bloccato perché ciascuno consuma output del
|
||||
precedente.
|
||||
@@ -0,0 +1,243 @@
|
||||
# 04 — MCP Integration
|
||||
|
||||
Tutti i server MCP sono già configurati a livello di `CerberoSuite` (vedi
|
||||
`Cerbero_Office/.mcp.json`). Cerbero Bite vi si connette come **client
|
||||
MCP** usando l'SDK ufficiale `mcp` per Python.
|
||||
|
||||
## Configurazione di connessione
|
||||
|
||||
Cerbero Bite legge `~/.config/cerbero-suite/mcp.json` (o, in dev, da
|
||||
`.mcp.json` locale puntato via env var `CERBERO_BITE_MCP_CONFIG`). I
|
||||
server vengono risolti **per nome** dichiarato nel file di config.
|
||||
|
||||
```python
|
||||
# clients/_base.py — abstract
|
||||
class McpClient:
|
||||
name: str # "cerbero-deribit", ecc.
|
||||
timeout_s: float = 8.0
|
||||
retry_max: int = 3
|
||||
retry_base_delay: float = 1.0 # esponenziale
|
||||
|
||||
async def call(self, tool: str, **params) -> dict: ...
|
||||
```
|
||||
|
||||
Ogni wrapper concreto eredita `McpClient` ed espone metodi tipizzati.
|
||||
La logica di retry e timeout è centralizzata.
|
||||
|
||||
## Server MCP usati
|
||||
|
||||
### `cerbero-deribit`
|
||||
|
||||
Tool consumati:
|
||||
|
||||
| Tool | Uso | Frequenza |
|
||||
|---|---|---|
|
||||
| `get_index_price(asset="ETH")` | Spot ETH per calcolo strike | Ogni ciclo entry + monitor |
|
||||
| `get_dvol()` | Volatilità implicita aggregata ETH | Ogni ciclo entry + monitor |
|
||||
| `get_options_chain(asset, expiry_window)` | Lista strumenti per dato DTE | Solo entry |
|
||||
| `get_instrument(instrument_name)` | Mid, bid, ask, greche su singolo strumento | Entry + monitor |
|
||||
| `get_orderbook(instrument_name, depth=5)` | Profondità per liquidity_gate e slippage | Solo entry |
|
||||
| `get_combo_mark(legs)` | Mark price del combo (debito di chiusura) | Solo monitor |
|
||||
| `get_account_summary(currency="USDC")` | Equity Deribit, margin libero | Periodico |
|
||||
|
||||
Wrapper:
|
||||
|
||||
```python
|
||||
# clients/deribit.py
|
||||
class DeribitClient(McpClient):
|
||||
name = "cerbero-deribit"
|
||||
|
||||
async def index_price(self, asset: str) -> Decimal: ...
|
||||
async def dvol(self) -> Decimal: ...
|
||||
async def options_chain(self, asset: str, dte_min: int, dte_max: int) -> list[InstrumentSnapshot]: ...
|
||||
async def instrument(self, name: str) -> InstrumentSnapshot: ...
|
||||
async def orderbook(self, name: str, depth: int = 5) -> OrderbookSnapshot: ...
|
||||
async def combo_mark(self, legs: list[OptionLeg]) -> Decimal: ...
|
||||
async def account_summary(self) -> AccountSummary: ...
|
||||
```
|
||||
|
||||
**Note:**
|
||||
- Tutti i prezzi sono ricevuti come float dal MCP, convertiti in
|
||||
`Decimal` con `quantize` a 6 cifre nel wrapper.
|
||||
- Greche convertite con la stessa quantizzazione.
|
||||
- Se `mark_iv = 7%` o `300%` o `bid = 0` su orderbook ATM su tutti gli
|
||||
strumenti → wrapper solleva `DeribitDataAnomalyError` (probabile
|
||||
testnet o feed rotto). Il decision orchestrator cattura, alert,
|
||||
skippa il ciclo.
|
||||
|
||||
### `cerbero-hyperliquid`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `get_perp_funding_rate(asset="ETH")` | Filtro entry §2.6 |
|
||||
| `get_perp_summary(asset="ETH")` | Volume 24h, conferma liquidità correlata |
|
||||
| `get_account_summary()` | Solo per coerenza, non usato in decision loop |
|
||||
|
||||
```python
|
||||
class HyperliquidClient(McpClient):
|
||||
async def funding_rate_annualized(self, asset: str) -> Decimal: ...
|
||||
async def perp_summary(self, asset: str) -> PerpSummary: ...
|
||||
```
|
||||
|
||||
### `cerbero-sentiment`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `get_funding_cross_exchange(asset="ETH")` | Bias direzionale §3.1 (mediana 4 maggiori) |
|
||||
|
||||
Le news qualitative **non sono usate** nel decision loop (no LLM).
|
||||
Vengono eventualmente lette da Adriano in occasione del report
|
||||
settimanale.
|
||||
|
||||
```python
|
||||
class SentimentClient(McpClient):
|
||||
async def funding_cross_median(self, asset: str) -> Decimal: ...
|
||||
```
|
||||
|
||||
### `cerbero-macro`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `get_calendar(days_ahead=18)` | Filtro eventi macro pre-entry |
|
||||
|
||||
Eventi rilevanti (filtra per `severity = high` e `country in {US, EU}`):
|
||||
FOMC, FED minutes, CPI, NFP, ECB, GDP, Powell speech, Lagarde speech.
|
||||
|
||||
```python
|
||||
class MacroClient(McpClient):
|
||||
async def upcoming_events(self, days_ahead: int) -> list[MacroEvent]: ...
|
||||
async def first_high_severity_within(self, days: int) -> int | None:
|
||||
"""Days until first high-severity event, None if none in window."""
|
||||
```
|
||||
|
||||
### `cerbero-portfolio`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `get_holdings()` | Capitale corrente complessivo |
|
||||
| `get_holdings_by_asset()` | Filtro entry §2.7 (ETH < 30% portfolio) |
|
||||
| `get_correlation()` | Sanity check, non bloccante |
|
||||
|
||||
```python
|
||||
class PortfolioClient(McpClient):
|
||||
async def total_equity_usd(self) -> Decimal: ...
|
||||
async def asset_pct(self, asset: str) -> Decimal: ...
|
||||
```
|
||||
|
||||
### `cerbero-memory`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `push_user_instruction(payload, source="cerbero-bite")` | Invio istruzione apertura/chiusura a Cerbero core |
|
||||
| `get_pending(source="cerbero-bite")` | Verifica ack di Cerbero core |
|
||||
|
||||
```python
|
||||
class MemoryClient(McpClient):
|
||||
async def push_instruction(self, instruction: CerberoInstruction) -> str:
|
||||
"""Returns instruction_id."""
|
||||
async def is_acknowledged(self, instruction_id: str) -> bool: ...
|
||||
```
|
||||
|
||||
**Payload `CerberoInstruction`** (schema condiviso con Cerbero core,
|
||||
documentato in `Cerbero/prompt.base v4`):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "cerbero-bite",
|
||||
"kind": "open_combo" | "close_combo",
|
||||
"exchange": "deribit",
|
||||
"asset": "ETH",
|
||||
"proposal_id": "uuid-...",
|
||||
"legs": [
|
||||
{"instrument": "ETH-13MAY26-1900-P", "side": "SELL", "size": 2,
|
||||
"limit_price_eth": "0.0048"},
|
||||
{"instrument": "ETH-13MAY26-1810-P", "side": "BUY", "size": 2,
|
||||
"limit_price_eth": "0.0021"}
|
||||
],
|
||||
"limit_combo_eth": "0.0027",
|
||||
"tif": "GTC",
|
||||
"expires_at": "2026-04-27T16:00:00Z",
|
||||
"max_slippage_eth": "0.0005",
|
||||
"reason": "weekly_open" | "profit_take" | "stop_loss" | "vol_stop" |
|
||||
"time_stop" | "delta_breach" | "adverse_move",
|
||||
"milestone": "advisory_only" | "approved_by_user"
|
||||
}
|
||||
```
|
||||
|
||||
Cerbero core deduplica per `proposal_id` (idempotenza in caso di retry).
|
||||
|
||||
### `cerbero-telegram`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `send_message(text, parse_mode="MarkdownV2")` | Report pre/post trade, alert |
|
||||
| `send_with_buttons(text, buttons)` | Conferma ad Adriano (yes/no) |
|
||||
|
||||
Le conferme devono ritornare entro 60 minuti (entry) o 30 minuti (exit).
|
||||
Implementazione: l'engine si mette in `await` su una coda interna
|
||||
alimentata dal callback Telegram via webhook locale.
|
||||
|
||||
```python
|
||||
class TelegramClient(McpClient):
|
||||
async def send(self, text: str, parse_mode: str = "MarkdownV2") -> int: ...
|
||||
async def request_confirmation(self, text: str, timeout_s: int) -> bool: ...
|
||||
```
|
||||
|
||||
### `cerbero-brain-bridge`
|
||||
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `kb_search(query)` | Lookup pre-trade su pattern simili (consultivo) |
|
||||
| `kb_read(path)` | Lettura nota wiki specifica |
|
||||
| `kb_write(path, content)` | Salvataggio learning post-trade |
|
||||
|
||||
**Importante:** il brain-bridge non partecipa al decision loop. Le
|
||||
chiamate `kb_search` sono **consultive** e i risultati allegati al
|
||||
report di Adriano per contesto, mai consumati come input ai filtri
|
||||
deterministici.
|
||||
|
||||
```python
|
||||
class BrainBridgeClient(McpClient):
|
||||
async def search(self, query: str, limit: int = 5) -> list[KbHit]: ...
|
||||
async def write_note(self, path: str, content: str) -> None: ...
|
||||
```
|
||||
|
||||
### `cerbero-scheduler`
|
||||
|
||||
**Non usato** dal decision loop. Cerbero Bite ha il proprio scheduler
|
||||
APScheduler interno. Il MCP scheduler resta a disposizione del core
|
||||
Cerbero per altre routine.
|
||||
|
||||
## Errori e degradation
|
||||
|
||||
| Server down | Comportamento |
|
||||
|---|---|
|
||||
| `cerbero-deribit` | Skip ciclo entry; per monitor → alert e marca posizione come `unknown_state` (non chiude alla cieca) |
|
||||
| `cerbero-hyperliquid` | Skip filtro funding §2.6 con warning; entry può proseguire se altre condizioni soddisfatte |
|
||||
| `cerbero-sentiment` | Bias §3.1 cade in `no_entry` per default (no funding cross → niente direzione) |
|
||||
| `cerbero-macro` | **Hard fail**: senza calendar non si apre. È un filtro irrinunciabile |
|
||||
| `cerbero-portfolio` | Skip filtro §2.7 con warning; sizing usa ultimo capitale noto da SQLite con warning |
|
||||
| `cerbero-memory` | Hard fail per esecuzione: senza push_user_instruction non si può aprire/chiudere |
|
||||
| `cerbero-telegram` | Skip ciclo: senza canale di conferma niente proposta |
|
||||
| `cerbero-brain-bridge` | Skip lookup, log warning. Mai bloccante |
|
||||
|
||||
Ogni "hard fail" → alert sonoro su Telegram via canale di backup
|
||||
(BotPapà), kill switch armato fino al ripristino.
|
||||
|
||||
## Versioning
|
||||
|
||||
Cerbero Bite verifica all'avvio la versione di ciascun MCP via
|
||||
`get_version()` (tool standard). Schema di versioning attesa:
|
||||
|
||||
```python
|
||||
EXPECTED_MCP_VERSIONS = {
|
||||
"cerbero-deribit": "^2.0.0",
|
||||
"cerbero-hyperliquid": "^1.5.0",
|
||||
"cerbero-memory": "^4.0.0",
|
||||
"cerbero-portfolio": "^1.2.0",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Mismatch → kill switch e alert manuale. Mai partire con MCP a versione
|
||||
incompatibile.
|
||||
@@ -0,0 +1,241 @@
|
||||
# 05 — Data Model
|
||||
|
||||
Persistenza locale su SQLite (`data/state.sqlite`) + log append-only su
|
||||
file `.jsonl`. Niente database remoti, niente broker esterni.
|
||||
|
||||
## Filosofia
|
||||
|
||||
- **Lo stato è la verità per ciò che è in volo.** Posizioni aperte,
|
||||
proposte in attesa di conferma, ack di Cerbero core.
|
||||
- **Il log è la verità per ciò che è successo.** Ogni evento (entry,
|
||||
exit, errore, decisione di hold) viene scritto in modo immutabile
|
||||
prima di toccare lo stato.
|
||||
- Lo stato si può sempre **ricostruire dal log** in caso di corruzione.
|
||||
|
||||
## Schema SQLite
|
||||
|
||||
Migrazione gestita con file SQL semplici sotto `state/migrations/0001_init.sql`,
|
||||
applicati in sequenza. No ORM heavy: si usa `sqlalchemy.core` o
|
||||
addirittura `sqlite3` nativo con piccole utility.
|
||||
|
||||
### `positions`
|
||||
|
||||
Posizione Cerberus Bite (aperta, in chiusura, chiusa).
|
||||
|
||||
```sql
|
||||
CREATE TABLE positions (
|
||||
proposal_id TEXT PRIMARY KEY, -- UUID v4
|
||||
spread_type TEXT NOT NULL, -- bull_put, bear_call, iron_condor
|
||||
asset TEXT NOT NULL DEFAULT 'ETH',
|
||||
expiry TEXT NOT NULL, -- ISO8601 UTC
|
||||
short_strike NUMERIC NOT NULL,
|
||||
long_strike NUMERIC NOT NULL,
|
||||
short_instrument TEXT NOT NULL,
|
||||
long_instrument TEXT NOT NULL,
|
||||
n_contracts INTEGER NOT NULL,
|
||||
spread_width_usd NUMERIC NOT NULL,
|
||||
spread_width_pct NUMERIC NOT NULL,
|
||||
credit_eth NUMERIC NOT NULL, -- ricevuto effettivo (post fill)
|
||||
credit_usd NUMERIC NOT NULL,
|
||||
max_loss_usd NUMERIC NOT NULL,
|
||||
spot_at_entry NUMERIC NOT NULL,
|
||||
dvol_at_entry NUMERIC NOT NULL,
|
||||
delta_at_entry NUMERIC NOT NULL,
|
||||
eth_price_at_entry NUMERIC NOT NULL,
|
||||
proposed_at TEXT NOT NULL,
|
||||
opened_at TEXT, -- NULL se non ancora confermato fill
|
||||
closed_at TEXT, -- NULL se aperta
|
||||
close_reason TEXT, -- ENUM exit action
|
||||
debit_paid_eth NUMERIC, -- pagato a chiusura
|
||||
pnl_eth NUMERIC,
|
||||
pnl_usd NUMERIC,
|
||||
status TEXT NOT NULL, -- proposed, awaiting_fill, open, closing, closed, cancelled
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_positions_status ON positions(status);
|
||||
CREATE INDEX idx_positions_closed_at ON positions(closed_at);
|
||||
```
|
||||
|
||||
Stati validi:
|
||||
|
||||
```
|
||||
proposed → proposta inviata a Adriano, in attesa di conferma
|
||||
awaiting_fill → istruzione push_user_instruction inviata, in attesa fill
|
||||
open → fill confermato, monitoring attivo
|
||||
closing → istruzione close inviata, in attesa fill
|
||||
closed → close fill confermato, P&L calcolato
|
||||
cancelled → proposta rifiutata da Adriano o no fill
|
||||
```
|
||||
|
||||
### `instructions`
|
||||
|
||||
Tracciamento di ogni `push_user_instruction` inviata a Cerbero core.
|
||||
|
||||
```sql
|
||||
CREATE TABLE instructions (
|
||||
instruction_id TEXT PRIMARY KEY, -- UUID dato a Cerbero core
|
||||
proposal_id TEXT NOT NULL REFERENCES positions(proposal_id),
|
||||
kind TEXT NOT NULL, -- open_combo, close_combo
|
||||
payload_json TEXT NOT NULL, -- JSON dell'istruzione completa
|
||||
sent_at TEXT NOT NULL,
|
||||
acknowledged_at TEXT,
|
||||
filled_at TEXT,
|
||||
cancelled_at TEXT,
|
||||
actual_fill_eth NUMERIC, -- prezzo medio fill effettivo
|
||||
actual_fees_eth NUMERIC
|
||||
);
|
||||
|
||||
CREATE INDEX idx_instructions_proposal ON instructions(proposal_id);
|
||||
```
|
||||
|
||||
### `decisions`
|
||||
|
||||
Storia delle valutazioni del decision loop (anche quando l'esito è
|
||||
HOLD o no_entry). Serve per audit e analisi delle scelte.
|
||||
|
||||
```sql
|
||||
CREATE TABLE decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision_type TEXT NOT NULL, -- entry_check, exit_check, kelly_recalib
|
||||
proposal_id TEXT, -- NULL per entry_check senza proposta
|
||||
timestamp TEXT NOT NULL,
|
||||
inputs_json TEXT NOT NULL, -- snapshot input al modulo core
|
||||
outputs_json TEXT NOT NULL, -- output del modulo core
|
||||
action_taken TEXT, -- HOLD, no_entry, propose_open, propose_close
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_decisions_timestamp ON decisions(timestamp);
|
||||
CREATE INDEX idx_decisions_proposal ON decisions(proposal_id);
|
||||
```
|
||||
|
||||
### `dvol_history`
|
||||
|
||||
Snapshot DVOL ad ogni evaluation (utile per analisi di lungo periodo).
|
||||
|
||||
```sql
|
||||
CREATE TABLE dvol_history (
|
||||
timestamp TEXT PRIMARY KEY,
|
||||
dvol NUMERIC NOT NULL,
|
||||
eth_spot NUMERIC NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### `manual_actions`
|
||||
|
||||
Coda di azioni manuali generate dalla GUI Streamlit (vedi `11-gui-streamlit.md`).
|
||||
L'engine consuma le righe non processate via job APScheduler ogni 30s.
|
||||
|
||||
```sql
|
||||
CREATE TABLE manual_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- approve_proposal, reject_proposal, force_close, arm_kill, disarm_kill
|
||||
proposal_id TEXT, -- NULL se l'azione non è legata a una proposta
|
||||
payload_json TEXT, -- JSON con motivo, conferma typed, ecc.
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT, -- NULL = ancora da processare
|
||||
consumed_by TEXT,
|
||||
result TEXT
|
||||
);
|
||||
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
|
||||
```
|
||||
|
||||
Le `manual_actions` non bypassano i risk control: il consumer applica
|
||||
gli stessi check di `safety.system_healthy()` prima di eseguire.
|
||||
|
||||
### `system_state`
|
||||
|
||||
Singleton di stato globale (kill switch, last health check).
|
||||
|
||||
```sql
|
||||
CREATE TABLE system_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
kill_switch INTEGER NOT NULL DEFAULT 0,
|
||||
kill_reason TEXT,
|
||||
kill_at TEXT,
|
||||
last_health_check TEXT NOT NULL,
|
||||
last_kelly_calib TEXT,
|
||||
config_version TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Log file
|
||||
|
||||
Sotto `data/log/` un file per giorno: `cerbero-bite-YYYY-MM-DD.jsonl`.
|
||||
Ogni linea è un evento JSON.
|
||||
|
||||
### Schema evento
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-04-27T14:00:01.234Z",
|
||||
"event": "ENTRY_PROPOSED",
|
||||
"level": "INFO",
|
||||
"module": "runtime.orchestrator",
|
||||
"proposal_id": "uuid-...",
|
||||
"data": { ... },
|
||||
"decision_id": 12345
|
||||
}
|
||||
```
|
||||
|
||||
### Eventi previsti
|
||||
|
||||
| Event | Quando |
|
||||
|---|---|
|
||||
| `ENGINE_START` | Avvio engine |
|
||||
| `ENGINE_STOP` | Stop pulito |
|
||||
| `HEALTH_OK` / `HEALTH_DEGRADED` | Health check periodico |
|
||||
| `MCP_CALL` | Ogni chiamata MCP (livello DEBUG) |
|
||||
| `MCP_FAIL` | Fallimento MCP (livello WARN/ERROR) |
|
||||
| `ENTRY_EVALUATED` | Ciclo settimanale completato |
|
||||
| `ENTRY_REJECTED` | Filtri non passati |
|
||||
| `ENTRY_PROPOSED` | Proposta inviata a Telegram |
|
||||
| `ENTRY_CONFIRMED` | Adriano conferma |
|
||||
| `ENTRY_REJECTED_BY_USER` | Adriano nega |
|
||||
| `ENTRY_TIMEOUT` | Adriano non risponde nei 60 min |
|
||||
| `INSTRUCTION_SENT` | push_user_instruction completato |
|
||||
| `INSTRUCTION_ACK` | Cerbero core ack |
|
||||
| `FILL_CONFIRMED` | Posizione effettivamente aperta |
|
||||
| `EXIT_EVALUATED` | Ciclo monitor completato (anche HOLD) |
|
||||
| `EXIT_PROPOSED` | Proposta chiusura |
|
||||
| `EXIT_CONFIRMED` | Adriano conferma chiusura |
|
||||
| `EXIT_FILLED` | Fill chiusura confermato |
|
||||
| `KILL_SWITCH_ARMED` | Disarm manuale richiesto |
|
||||
| `KILL_SWITCH_DISARMED` | Manuale via CLI |
|
||||
| `KELLY_RECALIBRATED` | Output report mensile |
|
||||
|
||||
### Rotation e ritenzione
|
||||
|
||||
- File giornaliero, gzip dopo 30 giorni.
|
||||
- Ritenzione 365 giorni; oltre, archiviazione manuale.
|
||||
- Mai cancellati i log relativi a posizioni con `pnl != 0`: archivio
|
||||
permanente per fini fiscali.
|
||||
|
||||
## Stato in memoria
|
||||
|
||||
L'engine mantiene una cache delle posizioni aperte in RAM, ricaricata
|
||||
da SQLite all'avvio e refreshed dopo ogni transizione di stato.
|
||||
Ogni mutazione segue il pattern:
|
||||
|
||||
1. Append evento al log file (fsync).
|
||||
2. Begin transaction SQLite.
|
||||
3. Update tabelle.
|
||||
4. Commit.
|
||||
5. Update cache RAM.
|
||||
|
||||
Se il sistema crasha tra (1) e (5), al restart un riconciliatore confronta
|
||||
log vs SQLite e ripristina la consistenza.
|
||||
|
||||
## Migrations
|
||||
|
||||
Lo schema viene tracciato con un counter `pragma user_version`. La
|
||||
prima volta `0001_init.sql` viene applicato e versione → 1. Aggiunte
|
||||
future incrementano. Nessun rollback supportato (migrations forward-only).
|
||||
|
||||
## Backup
|
||||
|
||||
Backup orari di `state.sqlite` in `data/backups/state-YYYYMMDD-HH.sqlite`,
|
||||
ritenuti per 30 giorni. Operazione idempotente con SQLite `VACUUM INTO`.
|
||||
@@ -0,0 +1,216 @@
|
||||
# 06 — Flussi operativi
|
||||
|
||||
Tre flussi principali, tutti orchestrati dallo scheduler interno
|
||||
APScheduler.
|
||||
|
||||
## Flusso 1 — Avvio engine
|
||||
|
||||
```
|
||||
1. Carica strategy.yaml + strategy.local.yaml (override)
|
||||
2. Validazione schema (pydantic) — fail veloce
|
||||
3. Acquisisci lock file (data/.lockfile)
|
||||
4. Apri SQLite, esegui migrations
|
||||
5. Health check MCP server (versioni + ping)
|
||||
6. Riconciliatore stato:
|
||||
a. legge positions con status in {awaiting_fill, open, closing}
|
||||
b. per ognuna chiama cerbero-memory.is_acknowledged + Deribit get_positions
|
||||
c. allinea SQLite alla realtà del broker (sempre la realtà vince)
|
||||
d. log delle discrepanze come WARNING
|
||||
7. Health check sistema OK? → arma scheduler
|
||||
KO? → kill switch + alert + idle
|
||||
8. Log ENGINE_START
|
||||
```
|
||||
|
||||
L'avvio è progettato per essere **safe**: se qualcosa non torna, il
|
||||
sistema si rifiuta di operare. Mai partire con uno stato dubbio.
|
||||
|
||||
## Flusso 2 — Settimanale (entry)
|
||||
|
||||
Trigger: cron `0 14 * * MON` (lunedì 14:00 UTC).
|
||||
|
||||
```
|
||||
START
|
||||
├── safety.system_healthy()? → no → log + skip
|
||||
├── state.has_open_position()? → yes → log + skip
|
||||
├── snapshot dati di mercato (parallel):
|
||||
│ spot, dvol, funding_perp, funding_cross, macro_calendar,
|
||||
│ holdings_pct, capital
|
||||
├── entry_validator.validate_entry → fail → log ENTRY_REJECTED + reasons
|
||||
├── entry_validator.compute_bias → None → log ENTRY_REJECTED ("no_bias")
|
||||
├── deribit.get_options_chain (DTE 14-21)
|
||||
├── combo_builder.select_strikes → None → log ENTRY_REJECTED ("no_strike")
|
||||
├── deribit.get_orderbook per le 2 gambe
|
||||
├── liquidity_gate.check → fail → log ENTRY_REJECTED ("illiquid")
|
||||
├── sizing_engine.compute_contracts → 0 → log ENTRY_REJECTED ("undersize")
|
||||
├── combo_builder.build → ComboProposal
|
||||
├── greeks_aggregator.aggregate
|
||||
├── reporting.pre_trade(proposal) → testo Telegram
|
||||
├── telegram.request_confirmation(timeout=60min)
|
||||
│ ├── confermato:
|
||||
│ │ ├── state.create_position (status="proposed")
|
||||
│ │ ├── memory.push_user_instruction
|
||||
│ │ ├── state.update(status="awaiting_fill")
|
||||
│ │ └── log ENTRY_CONFIRMED
|
||||
│ ├── rifiutato:
|
||||
│ │ └── log ENTRY_REJECTED_BY_USER
|
||||
│ └── timeout:
|
||||
│ └── log ENTRY_TIMEOUT
|
||||
└── END
|
||||
```
|
||||
|
||||
### Tempo previsto end-to-end
|
||||
|
||||
- Snapshot dati: 3-5 secondi
|
||||
- Algoritmi puri: < 0.5 secondi
|
||||
- Conferma utente: variabile (max 60 min)
|
||||
|
||||
Il critical path automatico è **sotto 10 secondi**. La latenza umana
|
||||
non rallenta i prezzi: se Adriano risponde dopo 30 minuti, l'engine
|
||||
prima di inviare l'istruzione **rivaluta** spot, mid e dvol e abortisce
|
||||
l'apertura se le condizioni sono cambiate (slippage > 8% o dvol fuori
|
||||
banda o macro nuova).
|
||||
|
||||
## Flusso 3 — Monitoring (12h)
|
||||
|
||||
Trigger: cron `0 2,14 * * *` (02:00 e 14:00 UTC, ogni giorno).
|
||||
|
||||
```
|
||||
START
|
||||
├── safety.system_healthy()? → no → kill switch (no auto-close ciechi)
|
||||
├── per ogni position con status="open":
|
||||
│ ├── snapshot:
|
||||
│ │ spot, dvol, mark_combo, delta_short, return_4h
|
||||
│ ├── exit_decision.evaluate
|
||||
│ ├── action == HOLD:
|
||||
│ │ └── log EXIT_EVALUATED("hold")
|
||||
│ ├── action == CLOSE_*:
|
||||
│ │ ├── reporting.exit_proposal(position, decision)
|
||||
│ │ ├── telegram.request_confirmation(timeout=30min)
|
||||
│ │ │ ├── confermato:
|
||||
│ │ │ │ ├── memory.push_user_instruction (close)
|
||||
│ │ │ │ ├── state.update(status="closing")
|
||||
│ │ │ │ └── log EXIT_CONFIRMED
|
||||
│ │ │ ├── rifiutato:
|
||||
│ │ │ │ └── log EXIT_DEFERRED (resterà in HOLD fino a prossimo ciclo)
|
||||
│ │ │ └── timeout:
|
||||
│ │ │ └── escalation: per CLOSE_STOP/CLOSE_VOL/CLOSE_DELTA
|
||||
│ │ │ → invia comunque l'istruzione (urgenza prevale)
|
||||
│ │ │ per altri motivi → log EXIT_TIMEOUT, attende prossimo ciclo
|
||||
│ └── continue
|
||||
└── END
|
||||
```
|
||||
|
||||
### Politica di escalation
|
||||
|
||||
I trigger di uscita non sono uguali. Alcuni hanno **urgenza alta** che
|
||||
giustifica l'esecuzione anche senza conferma utente entro la finestra:
|
||||
|
||||
| Trigger | Urgenza | Comportamento su timeout |
|
||||
|---|---|---|
|
||||
| `CLOSE_PROFIT` | Bassa | Attendi prossimo ciclo (può migliorare) |
|
||||
| `CLOSE_STOP` | **Alta** | Esegui chiusura senza conferma esplicita |
|
||||
| `CLOSE_VOL` | **Alta** | Esegui chiusura senza conferma |
|
||||
| `CLOSE_DELTA` | **Alta** | Esegui chiusura senza conferma |
|
||||
| `CLOSE_TIME` | Media | Attendi 1 ciclo extra; al secondo timeout esegui |
|
||||
| `CLOSE_AVERSE` | Media | Attendi prossimo ciclo |
|
||||
|
||||
Su escalation hard, Telegram riceve un messaggio **post-fact**: "ho
|
||||
chiuso la posizione X per stop loss perché non ho ricevuto risposta in
|
||||
30 min e l'urgenza non consentiva attesa".
|
||||
|
||||
## Flusso 4 — Mensile (Kelly recalibration)
|
||||
|
||||
Trigger: cron `0 12 1 * *` (primo di ogni mese alle 12:00 UTC).
|
||||
|
||||
```
|
||||
1. Carica trades chiusi negli ultimi 365 giorni
|
||||
2. kelly_recalibration.recalibrate
|
||||
3. Genera report mensile:
|
||||
- performance vs simulazione MC
|
||||
- win rate, avg win/loss, edge
|
||||
- kelly suggerito vs corrente
|
||||
- violazioni regole (deve essere 0)
|
||||
4. telegram.send (informativo, no conferma)
|
||||
5. brain_bridge.write_note(
|
||||
path="strategies/cerberus-bite/monthly-report-YYYY-MM.md",
|
||||
content=report
|
||||
)
|
||||
6. log KELLY_RECALIBRATED
|
||||
```
|
||||
|
||||
L'aggiornamento di `strategy.yaml` resta **manuale**: Adriano legge il
|
||||
report, discute con Milito, e committa il nuovo file di config (con
|
||||
giustificazione nel commit message). Nessun auto-update.
|
||||
|
||||
## Flusso 5 — Health check periodico
|
||||
|
||||
Trigger: ogni 5 minuti.
|
||||
|
||||
```
|
||||
1. ping ogni MCP usato → ms latency
|
||||
2. SQLite read-write probe (transazione fittizia)
|
||||
3. lock file ancora valido
|
||||
4. ultima fill di Cerbero core ricevuta entro tempo atteso
|
||||
5. state.system_state.kill_switch == 0
|
||||
Se qualsiasi passo fallisce:
|
||||
- log HEALTH_DEGRADED
|
||||
- se 3 fallimenti consecutivi → kill_switch + alert
|
||||
- se 5 fallimenti consecutivi → restart automatico (1 sola volta al giorno)
|
||||
```
|
||||
|
||||
## Flusso 6 — Recovery dopo crash
|
||||
|
||||
Quando l'engine riparte:
|
||||
|
||||
1. Riapre SQLite + log della giornata.
|
||||
2. Per ogni posizione `awaiting_fill`:
|
||||
- Chiede a `cerbero-memory.get_status(instruction_id)`
|
||||
- Se filled → aggiorna a `open`
|
||||
- Se cancelled → aggiorna a `cancelled`
|
||||
- Se ancora pending → mantiene stato e riprende il monitoraggio
|
||||
3. Per ogni posizione `closing`:
|
||||
- Stessa logica
|
||||
4. Per ogni posizione `open`:
|
||||
- Verifica che esista realmente sul broker (Deribit `get_positions`)
|
||||
- Se non esiste → flag `state_inconsistent` + alert + kill switch
|
||||
5. Riconciliazione completata → ENGINE_START.
|
||||
|
||||
Il sistema **mai** prende decisioni di trading durante recovery: solo
|
||||
allinea lo stato. Il primo decision loop avviene al prossimo trigger
|
||||
naturale.
|
||||
|
||||
## Diagramma di stato di una posizione
|
||||
|
||||
```
|
||||
┌─ rejected_by_user ──→ cancelled
|
||||
│
|
||||
(entry) ├─ timeout ──────────→ cancelled
|
||||
proposed ─────────────► │
|
||||
├─ confirmed
|
||||
▼
|
||||
awaiting_fill
|
||||
│
|
||||
├─ no_fill ──────────→ cancelled
|
||||
├─ filled ───────────► open
|
||||
│ │
|
||||
│ (exit_decision != HOLD)
|
||||
│ │
|
||||
│ ▼
|
||||
│ closing
|
||||
│ │
|
||||
│ ├─ no_fill (escalated) → manual_intervention
|
||||
│ └─ filled ──────────→ closed
|
||||
```
|
||||
|
||||
## Cron summary
|
||||
|
||||
| Cron | Trigger | Frequenza |
|
||||
|---|---|---|
|
||||
| `0 14 * * MON` | Entry evaluation | Settimanale |
|
||||
| `0 2,14 * * *` | Position monitoring | 2× giorno |
|
||||
| `0 12 1 * *` | Kelly recalibration | Mensile |
|
||||
| `*/5 * * * *` | Health check | 5 min |
|
||||
| `0 0 * * *` | Backup SQLite + rotation log | Giornaliero |
|
||||
| `0 8 * * *` | Daily digest Telegram | Giornaliero |
|
||||
|
||||
Tutti gli orari in UTC.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 07 — Risk Controls
|
||||
|
||||
Controlli di sicurezza trasversali che non sono parte della strategia
|
||||
ma proteggono il sistema da bug, dati corrotti, fallimenti
|
||||
infrastrutturali o decisioni umane fuori posto.
|
||||
|
||||
## Filosofia
|
||||
|
||||
- **Default deny**: in caso di dubbio, il sistema non fa nulla.
|
||||
- **Disarm manuale**: ogni kill switch viene disarmato esplicitamente
|
||||
da Adriano via CLI o comando Telegram, mai automaticamente.
|
||||
- **Visibilità**: ogni evento di sicurezza viene loggato e notificato
|
||||
immediatamente.
|
||||
- **No silent close**: una posizione viene chiusa solo a seguito di
|
||||
decisione esplicita dei trigger di strategia, mai per "evento
|
||||
sospetto".
|
||||
|
||||
## Kill switch
|
||||
|
||||
### Stato
|
||||
|
||||
`system_state.kill_switch ∈ {0, 1}`. Quando = 1, l'engine:
|
||||
|
||||
- continua i flussi di **sola lettura** (health check, monitoring di
|
||||
stato, log)
|
||||
- **non** invia istruzioni di apertura
|
||||
- **non** invia istruzioni di chiusura **automatiche** (richiede
|
||||
intervento manuale via CLI con flag `--force-close-position`)
|
||||
- continua a notificare i trigger di uscita ad Adriano via Telegram
|
||||
con escalation manuale
|
||||
|
||||
### Trigger automatici
|
||||
|
||||
| Causa | Auto-arm | Note |
|
||||
|---|---|---|
|
||||
| MCP `cerbero-deribit` versione mismatch | Sì | Non procede senza schema atteso |
|
||||
| MCP `cerbero-macro` non risponde per >30 min | Sì | No entry possibile |
|
||||
| MCP `cerbero-memory` non risponde per >5 min | Sì | No esecuzione possibile |
|
||||
| Stato SQLite incoerente con broker | Sì | Diff tra DB e Deribit positions |
|
||||
| 3 health check consecutivi falliti | Sì | Engine instabile |
|
||||
| Perdita giornaliera > 3% equity | Sì | Hard prohibition Cerbero v4 |
|
||||
| Numero posizioni concorrenti > cap | Sì | Bug del sizing engine |
|
||||
| Push instruction respinta da Cerbero core | Sì | Dopo 2 retry |
|
||||
| Schema config invalido al reload | Sì | Mai partire con config rotta |
|
||||
| Comando Adriano `/kill` su Telegram | Sì | Trigger volontario |
|
||||
|
||||
### Disarm
|
||||
|
||||
```bash
|
||||
cerbero-bite kill-switch --disarm --reason "<motivo>"
|
||||
```
|
||||
|
||||
oppure via Telegram:
|
||||
|
||||
```
|
||||
/disarm <motivo>
|
||||
```
|
||||
|
||||
In entrambi i casi il sistema chiede una conferma esplicita ("yes I am
|
||||
sure").
|
||||
|
||||
## Cap di rischio (oltre alle regole di strategia)
|
||||
|
||||
Questi cap sono ridondanti rispetto a quelli del §5 della strategia,
|
||||
ma sono applicati in modo difensivo come **ultima linea**:
|
||||
|
||||
| Misura | Limite | Comportamento se superato |
|
||||
|---|---|---|
|
||||
| Notional combo singolo | 200 EUR | Sizing engine reject |
|
||||
| Engagement totale aperto | 1.000 EUR | Sizing engine reject |
|
||||
| Posizioni concorrenti CB | 4 (default 1 per strategia) | Reject pre-push |
|
||||
| Trade aperti per giorno | 6 (intera Cerbero suite) | Lookup `cerbero-memory.daily_trade_count` |
|
||||
| Perdita giornaliera | 3% equity | Kill switch |
|
||||
| Distance short strike | < 15% spot OTM | Combo builder reject |
|
||||
| Credit / width | < 0.30 | Combo builder reject |
|
||||
|
||||
Doppia verifica: il sizing_engine applica il cap, **e** il watchdog
|
||||
runtime ricontrolla il payload prima di chiamare `push_user_instruction`.
|
||||
Se le due misure divergono → kill switch + alert.
|
||||
|
||||
## Dead-man switch
|
||||
|
||||
Se l'engine non scrive un evento `HEALTH_OK` per **15 minuti** consecutivi:
|
||||
|
||||
1. Un processo separato `data/dead-man.sh` (cron in user crontab,
|
||||
indipendente dall'engine) rileva il silenzio.
|
||||
2. Invia alert su canale Telegram di backup.
|
||||
3. Marca SQLite con `system_state.kill_switch=1`.
|
||||
4. Adriano interviene manualmente.
|
||||
|
||||
Il dead-man è scritto in shell minimale (no dipendenze Python) per
|
||||
sopravvivere a corruzioni dell'env Python.
|
||||
|
||||
## Audit log immutabile
|
||||
|
||||
Oltre al log JSONL standard, ogni decisione di trading produce una
|
||||
linea append-only in `data/audit.log` con il **digest SHA-256** della
|
||||
linea precedente (chain di hash, stile blockchain semplificato).
|
||||
|
||||
Esempio:
|
||||
|
||||
```
|
||||
2026-04-27T14:00:01Z|ENTRY_PROPOSED|{...full payload...}|prev_hash=abc123...|hash=def456...
|
||||
```
|
||||
|
||||
Verificabile retroattivamente con `cerbero-bite audit verify`. Una
|
||||
discrepanza dell'hash chain è trattata come tampering e arma il kill
|
||||
switch.
|
||||
|
||||
## Dry-run mode
|
||||
|
||||
Tutti i flussi possono essere eseguiti in modalità `--dry-run`:
|
||||
|
||||
- Tutti i tool MCP **read-only** vengono chiamati normalmente.
|
||||
- Tool MCP **write** (`push_user_instruction`, `kb_write`, `send`) vengono
|
||||
loggati ma **non** chiamati.
|
||||
- `state.create_position` viene scritto in tabella `dry_positions`
|
||||
(schema identico a `positions` ma separata).
|
||||
|
||||
Usato per:
|
||||
- Testing in produzione prima di andare live.
|
||||
- Replay di giornate storiche per validazione.
|
||||
- Test di nuove versioni di config o algoritmi.
|
||||
|
||||
## Versionamento config
|
||||
|
||||
Ogni `strategy.yaml` ha:
|
||||
|
||||
```yaml
|
||||
config_version: "1.0.0"
|
||||
config_hash: "<sha256 del file senza questa riga>"
|
||||
last_review: "2026-04-26"
|
||||
last_reviewer: "Adriano"
|
||||
```
|
||||
|
||||
All'avvio l'engine verifica che `config_hash` corrisponda al contenuto.
|
||||
Mismatch → kill switch (qualcuno ha modificato la config senza
|
||||
aggiornare l'hash, possibile tampering o errore umano).
|
||||
|
||||
## Escalation tree
|
||||
|
||||
```
|
||||
Evento anomalo
|
||||
│
|
||||
├── Severity LOW (es. 1 health check fallito)
|
||||
│ └── Log WARNING, continua
|
||||
│
|
||||
├── Severity MEDIUM (es. MCP timeout occasionale)
|
||||
│ ├── Log WARNING + Telegram digest giornaliero
|
||||
│ └── Continua, retry next cycle
|
||||
│
|
||||
├── Severity HIGH (es. 3 health check consecutivi falliti)
|
||||
│ ├── Kill switch ARM
|
||||
│ ├── Telegram alert immediato
|
||||
│ └── Adriano interviene
|
||||
│
|
||||
└── Severity CRITICAL (es. stato incoerente, hash chain rotto)
|
||||
├── Kill switch ARM
|
||||
├── Telegram + canale backup BotPapà
|
||||
├── Engine si mette in idle (no decisioni, solo monitoring)
|
||||
└── Richiede intervento umano per disarmo
|
||||
```
|
||||
|
||||
## Test di resilienza obbligatori
|
||||
|
||||
Prima del go-live e ad ogni release minor:
|
||||
|
||||
1. **Chaos test MCP**: simula timeout/errori su ogni MCP, verifica
|
||||
che il comportamento documentato in `04-mcp-integration.md` sia
|
||||
rispettato.
|
||||
2. **State corruption test**: corrompi una riga `positions` e verifica
|
||||
che il riconciliatore lo rilevi.
|
||||
3. **Hash chain test**: modifica una linea audit e verifica che
|
||||
`audit verify` fallisca.
|
||||
4. **Replay test**: rigioca una giornata storica in dry-run, confronta
|
||||
le decisioni con un set golden.
|
||||
5. **Cap saturation test**: simula 4 posizioni concorrenti, verifica
|
||||
che il quinto trade venga rifiutato.
|
||||
|
||||
I risultati sono documentati in `tests/golden/results-YYYY-MM-DD.md`.
|
||||
|
||||
## Cosa NON è un risk control
|
||||
|
||||
Per chiarezza, queste cose **non** sono cap né kill switch — sono
|
||||
parte della strategia, gestite altrove:
|
||||
|
||||
- Profit take 50%: regola di strategia.
|
||||
- Stop loss 1.5×: regola di strategia.
|
||||
- Vol stop +10 DVOL: regola di strategia.
|
||||
- Time stop 7 DTE: regola di strategia.
|
||||
|
||||
I risk control proteggono il **sistema**. La strategia protegge il
|
||||
**capitale**. Sono livelli diversi.
|
||||
@@ -0,0 +1,268 @@
|
||||
# 08 — Testing & Validation
|
||||
|
||||
Approccio TDD imposto dalla skill `superpowers:test-driven-development`
|
||||
e coerente con `mcp_cerbero_brain`. Niente codice senza test che fallisce
|
||||
prima e passa dopo.
|
||||
|
||||
## Piramide dei test
|
||||
|
||||
```
|
||||
┌────────────────┐
|
||||
│ golden / e2e │ ~10 scenari, lenti, eseguiti pre-release
|
||||
└────────────────┘
|
||||
┌──────────────────────┐
|
||||
│ integration tests │ ~50, MCP fake, eseguiti su PR
|
||||
└──────────────────────┘
|
||||
┌──────────────────────────┐
|
||||
│ unit tests │ ~300, < 5 sec totali, ad ogni save
|
||||
└──────────────────────────┘
|
||||
┌────────────────────┐
|
||||
│ property tests │ hypothesis su algoritmi puri
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
## Unit tests (`tests/unit/`)
|
||||
|
||||
Coprono ogni funzione in `core/`. Sono **veloci** (< 5 sec totali),
|
||||
**deterministici** (no rete, no time, no random).
|
||||
|
||||
Convenzioni:
|
||||
|
||||
- Un file di test per modulo: `test_sizing_engine.py`, `test_exit_decision.py`, ecc.
|
||||
- Naming: `test_<funzione>_<scenario>_<aspettativa>`. Es:
|
||||
- `test_compute_contracts_capital_720_dvol_40_returns_one`
|
||||
- `test_evaluate_mark_at_50pct_returns_close_profit`
|
||||
- Fixture: dataclasses pre-costruite in `tests/fixtures/scenarios.py`.
|
||||
|
||||
### Coverage minima richiesta
|
||||
|
||||
| Modulo | Coverage |
|
||||
|---|---|
|
||||
| `core/*` | 100% statement + 100% branch |
|
||||
| `safety/*` | 100% statement |
|
||||
| `state/*` | ≥ 90% |
|
||||
| `clients/*` | ≥ 80% |
|
||||
| `runtime/*` | ≥ 80% |
|
||||
|
||||
Coverage misurata con `coverage.py`, soglia bloccante in CI.
|
||||
|
||||
### Esempi di test obbligatori
|
||||
|
||||
**`test_entry_validator.py`**:
|
||||
|
||||
```python
|
||||
def test_validate_entry_capital_below_minimum_returns_fail(default_cfg):
|
||||
ctx = EntryContext(capital_usd=Decimal("700"), dvol_now=Decimal("40"), ...)
|
||||
result = validate_entry(ctx, default_cfg)
|
||||
assert result.accepted is False
|
||||
assert "capital_below_720" in result.reasons
|
||||
|
||||
def test_validate_entry_dvol_too_high_returns_fail(default_cfg):
|
||||
ctx = EntryContext(capital_usd=Decimal("1500"), dvol_now=Decimal("95"), ...)
|
||||
result = validate_entry(ctx, default_cfg)
|
||||
assert "dvol_above_90" in result.reasons
|
||||
|
||||
def test_validate_entry_macro_event_inside_dte_returns_fail(default_cfg):
|
||||
ctx = EntryContext(..., next_macro_event_in_days=5)
|
||||
result = validate_entry(ctx, default_cfg)
|
||||
assert "macro_event_within_dte" in result.reasons
|
||||
|
||||
def test_validate_entry_all_conditions_met_returns_accepted(default_cfg):
|
||||
ctx = EntryContext(...)
|
||||
result = validate_entry(ctx, default_cfg)
|
||||
assert result.accepted is True
|
||||
assert result.reasons == []
|
||||
```
|
||||
|
||||
**`test_sizing_engine.py`**:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("capital,dvol,expected_n", [
|
||||
(720, 40, 1),
|
||||
(1500, 40, 2),
|
||||
(1500, 50, 1), # adj 0.85, 195*0.85/93 ≈ 1.78 → 1
|
||||
(5000, 40, 2), # cap 200 EUR ≈ 215 USD; 215/93 ≈ 2
|
||||
(100000, 40, 2), # cap saturo
|
||||
(500, 40, 0), # undersize
|
||||
])
|
||||
def test_compute_contracts(capital, dvol, expected_n, default_cfg):
|
||||
ctx = SizingContext(
|
||||
capital_usd=Decimal(capital),
|
||||
max_loss_per_contract_usd=Decimal("93"),
|
||||
dvol_now=Decimal(dvol),
|
||||
eur_to_usd=Decimal("1.075"),
|
||||
open_engagement_usd=Decimal(0),
|
||||
other_open_positions=0,
|
||||
)
|
||||
assert sizing_engine.compute_contracts(ctx, default_cfg).n_contracts == expected_n
|
||||
```
|
||||
|
||||
**`test_exit_decision.py`**: ogni branch dell'ordine di valutazione
|
||||
deve avere almeno un test.
|
||||
|
||||
## Property tests (`tests/unit/test_*_properties.py`)
|
||||
|
||||
Usiamo `hypothesis` per le invarianti:
|
||||
|
||||
```python
|
||||
@given(
|
||||
capital=decimals(min_value=720, max_value=200_000),
|
||||
dvol=decimals(min_value=20, max_value=89),
|
||||
max_loss=decimals(min_value=50, max_value=300),
|
||||
)
|
||||
def test_sizing_never_exceeds_cap_eur(capital, dvol, max_loss, default_cfg):
|
||||
"""Invariante: il rischio totale non eccede mai il cap EUR."""
|
||||
ctx = SizingContext(capital_usd=capital, dvol_now=dvol, ...)
|
||||
result = sizing_engine.compute_contracts(ctx, default_cfg)
|
||||
cap_usd = Decimal(200) * default_cfg.eur_to_usd
|
||||
assert result.risk_dollars <= cap_usd
|
||||
```
|
||||
|
||||
Property tests obbligatori:
|
||||
|
||||
- Sizing: rischio ≤ cap; n_contracts ≥ 0; mai > 4.
|
||||
- Exit decision: ordine dei trigger rispettato (CLOSE_PROFIT prima di CLOSE_DELTA, ecc.).
|
||||
- Combo builder: short_strike < long_strike per bear_call, > per bull_put.
|
||||
|
||||
## Integration tests (`tests/integration/`)
|
||||
|
||||
Testano l'interazione tra `core/` + `clients/` + `state/` con MCP
|
||||
**fake** (in-memory).
|
||||
|
||||
### Fake MCP
|
||||
|
||||
```python
|
||||
class FakeDeribit(McpClient):
|
||||
def __init__(self, scenario: dict): ...
|
||||
async def index_price(self, asset): return Decimal(self._scenario["spot"])
|
||||
async def dvol(self): return Decimal(self._scenario["dvol"])
|
||||
# ...
|
||||
```
|
||||
|
||||
I fake sono guidati da uno scenario YAML:
|
||||
|
||||
```yaml
|
||||
# tests/fixtures/scenarios/happy_path.yaml
|
||||
spot: 2330
|
||||
dvol: 42
|
||||
funding_perp: 0.05
|
||||
funding_cross: 0.04
|
||||
macro_calendar: []
|
||||
chain:
|
||||
- {instrument: ETH-13MAY26-1900-P, strike: 1900, delta: -0.12, mid: 0.0048, ...}
|
||||
- ...
|
||||
```
|
||||
|
||||
### Cosa coprono
|
||||
|
||||
| Test | Scenario |
|
||||
|---|---|
|
||||
| `test_weekly_open_happy_path` | Tutto OK → proposta inviata |
|
||||
| `test_weekly_open_no_strike_available` | Chain vuota nel range delta |
|
||||
| `test_weekly_open_macro_blocks` | FOMC entro 5 giorni |
|
||||
| `test_monitor_profit_take` | Mark = 50% credito → close_profit |
|
||||
| `test_monitor_vol_stop` | DVOL +12 → close_vol |
|
||||
| `test_recovery_after_crash_open_position` | Crash mid-fill, restart, riconcilia |
|
||||
| `test_kill_switch_blocks_new_entries` | Kill switch armed → no proposta |
|
||||
| `test_user_rejection_logs_and_skips` | Adriano dice no → cancelled |
|
||||
| `test_user_timeout_with_revaluation` | Adriano risponde dopo 30 min, slippage > 8% → abort |
|
||||
|
||||
## Golden tests (`tests/golden/`)
|
||||
|
||||
Replay di scenari deterministici end-to-end con tutti gli MCP fake.
|
||||
Output (decisioni, log) confrontato byte-per-byte con un golden file
|
||||
checked-in.
|
||||
|
||||
```
|
||||
tests/golden/
|
||||
├── 2026-04-27_weekly_open_bull_put.yaml # input snapshot
|
||||
├── 2026-04-27_weekly_open_bull_put.golden # output atteso
|
||||
└── runner.py
|
||||
```
|
||||
|
||||
Modifica intenzionale di un algoritmo richiede aggiornamento del golden,
|
||||
con commit message che spiega perché.
|
||||
|
||||
## Backtest deterministico (replay storico)
|
||||
|
||||
Un comando dedicato:
|
||||
|
||||
```bash
|
||||
cerbero-bite replay --from 2024-01-01 --to 2026-04-25 --capital 1500 --dry-run
|
||||
```
|
||||
|
||||
Carica:
|
||||
- Storia oraria spot ETH (CSV)
|
||||
- Storia DVOL (CSV)
|
||||
- Calendar macro storico (CSV)
|
||||
- Chain opzioni storiche (Deribit API archive, dove possibile)
|
||||
|
||||
Itera giorno per giorno applicando esattamente le stesse regole. Output:
|
||||
|
||||
- File CSV con tutte le posizioni, P&L, trigger di uscita
|
||||
- Plot equity curve
|
||||
- Confronto con simulazione Monte Carlo del documento
|
||||
|
||||
Il replay è **non sostituto** del Monte Carlo ma utile per validare
|
||||
l'engine su dati reali una volta avuti.
|
||||
|
||||
## Paper trading (fase di go-live)
|
||||
|
||||
Pre-live di **3 mesi minimo**:
|
||||
|
||||
- Engine in `--dry-run` ma con Telegram alert reali (Adriano riceve i
|
||||
segnali ma non li esegue)
|
||||
- Adriano replica manualmente su Deribit testnet alcuni trade per
|
||||
toccare con mano
|
||||
- Confronto giornaliero tra paper P&L e replica reale per misurare
|
||||
slippage realistico
|
||||
|
||||
Soglie di go-live:
|
||||
|
||||
- ≥ 30 trade in paper completati con esito coerente con Monte Carlo
|
||||
- 0 incidenti operativi (timeout, stato incoerente, hash chain rotto)
|
||||
- Win rate paper ≥ 70%
|
||||
- Adriano firma esplicitamente l'autorizzazione su Telegram (logga in audit chain)
|
||||
|
||||
## Validazione live (fase iniziale)
|
||||
|
||||
Primi 30 giorni live:
|
||||
|
||||
- Cap dimezzato: 100 EUR per trade, 500 EUR engagement totale
|
||||
- Monitoraggio quotidiano via daily digest
|
||||
- Review settimanale con Milito (in chat) per anomalie
|
||||
- Promozione a cap pieno (200 / 1.000 EUR) solo dopo 10 trade reali
|
||||
conclusi entro range atteso
|
||||
|
||||
## Linting e static analysis
|
||||
|
||||
Ogni PR:
|
||||
|
||||
- `ruff check` — passing
|
||||
- `ruff format --check` — passing
|
||||
- `mypy --strict src/` — passing
|
||||
- `pytest --cov` — coverage soglie rispettate
|
||||
- `bandit -r src/` — no security warning
|
||||
|
||||
Nessun merge se uno dei check fallisce.
|
||||
|
||||
## CI
|
||||
|
||||
Anche locale (no GitHub remoto al momento). Pre-commit hook esegue
|
||||
unit + integration in < 30 sec. Pre-push esegue golden suite (~3 min).
|
||||
|
||||
```yaml
|
||||
# .pre-commit-config.yaml
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
- id: mypy
|
||||
- id: pytest-fast
|
||||
entry: uv run pytest tests/unit tests/integration -x
|
||||
stages: [commit]
|
||||
- id: pytest-golden
|
||||
entry: uv run pytest tests/golden -x
|
||||
stages: [push]
|
||||
```
|
||||
@@ -0,0 +1,269 @@
|
||||
# 09 — Development Roadmap
|
||||
|
||||
Sviluppo per fasi, ognuna con obiettivo chiaro, criteri di completamento
|
||||
e dipendenze. La sequenza è stretta: ogni fase aggiunge valore e si
|
||||
costruisce sulla precedente.
|
||||
|
||||
## Fase 0 — Setup (3-5 giorni)
|
||||
|
||||
**Obiettivo:** scheletro di progetto eseguibile, niente logica di
|
||||
business.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. `git init` + branch `main` + `.gitignore` (esclude `data/`, `.lockfile`,
|
||||
`*.local.yaml`).
|
||||
2. `pyproject.toml` con `uv`, deps base: pydantic, sqlalchemy, apscheduler,
|
||||
mcp, rich (CLI), pytest, mypy, ruff.
|
||||
3. Layout cartelle come da `02-architecture.md`.
|
||||
4. Logger strutturato con `structlog`, output JSONL su file.
|
||||
5. `__main__.py` con CLI Click: comandi `start`, `stop`, `status`,
|
||||
`dry-run`, `kill-switch`, `replay`.
|
||||
6. Pre-commit hooks installati.
|
||||
7. `tests/conftest.py` con fixtures di base.
|
||||
8. Hello-world test che valida l'avvio dell'engine senza nessun
|
||||
tool MCP reale (tutti fake).
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- `uv run cerbero-bite status` stampa "engine idle, kill_switch=0"
|
||||
- `uv run pytest` passa con almeno 5 test triviali
|
||||
- `mypy --strict src/` passa
|
||||
- Repository committato
|
||||
|
||||
## Fase 1 — Core algorithms (7-10 giorni)
|
||||
|
||||
**Obiettivo:** i 7 algoritmi puri implementati, con test al 100%.
|
||||
|
||||
Tasks (ordine TDD):
|
||||
|
||||
1. `entry_validator.validate_entry` + `compute_bias`
|
||||
2. `liquidity_gate.check`
|
||||
3. `sizing_engine.compute_contracts`
|
||||
4. `combo_builder.select_strikes` + `build`
|
||||
5. `greeks_aggregator.aggregate`
|
||||
6. `exit_decision.evaluate` (questo è il più complesso, due giornate dedicate)
|
||||
7. `kelly_recalibration.recalibrate`
|
||||
|
||||
Per ogni modulo:
|
||||
- scrivere test che falliscono
|
||||
- implementare la versione minima che li passa
|
||||
- aggiungere property test con hypothesis
|
||||
- code review (skill `superpowers:requesting-code-review`)
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- 100% statement+branch coverage su `core/`
|
||||
- Property test per ogni invariante documentata
|
||||
- 0 errori `mypy --strict`
|
||||
- Tutti i test obbligatori in `08-testing-validation.md` presenti
|
||||
|
||||
## Fase 2 — Persistenza e safety (5-7 giorni)
|
||||
|
||||
**Obiettivo:** state machine + risk control implementati.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. Schema SQLite + migration 0001
|
||||
2. `state/repository.py` con CRUD typed
|
||||
3. Audit log con hash chain
|
||||
4. `safety/kill_switch.py` con tutti i trigger automatici
|
||||
5. `safety/dead_man.sh` script shell separato
|
||||
6. Backup utility + retention policy
|
||||
7. CLI `audit verify`, `kill-switch arm/disarm`, `state inspect`
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Coverage `state/` ≥ 90%, `safety/` 100%
|
||||
- Test di corruzione SQLite e hash chain passano
|
||||
- Recovery test: kill engine mid-write, restart, stato consistente
|
||||
|
||||
## Fase 3 — MCP clients (4-6 giorni)
|
||||
|
||||
**Obiettivo:** wrapper tipizzati per tutti i server MCP usati.
|
||||
|
||||
Tasks (parallelizzabili tra collaboratori se ci fossero):
|
||||
|
||||
1. `clients/_base.py`: McpClient abstract + retry/timeout
|
||||
2. `clients/deribit.py` con tutti i metodi documentati
|
||||
3. `clients/hyperliquid.py`
|
||||
4. `clients/macro.py`
|
||||
5. `clients/sentiment.py`
|
||||
6. `clients/portfolio.py`
|
||||
7. `clients/memory.py`
|
||||
8. `clients/telegram.py` (anche listener webhook per conferme)
|
||||
9. `clients/brain_bridge.py`
|
||||
|
||||
Per ogni client: fake corrispondente in `tests/fixtures/fakes/`.
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Coverage `clients/` ≥ 80%
|
||||
- Fake test scenari happy/timeout/anomaly per ognuno
|
||||
- `cerbero-bite ping` lista lo stato di ogni MCP
|
||||
|
||||
## Fase 4 — Orchestrator e flussi (5-7 giorni)
|
||||
|
||||
**Obiettivo:** i flussi di `06-operational-flow.md` cablati.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. `runtime/orchestrator.py` — composizione core + clients + state
|
||||
2. `runtime/scheduler.py` — APScheduler con i cron documentati
|
||||
3. `runtime/monitoring.py` — loop ogni 12h
|
||||
4. `runtime/alert_manager.py` — escalation policy
|
||||
|
||||
Test integration su scenari completi (vedi `08-testing-validation.md`):
|
||||
- weekly open happy path
|
||||
- monitor profit take
|
||||
- monitor vol stop
|
||||
- recovery dopo crash
|
||||
- kill switch blocca entry
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Tutti gli integration test passano
|
||||
- Engine può girare in `--dry-run` per 24h senza errori
|
||||
- I log sono leggibili e completi
|
||||
|
||||
## Fase 4.5 — GUI Streamlit (4 giorni)
|
||||
|
||||
**Obiettivo:** dashboard locale per osservazione e azioni manuali. Spec
|
||||
dettagliata in `11-gui-streamlit.md`.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. Setup `gui/main.py` + sidebar nav + auto-refresh
|
||||
2. Pagina Status (engine, capitale, MCP health, kill switch panel)
|
||||
3. Pagina Equity (curve, drawdown, monthly stats)
|
||||
4. Pagina Position (legs, payoff plotly, decision history, force-close)
|
||||
5. Pagina History (filtri, KPI, export CSV)
|
||||
6. Pagina Audit (log live, verify chain, search)
|
||||
7. Tabella `manual_actions` + consumer job APScheduler nell'engine
|
||||
8. Test integration con `streamlit.testing.v1.AppTest`
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- `cerbero-bite gui` lancia la dashboard su `127.0.0.1:8765`
|
||||
- Tutte le 5 pagine raggiungibili e popolate
|
||||
- Disarm da GUI loggato in audit chain ed effettivo entro 30 sec
|
||||
- Force-close da GUI consumato dall'engine entro 30 sec
|
||||
- Test integration su ogni pagina passing
|
||||
|
||||
## Fase 5 — Reporting e UX (3-5 giorni)
|
||||
|
||||
**Obiettivo:** report Telegram puliti, daily digest, replay command.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. `reporting/pre_trade.py` — testo Markdown con sezioni
|
||||
2. `reporting/exit_proposal.py`
|
||||
3. `reporting/daily_digest.py`
|
||||
4. `reporting/monthly_report.py`
|
||||
5. CLI `cerbero-bite replay` per backtest
|
||||
6. CLI `cerbero-bite recalibrate-kelly --dry-run`
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Tre messaggi Telegram esempio committati come fixtures
|
||||
- Replay command funziona su 30 giorni di dati storici simulati
|
||||
|
||||
## Fase 6 — Golden tests e backtest (3-4 giorni)
|
||||
|
||||
**Obiettivo:** suite di scenari golden + replay storico.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. Almeno 10 scenari golden coprenti tutti i path principali
|
||||
2. Storia ETH spot + DVOL caricata da CSV in `tests/data/historical/`
|
||||
3. Confronto P&L replay vs simulazione Monte Carlo del documento
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- Replay 2024-01 → 2026-04 senza errori
|
||||
- Equity curve riprodotta con stesso seed numerico
|
||||
- Differenza con MC entro intervalli attesi
|
||||
|
||||
## Fase 7 — Paper trading (90 giorni)
|
||||
|
||||
**Obiettivo:** validazione su segnali reali ma niente capitale.
|
||||
|
||||
Setup:
|
||||
|
||||
- Engine live in `--dry-run`
|
||||
- Telegram alert reali
|
||||
- Adriano replica trade su **testnet Deribit** o paper book per misurare
|
||||
slippage reale
|
||||
|
||||
Metriche da raccogliere:
|
||||
|
||||
- Numero proposte settimanali emesse
|
||||
- Quante passano i filtri
|
||||
- Win rate, avg P&L paper
|
||||
- Discrepanze tra mid stimato e fill reale (slippage)
|
||||
- Errori di sistema, kill switch armati
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- ≥ 30 trade paper concluso
|
||||
- 0 errori critici
|
||||
- Win rate ≥ 70% (paper ETH credit spread baseline atteso)
|
||||
- Sign-off Adriano via audit chain
|
||||
|
||||
## Fase 8 — Soft launch (30 giorni live, cap dimezzato)
|
||||
|
||||
**Obiettivo:** primi trade reali con capitale ridotto.
|
||||
|
||||
Setup:
|
||||
|
||||
- Cap 100 EUR per trade, 500 EUR engagement totale
|
||||
- Solo Bull Put Spread (no IC) per ridurre superficie di rischio
|
||||
- Daily digest manuale ogni mattina con Adriano
|
||||
|
||||
Metriche di passaggio a full:
|
||||
|
||||
- ≥ 10 trade reali conclusi
|
||||
- P&L coerente con Monte Carlo (entro 1 sigma)
|
||||
- 0 incidenti operativi
|
||||
- Spread fill mediano ≤ 5% slippage rispetto al mid stimato
|
||||
|
||||
## Fase 9 — Full launch
|
||||
|
||||
**Obiettivo:** operatività regime.
|
||||
|
||||
- Cap pieno: 200 EUR per trade, 1.000 EUR engagement
|
||||
- Iron Condor abilitato (regime laterale)
|
||||
- Mensile review Kelly + report Brain-Bridge
|
||||
- Ogni 6 mesi review completa con Milito
|
||||
|
||||
## Roadmap futura (post-launch, idee)
|
||||
|
||||
- **Sottostante BTC**: stessa strategia, parametri ricalibrati. Richiede
|
||||
fase di paper-trading dedicata.
|
||||
- **Multi-exchange execution**: alternativa a Deribit (Bybit options,
|
||||
Binance options) per ridurre counterparty risk.
|
||||
- **Dashboard locale**: pagina HTML statica generata dai log per
|
||||
visualizzare equity curve e trade list senza tool esterni.
|
||||
- **Auto Kelly update**: dopo 100 trade reali, valutare l'auto-update
|
||||
con sand-box di review Milito.
|
||||
- **Promozione MCP options-engine**: estrarre `core/` come server MCP
|
||||
dedicato, riusabile da Cerbero core stesso e da Milito.
|
||||
|
||||
## Stima cumulativa
|
||||
|
||||
| Fase | Giorni di lavoro | Calendar (con review) |
|
||||
|---|---:|---:|
|
||||
| 0 — Setup | 3-5 | 1 settimana |
|
||||
| 1 — Core | 7-10 | 2 settimane |
|
||||
| 2 — State & Safety | 5-7 | 1.5 settimane |
|
||||
| 3 — Clients | 4-6 | 1 settimana |
|
||||
| 4 — Orchestrator | 5-7 | 1.5 settimane |
|
||||
| 4.5 — GUI Streamlit | 4 | 1 settimana |
|
||||
| 5 — Reporting | 3-5 | 1 settimana |
|
||||
| 6 — Golden + Backtest | 3-4 | 1 settimana |
|
||||
| **Totale fino a paper-ready** | **34-48** | **~10 settimane** |
|
||||
| 7 — Paper trading | — | 12-13 settimane |
|
||||
| 8 — Soft launch | — | 4-5 settimane |
|
||||
| 9 — Full launch | — | ongoing |
|
||||
|
||||
**Tempo a regime: ~6 mesi dal kickoff.**
|
||||
@@ -0,0 +1,309 @@
|
||||
# 10 — Config Spec (`strategy.yaml`)
|
||||
|
||||
Tutti i parametri della strategia sono esposti in un file YAML
|
||||
versionato. Modificare il file = decisione esplicita di Adriano.
|
||||
Modifiche richiedono nuovo `config_version`, ricalcolo `config_hash`
|
||||
e commit con motivazione.
|
||||
|
||||
## Schema completo (golden default)
|
||||
|
||||
```yaml
|
||||
# strategy.yaml — Cerberus Bite golden config v1.0.0
|
||||
# Riferimento: docs/01-strategy-rules.md
|
||||
# NB: hash auto-calcolato dal CLI `cerbero-bite config hash`
|
||||
|
||||
config_version: "1.0.0"
|
||||
config_hash: "0000000000000000000000000000000000000000000000000000000000000000" # placeholder
|
||||
last_review: "2026-04-26"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
asset:
|
||||
symbol: "ETH"
|
||||
exchange: "deribit"
|
||||
|
||||
# === ENTRY ===
|
||||
|
||||
entry:
|
||||
# finestra di valutazione settimanale
|
||||
cron: "0 14 * * MON" # lunedì 14:00 UTC
|
||||
skip_holidays_country: "IT"
|
||||
|
||||
# filtri di accesso (vedi 01-strategy-rules.md §2)
|
||||
capital_min_usd: 720
|
||||
dvol_min: 35
|
||||
dvol_max: 90
|
||||
funding_perp_abs_max_annualized: 0.80
|
||||
eth_holdings_pct_max: 0.30
|
||||
no_position_concurrent: true # 1 sola posizione alla volta
|
||||
exclude_macro_severity: ["high"]
|
||||
exclude_macro_countries: ["US", "EU"]
|
||||
|
||||
# bias direzionale (§3.1)
|
||||
trend_window_days: 30
|
||||
trend_bull_threshold_pct: 0.05
|
||||
trend_bear_threshold_pct: -0.05
|
||||
funding_bull_threshold_annualized: 0.20
|
||||
funding_bear_threshold_annualized: -0.20
|
||||
iron_condor_dvol_min: 55
|
||||
iron_condor_adx_max: 20
|
||||
iron_condor_trend_neutral_band_pct: 0.05
|
||||
|
||||
# === STRUCTURE ===
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
short_strike:
|
||||
delta_target: 0.12
|
||||
delta_min: 0.10
|
||||
delta_max: 0.15
|
||||
distance_otm_pct_min: 0.15
|
||||
distance_otm_pct_max: 0.25
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: 0.04
|
||||
min_pct_of_spot: 0.03
|
||||
max_pct_of_spot: 0.05
|
||||
|
||||
credit_to_width_ratio_min: 0.30
|
||||
|
||||
# === LIQUIDITY GATE ===
|
||||
|
||||
liquidity:
|
||||
open_interest_min: 100
|
||||
volume_24h_min: 20
|
||||
bid_ask_spread_pct_max: 0.15
|
||||
book_depth_top3_min: 5
|
||||
slippage_pct_of_credit_max: 0.08
|
||||
|
||||
# === SIZING ===
|
||||
|
||||
sizing:
|
||||
kelly_fraction: 0.13 # Quarter Kelly basato su MC
|
||||
|
||||
# cap nominali (Cerbero v4 hard prohibitions)
|
||||
cap_per_trade_eur: 200
|
||||
cap_aggregate_open_eur: 1000
|
||||
max_concurrent_positions: 1 # da 2+ solo via override esplicito
|
||||
|
||||
# soft cap difensivi (interni a Cerbero Bite)
|
||||
max_contracts_per_trade: 4
|
||||
|
||||
# aggiustamento volatilità
|
||||
dvol_adjustment:
|
||||
- {dvol_under: 45, multiplier: 1.00}
|
||||
- {dvol_under: 60, multiplier: 0.85}
|
||||
- {dvol_under: 80, multiplier: 0.65}
|
||||
# >= 80 → no entry (gestito da entry filters)
|
||||
|
||||
# === EXIT ===
|
||||
|
||||
exit:
|
||||
# ordine di valutazione vincolante
|
||||
profit_take_pct_of_credit: 0.50
|
||||
stop_loss_mark_x_credit: 2.50 # mark = 250% credito → perdita 1.5×
|
||||
vol_stop_dvol_increase: 10
|
||||
time_stop_dte_remaining: 7
|
||||
time_stop_skip_if_close_to_profit_pct: 0.70 # skip se mark ≤ 70% di profit_take
|
||||
delta_breach_threshold: 0.30
|
||||
adverse_move_4h_pct: 0.05
|
||||
|
||||
monitor_cron: "0 2,14 * * *" # 02 + 14 UTC
|
||||
user_confirmation_timeout_min: 30
|
||||
|
||||
# escalation per urgenza alta
|
||||
escalate_on_timeout:
|
||||
- "CLOSE_STOP"
|
||||
- "CLOSE_VOL"
|
||||
- "CLOSE_DELTA"
|
||||
|
||||
# === EXECUTION ===
|
||||
|
||||
execution:
|
||||
combo_only: true # mai due ordini separati
|
||||
initial_limit: "mid"
|
||||
reprice_step_ticks: 1
|
||||
reprice_max_steps: 3
|
||||
reprice_max_steps_urgent: 5 # per chiusure urgenti
|
||||
order_tif: "GTC"
|
||||
order_expiry_min: 30
|
||||
ack_timeout_s: 300 # ack Cerbero core
|
||||
|
||||
# === MONITORING ===
|
||||
|
||||
monitoring:
|
||||
health_check_interval_s: 300
|
||||
health_failures_before_kill: 3
|
||||
health_failures_before_restart: 5
|
||||
|
||||
daily_digest_cron: "0 8 * * *"
|
||||
monthly_report_cron: "0 12 1 * *"
|
||||
|
||||
# === STORAGE ===
|
||||
|
||||
storage:
|
||||
sqlite_path: "data/state.sqlite"
|
||||
log_path: "data/log/"
|
||||
log_retention_days: 365
|
||||
backup_path: "data/backups/"
|
||||
backup_retention_days: 30
|
||||
|
||||
# === MCP ===
|
||||
|
||||
mcp:
|
||||
config_file: "~/.config/cerbero-suite/mcp.json"
|
||||
call_timeout_s: 8
|
||||
retry_max: 3
|
||||
retry_base_delay_s: 1
|
||||
|
||||
required_versions:
|
||||
cerbero-deribit: "^2.0.0"
|
||||
cerbero-hyperliquid: "^1.5.0"
|
||||
cerbero-memory: "^4.0.0"
|
||||
cerbero-portfolio: "^1.2.0"
|
||||
cerbero-macro: "^1.0.0"
|
||||
cerbero-sentiment: "^1.0.0"
|
||||
cerbero-telegram: "^1.0.0"
|
||||
cerbero-brain-bridge: "^1.0.0"
|
||||
|
||||
# === TELEGRAM ===
|
||||
|
||||
telegram:
|
||||
parse_mode: "MarkdownV2"
|
||||
confirmation_timeout_min: 60
|
||||
exit_confirmation_timeout_min: 30
|
||||
backup_channel_on_critical: true # canale BotPapà su severity critical
|
||||
|
||||
# === KELLY RECALIBRATION ===
|
||||
|
||||
kelly_recalibration:
|
||||
lookback_days: 365
|
||||
min_sample_low_confidence: 30
|
||||
min_sample_high_confidence: 100
|
||||
weight_when_medium_confidence: 0.50
|
||||
```
|
||||
|
||||
## Validazione (`config/schema.py`)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from decimal import Decimal
|
||||
|
||||
class StrategyConfig(BaseModel):
|
||||
config_version: str
|
||||
config_hash: str
|
||||
last_review: str
|
||||
last_reviewer: str
|
||||
asset: AssetConfig
|
||||
entry: EntryConfig
|
||||
structure: StructureConfig
|
||||
liquidity: LiquidityConfig
|
||||
sizing: SizingConfig
|
||||
exit: ExitConfig
|
||||
execution: ExecutionConfig
|
||||
monitoring: MonitoringConfig
|
||||
storage: StorageConfig
|
||||
mcp: McpConfig
|
||||
telegram: TelegramConfig
|
||||
kelly_recalibration: KellyConfig
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_consistency(self) -> "StrategyConfig":
|
||||
# short delta range coerente
|
||||
s = self.structure.short_strike
|
||||
if not (s.delta_min <= s.delta_target <= s.delta_max):
|
||||
raise ValueError("delta_target outside [delta_min, delta_max]")
|
||||
# OTM range coerente
|
||||
if s.distance_otm_pct_min >= s.distance_otm_pct_max:
|
||||
raise ValueError("OTM range invalid")
|
||||
# Kelly range
|
||||
if not (0 < self.sizing.kelly_fraction < 0.5):
|
||||
raise ValueError("kelly_fraction out of safe range (0, 0.5)")
|
||||
# spread width range
|
||||
sw = self.structure.spread_width
|
||||
if not (sw.min_pct_of_spot <= sw.target_pct_of_spot <= sw.max_pct_of_spot):
|
||||
raise ValueError("spread_width target outside min/max")
|
||||
# exit thresholds sensati
|
||||
if self.exit.profit_take_pct_of_credit >= 1.0:
|
||||
raise ValueError("profit_take >= 100% impossible")
|
||||
if self.exit.stop_loss_mark_x_credit <= 1.0:
|
||||
raise ValueError("stop_loss multiple <= 1× makes no sense")
|
||||
return self
|
||||
```
|
||||
|
||||
## Override locale
|
||||
|
||||
Se esiste `strategy.local.yaml` (gitignored), i suoi campi sovrascrivono
|
||||
`strategy.yaml`. Usato per:
|
||||
|
||||
- Test in dry-run con cap dimezzati
|
||||
- Personalizzazioni dello sviluppatore senza inquinare il main
|
||||
- Override emergenza ("max_concurrent_positions: 0" per congelare entry
|
||||
senza disabilitare l'engine)
|
||||
|
||||
L'override viene loggato all'avvio:
|
||||
|
||||
```
|
||||
2026-04-27T10:00:00Z | OVERRIDE_APPLIED | sizing.cap_per_trade_eur: 200 → 100
|
||||
```
|
||||
|
||||
## Hash integrity
|
||||
|
||||
```bash
|
||||
# Calcolo manuale
|
||||
cerbero-bite config hash
|
||||
|
||||
# Aggiornamento automatico (richiede review prompt)
|
||||
cerbero-bite config update --reason "Reduce kelly to 0.10 after Apr drawdown"
|
||||
```
|
||||
|
||||
Il comando:
|
||||
1. Apre il file in editor.
|
||||
2. All'esci, ricalcola SHA-256 escludendo la riga `config_hash`.
|
||||
3. Aggiorna `config_hash`, `last_review`, `last_reviewer`.
|
||||
4. Mostra diff ad Adriano.
|
||||
5. Firma in audit chain.
|
||||
|
||||
Mismatch hash a runtime = kill switch immediato (vedi `07-risk-controls.md`).
|
||||
|
||||
## Esempi di modifiche valide
|
||||
|
||||
### Riduzione difensiva post-drawdown
|
||||
|
||||
```diff
|
||||
sizing:
|
||||
- kelly_fraction: 0.13
|
||||
+ kelly_fraction: 0.10
|
||||
```
|
||||
Commit: `chore(config): reduce kelly to 0.10 after April -20% DD`.
|
||||
|
||||
### Aggiunta nuovo evento macro escluso
|
||||
|
||||
```diff
|
||||
entry:
|
||||
exclude_macro_severity: ["high"]
|
||||
+ exclude_macro_keywords: ["Powell", "Lagarde", "ETF approval"]
|
||||
```
|
||||
Commit: `chore(config): exclude ETF-related events from entry window`.
|
||||
|
||||
### Disabilitazione temporanea iron condor
|
||||
|
||||
```diff
|
||||
entry:
|
||||
- iron_condor_dvol_min: 55
|
||||
+ iron_condor_dvol_min: 999 # de facto disabled
|
||||
```
|
||||
Commit: `chore(config): disable IC pending review of vol regime`.
|
||||
|
||||
## Cosa NON è in config
|
||||
|
||||
Non è permesso parametrizzare:
|
||||
|
||||
- L'**ordine** dei trigger di uscita (è una scelta strategica codificata).
|
||||
- Le **strutture** ammesse (vietato vendita nuda, ecc., scolpito nel codice).
|
||||
- Le **hard prohibitions** Cerbero v4 (cap 200/1000 EUR sono limiti
|
||||
superiori, non ulteriormente liberalizzabili).
|
||||
- Lo **scheduler** per intervalli più stretti (un'ottimizzazione che
|
||||
non si fa via config).
|
||||
@@ -0,0 +1,276 @@
|
||||
# 11 — GUI Streamlit
|
||||
|
||||
GUI desktop locale per osservazione e azioni manuali. Implementazione
|
||||
con **Streamlit**: un singolo processo Python, niente HTML/JS custom,
|
||||
niente bundler, niente porte esposte all'esterno.
|
||||
|
||||
## Filosofia
|
||||
|
||||
- **Read-mostly**: la dashboard è soprattutto per *guardare* cosa fa il
|
||||
sistema. Le azioni interattive sono poche e ben delimitate (arm/disarm,
|
||||
force-close di emergenza, conferma manuale di una proposta in coda).
|
||||
- **Stesso state, due frontend**: Streamlit non duplica logica. Legge
|
||||
da `state/repository.py` e dai log, esattamente come fa il decision
|
||||
loop. Non parla mai direttamente al broker.
|
||||
- **Localhost only**: `streamlit run --server.address 127.0.0.1`.
|
||||
Mai bind su `0.0.0.0`. Niente autenticazione, niente HTTPS: la
|
||||
protezione è "macchina di Adriano, browser locale".
|
||||
- **Single-user, single-tab**: Streamlit non è progettato per concorrenza.
|
||||
Una sola sessione attiva alla volta.
|
||||
|
||||
## Stack tecnico
|
||||
|
||||
| Componente | Scelta |
|
||||
|---|---|
|
||||
| Framework | `streamlit` ≥ 1.40 |
|
||||
| Charts | `streamlit` builtin (`st.line_chart`, `st.bar_chart`) + `plotly` per payoff diagram |
|
||||
| Tabelle | `streamlit.dataframe` con filtri |
|
||||
| Auto-refresh | `st_autorefresh` (component) ogni 5-10 sec sulle pagine live |
|
||||
| Config interna | letta da `strategy.yaml` via `config.loader` (sola lettura) |
|
||||
| Sicurezza | localhost-only, lock file condiviso con engine |
|
||||
| Process model | Processo separato da quello dell'engine, comunicazione **solo via SQLite e log** |
|
||||
|
||||
## Avvio
|
||||
|
||||
```bash
|
||||
cerbero-bite gui # alias per `streamlit run src/cerbero_bite/gui/main.py`
|
||||
```
|
||||
|
||||
oppure manualmente:
|
||||
|
||||
```bash
|
||||
uv run streamlit run src/cerbero_bite/gui/main.py \
|
||||
--server.address 127.0.0.1 \
|
||||
--server.port 8765 \
|
||||
--server.headless true \
|
||||
--browser.gatherUsageStats false
|
||||
```
|
||||
|
||||
## Layout cartelle
|
||||
|
||||
```
|
||||
src/cerbero_bite/gui/
|
||||
├── __init__.py
|
||||
├── main.py # entry point streamlit, sidebar nav
|
||||
├── pages/
|
||||
│ ├── 1_📊_status.py
|
||||
│ ├── 2_📈_equity.py
|
||||
│ ├── 3_💼_position.py
|
||||
│ ├── 4_📜_history.py
|
||||
│ └── 5_🔍_audit.py
|
||||
├── components/
|
||||
│ ├── kill_switch_panel.py
|
||||
│ ├── mcp_health_grid.py
|
||||
│ ├── pending_proposal_card.py
|
||||
│ ├── payoff_chart.py
|
||||
│ └── greeks_panel.py
|
||||
└── data_layer.py # wrapper read-only verso state.repository
|
||||
```
|
||||
|
||||
## Pagine
|
||||
|
||||
### 1. 📊 Status (home)
|
||||
|
||||
Vista a colpo d'occhio dello stato corrente.
|
||||
|
||||
Sezioni:
|
||||
|
||||
- **Engine status**: badge verde/giallo/rosso (running/degraded/killed),
|
||||
uptime, ultimo health check, kill_switch state, kill_reason se armato.
|
||||
- **Capitale**: equity corrente da `cerbero-portfolio` (cache ultimo
|
||||
valore noto + timestamp), variazione % vs giorno prima, vs settimana,
|
||||
vs mese.
|
||||
- **Posizione attiva**: card con riepilogo (proposal_id, expiry, credit,
|
||||
P&L unrealized stimato, days_to_expiry) o "nessuna posizione aperta".
|
||||
- **MCP health grid**: 8 box, uno per server, con latenza ms e semaforo.
|
||||
- **Pending action**: se l'engine ha una proposta in attesa di conferma
|
||||
e il timeout Telegram è scaduto, qui appare una card con `Approve`/`Reject`.
|
||||
Effetto: la decisione viene scritta in coda e il decision orchestrator
|
||||
la legge al prossimo health-check.
|
||||
- **Big buttons**: `🟢 Disarm` / `🔴 Arm Kill Switch` (con conferma
|
||||
typed `"yes I am sure"`).
|
||||
|
||||
Auto-refresh: 5 secondi.
|
||||
|
||||
### 2. 📈 Equity
|
||||
|
||||
Grafico storia capitale e analitica.
|
||||
|
||||
Sezioni:
|
||||
|
||||
- **Equity curve** (line chart): capitale nel tempo dall'inizio del
|
||||
tracking. Risoluzione giornaliera. Sovrapposizione opzionale:
|
||||
- banda Monte Carlo P5/P50/P95 (statica, dal documento)
|
||||
- DVOL nel tempo (asse Y secondario)
|
||||
- eventi macro (vertical lines sui giorni FOMC/CPI)
|
||||
- **Drawdown rolling** (sotto curve): area chart del DD% corrente.
|
||||
- **P&L distribution** (histogram): trade chiusi raggruppati per outcome
|
||||
(profit_take, stop_loss, vol_stop, time_stop, ecc.).
|
||||
- **Tabella mensile**: per ogni mese — n trade, win rate, P&L, max DD.
|
||||
|
||||
Filtri: range temporale, asset (solo ETH per ora).
|
||||
|
||||
Auto-refresh: 30 secondi (cambia raramente).
|
||||
|
||||
### 3. 💼 Position
|
||||
|
||||
Drill-down sulla posizione attualmente aperta (se esiste).
|
||||
|
||||
Sezioni:
|
||||
|
||||
- **Header**: proposal_id, opened_at, expiry, days_left, status.
|
||||
- **Legs table**: instrument, side, size, mid corrente, delta,
|
||||
theta, vega — refresh periodico via `clients.deribit`.
|
||||
- **Greche aggregate**: delta/theta/vega netti.
|
||||
- **Payoff diagram** (plotly): P&L vs spot ETH a scadenza, con
|
||||
breakeven, max profit, max loss, spot corrente come marker.
|
||||
- **Decision history**: tabella con tutte le `decisions` di tipo
|
||||
`exit_check` per questa posizione, in ordine cronologico, con
|
||||
outcome HOLD / CLOSE_*.
|
||||
- **Distance metrics**: short strike a `X% OTM`, delta corrente,
|
||||
distanza in sigma.
|
||||
- **Force close** (collapsibile): typed confirmation + reason field.
|
||||
Su submit: scrive in coda azione `manual_close`, l'engine la consuma
|
||||
al prossimo monitor cycle.
|
||||
|
||||
Auto-refresh: 10 secondi.
|
||||
|
||||
### 4. 📜 History
|
||||
|
||||
Storico trade chiusi.
|
||||
|
||||
Sezioni:
|
||||
|
||||
- **Filtri**: range temporale, outcome (multiselect), P&L > 0 / < 0 / tutti.
|
||||
- **Tabella trade chiusi** (`st.dataframe` sortable): proposal_id,
|
||||
opened_at, closed_at, expiry, n_contracts, credit_usd, debit_paid_usd,
|
||||
pnl_usd, outcome, days_held.
|
||||
- **KPI strip**: n trade, win rate, avg win, avg loss, edge per trade,
|
||||
edge cumulato.
|
||||
- **Confronto Monte Carlo**: side-by-side delle metriche reali vs
|
||||
attese da simulazione, con delta in %.
|
||||
- **Export CSV**: bottone download per uso fiscale.
|
||||
|
||||
Auto-refresh: manuale (button).
|
||||
|
||||
### 5. 🔍 Audit
|
||||
|
||||
Log e audit chain.
|
||||
|
||||
Sezioni:
|
||||
|
||||
- **Live log stream**: ultimi 100 eventi, filtro per `level` e `event`.
|
||||
Auto-refresh 5 sec.
|
||||
- **Audit chain status**: bottone `Verify`. Mostra "✅ chain integra
|
||||
fino a 14.382 eventi" o "❌ tampering rilevato a evento N".
|
||||
- **Search**: ricerca testuale negli ultimi 30 giorni di log.
|
||||
- **Stats engine**: numero kill switch armati nell'ultimo mese, MCP
|
||||
failure count per server, average decision loop latency.
|
||||
- **Export log**: download `.jsonl.gz` per analisi forensica.
|
||||
|
||||
Auto-refresh: manuale.
|
||||
|
||||
## Comunicazione GUI ↔ Engine
|
||||
|
||||
La GUI **non importa** moduli `runtime/` né chiama direttamente i client
|
||||
MCP. Tutto passa via:
|
||||
|
||||
| Azione GUI | Effetto |
|
||||
|---|---|
|
||||
| Visualizzazione stato | Read da `state/repository.py` (SQLite) |
|
||||
| Equity / storico | Read da SQLite + `data/log/*.jsonl` |
|
||||
| MCP health | Read da `state.system_state.last_health_check` (l'engine fa il check) |
|
||||
| **Disarm kill switch** | Write su `system_state` con `kill_switch=0`; l'engine al prossimo health check rileva e log `KILL_SWITCH_DISARMED` |
|
||||
| **Arm kill switch** | Write su `system_state` con `kill_switch=1, kill_reason="manual via GUI"` |
|
||||
| **Force close** | Insert riga in tabella `manual_actions` (nuova) con `kind="force_close", proposal_id=...`; l'engine al prossimo monitor cycle la consuma |
|
||||
| **Approve pending proposal** | Insert riga in `manual_actions` con `kind="approve_proposal", proposal_id=...` |
|
||||
|
||||
**Nuova tabella SQLite** (`05-data-model.md` da estendere):
|
||||
|
||||
```sql
|
||||
CREATE TABLE manual_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- approve_proposal, reject_proposal, force_close, etc.
|
||||
proposal_id TEXT,
|
||||
payload_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT, -- NULL = ancora da processare
|
||||
consumed_by TEXT,
|
||||
result TEXT
|
||||
);
|
||||
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
|
||||
```
|
||||
|
||||
L'engine include un nuovo job APScheduler `every 30s`:
|
||||
|
||||
```python
|
||||
async def consume_manual_actions():
|
||||
actions = state.fetch_unconsumed_manual_actions()
|
||||
for a in actions:
|
||||
if a.kind == "force_close":
|
||||
await orchestrator.handle_force_close(a.proposal_id, a.payload)
|
||||
elif a.kind == "approve_proposal":
|
||||
await orchestrator.handle_proposal_approved(a.proposal_id)
|
||||
# etc.
|
||||
state.mark_action_consumed(a.id, result="ok")
|
||||
```
|
||||
|
||||
Le azioni write **non bypassano** i risk control: una `force_close` deve
|
||||
comunque passare dal `safety.system_healthy()` e da una conferma typed
|
||||
nella GUI prima di essere scritta in coda.
|
||||
|
||||
## Lock e concorrenza
|
||||
|
||||
- L'engine tiene `data/.lockfile` esclusivo.
|
||||
- La GUI tiene `data/.gui-lockfile` esclusivo (impedisce due tab/Streamlit aperti).
|
||||
- Entrambi possono leggere SQLite (modalità WAL).
|
||||
- Le `manual_actions` sono il **canale di scrittura** condiviso, con
|
||||
primary key auto-increment e flag `consumed_at` per consumo idempotente.
|
||||
|
||||
## Sicurezza
|
||||
|
||||
- Bind solo `127.0.0.1`. Mai `0.0.0.0`.
|
||||
- Streamlit avviato con `--server.headless true` per evitare apertura
|
||||
automatica del browser via tunnel.
|
||||
- Nessuna autenticazione HTTP: la barriera è il fatto che la macchina
|
||||
è personale di Adriano. Se il sistema fosse mai esposto, va aggiunto
|
||||
reverse proxy con basic auth — ma non è il caso.
|
||||
- Le azioni write richiedono **typed confirmation** (`"yes I am sure"`,
|
||||
identico al flusso CLI). Mai `st.button` senza challenge.
|
||||
- CSRF: non rilevante in Streamlit (no form HTML, tutto via session state).
|
||||
|
||||
## Cosa la GUI **non** fa
|
||||
|
||||
Per chiarezza:
|
||||
|
||||
- Non interroga direttamente Deribit o altri exchange.
|
||||
- Non chiama mai `cerbero-memory.push_user_instruction`.
|
||||
- Non muta `strategy.yaml` (modifiche restano da CLI con audit chain).
|
||||
- Non riavvia l'engine (start/stop sono CLI).
|
||||
- Non sostituisce Telegram come canale di conferma di apertura.
|
||||
Telegram resta il canale primario; la GUI è canale di **fallback**
|
||||
per quando Adriano è davanti al laptop e non al telefono.
|
||||
|
||||
## Stima di sforzo
|
||||
|
||||
Inserita come **Fase 4.5** nella roadmap, tra Orchestrator e Reporting:
|
||||
|
||||
| Task | Giorni |
|
||||
|---|---|
|
||||
| Setup `gui/main.py` + sidebar nav + autorefresh | 0.5 |
|
||||
| Pagina Status + MCP health grid + kill_switch panel | 0.5 |
|
||||
| Pagina Equity + drawdown + plot mensili | 0.5 |
|
||||
| Pagina Position + payoff plotly + decision history | 1.0 |
|
||||
| Pagina History + filtri + export CSV | 0.5 |
|
||||
| Pagina Audit + search log + verify chain | 0.5 |
|
||||
| `manual_actions` table + consumer job APScheduler | 0.5 |
|
||||
| Test integration (Streamlit AppTest framework) | 0.5 |
|
||||
| **Totale** | **~4 giorni** |
|
||||
|
||||
Definition of Done:
|
||||
|
||||
- `cerbero-bite gui` lancia la dashboard
|
||||
- Tutte le 5 pagine raggiungibili e popolate (anche con dati fake)
|
||||
- Disarm da GUI loggato in audit chain ed effettivo entro 30 sec
|
||||
- Force-close da GUI consumato dall'engine entro 30 sec
|
||||
- Test integration con `streamlit.testing.v1.AppTest` per ogni pagina
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
[project]
|
||||
name = "cerbero-bite"
|
||||
version = "0.0.1"
|
||||
description = "Rule-based deterministic engine for Cerberus Bite credit spread strategy on ETH options"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" }]
|
||||
license = { text = "Proprietary" }
|
||||
|
||||
dependencies = [
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.5",
|
||||
"click>=8.1",
|
||||
"rich>=13.9",
|
||||
"structlog>=24.4",
|
||||
"apscheduler>=3.10",
|
||||
"sqlalchemy>=2.0",
|
||||
"aiosqlite>=0.20",
|
||||
"pyyaml>=6.0",
|
||||
"httpx>=0.27",
|
||||
"mcp>=1.0",
|
||||
"tenacity>=9.0",
|
||||
"python-dateutil>=2.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
"streamlit>=1.40",
|
||||
"plotly>=5.24",
|
||||
"pandas>=2.2",
|
||||
"numpy>=2.0",
|
||||
]
|
||||
backtest = [
|
||||
"scipy>=1.14",
|
||||
"matplotlib>=3.9",
|
||||
"pandas>=2.2",
|
||||
"numpy>=2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cerbero-bite = "cerbero_bite.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=5.0",
|
||||
"hypothesis>=6.115",
|
||||
"mypy>=1.13",
|
||||
"ruff>=0.7",
|
||||
"pre-commit>=4.0",
|
||||
"types-PyYAML>=6.0",
|
||||
"types-python-dateutil>=2.9",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/cerbero_bite"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"N", # pep8-naming
|
||||
"SIM", # flake8-simplify
|
||||
"RET", # flake8-return
|
||||
"ARG", # flake8-unused-arguments
|
||||
"PTH", # flake8-use-pathlib
|
||||
"ERA", # eradicate
|
||||
"PL", # pylint
|
||||
"RUF", # ruff-specific
|
||||
]
|
||||
ignore = [
|
||||
"PLR0913", # too many arguments (we accept config-heavy functions)
|
||||
"PLR2004", # magic value (we have many domain constants in tests)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["PLR2004", "ARG", "S101"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
disallow_any_generics = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_reexport = true
|
||||
files = ["src/cerbero_bite"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["apscheduler.*", "mcp.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = [
|
||||
"-ra",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: integration tests with fake MCP",
|
||||
"golden: golden scenario tests",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/cerbero_bite"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Cerbero Bite — rule-based deterministic engine for ETH credit spreads."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Allow ``python -m cerbero_bite`` to invoke the CLI."""
|
||||
|
||||
from cerbero_bite.cli import _entrypoint
|
||||
|
||||
if __name__ == "__main__":
|
||||
_entrypoint()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Command-line interface for Cerbero Bite.
|
||||
|
||||
The CLI is the single user-facing entry point. Each subcommand maps to a
|
||||
specific operational action documented in ``docs/06-operational-flow.md``
|
||||
and ``docs/07-risk-controls.md``. All commands are placeholders in
|
||||
Phase 0; subsequent phases will replace the bodies with real logic
|
||||
without changing the surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from cerbero_bite import __version__
|
||||
from cerbero_bite.logging import configure as configure_logging
|
||||
from cerbero_bite.logging import get_logger
|
||||
|
||||
console = Console()
|
||||
log = get_logger("cli")
|
||||
|
||||
|
||||
def _phase0_notice(action: str) -> None:
|
||||
console.print(f"[yellow]\\[phase 0 placeholder][/yellow] {action}")
|
||||
|
||||
|
||||
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.version_option(__version__, prog_name="cerbero-bite")
|
||||
@click.option(
|
||||
"--log-dir",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
default=Path("data/log"),
|
||||
show_default=True,
|
||||
help="Directory for JSONL log files.",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
|
||||
default="INFO",
|
||||
show_default=True,
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, log_dir: Path, log_level: str) -> None:
|
||||
"""Cerbero Bite — rule-based ETH credit spread engine."""
|
||||
configure_logging(log_dir=log_dir, level=log_level.upper())
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["log_dir"] = log_dir
|
||||
|
||||
|
||||
@main.command()
|
||||
def status() -> None:
|
||||
"""Print engine status snapshot."""
|
||||
console.print(
|
||||
f"[bold cyan]Cerbero Bite[/bold cyan] v{__version__}\n"
|
||||
f"engine state: [yellow]idle[/yellow]\n"
|
||||
f"kill_switch: [green]0 (disarmed)[/green]\n"
|
||||
f"open positions: 0\n"
|
||||
f"phase: 0 (skeleton)"
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
def start() -> None:
|
||||
"""Start the engine main loop (scheduler + monitoring)."""
|
||||
_phase0_notice("start command not yet implemented; engine remains idle.")
|
||||
|
||||
|
||||
@main.command()
|
||||
def stop() -> None:
|
||||
"""Gracefully stop a running engine."""
|
||||
_phase0_notice("stop command not yet implemented.")
|
||||
|
||||
|
||||
@main.command(name="dry-run")
|
||||
def dry_run() -> None:
|
||||
"""Run the decision loop once in dry-run mode (no MCP writes)."""
|
||||
_phase0_notice("dry-run command not yet implemented.")
|
||||
|
||||
|
||||
@main.group(name="kill-switch")
|
||||
def kill_switch() -> None:
|
||||
"""Manage the engine kill switch."""
|
||||
|
||||
|
||||
@kill_switch.command(name="arm")
|
||||
@click.option("--reason", required=True, help="Why you are arming the kill switch.")
|
||||
def kill_switch_arm(reason: str) -> None:
|
||||
"""Arm the kill switch (engine refuses new entries)."""
|
||||
_phase0_notice(f"kill-switch arm placeholder (reason: {reason!r}).")
|
||||
|
||||
|
||||
@kill_switch.command(name="disarm")
|
||||
@click.option("--reason", required=True, help="Why you are disarming.")
|
||||
def kill_switch_disarm(reason: str) -> None:
|
||||
"""Disarm the kill switch."""
|
||||
_phase0_notice(f"kill-switch disarm placeholder (reason: {reason!r}).")
|
||||
|
||||
|
||||
@main.command()
|
||||
def gui() -> None:
|
||||
"""Launch the Streamlit dashboard."""
|
||||
_phase0_notice("gui command not yet implemented (will run streamlit on 127.0.0.1:8765).")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("--from", "date_from", required=True, help="ISO date YYYY-MM-DD.")
|
||||
@click.option("--to", "date_to", required=True, help="ISO date YYYY-MM-DD.")
|
||||
@click.option("--capital", type=float, default=1500.0, show_default=True)
|
||||
@click.option("--dry-run/--no-dry-run", default=True, show_default=True)
|
||||
def replay(date_from: str, date_to: str, capital: float, dry_run: bool) -> None:
|
||||
"""Replay historical period through the decision engine."""
|
||||
_phase0_notice(
|
||||
f"replay {date_from} → {date_to}, capital={capital}, dry_run={dry_run}"
|
||||
)
|
||||
|
||||
|
||||
@main.group()
|
||||
def config() -> None:
|
||||
"""Strategy configuration utilities."""
|
||||
|
||||
|
||||
@config.command(name="hash")
|
||||
def config_hash() -> None:
|
||||
"""Compute and print SHA-256 of strategy.yaml."""
|
||||
_phase0_notice("config hash placeholder; will read strategy.yaml and compute SHA-256.")
|
||||
|
||||
|
||||
@main.group()
|
||||
def audit() -> None:
|
||||
"""Audit log utilities."""
|
||||
|
||||
|
||||
@audit.command(name="verify")
|
||||
def audit_verify() -> None:
|
||||
"""Verify audit chain integrity."""
|
||||
_phase0_notice("audit verify placeholder; will walk audit.log hash chain.")
|
||||
|
||||
|
||||
def _entrypoint() -> None:
|
||||
"""Wrapper used by ``cerbero-bite`` console script."""
|
||||
try:
|
||||
main(prog_name="cerbero-bite")
|
||||
except KeyboardInterrupt:
|
||||
console.print("[red]interrupted[/red]")
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_entrypoint()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Structured logging setup for Cerbero Bite.
|
||||
|
||||
Outputs JSONL to a daily file under ``data/log/`` and pretty-prints to
|
||||
stderr in development mode. Every event line is the canonical record for
|
||||
audit purposes; the audit chain (see ``safety/audit_log.py``) is built on
|
||||
top of this stream for trade-relevant events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import BoundLogger
|
||||
from structlog.types import EventDict, Processor
|
||||
|
||||
|
||||
def _utc_timestamp(_: object, __: str, event_dict: EventDict) -> EventDict:
|
||||
"""Add ISO-8601 UTC timestamp with millisecond precision."""
|
||||
event_dict["ts"] = datetime.now(UTC).isoformat(timespec="milliseconds")
|
||||
return event_dict
|
||||
|
||||
|
||||
def _daily_log_path(log_dir: Path) -> Path:
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
return log_dir / f"cerbero-bite-{today}.jsonl"
|
||||
|
||||
|
||||
def configure(
|
||||
log_dir: Path | str = "data/log",
|
||||
*,
|
||||
level: str = "INFO",
|
||||
pretty_console: bool = True,
|
||||
) -> None:
|
||||
"""Configure structlog + stdlib logging.
|
||||
|
||||
Args:
|
||||
log_dir: Directory where the daily JSONL log file is written.
|
||||
Created if missing.
|
||||
level: Minimum log level for both handlers.
|
||||
pretty_console: When True, also write a human-readable stream to
|
||||
stderr. Disable in production daemon mode if not desired.
|
||||
"""
|
||||
log_dir_path = Path(log_dir)
|
||||
log_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_file = _daily_log_path(log_dir_path)
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(level)
|
||||
|
||||
handlers: list[logging.Handler] = [file_handler]
|
||||
if pretty_console:
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setLevel(level)
|
||||
handlers.append(console_handler)
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
handlers=handlers,
|
||||
level=level,
|
||||
force=True,
|
||||
)
|
||||
|
||||
shared_processors: list[Processor] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
_utc_timestamp,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*shared_processors,
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
if pretty_console:
|
||||
# Replace stderr handler's formatter with a pretty renderer
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.dev.ConsoleRenderer(colors=True),
|
||||
],
|
||||
foreign_pre_chain=shared_processors,
|
||||
)
|
||||
for handler in handlers:
|
||||
if isinstance(handler, logging.StreamHandler) and not isinstance(
|
||||
handler, logging.FileHandler
|
||||
):
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
|
||||
def get_logger(name: str | None = None, **initial_context: Any) -> BoundLogger:
|
||||
"""Return a bound structlog logger with optional initial context."""
|
||||
logger: BoundLogger = structlog.get_logger(name)
|
||||
if initial_context:
|
||||
logger = logger.bind(**initial_context)
|
||||
return logger
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared pytest fixtures for Cerbero Bite tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_data_dir(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Isolated data directory with the standard subfolder layout."""
|
||||
data = tmp_path / "data"
|
||||
(data / "log").mkdir(parents=True)
|
||||
(data / "backups").mkdir()
|
||||
yield data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_root() -> Path:
|
||||
"""Path to the project root (parent of ``src/``)."""
|
||||
return Path(__file__).resolve().parent.parent
|
||||
Vendored
@@ -0,0 +1,55 @@
|
||||
"""Smoke tests: package importable, version exposed, CLI invokable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
import cerbero_bite
|
||||
from cerbero_bite import __version__
|
||||
from cerbero_bite.cli import main as cli_main
|
||||
|
||||
|
||||
def test_package_version_string() -> None:
|
||||
assert isinstance(__version__, str)
|
||||
assert __version__.count(".") == 2
|
||||
|
||||
|
||||
def test_package_module_attribute() -> None:
|
||||
assert cerbero_bite.__version__ == __version__
|
||||
|
||||
|
||||
def test_cli_help_lists_status_command() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_main, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "status" in result.output
|
||||
|
||||
|
||||
def test_cli_status_runs(tmp_data_dir: Path) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
["--log-dir", str(tmp_data_dir / "log"), "status"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Cerbero Bite" in result.output
|
||||
assert "phase: 0" in result.output
|
||||
|
||||
|
||||
def test_cli_kill_switch_arm_placeholder(tmp_data_dir: Path) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
["--log-dir", str(tmp_data_dir / "log"), "kill-switch", "arm", "--reason", "test"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "phase 0 placeholder" in result.output
|
||||
|
||||
|
||||
def test_cli_version_flag() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_main, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert __version__ in result.output
|
||||
Reference in New Issue
Block a user