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:
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user