options finding feature

This commit is contained in:
Chris Amow 2026-08-14 06:18:53 -05:00
parent a921db4610
commit 6e781ed163
8 changed files with 863 additions and 67 deletions

View file

@ -1,9 +1,11 @@
import asyncio
import json import json
import base64 import base64
import json import json
import logging import logging
import time import time
import uuid import uuid
from datetime import date as Date
from typing import Literal from typing import Literal
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -15,6 +17,8 @@ from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine from app.analysis.manual_lines import ManualLine
from app.api.deps import require_token from app.api.deps import require_token
from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture 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 # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose. # /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 raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels() request.app.state.runtime.rebuild_levels()
return Response(status_code=204) 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

298
app/market/es_options.py Normal file
View file

@ -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,
}

View file

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

View file

@ -1,88 +1,71 @@
# /ES futures-options quote finder # /ES futures-options quote finder
**Status:** future feature; no quote-finder UI, API route, or futures-options **Status:** first version shipped. Sidebar **Options** is a lazy-load snapshot
subscription is implemented yet. finder. It does not stream, poll, or place orders.
## Purpose ## Purpose
Find a small, user-selected set of `/ES` futures options more quickly than the Find `/ES` futures options faster than the thinkorswim chain UI: pick a nearby
thinkorswim option-chain UI, show their live tradable quotes, and produce an expiration, filter by approximate delta or mark, list the matches, and copy a
unambiguous leg or spread description that the user can copy into thinkorswim. thinkorswim contract string. The user reviews and enters every order in
The app assists research and pricing only. The user reviews and enters every thinkorswim.
order in thinkorswim; it must not submit, simulate, or automate orders.
The first useful workflow is deliberately narrow: ## UI
1. Select a futures-option expiration and call/put side. Same sidebar level as Tools and Drawings. Closed by default.
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.
Do not subscribe to or poll the whole option universe. Discovery should narrow - **Until opened:** no calendar work, no HTTP, no Schwab.
the candidates first, then one REST request obtains the selected quotes. A user - **On first open:** `GET /api/es-options/expirations` (calendar only).
may explicitly refresh a stale snapshot; continuous streaming is out of scope. - **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 ## Schwab API status
Checked against the configured live Schwab credentials on 2026-08-14. These Checked against the configured live credentials on 2026-08-14. Read-only.
were read-only requests; no account or order endpoint was called.
| Capability | Result | Implication | | Capability | Result | Implication |
|---|---|---| |---|---|---|
| OAuth token and Schwab client | Available | The existing app already authenticates and streams `/ES` futures. | | OAuth token and Schwab client | Available | The app already authenticates and streams `/ES`. |
| `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_quotes(["/ES"])` | Works: `/ESU26`, `assetMainType: FUTURE` | Use the plural quote endpoint. |
| `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_expiration_chain("/ES")` | Works, but only four standard `ES` monthlies | Not used. Dailies/weeklies are built from a calendar. |
| `get_option_chain("/ES")` | Fails: HTTP 400 | It is not a usable `/ES` futures-options chain/discovery endpoint. | | `get_option_chain("/ES")` / `("/ESU26")` | HTTP 400 | No chain discovery. |
| `get_option_chain("/ESU26")` | Fails: HTTP 400 | Resolving the active underlying contract does not make the chain endpoint work. | | `get_quotes(["./E3AQ26P7780:XCME"])` | HTTP 200 + `errors.invalidSymbols` | TOS text is not the REST symbol. |
| `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"])` | `FUTURE_OPTION`; description is the TOS form | REST symbol = TOS text without `:XCME`. |
| `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. | | Futures-option quote fields | bid, ask, mark, last, volume, OI; **no Greeks** | Delta is Black-76, labeled approximate. |
| `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. | | Weekday 5-point grids | 81/81 valid for `EW2`, `E3A`, `E3B`, `EW3` | Daily/weekly search uses a ±200 / 5-point grid. |
| 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. | | Monthly `ESU26` | 18/81 at 5-point; 25-point strikes quote | Monthly `ES` roots use a ±600 / 25-point grid. |
| 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. | | `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 ## Symbol construction
strike or a quoteable option symbol. Exact futures-option symbol resolution is
therefore the feature's critical discovery problem, not a formatting detail.
## Symbol and thinkorswim contract Verified August 2026 weekday roots:
Do not invent a futures-option symbol from a guessed `ES`, month, strike, and | Weekday | Root | Example |
call/put pattern. The supplied thinkorswim string `./E3AQ26P7780:XCME` maps to |---|---|---|
the verified API symbol `./E3AQ26P7780`: removing `:XCME` returned the intended | Mon | `E{n}A` | `E3AQ26` = Aug 17 |
`FUTURE_OPTION`, whose description returned the original thinkorswim form. This | Tue | `E{n}B` | `E3BQ26` = Aug 18 |
is one tested mapping, not yet a general rule for every exchange, product, | Wed | `E{n}C` | `E3CQ26` = Aug 19 |
expiration, or option root. Before building the finder, prove the following for | Thu | `E{n}D` | `E3DQ26` = Aug 20 |
representative current `/ES` options: | 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 API: `./{root}{C\|P}{strike}`. TOS copy: that string plus `:XCME`.
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.
Store separately any Schwab API symbol and the thinkorswim copy text. They may ## Implementation
be identical, but that is an acceptance criterion to prove, not an assumption.
## 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 ## Out of scope
`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.
## First implementation gate Spreads, streaming, prefetch on page load, order entry.
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.

View file

@ -142,6 +142,27 @@ createApp({
const levelWidth = ref(2); const levelWidth = ref(2);
const alertEarlyPoints = ref(null); const alertEarlyPoints = ref(null);
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); 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 timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const drawingColorRows = [ const drawingColorRows = [
{ id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] }, { id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
@ -275,6 +296,121 @@ createApp({
if (response.ok) status.value = await response.json(); 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() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
@ -1031,6 +1167,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -172,6 +172,54 @@
</div> </div>
</div> </div>
</details> </details>
<details class="sidebar-section options-section" @toggle="onOptionsToggle">
<summary>Options</summary>
<div class="options-body">
<label>Expiration
<select v-model="optionExpiryId" :disabled="!optionExpirations.length" aria-label="Option expiration">
<option v-if="!optionExpirations.length" value="">Open to load</option>
<option v-for="row in optionExpirations" :key="row.id" :value="row.id">{{ row.label }}</option>
</select>
</label>
<div class="row">
<label>Side
<select v-model="optionSide" aria-label="Calls or puts">
<option value="P">Puts</option>
<option value="C">Calls</option>
</select>
</label>
<label>Filter
<select v-model="optionMode" aria-label="Filter by delta or price">
<option value="delta">Delta</option>
<option value="price">Price</option>
</select>
</label>
</div>
<div class="row">
<label>From<input type="number" step="any" v-model.number="optionMin" :aria-label="optionMode === 'delta' ? 'Minimum delta' : 'Minimum mark'"></label>
<label>To<input type="number" step="any" v-model.number="optionMax" :aria-label="optionMode === 'delta' ? 'Maximum delta' : 'Maximum mark'"></label>
</div>
<div class="row price-row">
<button type="button" :disabled="optionBusy || !optionExpiryId" @click="searchOptions">{{ optionBusy ? 'Searching…' : optionContracts.length ? 'Refresh' : 'Search' }}</button>
<span class="hint" v-if="optionUnderlying">/ES {{ optionUnderlying.toFixed(2) }}</span>
</div>
<p class="hint" v-if="optionError">{{ optionError }}</p>
<p class="hint" v-else-if="!optionSearched">No quotes until you search.</p>
<p class="hint" v-else-if="!optionContracts.length">No contracts in that range.</p>
<div v-else class="option-results">
<div class="option-row option-head">
<span>Strike</span><span>Mark</span><span>Δ ≈</span><span></span>
</div>
<div v-for="row in optionContracts" :key="row.symbol" class="option-row">
<span>{{ row.strike.toFixed(0) }}</span>
<span>{{ row.mark.toFixed(2) }}</span>
<span>{{ row.abs_delta == null ? '—' : row.abs_delta.toFixed(2) }}</span>
<button type="button" :title="`${row.tos} · ${row.bid ?? '—'} × ${row.ask ?? '—'} · vol ${row.volume} · oi ${row.open_interest}`" @click="copyOption(row)">{{ optionCopied === row.symbol ? 'Copied' : 'Copy' }}</button>
</div>
<p class="hint" v-if="optionContracts.length">Bid/ask on copy target. Δ is approximate.</p>
</div>
</div>
</details>
<details class="sidebar-section" open> <details class="sidebar-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary> <summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="drawing-filters"> <div class="drawing-filters">

View file

@ -89,6 +89,16 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; } .chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; } .chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
/* --- drawings filter --------------------------------------------------- */ /* --- drawings filter --------------------------------------------------- */
.options-body { display:grid; gap:7px; }
.options-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
.options-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
.options-body input, .options-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.options-body .price-row { grid-template-columns:auto 1fr; }
.options-body .price-row button { font-size:11px; padding:5px 12px; }
.option-results { display:grid; gap:0; }
.option-row { display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:4px; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:10px; }
.option-row.option-head { color:var(--muted); text-transform:uppercase; letter-spacing:.3px; font-size:8px; }
.option-row button { padding:2px 6px; font-size:8px; }
.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; } .drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; }
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px; .drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; } border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }

197
tests/test_es_options.py Normal file
View file

@ -0,0 +1,197 @@
from datetime import date, datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router as api_router
from app.config import Settings
from app.runtime import Runtime
from app.market.es_options import (
api_symbol,
attach_deltas,
black76_delta,
daily_root,
filter_contracts,
from_tos_symbol,
monthly_root,
nearby_expirations,
parse_option_quote,
search_from_quotes,
tos_symbol,
)
def test_weekday_roots_match_the_verified_august_week():
assert daily_root(date(2026, 8, 14)) == "EW2Q26"
assert daily_root(date(2026, 8, 17)) == "E3AQ26"
assert daily_root(date(2026, 8, 18)) == "E3BQ26"
assert daily_root(date(2026, 8, 19)) == "E3CQ26"
assert daily_root(date(2026, 8, 20)) == "E3DQ26"
assert daily_root(date(2026, 8, 21)) == "EW3Q26"
def test_monthly_uses_es_root_only_on_quarterlies():
assert monthly_root(date(2026, 8, 21)) == "EW3Q26"
assert monthly_root(date(2026, 9, 18)) == "ESU26"
def test_tos_mapping_strips_and_restores_exchange_suffix():
assert from_tos_symbol("./E3AQ26P7780:XCME") == "./E3AQ26P7780"
assert tos_symbol("./E3AQ26P7780") == "./E3AQ26P7780:XCME"
assert api_symbol("E3AQ26", "P", 7780) == "./E3AQ26P7780"
def test_dropdown_for_friday_august_14():
rows = nearby_expirations(date(2026, 8, 14))
assert [(row.kind, row.date, row.root) for row in rows] == [
("daily", date(2026, 8, 14), "EW2Q26"),
("daily", date(2026, 8, 17), "E3AQ26"),
("daily", date(2026, 8, 18), "E3BQ26"),
("weekly", date(2026, 8, 14), "EW2Q26"),
("monthly", date(2026, 8, 21), "EW3Q26"),
]
def test_dropdown_skips_the_weekend():
rows = nearby_expirations(date(2026, 8, 15))
assert [row.date for row in rows if row.kind == "daily"] == [
date(2026, 8, 17),
date(2026, 8, 18),
date(2026, 8, 19),
]
assert rows[-1].root == "EW3Q26"
assert rows[-1].date == date(2026, 8, 21)
def test_monthly_rolls_to_the_september_es_root_after_the_august_third_friday():
rows = nearby_expirations(date(2026, 8, 22))
monthly = rows[-1]
assert monthly.kind == "monthly"
assert monthly.date == date(2026, 9, 18)
assert monthly.root == "ESU26"
def test_abs_delta_filter_keeps_the_015_to_025_band():
rows = [
{"strike": 7700, "mark": 3.0, "abs_delta": 0.05},
{"strike": 7770, "mark": 6.8, "abs_delta": 0.16},
{"strike": 7780, "mark": 8.3, "abs_delta": 0.206},
{"strike": 7850, "mark": 22.0, "abs_delta": 0.40},
]
kept = filter_contracts(rows, "delta", 0.15, 0.25)
assert [row["strike"] for row in kept] == [7770, 7780]
def test_price_filter_uses_mark():
rows = [
{"strike": 7765, "mark": 6.2, "abs_delta": 0.14},
{"strike": 7780, "mark": 8.3, "abs_delta": 0.21},
{"strike": 7790, "mark": 10.1, "abs_delta": 0.26},
]
kept = filter_contracts(rows, "price", 6.0, 8.0)
assert [row["strike"] for row in kept] == [7765]
def test_black76_put_delta_is_negative_and_increases_toward_atm():
otm = black76_delta(7827, 7780, 3 / 365.25, 0.0855, "P")
nearer = black76_delta(7827, 7800, 3 / 365.25, 0.0855, "P")
assert otm is not None and nearer is not None
assert otm < 0 and nearer < 0
assert abs(nearer) > abs(otm)
def test_search_filters_parsed_quotes_by_mark():
quotes = {
"./E3AQ26P7765": {
"assetMainType": "FUTURE_OPTION",
"quote": {"mark": 6.2, "bidPrice": 6.0, "askPrice": 6.2, "totalVolume": 43, "openInterest": 437},
"reference": {"strikePrice": 7765, "contractType": "P", "description": "./E3AQ26P7765:XCME"},
},
"./E3AQ26P7825": {
"assetMainType": "FUTURE_OPTION",
"quote": {"mark": 22.0, "bidPrice": 21.8, "askPrice": 22.0, "totalVolume": 10, "openInterest": 20},
"reference": {"strikePrice": 7825, "contractType": "P", "description": "./E3AQ26P7825:XCME"},
},
"errors": {"invalidSymbols": ["./E3AQ26P9999"]},
}
result = search_from_quotes(
day=date(2026, 8, 17),
side="P",
mode="price",
low=6,
high=8,
forward=7827,
quotes=quotes,
now=datetime(2026, 8, 14, 10, 0),
)
assert [row["tos"] for row in result["contracts"]] == ["./E3AQ26P7765:XCME"]
assert result["contracts"][0]["mark"] == 6.2
def test_equity_payload_is_not_treated_as_a_futures_option():
assert parse_option_quote("ES", {"assetMainType": "EQUITY", "quote": {"mark": 72}, "reference": {}}) is None
def test_attach_deltas_labels_the_015_band_from_live_shaped_marks():
contracts = [
{"strike": 7770.0, "mark": 6.8},
{"strike": 7780.0, "mark": 8.3},
{"strike": 7825.0, "mark": 22.0},
]
ranked = attach_deltas(contracts, 7827.0, 3 / 365.25, "P", 0.0855)
band = filter_contracts(ranked, "delta", 0.15, 0.25)
assert [row["strike"] for row in band] == [7770.0, 7780.0]
def _client(tmp_path):
app = FastAPI()
app.include_router(api_router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
return TestClient(app)
def test_expirations_endpoint_needs_no_schwab(tmp_path):
response = _client(tmp_path).get("/api/es-options/expirations")
assert response.status_code == 200
rows = response.json()["expirations"]
assert len(rows) == 5
assert [row["kind"] for row in rows] == ["daily", "daily", "daily", "weekly", "monthly"]
assert all(row["root"] and row["date"] and row["label"] for row in rows)
def test_search_endpoint_uses_the_injected_snapshot(tmp_path, monkeypatch):
def fake_search(settings, **kwargs):
assert kwargs["root"] == "E3AQ26"
assert kwargs["side"] == "P"
assert kwargs["mode"] == "price"
return {
"underlying": "/ESU26",
"underlying_price": 7827.0,
"iv": 0.085,
"delta_approx": True,
"contracts": [
{
"symbol": "./E3AQ26P7765",
"tos": "./E3AQ26P7765:XCME",
"strike": 7765,
"mark": 6.2,
"abs_delta": 0.139,
}
],
}
monkeypatch.setattr("app.api.routes.run_search", fake_search)
response = _client(tmp_path).get(
"/api/es-options/search",
params={"date": "2026-08-17", "root": "E3AQ26", "side": "P", "mode": "price", "min": 6, "max": 8},
)
assert response.status_code == 200
assert response.json()["contracts"][0]["tos"] == "./E3AQ26P7765:XCME"
def test_search_rejects_a_bad_date(tmp_path):
response = _client(tmp_path).get(
"/api/es-options/search",
params={"date": "17-08-2026", "root": "E3AQ26", "min": 0.15, "max": 0.25},
)
assert response.status_code == 400