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

14 KiB
Raw Blame History

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

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

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):

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.

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

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.

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.30None (spec §3.4).

5. greeks_aggregator

Responsabilità: dato un set di legs, calcolare le greche aggregate nette dello spread.

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

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):

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.

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.