298 lines
8.5 KiB
Python
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,
|
|
}
|