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