chart/app/market/es_options.py

298 lines
8.5 KiB
Python

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