# 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.