options finding feature
This commit is contained in:
parent
a921db4610
commit
6e781ed163
8 changed files with 863 additions and 67 deletions
|
|
@ -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
298
app/market/es_options.py
Normal 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,
|
||||||
|
}
|
||||||
80
app/market/schwab_quotes.py
Normal file
80
app/market/schwab_quotes.py
Normal 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
|
||||||
115
docs/esquotes.md
115
docs/esquotes.md
|
|
@ -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.
|
|
||||||
|
|
|
||||||
138
static/app.js
138
static/app.js
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
197
tests/test_es_options.py
Normal 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
|
||||||
Loading…
Reference in a new issue