Files
Cerbero-Bite/docs/03-algorithms.md
T
Adriano 881bc8a1bf 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>
2026-04-26 23:10:30 +02:00

461 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.