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:
2026-04-26 23:10:30 +02:00
commit 881bc8a1bf
40 changed files with 6018 additions and 0 deletions
+460
View File
@@ -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.