feat(strategy): abbandono gating settimanale — entry daily 24/7

Crypto opera 24/7: la cadenza settimanale lunedì-only era un retaggio
TradFi senza giustificazione. La nuova cadenza è giornaliera (cron
0 14 * * *), con i gate quantitativi a decidere se entrare o saltare.

Cambiamenti principali:

* runtime/orchestrator.py — _CRON_ENTRY 0 14 * * * (era MON)
* runtime/auto_pause.py — pause_until(days=) (era weeks=); minimo
  clamp 1 giorno (era 1 settimana)
* core/backtest.py — MondayPick→DailyPick, monday_picks→daily_picks
  (1 pick per calendar-day all'ora target); Sharpe annualization su
  ~120 trade/anno (era 52)
* config/schema.py — default cron daily; max_concurrent_positions 1→5;
  AutoPauseConfig.pause_weeks→pause_days, default 14
* runtime/option_chain_snapshot_cycle.py + orchestrator — cron */15
  per accumulo continuo dataset di backtest empirico

Strategy yamls (config_version 1.3.0 → 1.4.0, hash rigenerati):

* strategy.yaml — max_concurrent 1→5, cap_aggregate coerente
* strategy.aggressiva.yaml — max_concurrent 2→8, cap_aggregate
  3200→6400, max_contracts_per_trade invariato a 16
* strategy.conservativa.yaml — max_concurrent 1→3
* tutti — pause_weeks→pause_days: 14

GUI (pages/7_📚_Strategia.py):

* slider Trade/anno: range 20-200 (era 8-30), default 110, help
  riallineato sulla math 365 candidature × pass-rate 30-40%
* card profili: versione letta dinamicamente da config_version invece
  che hard-coded "v1.2.0"
* warning "entrambi perdono soldi" ora valuta i P/L effettivi
  (cons['annual_pl'], aggr['annual_pl']) invece del win_rate grezzo;
  aggiunto stato intermedio quando solo conservativo è in perdita

Tests (450/450 passati):

* test_auto_pause: pause_days, clamp ≥1 giorno
* test_backtest: rinomina + ridisegno daily picks (assert su
  calendar-day dedupe e hour filter)
* test_sizing_engine: other_open_positions=5 per cap default
* test_config_loader: version 1.4.0

Docs (README + 9 file in docs/) — tutti i riferimenti weekly/lunedì
allineati a daily/24-7, volume option_chain ricalcolato per cron
*/15 (~1.1 MB/giorno, ~400 MB/anno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 16:21:16 +00:00
parent dabcc8d15b
commit 6ff021fbf4
26 changed files with 216 additions and 181 deletions
+4 -4
View File
@@ -789,7 +789,7 @@ def backtest(
table = Table(title=f"Backtest report — {strategy_path.name}")
table.add_column("Metrica", style="cyan")
table.add_column("Valore", style="bold")
table.add_row("Picks (lunedì 14:00)", str(report.n_picks))
table.add_row("Picks (daily 14:00)", str(report.n_picks))
table.add_row(
"Accettati dai filtri",
f"{report.n_accepted} ({report.n_accepted / max(1, report.n_picks):.0%})",
@@ -815,7 +815,7 @@ def backtest(
if report.skip_reasons:
skip_table = Table(title="Motivi di skip aggregati")
skip_table.add_column("Motivo")
skip_table.add_column("Settimane", justify="right")
skip_table.add_column("Giorni", justify="right")
for reason, count in sorted(
report.skip_reasons.items(), key=lambda kv: -kv[1]
):
@@ -1004,8 +1004,8 @@ def option_chain_trigger(
) -> None:
"""Esegue UNA volta il collector della catena opzioni e persiste in DB.
Utile per popolare i dati senza aspettare il cron settimanale del
job ``option_chain_snapshot``. Riusa esattamente la stessa pipeline
Utile per popolare i dati senza aspettare il cron del job
``option_chain_snapshot``. Riusa esattamente la stessa pipeline
schedulata.
"""
from cerbero_bite.runtime.dependencies import build_runtime # noqa: PLC0415
+5 -5
View File
@@ -47,7 +47,7 @@ class EntryConfig(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
cron: str = "0 14 * * MON"
cron: str = "0 14 * * *"
skip_holidays_country: str = "IT"
# access filters (§2)
@@ -183,7 +183,7 @@ class SizingConfig(BaseModel):
cap_per_trade_eur: Decimal = Field(default=Decimal("200"))
cap_aggregate_open_eur: Decimal = Field(default=Decimal("1000"))
max_concurrent_positions: int = 1
max_concurrent_positions: int = 5
max_contracts_per_trade: int = 4
dvol_adjustment: list[DvolAdjustmentBand] = Field(default_factory=_default_dvol_bands)
@@ -266,8 +266,8 @@ class AutoPauseConfig(BaseModel):
Quando abilitato, il rule engine valuta — prima di ogni entry —
il P/L cumulato delle ultime `lookback_trades` posizioni chiuse
in proporzione al capitale attuale. Se la perdita supera la
soglia, l'engine si auto-mette in pausa per `pause_weeks`
settimane (skip-week). La pausa si annulla automaticamente alla
soglia, l'engine si auto-mette in pausa per `pause_days`
giorni (skip-day). La pausa si annulla automaticamente alla
scadenza, oppure manualmente via comando dalla GUI.
Difende da regime change non rilevati dai filtri quant: se i
@@ -282,7 +282,7 @@ class AutoPauseConfig(BaseModel):
enabled: bool = False
lookback_trades: int = 5
max_drawdown_pct: Decimal = Field(default=Decimal("0.10"))
pause_weeks: int = 2
pause_days: int = 14
# ---------------------------------------------------------------------------
+29 -30
View File
@@ -2,7 +2,7 @@
Two layers, both pure functions:
1. **Entry-filter simulation** — for each Monday 14:00 UTC tick in the
1. **Entry-filter simulation** — for each daily 14:00 UTC tick in the
recorded snapshots, evaluate which §2 gates would have passed,
reconstructing :class:`EntryContext` from the snapshot. This part
is **rigorous**: it uses the same :func:`validate_entry` the live
@@ -49,12 +49,12 @@ __all__ = [
"BacktestEntry",
"BacktestExit",
"BacktestReport",
"MondayPick",
"DailyPick",
"bs_put_delta",
"bs_put_price",
"daily_picks",
"estimate_credit_eth",
"find_strike_for_delta",
"monday_picks",
"normal_cdf",
"run_backtest",
"simulate_entry_filters",
@@ -184,41 +184,40 @@ def estimate_credit_eth(
@dataclass(frozen=True)
class MondayPick:
"""Indice di un tick "Monday 14:00 UTC" nella time-series."""
class DailyPick:
"""Indice di un tick "daily h:00 UTC" nella time-series."""
timestamp: datetime
snapshot: MarketSnapshotRecord
def monday_picks(
def daily_picks(
snapshots: list[MarketSnapshotRecord],
*,
weekday: int = 0, # Monday
hour_utc: int = 14,
asset: str = "ETH",
) -> list[MondayPick]:
"""Estrae i tick più vicini a "Monday h:00 UTC" per ogni settimana.
) -> list[DailyPick]:
"""Estrae un tick per giorno all'ora ``hour_utc``.
``snapshots`` deve essere ordinato per timestamp ascending. Per ogni
occorrenza di ``weekday + hour_utc`` (es. lun 14:00) presa l'unica
riga ETH che la copre. Settimane senza tick a quell'ora vengono
saltate.
Crypto è 24/7, quindi non gateiamo sul giorno della settimana: per
ogni giorno di calendario presente in ``snapshots``, prendiamo la
riga ETH che cade a ``hour_utc:00``. Giorni senza tick a quell'ora
vengono saltati. ``snapshots`` deve essere ordinato per timestamp
ascending.
"""
picks: list[MondayPick] = []
seen_dates: set[tuple[int, int]] = set() # (iso_year, iso_week)
picks: list[DailyPick] = []
seen_dates: set[tuple[int, int, int]] = set() # (year, month, day)
for snap in snapshots:
if snap.asset.upper() != asset.upper():
continue
ts = snap.timestamp.astimezone(UTC)
if ts.weekday() != weekday or ts.hour != hour_utc:
if ts.hour != hour_utc:
continue
iso_y, iso_w, _ = ts.isocalendar()
key = (iso_y, iso_w)
key = (ts.year, ts.month, ts.day)
if key in seen_dates:
continue
seen_dates.add(key)
picks.append(MondayPick(timestamp=ts, snapshot=snap))
picks.append(DailyPick(timestamp=ts, snapshot=snap))
return picks
@@ -231,9 +230,8 @@ def _entry_context_from_snapshot(
"""Costruisce :class:`EntryContext` dal tick storico.
``None`` quando la riga non ha i campi minimi (spot, dvol, funding).
Nel filtro questo si traduce in "skip della settimana" — è la
stessa logica del live: un tick incompleto è meglio di un'entry
al buio.
Nel filtro questo si traduce in "skip del giorno" — è la stessa
logica del live: un tick incompleto è meglio di un'entry al buio.
"""
if snap.dvol is None or snap.funding_perp_annualized is None:
return None
@@ -254,21 +252,21 @@ def _entry_context_from_snapshot(
@dataclass(frozen=True)
class EntryFilterResult:
"""Esito del check filtri per una singola Monday pick."""
"""Esito del check filtri per una singola daily pick."""
pick: MondayPick
pick: DailyPick
accepted: bool
reasons: list[str]
skipped_for_data: bool # True se il tick non aveva i campi minimi
def simulate_entry_filters(
picks: list[MondayPick],
picks: list[DailyPick],
cfg: StrategyConfig,
*,
capital_usd: Decimal,
) -> list[EntryFilterResult]:
"""Per ogni Monday pick, valuta validate_entry come farebbe il live.
"""Per ogni daily pick, valuta validate_entry come farebbe il live.
Rigoroso: usa esattamente :func:`validate_entry` e :class:`EntryContext`.
Restituisce la lista degli esiti, una entry per pick.
@@ -500,7 +498,7 @@ class BacktestReport(BaseModel):
def _build_entry_from_pick(
pick: MondayPick,
pick: DailyPick,
cfg: StrategyConfig,
*,
capital_usd: Decimal,
@@ -566,7 +564,8 @@ def _max_drawdown_usd(equity: list[Decimal]) -> tuple[Decimal, Decimal]:
def _sharpe_annualized(pnls_usd: list[Decimal], capital_usd: Decimal) -> Decimal | None:
"""Annualized Sharpe approximation: 52 trade/anno (settimanali).
"""Annualized Sharpe approximation: ~120 trade/anno (entry daily,
crypto 24/7, post-filtri + concurrency cap).
Restituisce ``None`` se ci sono <5 trade o stdev = 0.
"""
@@ -578,7 +577,7 @@ def _sharpe_annualized(pnls_usd: list[Decimal], capital_usd: Decimal) -> Decimal
std = math.sqrt(var)
if std == 0:
return None
sharpe = mean / std * math.sqrt(52)
sharpe = mean / std * math.sqrt(120)
return Decimal(str(round(sharpe, 3)))
@@ -593,7 +592,7 @@ def run_backtest(
"""Esegue il backtest end-to-end sui ``snapshots`` ETH ordinati per ts."""
snapshots = sorted(snapshots, key=lambda s: s.timestamp)
eth_snapshots = [s for s in snapshots if s.asset.upper() == asset.upper()]
picks = monday_picks(eth_snapshots, asset=asset)
picks = daily_picks(eth_snapshots, asset=asset)
filter_results = simulate_entry_filters(picks, cfg, capital_usd=capital_usd)
# Tally skip reasons
+31 -12
View File
@@ -435,7 +435,7 @@ def _compute_pl(
- ``A`` (delta dinamico, §3.2): +1.5 pp win-rate, sl_loss × 0.95.
- ``D`` (vol-harvest, §7-bis): 5% delle would-be-loss diventano
harvest exit a +0.20 × credito.
- ``F`` (auto-pause, §7-bis): 8% trade/anno (skip-week dopo
- ``F`` (auto-pause, §7-bis): 8% trade/anno (skip-day dopo
streak), e nei calcoli di drawdown atteso il streak_99 è
cappato a lookback_trades=5.
@@ -691,8 +691,13 @@ def _render_pl_panel(
),
)
trades_per_year = col_d.slider(
"Trade / anno (post-filtri)", 8, 30, value=18, step=1,
help="52 lunedì × probabilità di superare i filtri (3050%).",
"Trade / anno (post-filtri)", 20, 200, value=110, step=5,
help=(
"Crypto è 24/7: l'entry cycle gira ogni giorno alle 14:00 UTC "
"(`0 14 * * *`). 365 candidature × ~30-50% pass-rate effettivo "
"(post-filtri + cap concorrenza) ≈ 110-180/anno. Auto-pause F "
"riduce ulteriormente di ~8% in regime drawdown."
),
)
cons_caps = _profile_caps(strategy_conservativa or strategy_main)
@@ -746,13 +751,17 @@ def _render_pl_panel(
features=feats_aggr,
)
cons_version = getattr(
strategy_conservativa or strategy_main, "config_version", "?"
)
aggr_version = getattr(strategy_aggressiva, "config_version", "?")
col_cons, col_aggr = st.columns(2)
with col_cons:
_render_profile_card(
"🛡️ Conservativa",
cons_caps,
cons,
"_(golden config v1.2.0)_",
f"_(golden config v{cons_version})_",
features=feats_cons,
metrics_base=cons_base if apply_features and any(feats_cons.values()) else None,
)
@@ -761,7 +770,7 @@ def _render_pl_panel(
"🔥 Aggressiva",
aggr_caps,
aggr,
"_(deroga §11, richiede paper trading)_",
f"_(v{aggr_version} · deroga §11, richiede paper trading)_",
features=feats_aggr,
metrics_base=aggr_base if apply_features and any(feats_aggr.values()) else None,
)
@@ -774,14 +783,24 @@ def _render_pl_panel(
"APR). Drawdown atteso scala con lo stesso fattore."
)
if win_rate < 0.72:
if cons["annual_pl"] < 0 and aggr["annual_pl"] < 0:
st.error(
"**Win rate sotto 0.72: entrambi i profili perdono soldi.** "
"Selling vol nudo è strutturalmente neutro qui. L'edge della "
"strategia sono i FILTRI (dealer gamma>0, no macro, "
"liquidation≠high, bias chiaro) che alzano il win rate sopra "
"il 0.75. Senza filtri attivi nessuno dei due profili è "
"viable."
f"**Entrambi i profili in perdita** (cons {cons['apr']:+.1%}, "
f"aggr {aggr['apr']:+.1%} APR). Selling vol nudo a win rate "
f"{win_rate:.0%} è strutturalmente non profittevole. L'edge "
"sono i FILTRI (dealer gamma>0, no macro, liquidation≠high, "
"bias chiaro) e i miglioramenti F+D+A+IV-RV gate, che alzano "
"il win rate effettivo sopra ~0.75 e/o riducono i tail loss. "
"Spunta l'opzione 'Applica gli effetti dei miglioramenti' qui "
"sopra per vedere i numeri con i filtri attivi."
)
elif cons["annual_pl"] < 0:
st.warning(
f"**Conservativo in perdita** ({cons['apr']:+.1%} APR), "
f"aggressivo positivo ({aggr['apr']:+.1%} APR). I miglioramenti "
"F+D+A+IV-RV gate stanno facendo il loro lavoro sull'aggressivo. "
"Sul conservativo i cap stretti riducono troppo il P/L atteso "
"a questo win rate."
)
# === Mini-tabella: contributo marginale di ogni feature =====
+6 -6
View File
@@ -84,15 +84,15 @@ def is_paused(
)
def pause_until(now: datetime, weeks: int) -> datetime:
"""Calcola la scadenza della pausa (``now + weeks``).
def pause_until(now: datetime, days: int) -> datetime:
"""Calcola la scadenza della pausa (``now + days``).
Estratto in funzione separata per facilitare i test e per ricordare
che la pausa è espressa in **settimane** (la strategia ha cron
settimanale; pause più corte non avrebbero modo di evitare una
settimana di entry).
che la pausa è espressa in **giorni** (la strategia ha cron
giornaliero su crypto 24/7; pause sub-giornaliere non avrebbero
modo di evitare un'entry).
"""
return now + timedelta(weeks=max(1, weeks))
return now + timedelta(days=max(1, days))
def evaluate_drawdown_breach(
+9 -7
View File
@@ -1,9 +1,11 @@
"""Weekly entry decision loop (``docs/06-operational-flow.md`` §2).
"""Daily entry decision loop (``docs/06-operational-flow.md`` §2).
Pure orchestration over the existing core/clients/state primitives.
The cycle is auto-execute: when every gate passes, the engine sends
the combo order without asking Adriano. Telegram is used only to
notify the outcome.
Crypto è 24/7: la cadenza di candidatura non è gateata sulla
settimana, sono i gate quantitativi a decidere se entrare o saltare
il giorno. Pure orchestration over the existing core/clients/state
primitives. The cycle is auto-execute: when every gate passes, the
engine sends the combo order without asking Adriano. Telegram is
used only to notify the outcome.
"""
from __future__ import annotations
@@ -328,7 +330,7 @@ async def run_entry_cycle(
eur_to_usd_rate: Decimal,
now: datetime | None = None,
) -> EntryCycleResult:
"""Run one weekly entry evaluation cycle.
"""Run one daily entry evaluation cycle.
The function is idempotent and side-effect aware: it persists the
decision in the ``decisions`` table regardless of outcome and only
@@ -406,7 +408,7 @@ async def run_entry_cycle(
capital_usd=capital_usd,
)
if breach.should_pause:
until = auto_pause_module.pause_until(when, auto_cfg.pause_weeks)
until = auto_pause_module.pause_until(when, auto_cfg.pause_days)
conn = connect_state(ctx.db_path)
try:
with transaction(conn):
@@ -1,10 +1,11 @@
"""Periodic option-chain snapshot collector (§13).
Fetches the Deribit option chain for every strike entro la finestra
DTE configurata, prima del trigger entry settimanale (cron
``55 13 * * MON`` di default). Persiste un quote per ogni strumento
in ``option_chain_snapshots`` con un timestamp condiviso, che diventa
il dato di base per:
DTE configurata. Cadenza di default ``*/15 * * * *`` (allineata a
``market_snapshot``: crypto è 24/7 e l'accumulo dataset deve essere
continuo, non gateato sui rollover TradFi-style). Persiste un quote
per ogni strumento in ``option_chain_snapshots`` con un timestamp
condiviso, che diventa il dato di base per:
* il backtest non-stilizzato (vedi ``core/backtest.py``),
* la calibrazione empirica dello skew premium e del credit/width
+2 -2
View File
@@ -50,13 +50,13 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator")
Environment = Literal["testnet", "mainnet"]
# Default cron schedule (matches docs/06-operational-flow.md table).
_CRON_ENTRY = "0 14 * * MON"
_CRON_ENTRY = "0 14 * * *" # crypto 24/7: candidatura giornaliera; i gate decidono se entrare
_CRON_MONITOR = "0 2,14 * * *"
_CRON_HEALTH = "*/5 * * * *"
_CRON_BACKUP = "0 * * * *"
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
_CRON_MARKET_SNAPSHOT = "*/15 * * * *"
_CRON_OPTION_CHAIN_SNAPSHOT = "55 13 * * MON" # 5 min prima del trigger entry
_CRON_OPTION_CHAIN_SNAPSHOT = "*/15 * * * *" # crypto è 24/7: cadenza continua allineata a market_snapshot
_BACKUP_RETENTION_DAYS = 30