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:
@@ -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.
|
||||
Reference in New Issue
Block a user