881bc8a1bf
- 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>
461 lines
14 KiB
Markdown
461 lines
14 KiB
Markdown
# 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.
|