From 3497e1d092b1e9249ec1767186cafce7e862b92f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:40:17 +0000 Subject: [PATCH] feat: the OPTIONS QUANT page from public option chains; version 1.2.0 The terminal now writes {T}_options.json for US500, USTEC, XAUUSD, XAGUSD and XTIUSD from the SPY, QQQ, GLD, SLV and USO option chains (yfinance), every 15 minutes while New York trades. It covers GEX, the gamma flip, walls, max pain, HVL, expected move, 25-delta skew, IV against realized vol, put/call ratios, dealer scenarios, the structural and gravity ladders (the UI's own formulas, ported) and the entropy card, for the swing expiries and today's 0-DTE. Levels are in the feed's price space. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01649i4SjUGvhXrPV3AiNhHN --- CHANGELOG.md | 10 + README.ar.md | 37 +- README.md | 40 +- VERSION | 2 +- backend/local_analysis/options.py | 591 +++++++++++++++++++++++++++ backend/local_analysis/runner.py | 114 +++++- backend/tests/test_local_analysis.py | 6 +- backend/tests/test_options.py | 152 +++++++ backend/version_info.py | 2 +- electron_shell/package.json | 2 +- frontend/package.json | 2 +- 11 files changed, 937 insertions(+), 21 deletions(-) create mode 100644 backend/local_analysis/options.py create mode 100644 backend/tests/test_options.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b4699..bc6f329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,17 @@ Notable changes to Quantum Terminal. The format follows ## [Unreleased] +## [1.2.0] — 2026-10-17 + ### Added +- **The OPTIONS QUANT page from public option chains:** + - US500, USTEC, XAUUSD, XAGUSD and XTIUSD from the SPY, QQQ, GLD, SLV and USO chains (yfinance); + - GEX, the gamma flip, walls, max pain, HVL, expected move, 25-delta skew, IV against realized vol, + put/call ratios, dealer scenarios, the structural and gravity ladders and the entropy card, for the + swing expiries and today's 0-DTE; + - levels moved into your feed's price space; refreshed every 15 minutes while New York trades. + - **Market regimes computed by the terminal** (the REGIMES strip above the chart, and its shading): - **MICRO:** each instrument's volatility regime, calm to stressed, from a hidden Markov model on its daily bars (2–4 states by BIC). It is refitted walk-forward every month, so no day is labelled with @@ -139,5 +148,6 @@ Initial public pre-release: the Python backend and the Electron shell as source, build. [Unreleased]: https://github.com/wJubran/QuantumTerminal/compare/v1.1.0...HEAD +[1.2.0]: https://github.com/wJubran/QuantumTerminal/releases/tag/v1.2.0 [1.1.0]: https://github.com/wJubran/QuantumTerminal/releases/tag/v1.1.0 [0.1.0-alpha]: https://github.com/wJubran/QuantumTerminal/releases/tag/v0.1.0-alpha diff --git a/README.ar.md b/README.ar.md index 83f4bd6..deb952f 100644 --- a/README.ar.md +++ b/README.ar.md @@ -254,17 +254,16 @@ npm start # or: npm run start:sim | **تطبيق سطح المكتب** | غلاف Electron اختياري يشغّل الـ backend ويعرض المنصة في نافذة مستقلة | | **الأمان** | مفتاح التداول مُطفأ افتراضياً ويفرضه السيرفر أيضاً، والسيرفر محلي فقط، مع حماية من الطلبات القادمة من مواقع أخرى ومن هجمات DNS rebinding، ومفاتيح الـ API لا تعود أبداً إلى المتصفح | -### طبقات التحليل — تحتاج بيانات محسوبة مسبقاً +### مستويات الخيارات — من سلاسل الخيارات العامة | اللوحة | ما تعرضه | |---|---| -| **Options Quant** | الـ gamma exposure ‏(GEX) وجدران call/put والـ max pain ومراكز الـ dealers | +| **Options Quant** | الـ gamma exposure ‏(GEX) ونقطة الـ flip وجدران call/put والـ max pain والحركة المتوقعة والـ IV skew وسيناريوهات الـ dealers وسلالم المستويات | | **Options Cockpit** | لوحة الخيارات في Stress Lab | -تقرأ هذه اللوحات ملفات تحليل محسوبة مسبقاً من ذاكرة التخزين المحلية (cache) نفسها التي تُكتب فيها -الـ cones والـ bands. **هذا المستودع لا ينتج هذه الملفات بعد**، لذلك تبقى هذه اللوحات فارغة بعد التثبيت، -ويظهر في أعلى المنصة SERVER ○ NONE لأنه لا يوجد سيرفر بيانات مُعدّ. توسيع المحرك -المحلي ليشمل مستويات الخيارات هو أول بند في [خطة التطوير](#خطة-التطوير). +تحسب المنصة هذه اللوحات بنفسها للأدوات US500 وUSTEC وXAUUSD وXAGUSD وXTIUSD من سلاسل خيارات SPY وQQQ +وGLD وSLV وUSO (التفاصيل في [كيف تُبنى صفحة Options Quant](#كيف-تبنى-صفحة-options-quant)). وتبقى ثلاثة +أجزاء تحتاج بيانات لا ينتجها المستودع بعد: سطح الخريطة الحرارية (Heat Map)، وبطاقة Retail Trap، وبطاقة Vanna/Charm. ## مصادر البيانات @@ -507,6 +506,32 @@ MetaTrader 5 أو TradingView أو NinjaTrader كما هي. والإطار ال الرمز الذي لا يمكن تنزيله يُستبعد بدلاً من عرضه بلا تغيّر. وتحتاج الصفحة اتصالاً بالموقع query1.finance.yahoo.com، أما أشكال خريطة العالم فتأتي من cdn.jsdelivr.net. +## كيف تُبنى صفحة Options Quant + +كل 15 دقيقة أثناء تداول نيويورك (وكل ساعتين في غير ذلك) تنزّل المنصة سلاسل خيارات SPY وQQQ وGLD وSLV +وUSO من Yahoo Finance ‏(yfinance، بدون مفتاح، وأسعار الخيارات فيه متأخرة نحو 15 دقيقة)، وتكتب الملف +{T}_options.json للأدوات US500 وUSTEC وXAUUSD وXAGUSD وXTIUSD، إذا كانت ضمن قائمة أدواتك +وكان لمصدر بياناتك سعر لها. + +- **تواريخ الانتهاء:** خيارات اليوم (0-DTE حتى الساعة 16:00 بتوقيت نيويورك)، والتواريخ الثمانية التالية + (عرض الـ swing)، وأقرب تاريخين شهريين. +- **التقلب الضمني:** قيم Yahoo بعد حذف القيم غير المنطقية وقيم الحل الفاشل، ثم منحنى smile واحد لكل + تاريخ من الجهة out-of-the-money. +- **الـ GEX:** نفترض أن الـ dealers يشترون الـ calls ويبيعون الـ puts التي يتداولها العملاء. ويُقاس بالدولار + لكل حركة 1%. والـ **flip** هو السعر الذي تتغير عنده إشارة مجموع الـ GEX. والـ **call wall** هو الـ strike + فوق السعر الحالي صاحب أكبر gamma للـ calls، والـ **put wall** الـ strike تحته صاحب أكبر gamma للـ puts، + والـ **HVL** الـ strike صاحب أكبر gamma إجمالاً. أما الـ **max pain** والـ **حركة المتوقعة** (سعر الـ + straddle عند السعر الحالي) فمن أول تاريخ انتهاء بعد اليوم. +- **دلتا الـ dealers والسيناريوهات** بعدد أسهم الـ ETF: كم ستتغير تغطيتهم إذا تحرك السعر 0.5% أو 1% في + أي اتجاه. والـ asymmetry هي نسبة بيعهم إلى شرائهم عبر ±1%. +- **Structural Integrity وVolatility Gravity** بمعادلات الواجهة نفسها + (frontend/src/lib/options_0dte_derivations.js) بعد نقلها إلى Python. +- **مقياس الأسعار:** يُضرب كل مستوى في سعر مصدر بياناتك مقسوماً على سعر الـ ETF، وكلاهما في لحظة تسعير + الـ ETF، فتقع الخطوط على الرسم البياني لديك (مثلاً SPY عند 680 ← US500 عند 6,800). + +تحتاج اتصالاً بالموقعين query1.finance.yahoo.com وquery2.finance.yahoo.com. +وإذا فشل التنزيل يبقى آخر ملف. هذه المستويات تصف مراكز السوق، وليست إشارات تداول وحدها. + ## كيف تعمل المنصة يتصل الـ backend بمصدر البيانات النشط عبر واجهة موحّدة للمزوّدين (providers)، ويرسل الأوامر إلى حساب diff --git a/README.md b/README.md index df23e8b..2532655 100644 --- a/README.md +++ b/README.md @@ -237,17 +237,17 @@ electron-builder to distribute it. | **Desktop app** | Optional Electron shell that starts the backend and opens the terminal in its own window | | **Safety** | Trading switch off by default and enforced by the server, local-only server, protection against cross-site requests and DNS rebinding, API keys never sent back to the browser | -### Analysis overlays — need pre-computed data +### Options levels — from public option chains | Panel | What it shows | |---|---| -| **Options Quant** | Gamma exposure (GEX), call/put walls, max pain and dealer positioning | +| **Options Quant** | Gamma exposure (GEX), the gamma flip, call/put walls, max pain, expected move, IV skew, dealer scenarios and the level ladders | | **Options Cockpit** | The options view in Stress Lab | -These panels read pre-computed analysis files from the same local cache the cones and bands are written -to. **This repository does not produce them yet**, so out of the box they stay empty, and the header -shows `SERVER ○ NONE` because no data server is configured. Extending the local engine to options -levels is the top item on the [roadmap](#roadmap). +The terminal computes these itself for US500, USTEC, XAUUSD, XAGUSD and XTIUSD from the SPY, QQQ, GLD, +SLV and USO option chains (see [How the Options Quant page is built](#how-the-options-quant-page-is-built)). +Three parts still need data the repository does not produce: the Heat Map surface, the Retail Trap card +and the Vanna/Charm card. ## Data sources @@ -485,6 +485,32 @@ The **MONEY FLOW** page is rebuilt every hour from daily ETF prices and volumes A ticker that can't be downloaded is left out rather than shown as flat. The page needs internet access to `query1.finance.yahoo.com`, and the world map's shapes come from `cdn.jsdelivr.net`. +## How the Options Quant page is built + +Every 15 minutes while New York trades (every 2 hours otherwise) the terminal downloads the option chains +of SPY, QQQ, GLD, SLV and USO from Yahoo Finance (yfinance, no key; Yahoo's option quotes are about 15 +minutes delayed) and writes `{T}_options.json` for US500, USTEC, XAUUSD, XAGUSD and XTIUSD, when they are +in your universe and your data source has a price for them. + +- **Expiries:** today's (0-DTE, until 16:00 New York), the next eight after today (the swing view) and the + next two monthly expiries. +- **Implied vols:** Yahoo's, cleaned of out-of-range values and of its failed solves, then one smile per + expiry from the out-of-the-money side. +- **GEX:** dealers are assumed long the calls and short the puts customers trade. GEX is in dollars per + 1% move. The **flip** is the price where total swing GEX changes sign. The **call wall** is the strike + above spot with the most call gamma, the **put wall** the strike below spot with the most put gamma, and + **HVL** the strike with the most gamma overall. **Max pain** and the **expected move** (the ATM + straddle) use the first expiry after today. +- **Dealer delta and scenarios** are in shares of the ETF: how much the dealers' hedge would change if + price moved 0.5% or 1% either way. The asymmetry is their selling over their buying across ±1%. +- **Structural Integrity and Volatility Gravity** use the UI's own formulas + (`frontend/src/lib/options_0dte_derivations.js`), ported to Python. +- **Price space:** every level is multiplied by your feed's price over the ETF's price, both taken at the + time of the ETF quote, so the lines sit on your chart (for example SPY 680 → US500 6,800). + +It needs internet access to `query1.finance.yahoo.com` and `query2.finance.yahoo.com`. When a download +fails the last file stays. These levels describe positioning; they are not trade signals on their own. + ## How it works ```mermaid @@ -631,7 +657,7 @@ Finance may be delayed. - **الأمان:** السيرفر محلي فقط، ولا تستطيع أي صفحة ويب تزورها إرسال أوامر عبر منصتك، ومفاتيح الـ API لا تغادر جهازك إلا إلى مصدرها. -لوحة Options Quant تحتاج بيانات تحليل محسوبة مسبقاً لا ينتجها المستودع بعد، فتبقى فارغة حالياً. التفاصيل في [النسخة العربية الكاملة](README.ar.md). +تحسب المنصة مستويات Options Quant بنفسها من سلاسل خيارات SPY وQQQ وGLD وSLV وUSO العامة، ما عدا الخريطة الحرارية وبطاقتي Retail Trap وVanna/Charm. التفاصيل في [النسخة العربية الكاملة](README.ar.md). ### المتطلبات diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/backend/local_analysis/options.py b/backend/local_analysis/options.py new file mode 100644 index 0000000..fc26594 --- /dev/null +++ b/backend/local_analysis/options.py @@ -0,0 +1,591 @@ +# version: v1 +""" +The OPTIONS QUANT page's file, {T}_options.json, from public ETF option +chains (Yahoo Finance through yfinance, no key, quotes delayed ~15 min): + + US500 ← SPY USTEC ← QQQ XAUUSD ← GLD XAGUSD ← SLV XTIUSD ← USO + +- Expiries: today's (0-DTE, until 16:00 New York), the next eight after + today ("swing") and the next two monthly (third-Friday) expiries. +- Implied vols: Yahoo's, cleaned (out-of-range values and its power-of-two + failure values are dropped) and turned into one smile per expiry from the + out-of-the-money side, so calls and puts at a strike share one vol. +- GEX: dealers long calls and short puts, $ of underlying per 1% move + (options_math). Flip = price where total swing GEX changes sign. +- Dealer delta (dealer_heatmap, dealer_scenarios): shares of the ETF + (x100 multiplier). dealer_delta at a strike = how much the dealers' delta + would change if price went there; negative means they would be buying. +- Prices are written in the chart's own price space ("levels_space": + "broker"): every ETF level is multiplied by broker price / ETF price, both + taken at the time of the ETF quote. Only gex_*.aggregate.strikes stay in + ETF strikes, with the ratio in cfd_ratio_used, as the UI expects. +- structural_integrity / volatility_gravity are the UI's own derivations + (frontend/src/lib/options_0dte_derivations.js), ported line for line. +""" + +from __future__ import annotations + +import logging +import math +from datetime import date, datetime, timedelta, timezone +from typing import Dict, List, Optional, Sequence + +import numpy as np + +from . import options_math as om + +log = logging.getLogger("qt.local_analysis.options") + +ASSETS = {"US500": "SPY", "USTEC": "QQQ", "XAUUSD": "GLD", "XAGUSD": "SLV", "XTIUSD": "USO"} +SWING_EXPIRIES = 8 +MONTHLIES = 2 +BAND = 0.20 # strikes kept within ±20% of spot +IV_LO, IV_HI = 0.03, 3.0 +HISTORY_CAP = 96 +YEAR = 365.0 * 86400.0 + + +# ── New York clock ────────────────────────────────────────────────────────── + +def et_offset(utc: datetime) -> timedelta: + """New York's UTC offset at a naive UTC time (US rules since 2007).""" + y = utc.year + start = datetime(y, 3, 8 + (6 - datetime(y, 3, 8).weekday()) % 7, 7) # 2nd Sunday of March, 2am EST + end = datetime(y, 11, 1 + (6 - datetime(y, 11, 1).weekday()) % 7, 6) # 1st Sunday of November, 2am EDT + return timedelta(hours=-4) if start <= utc < end else timedelta(hours=-5) + + +def to_et(utc: datetime) -> datetime: + return utc + et_offset(utc) + + +def close_utc(day: date) -> datetime: + """16:00 New York on `day`, as naive UTC.""" + return datetime(day.year, day.month, day.day, 16) - et_offset(datetime(day.year, day.month, day.day, 20)) + + +def market_open(utc: datetime) -> bool: + et = to_et(utc) + return et.weekday() < 5 and (9, 30) <= (et.hour, et.minute) < (16, 0) + + +def session_start(utc: datetime) -> datetime: + """The latest weekday 09:30 New York at or before `utc` (naive UTC).""" + et = to_et(utc) + d = et.date() + for _ in range(8): + start = datetime(d.year, d.month, d.day, 9, 30) - et_offset(datetime(d.year, d.month, d.day, 14)) + if d.weekday() < 5 and start <= utc: + return start + d -= timedelta(days=1) + return utc - timedelta(days=7) + + +def select_expiries(expiries: Sequence[str], now: datetime) -> List[str]: + """Today's expiry (until the close), the next SWING_EXPIRIES and the next MONTHLIES monthlies.""" + today = to_et(now).date() + days = sorted({date.fromisoformat(e) for e in expiries}) + future = [d for d in days if d > today] + chosen = set(future[:SWING_EXPIRIES]) + chosen |= set([d for d in future if d.weekday() == 4 and 15 <= d.day <= 21][:MONTHLIES]) + if today in days and now < close_utc(today): + chosen.add(today) + return [d.isoformat() for d in sorted(chosen)] + + +# ── Download ──────────────────────────────────────────────────────────────── + +def download_chain(etf: str, now: Optional[datetime] = None) -> Optional[dict]: + """The chain in the shape build() reads (Yahoo's own field names).""" + import yfinance as yf + now = now or datetime.now(timezone.utc).replace(tzinfo=None) + t = yf.Ticker(etf) + chains, quote = {}, {} + for e in select_expiries(list(t.options), now): + oc = t.option_chain(e) + if oc.calls is None: + continue + chains[e] = {"calls": oc.calls.to_dict("records"), "puts": oc.puts.to_dict("records")} + quote = oc.underlying or quote + if not chains: + return None + hist = t.history(period="3mo", interval="1d", auto_adjust=False) + closes = [float(c) for c in hist["Close"].tolist() if c == c and c > 0] if hist is not None else [] + return {"spot": quote.get("regularMarketPrice"), "time": quote.get("regularMarketTime"), + "open": quote.get("regularMarketOpen"), "closes": closes, "chains": chains} + + +# ── Math helpers ──────────────────────────────────────────────────────────── + +def _ncdf(x): + """Standard normal CDF (Abramowitz-Stegun 7.1.26, |error| < 1.5e-7).""" + x = np.asarray(x, float) + z = np.abs(x) / math.sqrt(2.0) + t = 1.0 / (1.0 + 0.3275911 * z) + poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))) + erf = 1.0 - poly * np.exp(-z * z) + return 0.5 * (1.0 + np.sign(x) * erf) + + +def _d1(s, k, t, iv): + t = np.maximum(t, om.MIN_T_YEARS) + with np.errstate(divide="ignore", invalid="ignore"): + return (np.log(s / k) + 0.5 * iv * iv * t) / (iv * np.sqrt(t)) + + +def dealer_delta(chain: om.Chain, spots) -> np.ndarray: + """Dealers' delta in shares (long calls, short puts) if the ETF were at each of `spots`.""" + use = chain.usable + k, t, iv, oi, call = chain.strike[use], chain.t[use], chain.iv[use], chain.oi[use], chain.is_call[use] + out = [] + for s in np.atleast_1d(spots): + nd1 = _ncdf(_d1(float(s), k, t, iv)) + delta = np.where(call, nd1, nd1 - 1.0) + out.append(float(np.sum(np.where(call, 1.0, -1.0) * delta * oi * om.MULTIPLIER))) + return np.array(out) + + +def _bs(s, k, t, iv, call: bool) -> float: + t = max(t, om.MIN_T_YEARS) + d1 = float(_d1(s, k, t, iv)) + d2 = d1 - iv * math.sqrt(t) + c = s * float(_ncdf(d1)) - k * float(_ncdf(d2)) + return c if call else c - s + k + + +def _jsround(v: float, d: int) -> float: + """JavaScript's Math.round(v * 10^d) / 10^d.""" + f = 10.0 ** d + return math.floor(v * f + 0.5) / f + + +def _r(v, d: int = 2): + return None if v is None or not math.isfinite(float(v)) else round(float(v), d) + + +def _num(v) -> float: + try: + v = float(v) + except (TypeError, ValueError): + return float("nan") + return v if math.isfinite(v) else float("nan") + + +def _iso(t: datetime) -> str: + return t.replace(microsecond=0).isoformat() + "Z" + + +def _clean(o): + if isinstance(o, dict): + return {k: _clean(v) for k, v in o.items()} + if isinstance(o, (list, tuple)): + return [_clean(v) for v in o] + if isinstance(o, (float, np.floating)): + return float(o) if math.isfinite(o) else None + if isinstance(o, np.integer): + return int(o) + return o + + +# ── The chain ─────────────────────────────────────────────────────────────── + +def _table(rows: Sequence[dict]) -> Dict[str, np.ndarray]: + get = {name: np.array([_num(r.get(key)) for r in rows], float) for name, key in + (("strike", "strike"), ("bid", "bid"), ("ask", "ask"), ("volume", "volume"), + ("oi", "openInterest"), ("iv", "impliedVolatility"))} + ok = np.isfinite(get["strike"]) & (get["strike"] > 0) + return {k: v[ok] for k, v in get.items()} + + +def _valid_iv(iv: np.ndarray) -> np.ndarray: + ok = np.isfinite(iv) & (iv >= IV_LO) & (iv <= IV_HI) + with np.errstate(divide="ignore", invalid="ignore"): + p2 = np.log2(np.where(ok, iv, 1.0)) + return ok & (np.abs(p2 - np.round(p2)) > 1e-9) # Yahoo's failed solves: 0.5, 0.25, 0.125, … + + +class Expiry: + def __init__(self, day: str, calls: dict, puts: dict, spot: float, now: datetime): + self.day = day + self.t = (close_utc(date.fromisoformat(day)) - now).total_seconds() / YEAR + otm_k = np.concatenate([calls["strike"][calls["strike"] >= spot], puts["strike"][puts["strike"] < spot]]) + otm_iv = np.concatenate([calls["iv"][calls["strike"] >= spot], puts["iv"][puts["strike"] < spot]]) + ok = _valid_iv(otm_iv) + order = np.argsort(otm_k[ok]) + self.smile_k, self.smile_iv = otm_k[ok][order], otm_iv[ok][order] + self.calls, self.puts = calls, puts + + @property + def usable(self) -> bool: + return self.t > 0 and self.smile_k.size >= 3 + + def iv(self, k) -> np.ndarray: + return np.interp(np.asarray(k, float), self.smile_k, self.smile_iv) + + def chain(self) -> om.Chain: + k = np.concatenate([self.calls["strike"], self.puts["strike"]]) + oi = np.nan_to_num(np.concatenate([self.calls["oi"], self.puts["oi"]])) + n = k.size + return om.Chain(strike=k, is_call=np.arange(n) < self.calls["strike"].size, oi=oi, iv=self.iv(k), + t=np.full(n, self.t), expiry=np.full(n, self.day)) + + def volume(self, call: bool) -> float: + return float(np.nansum((self.calls if call else self.puts)["volume"])) + + def oi(self, call: bool) -> float: + return float(np.nansum((self.calls if call else self.puts)["oi"])) + + +def _concat(chains: Sequence[om.Chain]) -> Optional[om.Chain]: + if not chains: + return None + return om.Chain(*(np.concatenate([getattr(c, f) for c in chains]) + for f in ("strike", "is_call", "oi", "iv", "t", "expiry"))) + + +def _on_grid(chain: om.Chain, spot: float, grid: np.ndarray): + """(call, put) GEX summed per grid strike.""" + g = om.contract_gex(chain, spot) + idx = np.searchsorted(grid, chain.strike) + hit = (idx < grid.size) & (grid[np.minimum(idx, grid.size - 1)] == chain.strike) + call = np.bincount(idx[hit], weights=np.where(chain.is_call, g, 0.0)[hit], minlength=grid.size) + put = np.bincount(idx[hit], weights=np.where(chain.is_call, 0.0, g)[hit], minlength=grid.size) + return call, put + + +# ── The UI's derivations, ported from options_0dte_derivations.js ─────────── + +TIERS = [ + (0.60, math.inf, "Reinforced Concrete", "🏗️", "Maximum"), + (0.30, 0.60, "Steel Beam", "🔩", "Strong"), + (0.10, 0.30, "Reinforced", "🧱", "Moderate"), + (0.00, 0.10, "Plywood", "🪵", "Light"), + (-0.15, 0.00, "Thin Ice", "🧊", "None"), + (-0.50, -0.15, "Glass Floor", "💎", "Amplified"), + (-math.inf, -0.50, "Free Fall", "🕳️", "Extreme ↓"), +] + + +def _window(strikes: List[float], spot: float, n: int): + spot_idx = int(np.argmin([abs(s - spot) for s in strikes])) + lo = max(0, spot_idx - n // 2) + hi = min(len(strikes), lo + n) + return max(0, hi - n), hi + + +def structural_integrity(strikes: Sequence[float], net_gex: Sequence[float], spot: float, + ratio: float = 1.0, n: int = 21) -> dict: + if not strikes or len(strikes) != len(net_gex): + return {"spot": spot, "levels": []} + ks = [s * ratio for s in strikes] + diffs = [b - a for a, b in zip(ks, ks[1:])] + spacing = float(np.median(diffs)) if diffs else 1.0 + lo, hi = _window(ks, spot, n) + sel_k, sel_g = ks[lo:hi], list(net_gex[lo:hi]) + abs_max = max([1e-6] + [abs(g) for g in sel_g]) + levels = [] + for k, g in zip(sel_k, sel_g): + x = g / abs_max + tier = next((t for t in TIERS if t[0] <= x < t[1]), TIERS[-1]) + levels.append({"strike": _jsround(k, 2), "gex": _jsround(g, 2), "gex_norm": _jsround(x, 3), + "hardness": tier[2], "icon": tier[3], "friction": tier[4], + "is_spot": abs(k - spot) < spacing * 0.6, + "color": "#00c853" if x > 0.10 else "#ffab00" if x > -0.15 else "#f44336"}) + return {"spot": _jsround(spot, 2), "levels": levels} + + +def volatility_gravity(strikes: Sequence[float], net_gex: Sequence[float], dd_strikes: Sequence[float], + dd: Sequence[float], spot: float, ratio: float = 1.0, n: int = 18) -> dict: + if len(strikes) < 3 or len(strikes) != len(net_gex): + return {"spot": spot, "levels": []} + ks = [s * ratio for s in strikes] + lo, hi = _window(ks, spot, n) + sel_k, sel_g = ks[lo:hi], list(net_gex[lo:hi]) + abs_max = max([1e-6] + [abs(g) for g in sel_g]) + have = len(dd_strikes) > 1 and len(dd) == len(dd_strikes) + sel_dd = list(np.interp(sel_k, dd_strikes, dd, left=0.0, right=0.0)) if have else [0.0] * len(sel_k) + grad = list(np.gradient(sel_dd)) if len(sel_dd) > 1 else [0.0] * len(sel_dd) + levels = [] + for k, g, d, gr in zip(sel_k, sel_g, sel_dd, grad): + x = g / abs_max + if x > 0.15 and abs(d) > abs_max * 0.10: + kind, color, desc = "magnet", "#00d4ff", "Dealers absorb moves here — expect chop and mean-reversion" + elif x < -0.15: + kind = "spring" + color = "#00c853" if d < 0 else "#f44336" + desc = ("Dealers amplify moves — expect violent bounce up" if d < 0 + else "Dealers amplify moves — expect breakdown") + elif abs(gr) > abs_max * 0.30: + kind, color, desc = "spring", "#f44336", "Dealer-delta sign flip — expect violent move" + else: + kind, color, desc = "magnet", "#00d4ff", "Mild absorption — minor pinning" + levels.append({"strike": _jsround(k, 2), "gex": _jsround(g, 2), "gex_norm": _jsround(x, 3), + "dealer_delta": _jsround(float(d), 2), "type": kind, "label": kind.upper(), + "icon": "🧲" if kind == "magnet" else "🚀", "color": color, "desc": desc, + "strength": min(100, math.floor(abs(x) * 100))}) + return {"spot": _jsround(spot, 2), "levels": levels} + + +# ── Blocks ────────────────────────────────────────────────────────────────── + +def _gex_block(exps: Sequence[Expiry], spot: float, ratio: float, at: str, label) -> Optional[dict]: + chains = [e.chain() for e in exps] + allk = np.unique(np.concatenate([c.strike for c in chains])) + grid = allk[(allk >= spot * (1 - BAND)) & (allk <= spot * (1 + BAND))] + if grid.size < 3: + return None + layers, call_sum, put_sum = [], np.zeros(grid.size), np.zeros(grid.size) + for i, (e, c) in enumerate(zip(exps, chains)): + call, put = _on_grid(c, spot, grid) + call_sum += call + put_sum += put + layers.append({"label": label(i), "expiry": e.day, "T_years": _r(e.t, 5), + "net_gex": [_r(v) for v in call + put]}) + net = call_sum + put_sum + total = float(net.sum()) + regime = "POSITIVE" if total >= 0 else "NEGATIVE" + return {"grid": grid, "call": call_sum, "put": put_sum, "chain": _concat(chains), + "block": {"computed_at": at, "strikes": [_r(k * ratio) for k in grid], "layers": layers, + "aggregate": {"computed_at": at, "strikes": [_r(k, 4) for k in grid], + "net_gex": [_r(v) for v in net], "net_total_gex": _r(total), + "regime": regime}}} + + +def _dealer(chain: om.Chain, spot: float, ratio: float, grid: np.ndarray, at: str) -> dict: + base = dealer_delta(chain, [spot])[0] + dd = dealer_delta(chain, grid) - base + flows = {m: float(-(dealer_delta(chain, [spot * (1 + m / 100.0)])[0] - base)) for m in (1.0, -1.0, 0.5, -0.5)} + sells = sum(-f for m, f in flows.items() if abs(m) == 1.0 and f < 0) + buys = sum(f for m, f in flows.items() if abs(m) == 1.0 and f > 0) + asym = min(9.99, sells / buys) if buys > 0 else (9.99 if sells > 0 else 1.0) + spring = "DOWNSIDE" if asym > 1.3 else "UPSIDE" if asym < 0.7 else "NEUTRAL" + scenarios = [{"move_pct": m, "target_price": _r(spot * ratio * (1 + m / 100.0)), + "direction": "BUY" if f > 0 else "SELL", "forced_flow": _r(f, 0)} for m, f in flows.items()] + return {"heatmap": {"computed_at": at, "strikes": [_r(k * ratio) for k in grid], + "dealer_delta": [_r(v, 0) for v in dd], + "net_dealer_delta": _r(-(flows[1.0] + flows[-1.0]), 0)}, + "scenarios": {"computed_at": at, "scenarios": scenarios, "asymmetry_ratio": round(asym, 2), + "spring_direction": spring}} + + +def _expected_move(e: Expiry, spot: float, ratio: float, at: str) -> dict: + ks = np.unique(np.concatenate([e.calls["strike"], e.puts["strike"]])) + k0 = float(ks[np.argmin(np.abs(ks - spot))]) + + def mid(tab): + i = np.nonzero(tab["strike"] == k0)[0] + if i.size and tab["bid"][i[0]] > 0 and tab["ask"][i[0]] >= tab["bid"][i[0]]: + return 0.5 * (tab["bid"][i[0]] + tab["ask"][i[0]]) + return None + c, p = mid(e.calls), mid(e.puts) + iv0 = float(e.iv(k0)) + straddle = (c + p) if c is not None and p is not None else _bs(spot, k0, e.t, iv0, True) + _bs(spot, k0, e.t, iv0, False) + return {"computed_at": at, "expiry": e.day, "atm_strike": _r(k0 * ratio), "straddle": _r(straddle * ratio), + "move_up": _r((spot + straddle) * ratio), "move_down": _r((spot - straddle) * ratio), + "pct": _r(straddle / spot * 100.0, 2)} + + +def _skew(e: Expiry, spot: float, ratio: float, at: str, previous: Optional[dict], now: datetime) -> dict: + k = np.linspace(spot * 0.6, spot * 1.4, 801) + iv = e.iv(k) + cd = _ncdf(_d1(spot, k, e.t, iv)) + kc = float(np.interp(-0.25, -cd, k)) # call delta falls from 1 to 0 with strike + kp = float(np.interp(0.25, 1.0 - cd, k)) # put delta falls from 0 to -1: -put delta rises + atm, c25, p25 = float(e.iv(spot)), float(e.iv(kc)), float(e.iv(kp)) + skew = p25 - c25 + vel = {"velocity": None, "velocity_bps_hr": None, "verdict": "UNKNOWN"} + if previous and previous.get("expiry") == e.day and isinstance(previous.get("skew"), (int, float)): + try: + hours = (now - datetime.fromisoformat(str(previous["computed_at"]).rstrip("Z"))).total_seconds() / 3600 + except (KeyError, ValueError): + hours = 0.0 + if 5 / 60 <= hours <= 3: + per_hr = (skew - previous["skew"]) / hours + bps = per_hr * 1e4 + side = "PUTS" if per_hr > 0 else "CALLS" + verdict = f"{side}_ACCELERATING" if abs(bps) > 2.0 else f"{side}_BUILDING" if abs(bps) > 0.5 else "STABLE" + vel = {"velocity": _r(per_hr * 100, 4), "velocity_bps_hr": _r(bps, 2), "verdict": verdict} + elif hours > 3: + vel["verdict"] = "STALE_PREV_SNAPSHOT" + return {"computed_at": at, "expiry": e.day, "atm_iv": _r(atm, 4), "put_25d_iv": _r(p25, 4), + "call_25d_iv": _r(c25, 4), "skew": _r(skew, 4), "skew_pct": _r(skew / atm * 100.0, 1), + "atm_strike": _r(spot * ratio), "put_25d_strike": _r(kp * ratio), "call_25d_strike": _r(kc * ratio), + "expiry_dte": max(0, (date.fromisoformat(e.day) - to_et(now).date()).days), + "expiry_class": "front_non_0dte", "velocity": vel} + + +def _iv30(exps: Sequence[Expiry], spot: float) -> Optional[float]: + pts = sorted((e.t, float(e.iv(spot)) ** 2 * e.t) for e in exps if e.t > 1 / 365) + if not pts: + return None + t30 = 30 / 365 + ts, ws = [p[0] for p in pts], [p[1] for p in pts] + if t30 >= ts[-1]: + return math.sqrt(ws[-1] / ts[-1]) + return math.sqrt(max(float(np.interp(t30, [0.0] + ts, [0.0] + ws)), 0.0) / t30) + + +def _entropy(chain: om.Chain, spot: float, ratio: float, grid: np.ndarray, weights=None): + """Gamma concentration across strikes vs an even spread of open interest: 1 = all + on one strike, 0 = as spread out as an even spread.""" + use = chain.usable + g = om.bs_gamma(spot, chain.strike, chain.t, np.where(use, chain.iv, 0.0)) + w = chain.oi if weights is None else weights + idx = np.searchsorted(grid, chain.strike) + hit = (idx < grid.size) & (grid[np.minimum(idx, grid.size - 1)] == chain.strike) & use + + def spread(mass): + m = np.bincount(idx[hit], weights=mass[hit], minlength=grid.size) + tot = m.sum() + if tot <= 0: + return None, None + p = m / tot + return float(np.exp(-np.sum(p[p > 0] * np.log(p[p > 0])))), p + n_eff, p = spread(g * w) + n_ref, _ = spread(g) + if n_eff is None or n_ref is None: + return None + score = max(0.0, min(1.0, 1.0 - n_eff / n_ref)) + top = np.argsort(p)[::-1] + return {"score": round(score, 3), "state": "ORDERED" if score >= 0.6 else "MODERATE" if score >= 0.35 else "CHAOTIC", + "top_strike": _r(grid[top[0]] * ratio), "concentration_top5": _r(p[top[:5]].sum() * 100, 1), + "n_strikes": int((p > 0).sum())} + + +def _history(prev: Optional[dict], key: str, value, now: datetime, live: bool) -> list: + start = session_start(now) + out = [] + for pt in (prev or {}).get(key) or []: + try: + if datetime.fromisoformat(str(pt["t"]).rstrip("Z")) >= start: + out.append(pt) + except (KeyError, TypeError, ValueError): + continue + if live and value is not None: + out.append({"t": _iso(now), "v": value}) + return out[-HISTORY_CAP:] + + +# ── The file ──────────────────────────────────────────────────────────────── + +def build(canonical: str, etf: str, data: dict, ratio: float, now: datetime, + previous: Optional[dict] = None) -> Optional[dict]: + """{T}_options.json from one chain download; None when it can't give levels.""" + spot = _num(data.get("spot")) + if not (spot > 0 and ratio > 0 and math.isfinite(ratio)): + return None + exps = [] + for day, sides in sorted((data.get("chains") or {}).items()): + e = Expiry(day, _table(sides.get("calls") or []), _table(sides.get("puts") or []), spot, now) + if e.usable: + exps.append(e) + today = to_et(now).date().isoformat() + zero = [e for e in exps if e.day == today] + swing = [e for e in exps if e.day > today] + if not swing: + return None + at, live, prev = _iso(now), market_open(now), previous or {} + sw = _gex_block(swing, spot, ratio, at, lambda i: f"GEX{i + 1}") + if sw is None: + return None + grid, chain = sw["grid"], sw["chain"] + front = swing[0] + flip = om.gamma_flip(chain, spot) + calls_up = np.where(grid >= spot, sw["call"], 0.0) + puts_dn = np.where(grid <= spot, sw["put"], 0.0) + gross = sw["call"] - sw["put"] + call_rank = [i for i in np.argsort(calls_up)[::-1][:3] if calls_up[i] > 0] + put_rank = [i for i in np.argsort(puts_dn)[:3] if puts_dn[i] < 0] + dealer = _dealer(chain, spot, ratio, grid, at) + em = _expected_move(front, spot, ratio, at) + skew = _skew(front, spot, ratio, at, prev.get("iv_skew"), now) + skew["skew_pct_history"] = _history(prev.get("iv_skew"), "skew_pct_history", skew["skew_pct"], now, live) + + closes = [c for c in (data.get("closes") or []) if c and c > 0][-31:] + rv = float(np.std(np.diff(np.log(closes)), ddof=1) * math.sqrt(252)) if len(closes) >= 11 else None + iv30 = _iv30(swing, spot) + div = iv30 - rv if iv30 is not None and rv is not None else None + signal = (None if div is None else "FADE_FAVORED" if div > 0.05 else "SLIGHTLY_RICH" if div > 0.02 + else "BREAKOUT_FAVORED" if div < -0.05 else "SLIGHTLY_CHEAP" if div < -0.02 else "NEUTRAL") + ivd = {"computed_at": at, "iv_atm": _r(iv30, 4), "realized_vol": _r(rv, 4), "divergence": _r(div, 4), + "signal": signal, "atm_strike": _r(spot * ratio)} + ivd["divergence_history"] = _history(prev.get("iv_divergence"), "divergence_history", ivd["divergence"], now, live) + + every = zero + swing + oi_c, oi_p = sum(e.oi(True) for e in every), sum(e.oi(False) for e in every) + v_c, v_p = sum(e.volume(True) for e in every), sum(e.volume(False) for e in every) + + def sentiment(x): + return None if x is None else "BEARISH" if x > 1.2 else "BULLISH" if x < 0.8 else "NEUTRAL" + oi_ratio = oi_p / oi_c if oi_c > 0 else None + vol_ratio = v_p / v_c if v_c > 0 else None + pc = {"computed_at": at, "oi_ratio": _r(oi_ratio, 3), "vol_ratio": _r(vol_ratio, 3), + "oi_sentiment": sentiment(oi_ratio), "vol_sentiment": sentiment(vol_ratio)} + pc["oi_ratio_history"] = _history(prev.get("pc_ratio"), "oi_ratio_history", pc["oi_ratio"], now, live) + + agg = sw["block"]["aggregate"] + s_b = spot * ratio + scen = dealer["scenarios"] + scen["asymmetry_ratio_history"] = _history((prev.get("sauce") or {}).get("dealer_scenarios"), + "asymmetry_ratio_history", scen["asymmetry_ratio"], now, live) + ent = _entropy(chain, spot, ratio, grid) + short = [e for e in every if e.t <= 7 / 365] + vol_chain = _concat([e.chain() for e in short]) + vols = (np.nan_to_num(np.concatenate([np.concatenate([e.calls["volume"], e.puts["volume"]]) for e in short])) + if short else None) + intra = _entropy(vol_chain, spot, ratio, grid, vols) if vol_chain is not None and vols.sum() > 0 else None + entropy = {"computed_at": at, "methodology": "gamma concentration vs an even spread of open interest"} + if ent: + entropy.update(entropy=round(1.0 - ent["score"], 3), state=ent["state"], + color={"ORDERED": "#00c853", "MODERATE": "#ffab00"}.get(ent["state"], "#f44336"), + swing={"score": ent["score"], "state": ent["state"], "computed_at": at}, + n_strikes=ent["n_strikes"], top_strike=ent["top_strike"], + concentration_top5=ent["concentration_top5"]) + entropy["intraday"] = ({"score": intra["score"], "state": intra["state"], "computed_at": at} if intra + else {"score": None, "state": "UNKNOWN", "computed_at": at}) + + payload = { + "format_version": "2.4-local", "canonical": canonical, "cboe_ticker": etf, "levels_space": "broker", + "cfd_ratio_used": round(ratio, 6), "spot": _r(s_b, 4), "spot_etf": _r(spot, 4), + "computed_at": at, "cached_at": at, "market_open": live, + "is_stale": bool(live and data.get("time") and (now - datetime.fromtimestamp( + float(data["time"]), timezone.utc).replace(tzinfo=None)).total_seconds() > 1800), + "expiry": front.day, "swing_expiry": front.day, "expiries": [e.day for e in every], + "gex_flip": _r(flip * ratio) if flip else None, "gex_flip_computed_at": at, + "max_pain": _r(om.max_pain(front.chain()) * ratio) if om.max_pain(front.chain()) else None, + "max_pain_computed_at": at, + "hvl": _r(grid[int(np.argmax(gross))] * ratio) if gross.max() > 0 else None, "hvl_computed_at": at, + "walls": {"primary_call_wall": _r(grid[call_rank[0]] * ratio) if call_rank else None, + "primary_put_wall": _r(grid[put_rank[0]] * ratio) if put_rank else None, + "call_walls": [{"strike": _r(grid[i] * ratio), "gex": _r(calls_up[i])} for i in call_rank], + "put_walls": [{"strike": _r(grid[i] * ratio), "gex": _r(puts_dn[i])} for i in put_rank]}, + "gex": {"computed_at": at, "strikes": sw["block"]["strikes"], "net_gex": agg["net_gex"], + "net_total_gex": agg["net_total_gex"], "regime": agg["regime"]}, + "gex_stack": sw["block"], + "dealer_heatmap": dealer["heatmap"], + "expected_move": em, + "iv_skew": skew, + "iv_divergence": ivd, + "pc_ratio": pc, + "sauce": { + "dealer_scenarios": scen, + "structural_integrity": {"computed_at": at, **structural_integrity(agg["strikes"], agg["net_gex"], s_b, ratio)}, + "volatility_gravity": {"computed_at": at, **volatility_gravity( + agg["strikes"], agg["net_gex"], dealer["heatmap"]["strikes"], dealer["heatmap"]["dealer_delta"], s_b, ratio)}, + "market_entropy": entropy, + }, + "gex_0dte": None, "gex_flip_0dte": None, "dealer_heatmap_0dte": None, "expected_move_0dte": None, + "iv_skew_0dte": None, "sauce_0dte": None, + } + z = _gex_block(zero, spot, ratio, at, lambda i: "0DTE") if zero else None + if z is not None: + zf = om.gamma_flip(z["chain"], spot) + zd = _dealer(z["chain"], spot, ratio, z["grid"], at) + em0 = _expected_move(zero[0], spot, ratio, at) + em0["straddle_history"] = _history(prev.get("expected_move_0dte"), "straddle_history", em0["straddle"], now, live) + sk0 = {"computed_at": at, "skew": _r(float(zero[0].iv(spot * 0.995) - zero[0].iv(spot * 1.005)), 4)} + sk0["skew_pct_history"] = _history(prev.get("iv_skew_0dte"), "skew_pct_history", sk0["skew"], now, live) + payload.update(gex_0dte=z["block"], gex_flip_0dte=_r(zf * ratio) if zf else None, + dealer_heatmap_0dte={k: zd["heatmap"][k] for k in ("computed_at", "strikes", "dealer_delta")}, + expected_move_0dte=em0, iv_skew_0dte=sk0, sauce_0dte={"dealer_scenarios": zd["scenarios"]}) + return _clean(payload) diff --git a/backend/local_analysis/runner.py b/backend/local_analysis/runner.py index 0ff14ba..4cb05b6 100644 --- a/backend/local_analysis/runner.py +++ b/backend/local_analysis/runner.py @@ -1,4 +1,4 @@ -# version: v3 +# version: v4 """ Keeps the analysis cache's files up to date from the active feed, so the chart's CONES / BATES / QUARTERLY / BANDS overlays, the REGIMES strip, the @@ -16,7 +16,9 @@ GLOBAL_regime_transitions.json) and the FUNDAMENTAL page's file (GLOBAL_fundamental_state.json: FRED liquidity, yields and VIX, CFTC COT, asset scores) and the MONEY FLOW page's (sector, country and asset-class - ETF performance and money-flow proxies, hourly). + ETF performance and money-flow proxies, hourly) and the OPTIONS QUANT + page's {T}_options.json from public ETF option chains (every 15 minutes + while New York trades, every 2 hours otherwise). - Files are stamped "producer": "quantum-terminal-local". A file written by any other producer is never overwritten. - The data source is part of each file's signature. After a switch @@ -29,6 +31,7 @@ meta.anchors, a Pulse Room rebuild after new live files. v3 — regimes (per asset and macro); a source switch starts a pass at once; the FUNDAMENTAL and MONEY FLOW pages' files. +v4 — the OPTIONS QUANT page's files. """ from __future__ import annotations @@ -43,7 +46,7 @@ from pathlib import Path from typing import Callable, Dict, Optional, Sequence, Tuple -from . import bands, cones, cot, fred, fundamental, money_flow, regimes +from . import bands, cones, cot, fred, fundamental, money_flow, options, regimes log = logging.getLogger("qt.local_analysis") @@ -75,6 +78,9 @@ FUNDAMENTAL_REFRESH_SEC = 3600 # the asset scores follow prices MONEY_FLOW_VERSION = 1 MONEY_FLOW_REFRESH_SEC = 3600 +OPTIONS_FILE = "{T}_options.json" +OPTIONS_REFRESH_SEC = 900 # while New York trades +OPTIONS_IDLE_SEC = 7200 # otherwise: overnight open-interest updates, the 0-DTE roll # (ticker, phase) → signature of the last build written (or found up to date) this run _built: Dict[Tuple[str, str], str] = {} @@ -366,6 +372,88 @@ def update_money_flow(cache_dir: Optional[Path] = None, download=None, force: bo return bool(wrote) +_options_note = {"logged": 0.0, "built": {}} + + +def _broker_price(provider, ticker: str, when: Optional[float], now: float) -> Optional[float]: + """The feed's price for `ticker` at `when` (Unix time of the ETF quote): + the latest tick when the quote is recent, else the 5-minute bar then.""" + live = None + try: + tick = (provider.get_latest_ticks([ticker]) or {}).get(ticker) + except Exception: + tick = None + if tick is not None: + bid, ask, last = (getattr(tick, k, None) for k in ("bid", "ask", "last")) + live = (bid + ask) / 2.0 if bid and ask else last + if when is not None and now - when > 600: + try: + bars = cones.normalize(provider.get_bars(ticker, "M5", min(2000, int((now - when) / 300) + 3)) or []) + except Exception: + bars = [] + at = datetime.fromtimestamp(when, timezone.utc).replace(tzinfo=None) + before = [b for b in bars if b.time <= at] + if before and (at - before[-1].time).total_seconds() < 3600: + return before[-1].close + return live + + +def update_options(provider, universe: Sequence[str] = (), cache_dir: Optional[Path] = None, fetch=None, + force: bool = False, now: Optional[float] = None) -> list: + """Rebuild {T}_options.json for the universe tickers that have a public + ETF option chain (options.ASSETS, or the ETF itself) and a price on the + active feed. Returns the tickers written.""" + if provider is None or not getattr(provider, "connected", False): + return [] + cache_dir = cache_dir or _cache_dir() + now = time.time() if now is None else now + now_dt = datetime.fromtimestamp(now, timezone.utc).replace(tzinfo=None) + source = f"{getattr(provider, 'provider_type', '?')}:{getattr(provider, 'provider_id', '?')}" + every = OPTIONS_REFRESH_SEC if options.market_open(now_dt) else OPTIONS_IDLE_SEC + wrote = [] + for ticker in [t.upper() for t in universe]: + etf = options.ASSETS.get(ticker) or (ticker if ticker in options.ASSETS.values() else None) + if etf is None: + continue + last = _options_note["built"].get(ticker) + if not force and last and last[0] == source and now - last[1] < every: + continue + path = cache_dir / OPTIONS_FILE.format(T=ticker) + existing = _read(path) + if existing is not None and existing.get("producer") != PRODUCER: + continue + _options_note["built"][ticker] = (source, now) + try: + data = (fetch or options.download_chain)(etf, now_dt) + except Exception as e: + data = None + if now - _options_note["logged"] > 3600: + _options_note["logged"] = now + log.info(f"Options chains unavailable: {e} (needs query2.finance.yahoo.com)") + if not data: + continue + price = _broker_price(provider, ticker, data.get("time"), now) + spot = data.get("spot") + if not price or not spot: + continue + previous = existing if existing is not None and existing.get("signature", "").startswith(source) else None + body = options.build(ticker, etf, data, price / float(spot), now_dt, previous) + if body is None: + continue + payload = {"producer": PRODUCER, "engine_version": ENGINE_VERSION, "ticker": ticker, + "signature": f"{source}|{body['expiries'][0]}", **body} + _write_atomic(path, payload) + try: + from data_sync_client import get_sync_client + get_sync_client()._get_store("options")[ticker] = payload + except Exception: + pass + wrote.append(ticker) + if wrote: + log.info(f"Options levels rebuilt: {', '.join(wrote)}") + return wrote + + def _discard_other_sources(ticker: str, cache_dir: Path, source_id: str, phases: Sequence[str]) -> None: """The source has no bars for this ticker: remove our files for it that were built from another source's prices. Files of other producers, and @@ -509,6 +597,25 @@ async def _money_flow_pass(broadcast_event: Optional[Callable]) -> bool: return wrote +async def _options_pass(app_state, broadcast_event: Optional[Callable]) -> list: + cfg = getattr(app_state, "cfg_manager", None) + universe = cfg.get_active_universe() if cfg is not None else [] + try: + wrote = await asyncio.to_thread(update_options, getattr(app_state, "provider", None), universe) + except asyncio.CancelledError: + raise + except Exception as e: + log.warning(f"Options build failed: {e}") + return [] + if wrote and broadcast_event is not None: + try: + await broadcast_event({"type": "data_sync_complete", "source": "local_analysis", + "phase": "options", "synced": len(wrote), "tickers": wrote}) + except Exception: + pass + return wrote + + async def run_loop(app_state, broadcast_event: Optional[Callable] = None, interval: float = CHECK_INTERVAL_SEC) -> None: global _wake @@ -522,6 +629,7 @@ async def run_loop(app_state, broadcast_event: Optional[Callable] = None, await _macro_pass(broadcast_event) await _fundamental_pass(app_state, broadcast_event) await _money_flow_pass(broadcast_event) + await _options_pass(app_state, broadcast_event) await _pass(app_state, "replay", broadcast_event) try: await asyncio.wait_for(_wake.wait(), timeout=interval) diff --git a/backend/tests/test_local_analysis.py b/backend/tests/test_local_analysis.py index 5416ce9..5084dde 100644 --- a/backend/tests/test_local_analysis.py +++ b/backend/tests/test_local_analysis.py @@ -239,8 +239,12 @@ async def fake_money_flow(broadcast_event): monkeypatch.setattr(runner, "_fundamental_pass", fake_fundamental) monkeypatch.setattr(runner, "_money_flow_pass", fake_money_flow) + + async def fake_options(app_state, broadcast_event): + phases.append("options") + monkeypatch.setattr(runner, "_options_pass", fake_options) monkeypatch.setattr(runner, "START_DELAY_SEC", 0) - one_pass = ["live", "regime", "macro", "fundamental", "money_flow", "replay"] + one_pass = ["live", "regime", "macro", "fundamental", "money_flow", "options", "replay"] async def main(): task = asyncio.create_task(runner.run_loop(object(), interval=3600)) diff --git a/backend/tests/test_options.py b/backend/tests/test_options.py new file mode 100644 index 0000000..7067906 --- /dev/null +++ b/backend/tests/test_options.py @@ -0,0 +1,152 @@ +"""The OPTIONS QUANT page's file: levels from a public ETF option chain, +moved into the feed's price space, in the shape the page reads.""" + +import json +import math +import os +from datetime import date, datetime, timedelta + +import pytest + +from local_analysis import options, runner + +SPOT = 500.0 +NOW = datetime(2026, 10, 14, 15, 0) # a Wednesday, 11:00 in New York +EXPIRIES = ["2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-19", "2026-10-21", + "2026-10-23", "2026-10-26", "2026-10-28", "2026-10-30", "2026-11-02", "2026-11-20", "2026-12-18"] + + +def _ncdf(x): + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def _rows(day, now, call): + t = max((options.close_utc(date.fromisoformat(day)) - now).total_seconds() / options.YEAR, 1e-4) + rows = [] + for k in range(400, 605, 5): + m = math.log(k / SPOT) + iv = 0.18 - 0.25 * m + 0.6 * m * m + d1 = (math.log(SPOT / k) + 0.5 * iv * iv * t) / (iv * math.sqrt(t)) + c = SPOT * _ncdf(d1) - k * _ncdf(d1 - iv * math.sqrt(t)) + price = c if call else c - SPOT + k + oi = 1000 + (20000 if call and k == 520 else 0) + (25000 if not call and k == 480 else 0) + rows.append({"strike": float(k), "bid": max(price * 0.99, 0.01), "ask": price * 1.01 + 0.01, + "volume": 300.0 if abs(k - SPOT) < 30 else 20.0, "openInterest": float(oi), + "impliedVolatility": 0.5 if k == 405 else iv}) # 0.5: one of Yahoo's failed solves + return rows + + +def fake_fetch(etf, now): + chains = {d: {"calls": _rows(d, now, True), "puts": _rows(d, now, False)} + for d in options.select_expiries(EXPIRIES, now)} + closes = [SPOT * math.exp(0.01 * math.sin(i)) for i in range(60)] + return {"spot": SPOT, "time": (now - datetime(1970, 1, 1)).total_seconds(), "open": SPOT, + "closes": closes, "chains": chains} + + +class Feed: + provider_type, provider_id, connected = "sim", "sim_default", True + + def get_latest_ticks(self, symbols): + return {s: type("Tick", (), {"bid": 4999.0, "ask": 5001.0, "last": 5000.0})() for s in symbols} + + def get_bars(self, ticker, timeframe, count): + return [] + + +def _build(tmp_path, now=NOW): + runner._options_note["built"].clear() + epoch = (now - datetime(1970, 1, 1)).total_seconds() + assert runner.update_options(Feed(), ["US500", "EURUSD"], cache_dir=tmp_path, fetch=fake_fetch, + force=True, now=epoch) == ["US500"] + return json.loads((tmp_path / "US500_options.json").read_text()) + + +def test_new_york_clock_and_expiry_choice(): + assert options.et_offset(datetime(2026, 1, 15, 12)) == timedelta(hours=-5) + assert options.et_offset(datetime(2026, 7, 15, 12)) == timedelta(hours=-4) + assert options.close_utc(date(2026, 10, 14)) == datetime(2026, 10, 14, 20, 0) + chosen = options.select_expiries(EXPIRIES, NOW) + assert chosen[0] == "2026-10-14" and "2026-10-13" not in chosen # today kept, yesterday gone + # 16 Oct is itself the October monthly, so the second monthly added is November's + assert len(chosen) == 1 + options.SWING_EXPIRIES + 1 and chosen[-1] == "2026-11-20" + after_close = options.select_expiries(EXPIRIES, datetime(2026, 10, 14, 21, 0)) + assert "2026-10-14" not in after_close + assert options.market_open(NOW) and not options.market_open(datetime(2026, 10, 17, 15, 0)) + + +def test_levels_are_in_the_feeds_price_space(tmp_path): + d = _build(tmp_path) + json.dumps(d, allow_nan=False) + assert d["producer"] == runner.PRODUCER and d["levels_space"] == "broker" + assert d["cfd_ratio_used"] == pytest.approx(10.0) and d["spot"] == 5000.0 and d["spot_etf"] == SPOT + assert d["walls"]["primary_call_wall"] == 5200.0 and d["walls"]["primary_put_wall"] == 4800.0 + assert 4800 < d["gex_flip"] < 5200 + assert d["gex"]["regime"] in ("POSITIVE", "NEGATIVE") and d["swing_expiry"] == "2026-10-15" + assert d["max_pain"] is not None and d["hvl"] in (4800.0, 5200.0) + em = d["expected_move"] + assert em["move_down"] < 5000 < em["move_up"] and em["pct"] == pytest.approx(em["straddle"] / 50, abs=0.01) + agg = d["gex_stack"]["aggregate"] + assert agg["strikes"][0] >= 400 and agg["strikes"][-1] <= 600 # chain space + assert d["gex_stack"]["strikes"][0] == agg["strikes"][0] * 10 # broker space + assert sum(sum(layer["net_gex"]) for layer in d["gex_stack"]["layers"]) == pytest.approx(agg["net_total_gex"], rel=1e-6) + skew = d["iv_skew"] + assert skew["put_25d_iv"] > skew["atm_iv"] > skew["call_25d_iv"] and skew["skew"] > 0 + assert skew["velocity"]["verdict"] == "UNKNOWN" and skew["expiry_dte"] == 1 + assert d["pc_ratio"]["oi_ratio"] > 1 and d["iv_divergence"]["signal"] is not None + + +def test_the_sauce_cards_and_the_0dte_blocks(tmp_path): + d = _build(tmp_path) + sauce = d["sauce"] + scen = sauce["dealer_scenarios"] + assert sorted(s["move_pct"] for s in scen["scenarios"]) == [-1.0, -0.5, 0.5, 1.0] + assert all(s["direction"] == ("BUY" if s["forced_flow"] > 0 else "SELL") for s in scen["scenarios"]) + assert scen["spring_direction"] in ("UPSIDE", "DOWNSIDE", "NEUTRAL") and scen["asymmetry_ratio"] > 0 + si = sauce["structural_integrity"]["levels"] + assert len(si) == 21 and [lv["strike"] for lv in si] == sorted(lv["strike"] for lv in si) + assert [lv["strike"] for lv in si if lv["is_spot"]] == [5000.0] + agg = d["gex_stack"]["aggregate"] + assert si == options.structural_integrity(agg["strikes"], agg["net_gex"], 5000.0, 10.0)["levels"] + vg = sauce["volatility_gravity"]["levels"] + assert len(vg) == 18 and all(lv["type"] in ("magnet", "spring") and 0 <= lv["strength"] <= 100 for lv in vg) + ent = sauce["market_entropy"] + assert ent["state"] in ("ORDERED", "MODERATE", "CHAOTIC") and 0 <= ent["swing"]["score"] <= 1 + assert ent["n_strikes"] > 0 and ent["intraday"]["state"] != "UNKNOWN" + assert d["gex_0dte"]["layers"][0]["expiry"] == "2026-10-14" and d["expected_move_0dte"]["straddle"] > 0 + hm0 = d["dealer_heatmap_0dte"] + assert len(hm0["strikes"]) == len(hm0["dealer_delta"]) and hm0["strikes"] == sorted(hm0["strikes"]) + assert len(d["iv_skew"]["skew_pct_history"]) == 1 # market open: one point + later = _build(tmp_path, NOW + timedelta(hours=6)) # after the close + assert later["gex_0dte"] is None and later["sauce_0dte"] is None + assert len(later["iv_skew"]["skew_pct_history"]) == 1 # no new point after hours + + +def test_cadence_other_producers_and_offline(tmp_path): + _build(tmp_path) + epoch = (NOW - datetime(1970, 1, 1)).total_seconds() + assert runner.update_options(Feed(), ["US500"], cache_dir=tmp_path, fetch=fake_fetch, now=epoch + 60) == [] + (tmp_path / "US500_options.json").write_text('{"producer": "data-center"}') + assert runner.update_options(Feed(), ["US500"], cache_dir=tmp_path, fetch=fake_fetch, force=True, now=epoch) == [] + + def offline(etf, now): + raise OSError("network unreachable") + assert runner.update_options(Feed(), ["XAUUSD"], cache_dir=tmp_path, fetch=offline, force=True, now=epoch) == [] + + +def test_the_route_serves_the_file_with_the_live_price(): + from fastapi.testclient import TestClient + os.environ["MK_PROVIDER"] = "sim" + import data_server + try: + with TestClient(data_server.app, base_url="http://127.0.0.1:8502") as client: + cache = runner._cache_dir() + runner._options_note["built"].clear() + epoch = (NOW - datetime(1970, 1, 1)).total_seconds() + assert runner.update_options(Feed(), ["US500"], cache_dir=cache, fetch=fake_fetch, + force=True, now=epoch) == ["US500"] + r = client.get("/api/options/US500/full").json() + assert r["_conversion_source"] == "broker_native" and r["sauce"]["structural_integrity"]["levels"] + (cache / "US500_options.json").unlink() + finally: + os.environ.pop("MK_PROVIDER", None) diff --git a/backend/version_info.py b/backend/version_info.py index bd2c6b0..6f0f78f 100644 --- a/backend/version_info.py +++ b/backend/version_info.py @@ -5,7 +5,7 @@ Exposes: GET /api/version[?refresh=true] → { - current: "1.1.0", + current: "1.2.0", latest: "1.2.0", update_available: true, download_url: "https://github.com/wJubran/QuantumTerminal/releases/tag/v1.2.0", diff --git a/electron_shell/package.json b/electron_shell/package.json index 362d0f4..25de1e4 100644 --- a/electron_shell/package.json +++ b/electron_shell/package.json @@ -1,6 +1,6 @@ { "name": "quantum-terminal", - "version": "1.1.0", + "version": "1.2.0", "description": "Quantum Terminal desktop shell: starts the Python backend and shows the terminal in a native window", "main": "main.js", "private": true, diff --git a/frontend/package.json b/frontend/package.json index 8ff264a..4c4aded 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "quantum-terminal-ui", - "version": "1.1.0", + "version": "1.2.0", "description": "Quantum Terminal user interface (React)", "private": true, "license": "MIT",