feat: Phase 1 — IV Rank, Term Structure, Skew, Options Flow (Sprint 1.1/1.2/1.3)
Backend: - iv_engine.py: ATM IV, term structure (30/60/90/180j), put/call skew, options flow (P/C OI ratio, unusual strikes, gamma bias), proxy map for futures→ETFs - database.py: iv_history table + save_iv_snapshot, get_iv_rank_percentile, get_iv_history - routers/options_vol.py: /api/options-vol/ endpoints (snapshot, batch, watchlist, history) - auto_cycle.py: inject IV context string into scoring prompt (step 3.5) - ai_analyzer.py: score_patterns_with_context accepts iv_context param - main.py: register options_vol router Frontend: - pages/OptionsLab.tsx: full IV dashboard (watchlist by IVR, term structure, skew, flow, sparkline) - pages/JournalDeBord.tsx: IvRankCell component + IV Rank column per trade - hooks/useApi.ts: useIvSnapshot, useIvWatchlist, useIvBatch, useIvHistory, useIvForTrade Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router
|
||||
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router
|
||||
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||
import os
|
||||
import uvicorn
|
||||
@@ -71,6 +71,7 @@ app.include_router(cycle_router.router)
|
||||
app.include_router(profiles_router.router)
|
||||
app.include_router(reasoning_router.router)
|
||||
app.include_router(knowledge_router.router)
|
||||
app.include_router(options_vol_router.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
157
backend/routers/options_vol.py
Normal file
157
backend/routers/options_vol.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Options volatility endpoints — IV Rank, Term Structure, Skew, Options Flow.
|
||||
"""
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
router = APIRouter(prefix="/api/options-vol", tags=["options-vol"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Simple in-memory cache to avoid hammering yfinance on every page load
|
||||
_iv_cache: dict = {}
|
||||
_CACHE_TTL_SECONDS = 3600 # 1h
|
||||
|
||||
|
||||
def _is_stale(key: str) -> bool:
|
||||
import time
|
||||
entry = _iv_cache.get(key)
|
||||
if not entry:
|
||||
return True
|
||||
return (time.time() - entry["ts"]) > _CACHE_TTL_SECONDS
|
||||
|
||||
|
||||
def _set_cache(key: str, data: dict):
|
||||
import time
|
||||
_iv_cache[key] = {"data": data, "ts": time.time()}
|
||||
|
||||
|
||||
@router.get("/snapshot/{ticker}")
|
||||
def get_iv_snapshot(ticker: str, force: bool = False):
|
||||
"""Full IV snapshot for one ticker: IV, Rank, Percentile, Term Structure, Skew, Flow."""
|
||||
key = f"snapshot:{ticker.upper()}"
|
||||
if not force and not _is_stale(key):
|
||||
return _iv_cache[key]["data"]
|
||||
|
||||
from services.iv_engine import get_full_iv_snapshot
|
||||
data = get_full_iv_snapshot(ticker)
|
||||
_set_cache(key, data)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/batch")
|
||||
def get_iv_batch(tickers: str = "SPY,QQQ,GLD,USO,UNG,XLE,TLT"):
|
||||
"""
|
||||
IV snapshot for multiple tickers (comma-separated).
|
||||
Returns a dict {ticker: snapshot}.
|
||||
Cached 1h per ticker.
|
||||
"""
|
||||
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()][:12]
|
||||
result = {}
|
||||
from services.iv_engine import get_full_iv_snapshot
|
||||
|
||||
for ticker in ticker_list:
|
||||
key = f"snapshot:{ticker}"
|
||||
if not _is_stale(key):
|
||||
result[ticker] = _iv_cache[key]["data"]
|
||||
else:
|
||||
snap = get_full_iv_snapshot(ticker)
|
||||
_set_cache(key, snap)
|
||||
result[ticker] = snap
|
||||
|
||||
return {"snapshots": result, "count": len(result)}
|
||||
|
||||
|
||||
@router.get("/watchlist")
|
||||
def get_iv_watchlist():
|
||||
"""
|
||||
IV for the core IV watchlist (portfolio-relevant tickers).
|
||||
Returns a summary list sorted by IV Rank descending.
|
||||
"""
|
||||
from services.iv_engine import IV_WATCHLIST, get_atm_iv, _resolve_ticker
|
||||
from services.database import get_iv_rank_percentile, save_iv_snapshot
|
||||
from datetime import date
|
||||
|
||||
results = []
|
||||
today = date.today().isoformat()
|
||||
|
||||
for ticker in IV_WATCHLIST:
|
||||
key = f"watchlist:{ticker}"
|
||||
if not _is_stale(key):
|
||||
results.append(_iv_cache[key]["data"])
|
||||
continue
|
||||
|
||||
iv = get_atm_iv(ticker, 30)
|
||||
if not iv:
|
||||
continue
|
||||
|
||||
proxy = _resolve_ticker(ticker)
|
||||
save_iv_snapshot(proxy, today, iv)
|
||||
rank_data = get_iv_rank_percentile(proxy, iv)
|
||||
|
||||
item = {
|
||||
"ticker": ticker,
|
||||
"iv_current_pct": round(iv * 100, 1),
|
||||
"iv_rank": rank_data.get("iv_rank"),
|
||||
"iv_percentile": rank_data.get("iv_percentile"),
|
||||
"history_days": rank_data.get("history_days", 0),
|
||||
"signal": (
|
||||
"sell_vol" if (rank_data.get("iv_rank") or 0) > 80
|
||||
else "buy_vol" if (rank_data.get("iv_rank") or 100) < 20
|
||||
else "neutral"
|
||||
),
|
||||
}
|
||||
_set_cache(key, item)
|
||||
results.append(item)
|
||||
|
||||
results.sort(key=lambda x: -(x.get("iv_rank") or 0))
|
||||
return {"items": results, "count": len(results)}
|
||||
|
||||
|
||||
@router.get("/history/{ticker}")
|
||||
def get_iv_history_endpoint(ticker: str, days: int = 90):
|
||||
"""Historical IV for a ticker (used to render IV chart)."""
|
||||
from services.database import get_iv_history
|
||||
from services.iv_engine import _resolve_ticker
|
||||
proxy = _resolve_ticker(ticker)
|
||||
history = get_iv_history(proxy, days)
|
||||
return {"ticker": ticker, "proxy": proxy, "history": history, "count": len(history)}
|
||||
|
||||
|
||||
@router.post("/refresh-watchlist")
|
||||
def refresh_watchlist(background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Trigger a background refresh of IV data for all watchlist tickers.
|
||||
Call this once daily to build up the 52-week IV history.
|
||||
"""
|
||||
def _refresh():
|
||||
from services.iv_engine import IV_WATCHLIST, get_full_iv_snapshot
|
||||
logger.info(f"[IVRefresh] Refreshing IV for {len(IV_WATCHLIST)} tickers")
|
||||
for ticker in IV_WATCHLIST:
|
||||
try:
|
||||
snap = get_full_iv_snapshot(ticker)
|
||||
key = f"snapshot:{ticker}"
|
||||
import time
|
||||
_iv_cache[key] = {"data": snap, "ts": time.time()}
|
||||
logger.debug(f"[IVRefresh] {ticker}: IV={snap.get('iv_current_pct')}% IVR={snap.get('iv_rank')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[IVRefresh] {ticker} failed: {e}")
|
||||
logger.info("[IVRefresh] Done")
|
||||
|
||||
background_tasks.add_task(_refresh)
|
||||
return {"status": "refresh started", "tickers": len(__import__("services.iv_engine", fromlist=["IV_WATCHLIST"]).IV_WATCHLIST)}
|
||||
|
||||
|
||||
@router.get("/for-trade/{underlying}")
|
||||
def get_iv_for_trade(underlying: str):
|
||||
"""
|
||||
IV snapshot specifically for a trade's underlying.
|
||||
Used by JournalDeBord to show the IV context at trade time.
|
||||
"""
|
||||
from services.iv_engine import get_full_iv_snapshot
|
||||
key = f"snapshot:{underlying.upper()}"
|
||||
if not _is_stale(key):
|
||||
return _iv_cache[key]["data"]
|
||||
data = get_full_iv_snapshot(underlying)
|
||||
_set_cache(key, data)
|
||||
return data
|
||||
@@ -326,6 +326,7 @@ def score_patterns_with_context(
|
||||
category_filter: str = None,
|
||||
macro_regime: Optional[Dict] = None,
|
||||
portfolio_lessons: Optional[Dict] = None,
|
||||
iv_context: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Score all patterns with rich context (news, prices, IV) using GPT-4o."""
|
||||
if not get_client():
|
||||
@@ -607,7 +608,7 @@ Leçons : {' | '.join(str(l)[:80] for l in lessons[:3])}
|
||||
prompt_header = f"""CONTEXTE GLOBAL:
|
||||
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
|
||||
- Top risques: {geo_score.get('top_risks', [])}
|
||||
{macro_section}{lessons_header}
|
||||
{macro_section}{lessons_header}{iv_context}
|
||||
TEMPLATE DE NOTATION:
|
||||
{scoring_template}
|
||||
|
||||
|
||||
@@ -231,6 +231,25 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
|
||||
summary["patterns_added"] = added_count
|
||||
|
||||
# ── Step 3.5: Collect IV context ─────────────────────────────────────
|
||||
iv_context = ""
|
||||
try:
|
||||
from services.iv_engine import get_iv_context_for_prompt, IV_WATCHLIST
|
||||
# Collect underlyings from current trade journal + default watchlist
|
||||
from services.database import get_mtm_trades_with_traces
|
||||
_mtm = get_mtm_trades_with_traces(days=90)
|
||||
_trade_tickers = list({
|
||||
(t.get("underlying") or "").upper()
|
||||
for t in _mtm.get("all_trades", [])
|
||||
if t.get("underlying")
|
||||
})
|
||||
_iv_tickers = (_trade_tickers + IV_WATCHLIST[:6])[:10]
|
||||
iv_context = get_iv_context_for_prompt(_iv_tickers)
|
||||
if iv_context:
|
||||
logger.info(f"[Cycle {run_id[:16]}] IV context collected for {len(_iv_tickers)} tickers")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[Cycle] IV context collection failed (non-blocking): {_e}")
|
||||
|
||||
# ── Step 4: Score ALL patterns ────────────────────────────────────────
|
||||
# Verify all patterns have IDs before scoring (guard against stale data)
|
||||
patterns_with_id = [p for p in existing if p.get("id")]
|
||||
@@ -251,6 +270,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
category_filter=None,
|
||||
macro_regime=macro_regime,
|
||||
portfolio_lessons=portfolio_lessons,
|
||||
iv_context=iv_context,
|
||||
)
|
||||
scored_with_id = [s for s in scored if s.get("pattern_id")]
|
||||
scored_without_id = [s for s in scored if not s.get("pattern_id")]
|
||||
|
||||
@@ -284,9 +284,21 @@ def init_db():
|
||||
trades_analyzed INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)""")
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS iv_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
recorded_date TEXT NOT NULL,
|
||||
iv_current REAL,
|
||||
iv_30d REAL,
|
||||
iv_60d REAL,
|
||||
iv_90d REAL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)""")
|
||||
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)")
|
||||
c.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_iv_history_ticker_date ON iv_history(ticker, recorded_date)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1501,3 +1513,63 @@ def delete_kb_entry(entry_id: int) -> bool:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── IV History ────────────────────────────────────────────────────────────────
|
||||
|
||||
def save_iv_snapshot(ticker: str, recorded_date: str, iv_current: float,
|
||||
iv_30d=None, iv_60d=None, iv_90d=None) -> None:
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO iv_history
|
||||
(ticker, recorded_date, iv_current, iv_30d, iv_60d, iv_90d)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(ticker.upper(), recorded_date, iv_current, iv_30d, iv_60d, iv_90d),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_iv_rank_percentile(ticker: str, current_iv: float, days: int = 252) -> Dict[str, Any]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT iv_current FROM iv_history
|
||||
WHERE ticker=? AND iv_current IS NOT NULL AND iv_current > 0
|
||||
ORDER BY recorded_date DESC LIMIT ?""",
|
||||
(ticker.upper(), days),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return {"iv_rank": None, "iv_percentile": None, "history_days": 0}
|
||||
|
||||
hist = [r["iv_current"] for r in rows]
|
||||
iv_min = min(hist)
|
||||
iv_max = max(hist)
|
||||
|
||||
iv_rank = (
|
||||
round((current_iv - iv_min) / (iv_max - iv_min) * 100, 1)
|
||||
if iv_max > iv_min else 50.0
|
||||
)
|
||||
iv_percentile = round(sum(1 for v in hist if v < current_iv) / len(hist) * 100, 1)
|
||||
|
||||
return {
|
||||
"iv_rank": iv_rank,
|
||||
"iv_percentile": iv_percentile,
|
||||
"history_days": len(hist),
|
||||
"iv_min_52w": round(iv_min * 100, 1),
|
||||
"iv_max_52w": round(iv_max * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def get_iv_history(ticker: str, days: int = 90) -> List[Dict]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT recorded_date, iv_current, iv_30d, iv_60d, iv_90d
|
||||
FROM iv_history WHERE ticker=? AND iv_current IS NOT NULL
|
||||
ORDER BY recorded_date DESC LIMIT ?""",
|
||||
(ticker.upper(), days),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
467
backend/services/iv_engine.py
Normal file
467
backend/services/iv_engine.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
Implied Volatility engine — IV Rank, IV Percentile, Term Structure, Skew, Options Flow.
|
||||
|
||||
Supports ETFs and US equities via yfinance options chains.
|
||||
Futures (CL=F, GC=F, etc.) don't have options in yfinance → mapped to ETF proxies.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import yfinance as yf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Ticker mapping: futures/indices → optionable ETF/stock proxies ────────────
|
||||
# Futures don't have listed options in yfinance; map to liquid ETF equivalents.
|
||||
_PROXY: Dict[str, str] = {
|
||||
"CL=F": "USO", # WTI oil → US Oil Fund
|
||||
"BZ=F": "BNO", # Brent → US Brent Oil Fund
|
||||
"NG=F": "UNG", # Natural Gas ETF
|
||||
"GC=F": "GLD", # Gold → SPDR Gold
|
||||
"SI=F": "SLV", # Silver ETF
|
||||
"HG=F": "COPX", # Copper miners ETF
|
||||
"PL=F": "PPLT", # Platinum ETF
|
||||
"ZC=F": "CORN", # Corn ETF
|
||||
"ZW=F": "WEAT", # Wheat ETF
|
||||
"ZS=F": "SOYB", # Soybean ETF
|
||||
"KC=F": "JO", # Coffee ETF
|
||||
"SB=F": "CANE", # Sugar ETF
|
||||
"^GSPC": "SPY", # S&P 500 → SPY
|
||||
"^NDX": "QQQ", # NASDAQ 100 → QQQ
|
||||
"^DJI": "DIA", # Dow → DIA
|
||||
"^STOXX50E": "FEZ", # Euro Stoxx → FEZ
|
||||
"^N225": "EWJ", # Nikkei → iShares Japan
|
||||
"^VIX": "VIXY", # VIX → VIXY (approximate)
|
||||
"EURUSD=X": "FXE", # EUR/USD ETF
|
||||
"USDJPY=X": "FXY", # Yen ETF
|
||||
"GBP=X": "FXB", # GBP ETF
|
||||
"USDCHF=X": "FXF", # CHF ETF
|
||||
"UUP": "UUP", # Already an ETF
|
||||
}
|
||||
|
||||
# Core optionable tickers to track IV for (portfolio-relevant)
|
||||
IV_WATCHLIST = [
|
||||
"SPY", "QQQ", "GLD", "SLV", "USO", "BNO", "UNG",
|
||||
"XLE", "UUP", "TLT", "GDX", "EWJ", "FEZ",
|
||||
"XOM", "CVX", "LMT", "RTX", "BA",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_ticker(ticker: str) -> str:
|
||||
"""Return the optionable proxy ticker for a given symbol."""
|
||||
return _PROXY.get(ticker.upper(), ticker.upper())
|
||||
|
||||
|
||||
def _get_current_price(t: yf.Ticker) -> Optional[float]:
|
||||
try:
|
||||
info = t.fast_info
|
||||
price = getattr(info, "last_price", None) or getattr(info, "regular_market_price", None)
|
||||
if price:
|
||||
return float(price)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
hist = t.history(period="2d", interval="1d")
|
||||
if not hist.empty:
|
||||
return float(hist["Close"].dropna().iloc[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_atm_iv(ticker: str, target_days: int = 30) -> Optional[float]:
|
||||
"""
|
||||
Get the ATM implied volatility for a ticker at the given target DTE.
|
||||
Returns IV as a decimal (0.25 = 25%). Returns None if unavailable.
|
||||
"""
|
||||
proxy = _resolve_ticker(ticker)
|
||||
try:
|
||||
t = yf.Ticker(proxy)
|
||||
price = _get_current_price(t)
|
||||
if not price:
|
||||
return None
|
||||
|
||||
expirations = t.options
|
||||
if not expirations:
|
||||
return None
|
||||
|
||||
today = date.today()
|
||||
target_date = today + timedelta(days=target_days)
|
||||
|
||||
# Find expiration closest to target_days
|
||||
best_exp = min(
|
||||
expirations,
|
||||
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - target_date).days),
|
||||
)
|
||||
|
||||
chain = t.option_chain(best_exp)
|
||||
calls = chain.calls[chain.calls["impliedVolatility"] > 0]
|
||||
puts = chain.puts[chain.puts["impliedVolatility"] > 0]
|
||||
|
||||
if calls.empty and puts.empty:
|
||||
return None
|
||||
|
||||
# ATM = strike closest to current price
|
||||
all_strikes = sorted(set(calls["strike"].tolist() + puts["strike"].tolist()))
|
||||
if not all_strikes:
|
||||
return None
|
||||
atm_strike = min(all_strikes, key=lambda s: abs(s - price))
|
||||
|
||||
ivs = []
|
||||
c = calls[calls["strike"] == atm_strike]["impliedVolatility"]
|
||||
p = puts[puts["strike"] == atm_strike]["impliedVolatility"]
|
||||
if not c.empty and float(c.iloc[0]) > 0.01:
|
||||
ivs.append(float(c.iloc[0]))
|
||||
if not p.empty and float(p.iloc[0]) > 0.01:
|
||||
ivs.append(float(p.iloc[0]))
|
||||
|
||||
if not ivs:
|
||||
return None
|
||||
return round(sum(ivs) / len(ivs), 4)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[IV] {proxy}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_term_structure(ticker: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get IV at 30d, 60d, 90d, 180d expirations.
|
||||
Returns structure type: contango | backwardation | flat.
|
||||
"""
|
||||
proxy = _resolve_ticker(ticker)
|
||||
result: Dict[str, Any] = {
|
||||
"ticker": ticker,
|
||||
"proxy": proxy,
|
||||
"iv_30d": None,
|
||||
"iv_60d": None,
|
||||
"iv_90d": None,
|
||||
"iv_180d": None,
|
||||
"structure": None,
|
||||
}
|
||||
targets = {"iv_30d": 30, "iv_60d": 60, "iv_90d": 90, "iv_180d": 180}
|
||||
|
||||
try:
|
||||
t = yf.Ticker(proxy)
|
||||
price = _get_current_price(t)
|
||||
if not price:
|
||||
return result
|
||||
|
||||
expirations = t.options
|
||||
if not expirations:
|
||||
return result
|
||||
|
||||
today = date.today()
|
||||
|
||||
for field, days in targets.items():
|
||||
target_date = today + timedelta(days=days)
|
||||
best_exp = min(
|
||||
expirations,
|
||||
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - target_date).days),
|
||||
)
|
||||
# Only use if within ±20 days of target
|
||||
actual_days = abs((datetime.strptime(best_exp, "%Y-%m-%d").date() - target_date).days)
|
||||
if actual_days > 20:
|
||||
continue
|
||||
|
||||
try:
|
||||
chain = t.option_chain(best_exp)
|
||||
calls = chain.calls[chain.calls["impliedVolatility"] > 0.01]
|
||||
puts = chain.puts[chain.puts["impliedVolatility"] > 0.01]
|
||||
all_strikes = sorted(set(calls["strike"].tolist() + puts["strike"].tolist()))
|
||||
if not all_strikes:
|
||||
continue
|
||||
atm = min(all_strikes, key=lambda s: abs(s - price))
|
||||
ivs = []
|
||||
c = calls[calls["strike"] == atm]["impliedVolatility"]
|
||||
p = puts[puts["strike"] == atm]["impliedVolatility"]
|
||||
if not c.empty:
|
||||
ivs.append(float(c.iloc[0]))
|
||||
if not p.empty:
|
||||
ivs.append(float(p.iloc[0]))
|
||||
if ivs:
|
||||
result[field] = round(sum(ivs) / len(ivs), 4)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Determine structure from 30d vs 90d
|
||||
iv30 = result.get("iv_30d")
|
||||
iv90 = result.get("iv_90d")
|
||||
if iv30 and iv90:
|
||||
diff = iv90 - iv30
|
||||
if diff > 0.015:
|
||||
result["structure"] = "contango" # vol increases with time → calm
|
||||
elif diff < -0.015:
|
||||
result["structure"] = "backwardation" # vol decreases with time → stress
|
||||
else:
|
||||
result["structure"] = "flat"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[TermStructure] {proxy}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_skew(ticker: str, target_days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
Put/Call skew: IV of 25-delta put minus IV of 25-delta call.
|
||||
Positive skew = market buying put protection (bearish hedging).
|
||||
"""
|
||||
proxy = _resolve_ticker(ticker)
|
||||
result: Dict[str, Any] = {
|
||||
"ticker": ticker,
|
||||
"proxy": proxy,
|
||||
"put_skew": None,
|
||||
"skew_pct": None,
|
||||
"interpretation": None,
|
||||
}
|
||||
try:
|
||||
t = yf.Ticker(proxy)
|
||||
price = _get_current_price(t)
|
||||
if not price:
|
||||
return result
|
||||
|
||||
expirations = t.options
|
||||
if not expirations:
|
||||
return result
|
||||
|
||||
today = date.today()
|
||||
target_date = today + timedelta(days=target_days)
|
||||
best_exp = min(
|
||||
expirations,
|
||||
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - target_date).days),
|
||||
)
|
||||
|
||||
chain = t.option_chain(best_exp)
|
||||
calls = chain.calls[chain.calls["impliedVolatility"] > 0.01].copy()
|
||||
puts = chain.puts[chain.puts["impliedVolatility"] > 0.01].copy()
|
||||
|
||||
if calls.empty or puts.empty:
|
||||
return result
|
||||
|
||||
# Approximate 25-delta strikes: ~85–90% of price for puts, ~110–115% for calls
|
||||
otm_put_strike = price * 0.90
|
||||
otm_call_strike = price * 1.10
|
||||
|
||||
put_row = puts.iloc[(puts["strike"] - otm_put_strike).abs().argsort()[:1]]
|
||||
call_row = calls.iloc[(calls["strike"] - otm_call_strike).abs().argsort()[:1]]
|
||||
|
||||
if put_row.empty or call_row.empty:
|
||||
return result
|
||||
|
||||
iv_put = float(put_row["impliedVolatility"].iloc[0])
|
||||
iv_call = float(call_row["impliedVolatility"].iloc[0])
|
||||
|
||||
put_skew = iv_put - iv_call
|
||||
result["put_skew"] = round(put_skew, 4)
|
||||
result["skew_pct"] = round(put_skew * 100, 1)
|
||||
result["iv_put_25d"] = round(iv_put * 100, 1)
|
||||
result["iv_call_25d"] = round(iv_call * 100, 1)
|
||||
|
||||
if put_skew > 0.03:
|
||||
result["interpretation"] = "Marché achète des puts — protection baissière élevée"
|
||||
elif put_skew < -0.02:
|
||||
result["interpretation"] = "Marché achète des calls — biais haussier spéculatif"
|
||||
else:
|
||||
result["interpretation"] = "Skew équilibré — pas de biais directionnel fort"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Skew] {proxy}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_options_flow(ticker: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Estimate options flow from open interest changes.
|
||||
Returns Put/Call OI ratio and unusual strike detection.
|
||||
"""
|
||||
proxy = _resolve_ticker(ticker)
|
||||
result: Dict[str, Any] = {
|
||||
"ticker": ticker,
|
||||
"proxy": proxy,
|
||||
"pc_oi_ratio": None,
|
||||
"total_call_oi": None,
|
||||
"total_put_oi": None,
|
||||
"unusual_strikes": [],
|
||||
"flow_bias": None,
|
||||
"gamma_bias": None,
|
||||
}
|
||||
try:
|
||||
t = yf.Ticker(proxy)
|
||||
price = _get_current_price(t)
|
||||
if not price:
|
||||
return result
|
||||
|
||||
expirations = t.options
|
||||
if not expirations:
|
||||
return result
|
||||
|
||||
today = date.today()
|
||||
# Aggregate OI across the next 90 days of expirations
|
||||
total_call_oi = 0
|
||||
total_put_oi = 0
|
||||
strike_oi: Dict[float, Dict[str, int]] = {}
|
||||
|
||||
for exp in expirations[:6]: # limit to first 6 expirations
|
||||
exp_date = datetime.strptime(exp, "%Y-%m-%d").date()
|
||||
if (exp_date - today).days > 90:
|
||||
break
|
||||
try:
|
||||
chain = t.option_chain(exp)
|
||||
total_call_oi += int(chain.calls["openInterest"].fillna(0).sum())
|
||||
total_put_oi += int(chain.puts["openInterest"].fillna(0).sum())
|
||||
|
||||
# Aggregate per strike
|
||||
for _, row in chain.calls.iterrows():
|
||||
s = float(row["strike"])
|
||||
strike_oi.setdefault(s, {"call": 0, "put": 0})
|
||||
strike_oi[s]["call"] += int(row.get("openInterest") or 0)
|
||||
for _, row in chain.puts.iterrows():
|
||||
s = float(row["strike"])
|
||||
strike_oi.setdefault(s, {"call": 0, "put": 0})
|
||||
strike_oi[s]["put"] += int(row.get("openInterest") or 0)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if total_call_oi + total_put_oi == 0:
|
||||
return result
|
||||
|
||||
result["total_call_oi"] = total_call_oi
|
||||
result["total_put_oi"] = total_put_oi
|
||||
pc_ratio = total_put_oi / total_call_oi if total_call_oi > 0 else None
|
||||
result["pc_oi_ratio"] = round(pc_ratio, 2) if pc_ratio else None
|
||||
|
||||
# Detect unusual strikes (OI > 1.5× median strike OI)
|
||||
all_oi = [v["call"] + v["put"] for v in strike_oi.values() if v["call"] + v["put"] > 0]
|
||||
if all_oi:
|
||||
import statistics
|
||||
median_oi = statistics.median(all_oi)
|
||||
threshold = median_oi * 3
|
||||
unusual = [
|
||||
{
|
||||
"strike": round(s, 2),
|
||||
"call_oi": d["call"],
|
||||
"put_oi": d["put"],
|
||||
"total_oi": d["call"] + d["put"],
|
||||
"type": "call" if d["call"] > d["put"] else "put",
|
||||
"pct_otm": round((s - price) / price * 100, 1),
|
||||
}
|
||||
for s, d in strike_oi.items()
|
||||
if d["call"] + d["put"] > threshold
|
||||
]
|
||||
result["unusual_strikes"] = sorted(unusual, key=lambda x: -x["total_oi"])[:5]
|
||||
|
||||
# Net gamma bias: are dealers long or short gamma at current price?
|
||||
atm_calls_oi = sum(d["call"] for s, d in strike_oi.items() if abs(s - price) / price < 0.03)
|
||||
atm_puts_oi = sum(d["put"] for s, d in strike_oi.items() if abs(s - price) / price < 0.03)
|
||||
if atm_calls_oi + atm_puts_oi > 0:
|
||||
# Dealers are typically short what they sell (opposite of OI)
|
||||
# More call OI = dealers are short calls = short gamma (amplifies moves)
|
||||
# More put OI = dealers are short puts = long gamma (dampens moves)
|
||||
if atm_puts_oi > atm_calls_oi * 1.3:
|
||||
result["gamma_bias"] = "Dealers long gamma (amortit les mouvements)"
|
||||
elif atm_calls_oi > atm_puts_oi * 1.3:
|
||||
result["gamma_bias"] = "Dealers short gamma (amplifie les mouvements)"
|
||||
else:
|
||||
result["gamma_bias"] = "Gamma neutre"
|
||||
|
||||
if pc_ratio:
|
||||
if pc_ratio > 1.3:
|
||||
result["flow_bias"] = "Défensif — smart money achète des puts"
|
||||
elif pc_ratio < 0.7:
|
||||
result["flow_bias"] = "Spéculatif — OI calls dominant"
|
||||
else:
|
||||
result["flow_bias"] = "Équilibré"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[OptionsFlow] {proxy}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_full_iv_snapshot(ticker: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Full IV snapshot for a ticker: current IV, rank/percentile, term structure, skew, flow.
|
||||
Calls DB for historical rank/percentile.
|
||||
"""
|
||||
from services.database import get_iv_rank_percentile, save_iv_snapshot
|
||||
|
||||
proxy = _resolve_ticker(ticker)
|
||||
today = date.today().isoformat()
|
||||
|
||||
iv_current = get_atm_iv(ticker, target_days=30)
|
||||
term = get_term_structure(ticker)
|
||||
skew = get_skew(ticker, target_days=30)
|
||||
flow = get_options_flow(ticker)
|
||||
|
||||
rank_data: Dict[str, Any] = {}
|
||||
if iv_current:
|
||||
# Save to history first, then calculate rank
|
||||
save_iv_snapshot(proxy, today, iv_current, term.get("iv_30d"), term.get("iv_60d"), term.get("iv_90d"))
|
||||
rank_data = get_iv_rank_percentile(proxy, iv_current)
|
||||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"proxy": proxy,
|
||||
"iv_current_pct": round(iv_current * 100, 1) if iv_current else None,
|
||||
"iv_rank": rank_data.get("iv_rank"),
|
||||
"iv_percentile": rank_data.get("iv_percentile"),
|
||||
"history_days": rank_data.get("history_days", 0),
|
||||
"iv_min_52w_pct": rank_data.get("iv_min_52w"),
|
||||
"iv_max_52w_pct": rank_data.get("iv_max_52w"),
|
||||
"term_structure": term,
|
||||
"skew": skew,
|
||||
"options_flow": flow,
|
||||
"fetched_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def get_iv_context_for_prompt(tickers: List[str]) -> str:
|
||||
"""
|
||||
Build a compact IV context string to inject into GPT-4o scoring prompts.
|
||||
"""
|
||||
lines = []
|
||||
for ticker in tickers[:8]:
|
||||
proxy = _resolve_ticker(ticker)
|
||||
iv = get_atm_iv(ticker, 30)
|
||||
if not iv:
|
||||
continue
|
||||
from services.database import get_iv_rank_percentile, save_iv_snapshot
|
||||
today = date.today().isoformat()
|
||||
save_iv_snapshot(proxy, today, iv, None, None, None)
|
||||
rank = get_iv_rank_percentile(proxy, iv)
|
||||
|
||||
iv_rank = rank.get("iv_rank")
|
||||
hist_days = rank.get("history_days", 0)
|
||||
|
||||
skew_data = get_skew(ticker, 30)
|
||||
skew_pct = skew_data.get("skew_pct")
|
||||
|
||||
term = get_term_structure(ticker)
|
||||
structure = term.get("structure")
|
||||
|
||||
rank_str = f"IVR={iv_rank:.0f}%" if iv_rank is not None and hist_days >= 30 else "IVR=N/A (historique insuffisant)"
|
||||
skew_str = f"skew={skew_pct:+.1f}pts" if skew_pct is not None else ""
|
||||
term_str = f"structure={structure}" if structure else ""
|
||||
|
||||
flag = ""
|
||||
if iv_rank and iv_rank > 80:
|
||||
flag = " ⚠️ IV ÉLEVÉE — préférer vendre de la vol"
|
||||
elif iv_rank and iv_rank < 20:
|
||||
flag = " ✓ IV BASSE — bon moment pour acheter de la convexité"
|
||||
|
||||
parts = [f"{ticker}/{proxy}", f"IV={iv*100:.1f}%", rank_str]
|
||||
if skew_str:
|
||||
parts.append(skew_str)
|
||||
if term_str:
|
||||
parts.append(term_str)
|
||||
if flag:
|
||||
parts.append(flag)
|
||||
|
||||
lines.append(" " + " | ".join(parts))
|
||||
|
||||
if not lines:
|
||||
return ""
|
||||
return "=== VOLATILITÉ IMPLICITE ===\n" + "\n".join(lines) + "\n⚠️ RÈGLE: Si IVR>80%, pénaliser les stratégies acheteuses de vol (long call/put, straddle). Si IVR<20%, favoriser l'achat de convexité.\n"
|
||||
Reference in New Issue
Block a user