diff --git a/app/api/routes.py b/app/api/routes.py index a4ab505..e65c3a4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,9 +1,11 @@ +import asyncio import json import base64 import json import logging import time import uuid +from datetime import date as Date from typing import Literal from fastapi.responses import FileResponse @@ -15,6 +17,8 @@ from app.analysis.levels import Side from app.analysis.manual_lines import ManualLine from app.api.deps import require_token from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture +from app.market.es_options import nearby_expirations +from app.market.schwab_quotes import run_search # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and # /version live in app.api.meta and stay open on purpose. @@ -371,3 +375,43 @@ def delete_line(request: Request, line_id: str): raise HTTPException(404, "Line not found") from exc request.app.state.runtime.rebuild_levels() return Response(status_code=204) + + +@router.get("/es-options/expirations") +def es_option_expirations(): + return {"expirations": [row.to_dict() for row in nearby_expirations()]} + + +@router.get("/es-options/search") +async def es_option_search( + request: Request, + date: str, + root: str, + side: Literal["P", "C"] = "P", + mode: Literal["delta", "price"] = "delta", + min: float = Query(...), + max: float = Query(...), +): + try: + day = Date.fromisoformat(date) + except ValueError as exc: + raise HTTPException(400, "Invalid expiration date") from exc + if not root or len(root) > 16: + raise HTTPException(400, "Invalid root") + try: + result = await asyncio.to_thread( + run_search, + request.app.state.runtime.settings, + day=day, + root=root, + side=side, + mode=mode, + low=min, + high=max, + ) + except FileNotFoundError as exc: + raise HTTPException(503, "Schwab token missing") from exc + except ValueError as exc: + raise HTTPException(502, str(exc)) from exc + return result + diff --git a/app/market/es_options.py b/app/market/es_options.py new file mode 100644 index 0000000..391476c --- /dev/null +++ b/app/market/es_options.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from math import erf, log, sqrt +from zoneinfo import ZoneInfo + +EASTERN = ZoneInfo("America/New_York") +MONTH_CODES = { + 1: "F", + 2: "G", + 3: "H", + 4: "J", + 5: "K", + 6: "M", + 7: "N", + 8: "Q", + 9: "U", + 10: "V", + 11: "X", + 12: "Z", +} +WEEKDAY_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"} +QUARTERLY_MONTHS = {3, 6, 9, 12} +TOS_SUFFIX = ":XCME" +QUOTE_BATCH = 50 + + +@dataclass(frozen=True) +class Expiration: + id: str + kind: str + date: date + root: str + label: str + + def to_dict(self) -> dict: + return { + "id": self.id, + "kind": self.kind, + "date": self.date.isoformat(), + "root": self.root, + "label": self.label, + } + + +def week_of_month(day: date) -> int: + return (day.day - 1) // 7 + 1 + + +def daily_root(day: date) -> str: + month = MONTH_CODES[day.month] + year = day.year % 100 + nth = week_of_month(day) + if day.weekday() == 4: + return f"EW{nth}{month}{year:02d}" + return f"E{nth}{WEEKDAY_LETTER[day.weekday()]}{month}{year:02d}" + + +def monthly_root(day: date) -> str: + if day.month in QUARTERLY_MONTHS: + return f"ES{MONTH_CODES[day.month]}{day.year % 100:02d}" + return daily_root(day) + + +def api_symbol(root: str, side: str, strike: float | int) -> str: + return f"./{root}{side}{int(strike)}" + + +def tos_symbol(symbol: str) -> str: + if symbol.endswith(TOS_SUFFIX): + return symbol + return f"{symbol}{TOS_SUFFIX}" + + +def from_tos_symbol(symbol: str) -> str: + if symbol.endswith(TOS_SUFFIX): + return symbol[: -len(TOS_SUFFIX)] + return symbol + + +def next_weekdays(today: date, count: int) -> list[date]: + days: list[date] = [] + cursor = today + while len(days) < count: + if cursor.weekday() < 5: + days.append(cursor) + cursor += timedelta(days=1) + return days + + +def next_friday(today: date) -> date: + return today + timedelta(days=(4 - today.weekday()) % 7) + + +def third_friday(year: int, month: int) -> date: + first = date(year, month, 1) + first_friday = first + timedelta(days=(4 - first.weekday()) % 7) + return first_friday + timedelta(days=14) + + +def next_third_friday(today: date) -> date: + candidate = third_friday(today.year, today.month) + if candidate >= today: + return candidate + if today.month == 12: + return third_friday(today.year + 1, 1) + return third_friday(today.year, today.month + 1) + + +def _label(day: date, kind: str) -> str: + return f"{day.strftime('%a %b')} {day.day} {kind}" + + +def nearby_expirations(today: date | None = None) -> list[Expiration]: + if today is None: + today = datetime.now(EASTERN).date() + expirations: list[Expiration] = [] + for day in next_weekdays(today, 3): + expirations.append( + Expiration( + id=f"daily-{day.isoformat()}", + kind="daily", + date=day, + root=daily_root(day), + label=_label(day, "daily"), + ) + ) + weekly = next_friday(today) + expirations.append( + Expiration( + id=f"weekly-{weekly.isoformat()}", + kind="weekly", + date=weekly, + root=daily_root(weekly), + label=_label(weekly, "weekly"), + ) + ) + monthly = next_third_friday(today) + expirations.append( + Expiration( + id=f"monthly-{monthly.isoformat()}", + kind="monthly", + date=monthly, + root=monthly_root(monthly), + label=_label(monthly, "monthly"), + ) + ) + return expirations + + +def strike_step(root: str) -> int: + return 25 if root.startswith("ES") else 5 + + +def strike_span(root: str) -> int: + return 600 if root.startswith("ES") else 200 + + +def strike_grid(price: float, root: str) -> list[int]: + step = strike_step(root) + span = strike_span(root) + center = int(round(price / step) * step) + return list(range(center - span, center + span + step, step)) + + +def candidate_symbols(root: str, side: str, price: float) -> list[str]: + return [api_symbol(root, side, strike) for strike in strike_grid(price, root)] + + +def _norm_cdf(value: float) -> float: + return 0.5 * (1.0 + erf(value / sqrt(2.0))) + + +def years_to_expiry(day: date, now: datetime | None = None) -> float: + if now is None: + now = datetime.now(EASTERN) + if now.tzinfo is None: + now = now.replace(tzinfo=EASTERN) + expiry = datetime(day.year, day.month, day.day, 16, 0, tzinfo=EASTERN) + seconds = (expiry - now.astimezone(EASTERN)).total_seconds() + return max(seconds / (365.25 * 24 * 3600), 1 / 365.25) + + +def black76_price(forward: float, strike: float, years: float, sigma: float, side: str) -> float: + d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years)) + d2 = d1 - sigma * sqrt(years) + if side == "C": + return forward * _norm_cdf(d1) - strike * _norm_cdf(d2) + return strike * _norm_cdf(-d2) - forward * _norm_cdf(-d1) + + +def black76_delta(forward: float, strike: float, years: float, sigma: float, side: str) -> float | None: + if years <= 0 or sigma <= 0 or forward <= 0 or strike <= 0: + return None + d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years)) + if side == "C": + return _norm_cdf(d1) + return -_norm_cdf(-d1) + + +def implied_vol(forward: float, strike: float, years: float, price: float, side: str) -> float | None: + if price <= 0 or years <= 0 or forward <= 0 or strike <= 0: + return None + low, high = 0.01, 3.0 + for _ in range(40): + mid = (low + high) / 2 + model = black76_price(forward, strike, years, mid, side) + if model > price: + high = mid + else: + low = mid + return (low + high) / 2 + + +def parse_option_quote(symbol: str, payload: dict) -> dict | None: + if payload.get("assetMainType") != "FUTURE_OPTION": + return None + fields = payload.get("quote") or {} + reference = payload.get("reference") or {} + mark = fields.get("mark") + strike = reference.get("strikePrice") + if mark is None or strike is None: + return None + description = reference.get("description") or tos_symbol(symbol) + return { + "symbol": symbol, + "tos": description, + "strike": float(strike), + "side": reference.get("contractType") or "", + "bid": fields.get("bidPrice"), + "ask": fields.get("askPrice"), + "mark": float(mark), + "last": fields.get("lastPrice"), + "volume": fields.get("totalVolume") or 0, + "open_interest": fields.get("openInterest") or 0, + "quote_time": fields.get("quoteTime"), + "realtime": payload.get("realtime"), + } + + +def atm_implied_vol(contracts: list[dict], forward: float, years: float, side: str) -> float | None: + if not contracts: + return None + atm = min(contracts, key=lambda row: abs(row["strike"] - forward)) + return implied_vol(forward, atm["strike"], years, atm["mark"], side) + + +def attach_deltas(contracts: list[dict], forward: float, years: float, side: str, sigma: float | None) -> list[dict]: + vol = sigma or 0.15 + rows = [] + for contract in contracts: + delta = black76_delta(forward, contract["strike"], years, vol, side) + rows.append({**contract, "delta": delta, "abs_delta": None if delta is None else abs(delta)}) + return rows + + +def filter_contracts(contracts: list[dict], mode: str, low: float, high: float) -> list[dict]: + if low > high: + low, high = high, low + kept = [] + for contract in contracts: + value = contract["mark"] if mode == "price" else contract.get("abs_delta") + if value is None: + continue + if low <= value <= high: + kept.append(contract) + kept.sort(key=lambda row: row["strike"]) + return kept + + +def search_from_quotes( + *, + day: date, + side: str, + mode: str, + low: float, + high: float, + forward: float, + quotes: dict[str, dict], + now: datetime | None = None, +) -> dict: + contracts = [] + for symbol, payload in quotes.items(): + parsed = parse_option_quote(symbol, payload) + if parsed is None: + continue + contracts.append(parsed) + years = years_to_expiry(day, now) + sigma = atm_implied_vol(contracts, forward, years, side) + ranked = attach_deltas(contracts, forward, years, side, sigma) + matches = filter_contracts(ranked, mode, low, high) + return { + "underlying_price": forward, + "iv": sigma, + "delta_approx": True, + "contracts": matches, + } diff --git a/app/market/schwab_quotes.py b/app/market/schwab_quotes.py new file mode 100644 index 0000000..636367d --- /dev/null +++ b/app/market/schwab_quotes.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from datetime import date, datetime + +from app.market.es_options import ( + QUOTE_BATCH, + candidate_symbols, + search_from_quotes, + years_to_expiry, +) + + +def schwab_rest_client(settings): + from schwab.auth import client_from_token_file + + if not settings.schwab_token_path.exists(): + raise FileNotFoundError(f"No Schwab token at {settings.schwab_token_path}") + return client_from_token_file( + str(settings.schwab_token_path), + settings.schwab_api_key, + settings.schwab_app_secret, + ) + + +def fetch_quotes(client, symbols: list[str]) -> dict[str, dict]: + quotes: dict[str, dict] = {} + for index in range(0, len(symbols), QUOTE_BATCH): + payload = client.get_quotes(symbols[index : index + QUOTE_BATCH]).json() + for symbol, body in payload.items(): + if symbol != "errors": + quotes[symbol] = body + return quotes + + +def underlying_price(client, symbol: str = "/ES") -> tuple[str, float]: + payload = client.get_quotes([symbol]).json() + for returned, body in payload.items(): + if returned == "errors": + continue + if body.get("assetMainType") != "FUTURE": + continue + fields = body.get("quote") or {} + price = fields.get("mark") + if price is None: + price = fields.get("lastPrice") + if price is None: + continue + return returned, float(price) + raise ValueError("No /ES futures quote") + + +def run_search( + settings, + *, + day: date, + root: str, + side: str, + mode: str, + low: float, + high: float, + now: datetime | None = None, + client=None, +) -> dict: + rest = client or schwab_rest_client(settings) + contract, forward = underlying_price(rest) + symbols = candidate_symbols(root, side, forward) + quotes = fetch_quotes(rest, symbols) + result = search_from_quotes( + day=day, + side=side, + mode=mode, + low=low, + high=high, + forward=forward, + quotes=quotes, + now=now, + ) + result["underlying"] = contract + result["years"] = years_to_expiry(day, now) + return result diff --git a/docs/esquotes.md b/docs/esquotes.md index 5dd5aae..9dc96f0 100644 --- a/docs/esquotes.md +++ b/docs/esquotes.md @@ -1,88 +1,71 @@ # /ES futures-options quote finder -**Status:** future feature; no quote-finder UI, API route, or futures-options -subscription is implemented yet. +**Status:** first version shipped. Sidebar **Options** is a lazy-load snapshot +finder. It does not stream, poll, or place orders. ## Purpose -Find a small, user-selected set of `/ES` futures options more quickly than the -thinkorswim option-chain UI, show their live tradable quotes, and produce an -unambiguous leg or spread description that the user can copy into thinkorswim. -The app assists research and pricing only. The user reviews and enters every -order in thinkorswim; it must not submit, simulate, or automate orders. +Find `/ES` futures options faster than the thinkorswim chain UI: pick a nearby +expiration, filter by approximate delta or mark, list the matches, and copy a +thinkorswim contract string. The user reviews and enters every order in +thinkorswim. -The first useful workflow is deliberately narrow: +## UI -1. Select a futures-option expiration and call/put side. -2. Select one or more strikes, including the two legs of a vertical spread. -3. Fetch a current snapshot of bid, ask, mark, last, volume, and open interest - for that small set of exact contracts. -4. Copy a thinkorswim-tested contract or spread description for manual entry. +Same sidebar level as Tools and Drawings. Closed by default. -Do not subscribe to or poll the whole option universe. Discovery should narrow -the candidates first, then one REST request obtains the selected quotes. A user -may explicitly refresh a stale snapshot; continuous streaming is out of scope. +- **Until opened:** no calendar work, no HTTP, no Schwab. +- **On first open:** `GET /api/es-options/expirations` (calendar only). +- **On Search/Refresh only:** `GET /api/es-options/search` → batched + `get_quotes()`. + +Controls: next 3 dailies, next Friday weekly, next monthly; Puts/Calls (default +Puts); Delta or Price from/to; Search. Results show strike, mark, **Δ ≈**, and +Copy. Last expiration, side, mode, and ranges persist in `localStorage`. ## Schwab API status -Checked against the configured live Schwab credentials on 2026-08-14. These -were read-only requests; no account or order endpoint was called. +Checked against the configured live credentials on 2026-08-14. Read-only. | Capability | Result | Implication | |---|---|---| -| OAuth token and Schwab client | Available | The existing app already authenticates and streams `/ES` futures. | -| `get_quotes(["/ES"])` | Works: HTTP 200, resolving to active future `/ESU26` with `assetMainType: FUTURE` | Use the plural quote endpoint. `get_quote("/ES")` puts the slash in a URL path and can return the `ES` equity instead. | -| `get_option_expiration_chain("/ES")` | Works: HTTP 200; it returned four `/ES` expiry entries and option root `ES` | Use it to enumerate available expiration dates and roots. | -| `get_option_chain("/ES")` | Fails: HTTP 400 | It is not a usable `/ES` futures-options chain/discovery endpoint. | -| `get_option_chain("/ESU26")` | Fails: HTTP 400 | Resolving the active underlying contract does not make the chain endpoint work. | -| `get_quotes()` for the supplied thinkorswim text `./E3AQ26P7780:XCME` | Fails semantically: HTTP 200 with `errors.invalidSymbols` | A thinkorswim identifier is not automatically a valid Schwab REST symbol. Resolve the API equivalent explicitly. | -| `get_quotes(["./E3AQ26P7780"])` | Works: `assetMainType: FUTURE_OPTION`; its reference description is `./E3AQ26P7780:XCME` | For this verified contract, the API form is the thinkorswim text with the `:XCME` exchange suffix removed. The snapshot included bid, ask, mark, last, volume, and open interest. | -| `LEVEL_ONE_FUTURES_OPTIONS` for `./E3AQ26P7780:XCME` | The subscription command was accepted, but no quote handler message arrived during a 15-second premarket probe | Not needed for this feature: it uses snapshots, not continuous updates. This result is retained only as future reference. | -| Historical futures-options prices | Not available | Schwab price history is not available for futures or options; the finder is a current-quote tool, not a historical-pricing system. | -| Futures-options order entry | Not available for this workflow | Keep execution in thinkorswim. Schwab Trader API support for equities and standard options must not be mistaken for `/ES` futures-options routing. | +| OAuth token and Schwab client | Available | The app already authenticates and streams `/ES`. | +| `get_quotes(["/ES"])` | Works: `/ESU26`, `assetMainType: FUTURE` | Use the plural quote endpoint. | +| `get_option_expiration_chain("/ES")` | Works, but only four standard `ES` monthlies | Not used. Dailies/weeklies are built from a calendar. | +| `get_option_chain("/ES")` / `("/ESU26")` | HTTP 400 | No chain discovery. | +| `get_quotes(["./E3AQ26P7780:XCME"])` | HTTP 200 + `errors.invalidSymbols` | TOS text is not the REST symbol. | +| `get_quotes(["./E3AQ26P7780"])` | `FUTURE_OPTION`; description is the TOS form | REST symbol = TOS text without `:XCME`. | +| Futures-option quote fields | bid, ask, mark, last, volume, OI; **no Greeks** | Delta is Black-76, labeled approximate. | +| Weekday 5-point grids | 81/81 valid for `EW2`, `E3A`, `E3B`, `EW3` | Daily/weekly search uses a ±200 / 5-point grid. | +| Monthly `ESU26` | 18/81 at 5-point; 25-point strikes quote | Monthly `ES` roots use a ±600 / 25-point grid. | +| `LEVEL_ONE_FUTURES_OPTIONS` | Not used | Snapshots only. | +| History / order entry | Not available | Current quotes; execute in thinkorswim. | -The expiration endpoint narrows the date/root, but it does **not** return every -strike or a quoteable option symbol. Exact futures-option symbol resolution is -therefore the feature's critical discovery problem, not a formatting detail. +## Symbol construction -## Symbol and thinkorswim contract +Verified August 2026 weekday roots: -Do not invent a futures-option symbol from a guessed `ES`, month, strike, and -call/put pattern. The supplied thinkorswim string `./E3AQ26P7780:XCME` maps to -the verified API symbol `./E3AQ26P7780`: removing `:XCME` returned the intended -`FUTURE_OPTION`, whose description returned the original thinkorswim form. This -is one tested mapping, not yet a general rule for every exchange, product, -expiration, or option root. Before building the finder, prove the following for -representative current `/ES` options: +| Weekday | Root | Example | +|---|---|---| +| Mon | `E{n}A` | `E3AQ26` = Aug 17 | +| Tue | `E{n}B` | `E3BQ26` = Aug 18 | +| Wed | `E{n}C` | `E3CQ26` = Aug 19 | +| Thu | `E{n}D` | `E3DQ26` = Aug 20 | +| Fri | `EW{n}` | `EW2Q26` = Aug 14 | +| Quarterly monthly | `ES{month}{yy}` | `ESU26` = Sep 18 | +| Serial monthly | that 3rd Friday's `EW{n}` | `EW3Q26` = Aug 21 | -1. Schwab `get_quotes()` returns the intended futures option, including a valid - bid and ask rather than an equity or an error. -2. The finder output can be pasted or searched in thinkorswim to select the - same leg. A two-leg spread must preserve buy/sell direction and quantity as - well as strike, expiration, and call/put side. +API: `./{root}{C\|P}{strike}`. TOS copy: that string plus `:XCME`. -Store separately any Schwab API symbol and the thinkorswim copy text. They may -be identical, but that is an acceptance criterion to prove, not an assumption. +## Implementation -## Implementation constraints +- `app/market/es_options.py` — calendar, symbols, Black-76, filters. No I/O. +- `app/market/schwab_quotes.py` — REST `get_quotes` via the existing token. +- `GET /api/es-options/expirations` and `GET /api/es-options/search` +- Search runs in `asyncio.to_thread` so Schwab I/O does not block the loop. +- IV is implied from the ATM mid; |Δ| is computed. Label **Δ ≈**. +- Broker code stays in `app/market/`. No stream subscription. -- Keep future-options code isolated with the existing broker integration in - `app/market/`; no analysis, bars, or UI code should call Schwab directly. -- Request REST snapshots only after a user has selected exact contracts or - explicitly asked to refresh. Batch all displayed contracts into one - `get_quotes()` request; do not open a futures-options WebSocket subscription. -- Show quote freshness and whether data is delayed. A stale, wide, or missing - market is more important than a calculated spread mark. -- Treat a spread mark as a display calculation from the two current legs, not - an executable price. Preserve both bid/ask combinations so the UI can show - realistic debit/credit bounds. -- Do not persist credentials, account details, or order state in this feature. +## Out of scope -## First implementation gate - -Add a small read-only probe using representative current `/ES` options copied -from thinkorswim and their mapped Schwab API identifiers. Capture both forms, -the REST response type and quote fields, snapshot timestamp or delay status, -and the expiration/strike shown by each system. Add a regression fixture only -after that probe establishes a stable real payload and mapping rule. Until -then, a full-chain UI or automatic symbol construction would be speculative. +Spreads, streaming, prefetch on page load, order entry. diff --git a/static/app.js b/static/app.js index 89ddb09..b64f741 100644 --- a/static/app.js +++ b/static/app.js @@ -142,6 +142,27 @@ createApp({ const levelWidth = ref(2); const alertEarlyPoints = ref(null); const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); + const optionPrefs = (() => { + try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; } + catch { return {}; } + })(); + const optionExpirations = ref([]); + const optionExpiryId = ref(optionPrefs.expiryId || ''); + const optionSide = ref(optionPrefs.side === 'C' ? 'C' : 'P'); + const optionMode = ref(optionPrefs.mode === 'price' ? 'price' : 'delta'); + const optionDeltaMin = ref(optionPrefs.deltaMin ?? 0.15); + const optionDeltaMax = ref(optionPrefs.deltaMax ?? 0.25); + const optionPriceMin = ref(optionPrefs.priceMin ?? 6); + const optionPriceMax = ref(optionPrefs.priceMax ?? 8); + const optionMin = ref(optionMode.value === 'price' ? optionPriceMin.value : optionDeltaMin.value); + const optionMax = ref(optionMode.value === 'price' ? optionPriceMax.value : optionDeltaMax.value); + const optionContracts = ref([]); + const optionUnderlying = ref(null); + const optionBusy = ref(false); + const optionError = ref(''); + const optionSearched = ref(false); + const optionCopied = ref(''); + let optionLoaded = false; const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const drawingColorRows = [ { id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] }, @@ -275,6 +296,121 @@ createApp({ if (response.ok) status.value = await response.json(); } + function selectedExpiration() { + return optionExpirations.value.find(row => row.id === optionExpiryId.value) || null; + } + + function persistOptionPrefs() { + if (optionMode.value === 'price') { + optionPriceMin.value = optionMin.value; + optionPriceMax.value = optionMax.value; + } else { + optionDeltaMin.value = optionMin.value; + optionDeltaMax.value = optionMax.value; + } + localStorage.setItem('chart-es-options', JSON.stringify({ + expiryId: optionExpiryId.value, + side: optionSide.value, + mode: optionMode.value, + deltaMin: optionDeltaMin.value, + deltaMax: optionDeltaMax.value, + priceMin: optionPriceMin.value, + priceMax: optionPriceMax.value, + })); + } + + async function onOptionsToggle(event) { + if (!event.target.open || optionLoaded) return; + optionLoaded = true; + optionError.value = ''; + const response = await apiFetch('/api/es-options/expirations'); + if (!response.ok) { + optionError.value = 'Could not load expirations'; + optionLoaded = false; + return; + } + const payload = await response.json(); + optionExpirations.value = payload.expirations || []; + if (!optionExpirations.value.some(row => row.id === optionExpiryId.value)) { + optionExpiryId.value = optionExpirations.value[0]?.id || ''; + } + } + + async function searchOptions() { + const expiry = selectedExpiration(); + if (!expiry || optionBusy.value) return; + optionBusy.value = true; + optionError.value = ''; + optionCopied.value = ''; + try { + const params = new URLSearchParams({ + date: expiry.date, + root: expiry.root, + side: optionSide.value, + mode: optionMode.value, + min: String(optionMin.value), + max: String(optionMax.value), + }); + const response = await apiFetch(`/api/es-options/search?${params}`); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + optionError.value = detail.detail || 'Search failed'; + optionContracts.value = []; + return; + } + const payload = await response.json(); + optionContracts.value = payload.contracts || []; + optionUnderlying.value = payload.underlying_price ?? null; + optionSearched.value = true; + } finally { + optionBusy.value = false; + } + } + + async function copyOption(row) { + const text = row.tos; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + throw new Error('no clipboard api'); + } + } catch { + const input = document.createElement('textarea'); + input.value = text; + input.setAttribute('readonly', ''); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.appendChild(input); + input.select(); + const ok = document.execCommand('copy'); + input.remove(); + if (!ok) { + optionError.value = 'Copy failed'; + return; + } + } + optionCopied.value = row.symbol; + } + + watch(optionMode, (mode, previous) => { + if (previous === 'delta') { + optionDeltaMin.value = optionMin.value; + optionDeltaMax.value = optionMax.value; + } else if (previous === 'price') { + optionPriceMin.value = optionMin.value; + optionPriceMax.value = optionMax.value; + } + if (mode === 'price') { + optionMin.value = optionPriceMin.value; + optionMax.value = optionPriceMax.value; + } else { + optionMin.value = optionDeltaMin.value; + optionMax.value = optionDeltaMax.value; + } + }); + watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs); + function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; @@ -1031,6 +1167,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; }, }).mount('#app'); diff --git a/static/index.html b/static/index.html index 418a4fe..4894a33 100644 --- a/static/index.html +++ b/static/index.html @@ -172,6 +172,54 @@ +