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 @@
+