feat(state+runtime+gui): market_snapshots — calibrazione soglie da dati

Sistema dedicato di raccolta dati per scegliere le soglie dei filtri
sui percentili reali invece di valori a istinto.

Nuovi componenti:

* state/migrations/0003_market_snapshots.sql — tabella + index, PK
  composta (timestamp, asset). Ogni colonna numerica è NULL-able per
  preservare la continuità della serie quando un singolo MCP fallisce.
* state/models.py — MarketSnapshotRecord Pydantic.
* state/repository.py — record_market_snapshot, list_market_snapshots,
  _row_to_market_snapshot.
* runtime/market_snapshot_cycle.py — collettore best-effort che chiama
  spot/dvol/realized_vol/dealer_gamma/funding_perp/funding_cross/
  liquidation_heatmap/macro per ogni asset; raccoglie gli errori in
  fetch_errors_json e segna fetch_ok=false ma persiste comunque la
  riga.
* clients/deribit.py — generalizzati dealer_gamma_profile(currency),
  realized_vol(currency), spot_perp_price(asset). dealer_gamma_profile_eth
  resta come alias per la chiamata dell'entry cycle.
* runtime/orchestrator.py — nuovo job APScheduler `market_snapshot`
  cron */15 con assets configurabili (default ETH+BTC); il consumer
  manual_actions ora dispatcha anche kind=run_cycle cycle=market_snapshot
  per la GUI.
* gui/data_layer.py — load_market_snapshots, enqueue_run_cycle accetta
  market_snapshot; tipo MarketSnapshotRecord esposto.
* gui/pages/6_📐_Calibrazione.py — selezione asset+finestra, conteggio
  fetch_ok, per ogni metrica: istogramma, soglia da strategy.yaml come
  vline rossa, percentili P5/P10/P25/P50/P75/P90/P95, % di tick che la
  soglia avrebbe filtrato.
* gui/pages/1_📊_Status.py — bottone "📐 Forza snapshot" (4° del pannello
  Forza ciclo) per popolare la tabella senza aspettare il cron.

5 nuovi test sul collector (happy, fault tolerance, asset switch,
macro fail, empty assets); test_orchestrator job set aggiornato.
368/368 tests pass; ruff clean; mypy strict src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:39:09 +02:00
parent 63d1aa4262
commit d9454fc996
11 changed files with 956 additions and 7 deletions
+86
View File
@@ -23,6 +23,7 @@ from cerbero_bite.state.models import (
DvolSnapshot,
InstructionRecord,
ManualAction,
MarketSnapshotRecord,
PositionRecord,
PositionStatus,
SystemStateRecord,
@@ -346,6 +347,66 @@ class Repository:
),
)
# ------------------------------------------------------------------
# market_snapshots
# ------------------------------------------------------------------
def record_market_snapshot(
self, conn: sqlite3.Connection, snapshot: MarketSnapshotRecord
) -> None:
conn.execute(
"INSERT OR REPLACE INTO market_snapshots("
"timestamp, asset, spot, dvol, realized_vol_30d, iv_minus_rv, "
"funding_perp_annualized, funding_cross_annualized, "
"dealer_net_gamma, gamma_flip_level, oi_delta_pct_4h, "
"liquidation_long_risk, liquidation_short_risk, "
"macro_days_to_event, fetch_ok, fetch_errors_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
_enc_dt(snapshot.timestamp),
snapshot.asset,
_enc_dec(snapshot.spot),
_enc_dec(snapshot.dvol),
_enc_dec(snapshot.realized_vol_30d),
_enc_dec(snapshot.iv_minus_rv),
_enc_dec(snapshot.funding_perp_annualized),
_enc_dec(snapshot.funding_cross_annualized),
_enc_dec(snapshot.dealer_net_gamma),
_enc_dec(snapshot.gamma_flip_level),
_enc_dec(snapshot.oi_delta_pct_4h),
snapshot.liquidation_long_risk,
snapshot.liquidation_short_risk,
snapshot.macro_days_to_event,
1 if snapshot.fetch_ok else 0,
snapshot.fetch_errors_json,
),
)
def list_market_snapshots(
self,
conn: sqlite3.Connection,
*,
asset: str,
start: datetime | None = None,
end: datetime | None = None,
limit: int = 5000,
) -> list[MarketSnapshotRecord]:
clauses: list[str] = ["asset = ?"]
params: list[Any] = [asset]
if start is not None:
clauses.append("timestamp >= ?")
params.append(_enc_dt(start))
if end is not None:
clauses.append("timestamp <= ?")
params.append(_enc_dt(end))
params.append(int(limit))
rows = conn.execute(
f"SELECT * FROM market_snapshots WHERE {' AND '.join(clauses)} "
f"ORDER BY timestamp DESC LIMIT ?",
params,
).fetchall()
return [_row_to_market_snapshot(r) for r in rows]
# ------------------------------------------------------------------
# manual_actions
# ------------------------------------------------------------------
@@ -559,6 +620,31 @@ def _row_to_manual(row: sqlite3.Row) -> ManualAction:
)
def _row_to_market_snapshot(row: sqlite3.Row) -> MarketSnapshotRecord:
return MarketSnapshotRecord(
timestamp=_dec_dt_required(row["timestamp"]),
asset=row["asset"],
spot=_dec_dec(row["spot"]),
dvol=_dec_dec(row["dvol"]),
realized_vol_30d=_dec_dec(row["realized_vol_30d"]),
iv_minus_rv=_dec_dec(row["iv_minus_rv"]),
funding_perp_annualized=_dec_dec(row["funding_perp_annualized"]),
funding_cross_annualized=_dec_dec(row["funding_cross_annualized"]),
dealer_net_gamma=_dec_dec(row["dealer_net_gamma"]),
gamma_flip_level=_dec_dec(row["gamma_flip_level"]),
oi_delta_pct_4h=_dec_dec(row["oi_delta_pct_4h"]),
liquidation_long_risk=row["liquidation_long_risk"],
liquidation_short_risk=row["liquidation_short_risk"],
macro_days_to_event=(
int(row["macro_days_to_event"])
if row["macro_days_to_event"] is not None
else None
),
fetch_ok=bool(int(row["fetch_ok"])),
fetch_errors_json=row["fetch_errors_json"],
)
def _dec_dec_required(value: Any) -> Decimal:
out = _dec_dec(value)
if out is None: