feat: IV gate — block ALERT trades before logging + configurable thresholds

- auto_cycle.py: pre-fetch IV snapshots at step 1.9; _apply_iv_gate() runs
  before log_trade_entries, removes ALERT-verdict trades (not just reports)
- options_technical_agent.py: _IVR_HIGH/_IVR_EXTREME/_SKEW_THRESH as
  module-level vars; straddle/strangle penalty -60 (vs -56 naked) at extreme IVR
  so Long Straddle at IVR ≥ 80% → ALERT; thresholds respected in rule engine
- database.py: seed 4 iv_gate config keys (iv_gate_enabled, iv_gate_ivr_high=60,
  iv_gate_ivr_extreme=80, iv_gate_skew_threshold=8) — editable from Config page
- Blocked trades logged as skipped_trades with [IV_GATE] detail + optimal strategy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 10:15:45 +02:00
parent 3ee39d5f08
commit c7ccf237d7
3 changed files with 189 additions and 14 deletions

View File

@@ -300,10 +300,11 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
dominant = scenarios.get("dominant", "incertain")
summary["dominant_regime"] = dominant
# ── Step 1.9: Pre-fetch IV context for strategy suggestion rules ─────
# ── Step 1.9: Pre-fetch IV context + snapshots (suggestion rules + gate)
iv_context = ""
_iv_snapshots: Dict[str, Dict] = {}
try:
from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST
from services.iv_engine import get_iv_context_for_prompt, get_full_iv_snapshot, IV_WATCHLIST
from services.database import get_mtm_trades_with_traces
_mtm_pre = get_mtm_trades_with_traces(days=90)
_trade_tickers_pre = list({
@@ -313,8 +314,17 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
})
_iv_tickers_pre = (_trade_tickers_pre + IV_WATCHLIST[:6])[:10]
iv_context = get_iv_context_for_prompt(_iv_tickers_pre)
# Also collect structured snapshots — used by IV gate before log_trade_entries
for _tkr in _iv_tickers_pre:
try:
_iv_snapshots[_tkr] = get_full_iv_snapshot(_tkr)
except Exception:
pass
if iv_context:
logger.info(f"[Cycle {run_id[:16]}] IV context pre-fetched for {len(_iv_tickers_pre)} tickers (suggestion step)")
logger.info(
f"[Cycle {run_id[:16]}] IV context pre-fetched: {len(_iv_tickers_pre)} tickers, "
f"{len(_iv_snapshots)} snapshots"
)
except Exception as _e:
logger.warning(f"[Cycle] IV context pre-fetch failed (non-blocking): {_e}")
@@ -540,6 +550,55 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
news_count=len(news), run_id=scoring_run_id)
_options_assessment = None
# ── IV Gate: block ALERT-verdict trades before logging ────────────────
_iv_gate_blocked: List[Dict] = []
try:
from services.database import get_config as _gc
if (_gc("iv_gate_enabled") or "true").lower() == "true":
# Augment _iv_snapshots with any new tickers from scored that weren't in the pre-fetch
from services.iv_engine import get_full_iv_snapshot as _gfiv
_scored_tickers = {
(tr.get("underlying") or "").upper()
for sp in scored
for tr in (sp.get("trade_rankings") or sp.get("suggested_trades") or [])
if tr.get("underlying")
}
for _stk in _scored_tickers - set(_iv_snapshots.keys()):
try:
_iv_snapshots[_stk] = _gfiv(_stk)
except Exception:
pass
scored, _iv_gate_blocked = _apply_iv_gate(scored, _iv_snapshots)
if _iv_gate_blocked:
summary["iv_gate_blocked"] = len(_iv_gate_blocked)
logger.warning(
f"[IVGate] {len(_iv_gate_blocked)} trade(s) blocked by IV rules: "
+ ", ".join(f"{b['ticker']} {b['strategy']}" for b in _iv_gate_blocked)
)
for _blk in _iv_gate_blocked:
try:
from services.database import log_skipped_trade
log_skipped_trade(
run_id=scoring_run_id,
pattern_id=_blk.get("pattern_id", ""),
pattern_name=_blk.get("pattern_name", ""),
underlying=_blk.get("ticker", ""),
strategy=_blk.get("strategy", ""),
score=_blk.get("score", 0),
expected_move_pct=0,
skip_detail=(
f"[IV_GATE] ALERT — {'; '.join(_blk.get('iv_issues', [])[:2])}. "
f"Stratégie optimale: {_blk.get('optimal_strategy', '?')}"
),
asset_class=_blk.get("asset_class", ""),
)
except Exception:
pass
except Exception as _ige:
logger.warning(f"[IVGate] Failed (non-blocking): {_ige}")
log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes)
# ── Step 5.2: Options Technical Agent — validate newly logged trades ──
@@ -808,6 +867,103 @@ Réponds UNIQUEMENT en JSON: {{"commentary": "<ton texte 4-6 phrases>", "key_ris
return None
# ── IV Gate ───────────────────────────────────────────────────────────────────
def _apply_iv_gate(
scored: List[Dict],
iv_snapshots: Dict[str, Dict],
) -> tuple:
"""
Filter ALERT-verdict trades from each scored pattern's suggested_trades/trade_rankings
before they reach log_trade_entries.
Returns (filtered_scored, blocked_trades_list).
IVR thresholds are read from app_config (iv_gate_ivr_high, iv_gate_ivr_extreme,
iv_gate_skew_threshold) so they can be tuned from the Config page.
"""
from services.options_technical_agent import assess_strategy_fit
# Read thresholds from config (fall back to hardcoded defaults)
try:
from services.database import get_config as _gc
_ivr_high = float(_gc("iv_gate_ivr_high") or 60)
_ivr_extreme = float(_gc("iv_gate_ivr_extreme") or 80)
_skew_thresh = float(_gc("iv_gate_skew_threshold") or 8)
except Exception:
_ivr_high, _ivr_extreme, _skew_thresh = 60.0, 80.0, 8.0
# Monkey-patch thresholds into the rule engine for this call
import services.options_technical_agent as _ota_mod
_orig_ivr_high = getattr(_ota_mod, "_IVR_HIGH", None)
try:
_ota_mod._IVR_HIGH = _ivr_high
_ota_mod._IVR_EXTREME = _ivr_extreme
_ota_mod._SKEW_THRESH = _skew_thresh
except Exception:
pass
all_blocked: List[Dict] = []
for sp in scored:
trade_key = "trade_rankings" if "trade_rankings" in sp else "suggested_trades"
trades = sp.get(trade_key) or []
allowed = []
for trade in trades:
ticker = (trade.get("underlying") or trade.get("ticker") or "").upper()
strategy = trade.get("strategy") or "Long Call"
snap = iv_snapshots.get(ticker, {})
if not snap:
# No IV data → can't block; allow but don't judge
allowed.append(trade)
continue
iv_rank = snap.get("iv_rank")
iv_current_pct = snap.get("iv_current_pct")
iv_min_52w = snap.get("iv_min_52w_pct")
iv_max_52w = snap.get("iv_max_52w_pct")
skew_pct = (snap.get("skew") or {}).get("skew_pct")
term_structure = (snap.get("term_structure") or {}).get("structure")
flow_bias = (snap.get("options_flow") or {}).get("flow_bias")
result = assess_strategy_fit(
strategy=strategy,
iv_rank=iv_rank,
iv_current_pct=iv_current_pct,
iv_min_52w=iv_min_52w,
iv_max_52w=iv_max_52w,
skew_pct=skew_pct,
term_structure=term_structure,
flow_bias=flow_bias,
)
if result["verdict"] == "ALERT":
blocked_entry = {
"ticker": ticker,
"strategy": strategy,
"pattern_id": sp.get("pattern_id"),
"pattern_name": sp.get("geo_trigger") or sp.get("pattern_name") or "",
"score": sp.get("score", 0),
"asset_class": trade.get("asset_class") or sp.get("asset_class") or "",
"iv_rank": iv_rank,
"iv_issues": result["issues"],
"optimal_strategy": result["optimal_strategy"],
"fit_score": result["fit_score"],
}
all_blocked.append(blocked_entry)
logger.warning(
f"[IVGate] BLOCKED {ticker} {strategy} (IVR={iv_rank}, score={result['fit_score']}): "
f"{result['issues'][0][:80] if result['issues'] else 'ALERT'}"
)
else:
allowed.append(trade)
sp[trade_key] = allowed
return scored, all_blocked
# ── Cycle report generation ───────────────────────────────────────────────────
def _generate_cycle_report(