Phase 4 hardening: dealer-gamma + liquidation-heatmap entry filters

Integra due nuovi filtri dal pacchetto quant indicators rilasciato in
Cerbero_mcp (commit a13e3fe). 335 test pass, mypy strict pulito,
ruff clean.

Filtri (§2.8 — nuovo):
- dealer-gamma: blocca entry quando total_net_dealer_gamma <
  dealer_gamma_min (default 0). Long-gamma regime favorisce credit
  spread (vol-suppressing dealer flow); short-gamma flow lo amplifica
  ed è da evitare.
- liquidation-heatmap: blocca entry quando il segnale euristico di
  cerbero-sentiment riporta long o short squeeze risk = "high"
  (cluster di liquidations imminenti entro 24h).

Entrambi sono best-effort: se il tool MCP fallisce o restituisce
dati anomali l'entry_cycle popola EntryContext con None e
validate_entry salta il gate per non bloccare entry su problemi
infrastrutturali.

Wrapper:
- DeribitClient.dealer_gamma_profile_eth → DealerGammaSnapshot.
- SentimentClient.liquidation_heatmap → LiquidationHeatmap con
  property has_high_squeeze_risk.

Schema:
- EntryConfig.dealer_gamma_min, dealer_gamma_filter_enabled,
  liquidation_filter_enabled.
- EntryContext.dealer_net_gamma, liquidation_squeeze_risk_high
  opzionali.
- strategy.yaml: nuovi campi documentati con commento + hash
  ricalcolato (4c2be4c5...).

Documentazione:
- docs/04-mcp-integration.md riscritto al modello attuale (HTTP
  REST, no mcp SDK, no memory/brain-bridge, place_combo_order
  documentato, environment_info al boot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 07:26:33 +02:00
parent b5b96f959c
commit f4faef6fd1
11 changed files with 489 additions and 190 deletions
+56
View File
@@ -22,6 +22,7 @@ from cerbero_bite.core.types import PutOrCall
__all__ = [
"ComboLegOrder",
"ComboOrderResult",
"DealerGammaSnapshot",
"DeribitClient",
"DeribitEnvironment",
"InstrumentMeta",
@@ -86,6 +87,17 @@ class ComboOrderResult(BaseModel):
raw: dict[str, Any]
class DealerGammaSnapshot(BaseModel):
"""Result of ``get_dealer_gamma_profile`` flattened to what Bite consumes."""
model_config = ConfigDict(frozen=True, extra="ignore")
spot_price: Decimal
total_net_dealer_gamma: Decimal
gamma_flip_level: Decimal | None
strikes_analyzed: int
def _parse_instrument(name: str) -> tuple[Decimal, datetime, PutOrCall]:
"""Return ``(strike, expiry, option_type)`` parsed from a Deribit instrument."""
match = _INSTRUMENT_RE.match(name)
@@ -291,6 +303,50 @@ class DeribitClient:
return Decimal(str(entry["close"]))
return None
async def dealer_gamma_profile_eth(
self,
*,
expiry_from: datetime | None = None,
expiry_to: datetime | None = None,
top_n_strikes: int = 50,
) -> DealerGammaSnapshot:
"""Return the aggregated dealer net gamma snapshot for ETH options.
Long-gamma regime (``total_net_dealer_gamma > 0``) is associated
with vol-suppressing dealer hedging — the entry filter §2.8 uses
this signal to avoid selling premium during short-gamma regimes
(vol-amplifying dealer flow).
"""
body: dict[str, Any] = {
"currency": "ETH",
"top_n_strikes": top_n_strikes,
}
if expiry_from is not None:
body["expiry_from"] = expiry_from.date().isoformat()
if expiry_to is not None:
body["expiry_to"] = expiry_to.date().isoformat()
raw = await self._http.call("get_dealer_gamma_profile", body)
if not isinstance(raw, dict):
raise McpDataAnomalyError(
"dealer_gamma_profile: unexpected response shape",
service=self.SERVICE,
tool="get_dealer_gamma_profile",
)
spot = raw.get("spot_price")
total = raw.get("total_net_dealer_gamma")
if spot is None or total is None:
raise McpDataAnomalyError(
"dealer_gamma_profile: missing spot_price or total",
service=self.SERVICE,
tool="get_dealer_gamma_profile",
)
return DealerGammaSnapshot(
spot_price=Decimal(str(spot)),
total_net_dealer_gamma=Decimal(str(total)),
gamma_flip_level=_to_decimal(raw.get("gamma_flip_level")),
strikes_analyzed=int(raw.get("strikes_analyzed") or 0),
)
async def adx_14(
self,
*,
+60 -1
View File
@@ -16,11 +16,14 @@ from __future__ import annotations
import statistics
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
__all__ = ["EXCHANGE_PERIODS_PER_YEAR", "SentimentClient"]
__all__ = ["EXCHANGE_PERIODS_PER_YEAR", "LiquidationHeatmap", "SentimentClient"]
# Funding settlement frequency per year. 1095 = 365 × 3 (8-hour funding).
@@ -32,6 +35,26 @@ EXCHANGE_PERIODS_PER_YEAR: dict[str, int] = {
}
SqueezeRiskLevel = Literal["low", "medium", "high"]
class LiquidationHeatmap(BaseModel):
"""Heuristic liquidation pressure snapshot for a single asset."""
model_config = ConfigDict(frozen=True, extra="ignore")
asset: str
avg_funding_rate: Decimal | None
oi_delta_pct_4h: Decimal | None
oi_delta_pct_24h: Decimal | None
long_squeeze_risk: SqueezeRiskLevel
short_squeeze_risk: SqueezeRiskLevel
@property
def has_high_squeeze_risk(self) -> bool:
return self.long_squeeze_risk == "high" or self.short_squeeze_risk == "high"
class SentimentClient:
SERVICE = "sentiment"
@@ -77,3 +100,39 @@ class SentimentClient:
# statistics.median works on Decimal: it returns an averaged
# Decimal for even counts, which is exactly what we want.
return Decimal(str(statistics.median(annualized)))
async def liquidation_heatmap(self, asset: str) -> LiquidationHeatmap:
"""Return the heuristic liquidation pressure snapshot for ``asset``.
Cerbero Bite uses ``has_high_squeeze_risk`` as an entry-time
filter (§2.8): when either side is flagged ``high`` we skip the
cycle to avoid selling premium right before a likely shock.
"""
raw = await self._http.call(
"get_liquidation_heatmap", {"asset": asset.upper()}
)
if not isinstance(raw, dict):
raise McpDataAnomalyError(
"liquidation_heatmap: unexpected response shape",
service=self.SERVICE,
tool="get_liquidation_heatmap",
)
def _maybe_dec(value: object) -> Decimal | None:
return None if value is None else Decimal(str(value))
long_risk = str(raw.get("long_squeeze_risk") or "low")
short_risk = str(raw.get("short_squeeze_risk") or "low")
if long_risk not in ("low", "medium", "high"):
long_risk = "low"
if short_risk not in ("low", "medium", "high"):
short_risk = "low"
return LiquidationHeatmap(
asset=str(raw.get("asset") or asset).upper(),
avg_funding_rate=_maybe_dec(raw.get("avg_funding_rate")),
oi_delta_pct_4h=_maybe_dec(raw.get("oi_delta_pct_4h")),
oi_delta_pct_24h=_maybe_dec(raw.get("oi_delta_pct_24h")),
long_squeeze_risk=long_risk, # type: ignore[arg-type]
short_squeeze_risk=short_risk, # type: ignore[arg-type]
)
+5
View File
@@ -70,6 +70,11 @@ class EntryConfig(BaseModel):
iron_condor_adx_max: Decimal = Field(default=Decimal("20"))
iron_condor_trend_neutral_band_pct: Decimal = Field(default=Decimal("0.05"))
# quant filters (§2.8 — added in Phase 4 hardening)
dealer_gamma_min: Decimal = Field(default=Decimal("0"))
dealer_gamma_filter_enabled: bool = True
liquidation_filter_enabled: bool = True
# ---------------------------------------------------------------------------
# Structure
+28
View File
@@ -37,6 +37,13 @@ class EntryContext(BaseModel):
next_macro_event_in_days: int | None
has_open_position: bool
# Quant filters (§2.8). Both are optional: when the snapshot
# collector cannot reach the underlying MCP tool the orchestrator
# passes ``None``, and ``validate_entry`` skips the gate to avoid
# blocking entries on infrastructure issues.
dealer_net_gamma: Decimal | None = None
liquidation_squeeze_risk_high: bool | None = None
class EntryDecision(BaseModel):
"""Result of :func:`validate_entry`. ``reasons`` holds *all* blocking reasons."""
@@ -103,6 +110,27 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
f"{entry_cfg.eth_holdings_pct_max})"
)
# §2.8: dealer-gamma regime gate. Skip the cycle when dealers are
# net short gamma (vol-amplifying flow) — selling premium during a
# short-gamma regime maximises path-dependent loss.
if (
entry_cfg.dealer_gamma_filter_enabled
and ctx.dealer_net_gamma is not None
and ctx.dealer_net_gamma < entry_cfg.dealer_gamma_min
):
reasons.append(
f"dealer short-gamma regime "
f"({ctx.dealer_net_gamma} < {entry_cfg.dealer_gamma_min})"
)
# §2.8: liquidation-pressure gate. Skip when the heuristic flags an
# imminent squeeze on either side.
if (
entry_cfg.liquidation_filter_enabled
and ctx.liquidation_squeeze_risk_high is True
):
reasons.append("imminent liquidation squeeze risk")
return EntryDecision(accepted=not reasons, reasons=reasons)
+33
View File
@@ -92,6 +92,8 @@ class _MarketSnapshot:
macro_days_to_event: int | None
eth_holdings_pct: Decimal
portfolio_eur: Decimal
dealer_net_gamma: Decimal | None
liquidation_squeeze_risk_high: bool | None
async def _gather_snapshot(
@@ -148,6 +150,15 @@ async def _gather_snapshot(
portfolio_t: asyncio.Task[Decimal] = asyncio.create_task(
portfolio.total_equity_eur()
)
# The two quant filters are best-effort: if the underlying tool
# fails the orchestrator passes ``None`` and validate_entry skips
# the gate (see core/entry_validator §2.8).
dealer_t: asyncio.Task[Decimal | None] = asyncio.create_task(
_safe_dealer_gamma(deribit)
)
liquidation_t: asyncio.Task[bool | None] = asyncio.create_task(
_safe_liquidation_squeeze(sentiment)
)
await asyncio.gather(
spot_t,
@@ -159,6 +170,8 @@ async def _gather_snapshot(
macro_t,
holdings_t,
portfolio_t,
dealer_t,
liquidation_t,
)
return _MarketSnapshot(
spot_eth_usd=spot_t.result(),
@@ -170,9 +183,27 @@ async def _gather_snapshot(
macro_days_to_event=macro_t.result(),
eth_holdings_pct=holdings_t.result(),
portfolio_eur=portfolio_t.result(),
dealer_net_gamma=dealer_t.result(),
liquidation_squeeze_risk_high=liquidation_t.result(),
)
async def _safe_dealer_gamma(deribit: DeribitClient) -> Decimal | None:
try:
snap = await deribit.dealer_gamma_profile_eth()
except Exception:
return None
return snap.total_net_dealer_gamma
async def _safe_liquidation_squeeze(sentiment: SentimentClient) -> bool | None:
try:
heatmap = await sentiment.liquidation_heatmap("ETH")
except Exception:
return None
return heatmap.has_high_squeeze_risk
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -321,6 +352,8 @@ async def run_entry_cycle(
eth_holdings_pct_of_portfolio=snap.eth_holdings_pct,
next_macro_event_in_days=snap.macro_days_to_event,
has_open_position=False,
dealer_net_gamma=snap.dealer_net_gamma,
liquidation_squeeze_risk_high=snap.liquidation_squeeze_risk_high,
)
decision = validate_entry(entry_ctx, cfg)
inputs = {