Junaid Hasan
Add V2 sweep demo notebook and Gradio universe sweep workflow
9c78a0a
Raw
History Blame Contribute Delete
11.1 kB
from __future__ import annotations
import numpy as np
import pandas as pd
def _calls_for_expiry(options: pd.DataFrame, expiry: pd.Timestamp) -> pd.DataFrame:
c = options[
(options["option_type"] == "call")
& (options["expiry"] == pd.to_datetime(expiry))
].copy()
c = c.sort_values("strike").drop_duplicates(subset=["strike"], keep="last")
if "openInterest" not in c.columns:
c["openInterest"] = 0.0
return c
def _puts_for_expiry(options: pd.DataFrame, expiry: pd.Timestamp) -> pd.DataFrame:
p = options[
(options["option_type"] == "put")
& (options["expiry"] == pd.to_datetime(expiry))
].copy()
p = p.sort_values("strike").drop_duplicates(subset=["strike"], keep="last")
if "openInterest" not in p.columns:
p["openInterest"] = 0.0
return p
def scan_vertical_arbitrage(
calls_df: pd.DataFrame,
tol: float = 1e-8,
min_edge: float = 0.0,
min_edge_per_width: float = 0.0,
min_leg_open_interest: int = 0,
) -> pd.DataFrame:
g = calls_df.sort_values("strike")
if "openInterest" not in g.columns:
g = g.copy()
g["openInterest"] = 0.0
k = g["strike"].to_numpy(dtype=float)
c = g["mid"].to_numpy(dtype=float)
oi = g["openInterest"].fillna(0.0).to_numpy(dtype=float)
rows: list[dict[str, object]] = []
for i in range(len(k) - 1):
edge = c[i + 1] - c[i]
if edge > max(tol, min_edge):
width = float(k[i + 1] - k[i])
edge_pw = float(edge / max(width, 1e-12))
leg_oi_min = float(min(oi[i], oi[i + 1]))
if edge_pw < min_edge_per_width:
continue
if leg_oi_min < float(min_leg_open_interest):
continue
rows.append(
{
"family": "vertical",
"k1": float(k[i]),
"k2": float(k[i + 1]),
"k3": np.nan,
"edge": float(edge),
"edge_per_width": edge_pw,
"leg_oi_min": leg_oi_min,
"notes": "Call should not increase with strike.",
}
)
return pd.DataFrame(rows)
def scan_butterfly_arbitrage(
calls_df: pd.DataFrame,
tol: float = 1e-8,
min_edge: float = 0.0,
min_edge_per_width: float = 0.0,
min_leg_open_interest: int = 0,
) -> pd.DataFrame:
g = calls_df.sort_values("strike")
if "openInterest" not in g.columns:
g = g.copy()
g["openInterest"] = 0.0
k = g["strike"].to_numpy(dtype=float)
c = g["mid"].to_numpy(dtype=float)
oi = g["openInterest"].fillna(0.0).to_numpy(dtype=float)
rows: list[dict[str, object]] = []
if len(k) < 3:
return pd.DataFrame(rows)
slope_left = (c[1:-1] - c[:-2]) / (k[1:-1] - k[:-2])
slope_right = (c[2:] - c[1:-1]) / (k[2:] - k[1:-1])
mismatch = slope_left - slope_right
for i, mm in enumerate(mismatch, start=1):
if mm > tol:
width = float(k[i + 1] - k[i - 1])
edge_pw = float(mm / max(width, 1e-12))
leg_oi_min = float(min(oi[i - 1], oi[i], oi[i + 1]))
if mm < min_edge:
continue
if edge_pw < min_edge_per_width:
continue
if leg_oi_min < float(min_leg_open_interest):
continue
rows.append(
{
"family": "butterfly",
"k1": float(k[i - 1]),
"k2": float(k[i]),
"k3": float(k[i + 1]),
"edge": float(mm),
"edge_per_width": edge_pw,
"leg_oi_min": leg_oi_min,
"notes": "Call slope decreases across strikes (convexity violation).",
}
)
return pd.DataFrame(rows)
def scan_arbitrage_candidates(
options: pd.DataFrame,
expiry: pd.Timestamp,
tol: float = 1e-8,
min_edge: float = 0.0,
min_edge_per_width: float = 0.0,
min_leg_open_interest: int = 0,
spot: float | None = None,
r: float = 0.0,
t_years: float = 0.25,
) -> pd.DataFrame:
calls = _calls_for_expiry(options, expiry)
puts = _puts_for_expiry(options, expiry)
if calls.empty:
return pd.DataFrame(
columns=[
"expiry",
"family",
"k1",
"k2",
"k3",
"edge",
"edge_per_width",
"leg_oi_min",
"notes",
"confidence",
]
)
v = scan_vertical_arbitrage(
calls,
tol=tol,
min_edge=min_edge,
min_edge_per_width=min_edge_per_width,
min_leg_open_interest=min_leg_open_interest,
)
b = scan_butterfly_arbitrage(
calls,
tol=tol,
min_edge=min_edge,
min_edge_per_width=min_edge_per_width,
min_leg_open_interest=min_leg_open_interest,
)
parity = scan_put_call_parity_arbitrage(
calls,
puts,
spot=spot,
r=r,
t_years=t_years,
tol=tol,
min_edge=min_edge,
min_leg_open_interest=min_leg_open_interest,
)
calendar = scan_calendar_arbitrage(
options,
tol=tol,
min_edge=min_edge,
min_leg_open_interest=min_leg_open_interest,
)
out = (
pd.concat([v, b, parity, calendar], ignore_index=True)
if not v.empty or not b.empty or not parity.empty or not calendar.empty
else pd.DataFrame()
)
if out.empty:
return pd.DataFrame(
columns=[
"expiry",
"family",
"k1",
"k2",
"k3",
"edge",
"edge_per_width",
"leg_oi_min",
"notes",
"confidence",
]
)
out.insert(0, "expiry", pd.to_datetime(expiry))
out["confidence"] = "unrated"
return out.sort_values("edge", ascending=False).reset_index(drop=True)
def scan_put_call_parity_arbitrage(
calls_df: pd.DataFrame,
puts_df: pd.DataFrame,
spot: float | None,
r: float,
t_years: float,
tol: float = 1e-8,
min_edge: float = 0.0,
min_leg_open_interest: int = 0,
) -> pd.DataFrame:
if spot is None:
return pd.DataFrame()
c = calls_df[["strike", "mid", "openInterest"]].rename(
columns={"mid": "call_mid", "openInterest": "call_oi"}
)
p = puts_df[["strike", "mid", "openInterest"]].rename(
columns={"mid": "put_mid", "openInterest": "put_oi"}
)
m = c.merge(p, on="strike", how="inner").sort_values("strike")
if m.empty:
return pd.DataFrame()
disc = float(np.exp(-float(r) * float(t_years)))
rows: list[dict[str, object]] = []
for _, row in m.iterrows():
k = float(row["strike"])
lhs = float(row["call_mid"] - row["put_mid"])
rhs = float(spot - disc * k)
resid = lhs - rhs
edge = abs(resid)
leg_oi_min = float(min(row["call_oi"], row["put_oi"]))
if edge <= max(tol, min_edge):
continue
if leg_oi_min < float(min_leg_open_interest):
continue
rows.append(
{
"family": "parity",
"k1": k,
"k2": np.nan,
"k3": np.nan,
"edge": edge,
"edge_per_width": edge,
"leg_oi_min": leg_oi_min,
"notes": "Put-call parity residual (American/dividend caveat applies).",
}
)
return pd.DataFrame(rows)
def scan_calendar_arbitrage(
options: pd.DataFrame,
tol: float = 1e-8,
min_edge: float = 0.0,
min_leg_open_interest: int = 0,
) -> pd.DataFrame:
calls = options[options["option_type"] == "call"].copy()
if calls.empty:
return pd.DataFrame()
if "openInterest" not in calls.columns:
calls["openInterest"] = 0.0
rows: list[dict[str, object]] = []
for strike, grp in calls.groupby("strike"):
g = grp.sort_values("expiry")
if len(g) < 2:
continue
mids = g["mid"].to_numpy(dtype=float)
expiries = pd.to_datetime(g["expiry"]).to_numpy()
ois = g["openInterest"].fillna(0.0).to_numpy(dtype=float)
for i in range(len(mids) - 1):
edge = float(mids[i] - mids[i + 1])
if edge <= max(tol, min_edge):
continue
leg_oi_min = float(min(ois[i], ois[i + 1]))
if leg_oi_min < float(min_leg_open_interest):
continue
rows.append(
{
"family": "calendar",
"k1": float(strike),
"k2": np.nan,
"k3": np.nan,
"edge": edge,
"edge_per_width": edge,
"leg_oi_min": leg_oi_min,
"notes": "Longer-dated call cheaper than shorter-dated call at same strike.",
}
)
return pd.DataFrame(rows)
def summarize_arbitrage(candidates: pd.DataFrame) -> pd.DataFrame:
if candidates.empty:
return pd.DataFrame(
[
{
"candidate_count": 0,
"max_edge": 0.0,
"median_edge": 0.0,
"vertical_count": 0,
"butterfly_count": 0,
"parity_count": 0,
"calendar_count": 0,
"high_conf_count": 0,
"medium_conf_count": 0,
"low_conf_count": 0,
}
]
)
return pd.DataFrame(
[
{
"candidate_count": int(len(candidates)),
"max_edge": float(candidates["edge"].max()),
"median_edge": float(candidates["edge"].median()),
"vertical_count": int((candidates["family"] == "vertical").sum()),
"butterfly_count": int((candidates["family"] == "butterfly").sum()),
"parity_count": int((candidates["family"] == "parity").sum()),
"calendar_count": int((candidates["family"] == "calendar").sum()),
"high_conf_count": int((candidates["confidence"] == "high").sum()),
"medium_conf_count": int((candidates["confidence"] == "medium").sum()),
"low_conf_count": int((candidates["confidence"] == "low").sum()),
}
]
)
def assign_candidate_confidence(
candidates: pd.DataFrame,
mean_violation_rate: float,
failed_checks: int,
) -> pd.DataFrame:
if candidates.empty:
return candidates
if failed_checks == 0 and mean_violation_rate <= 0.02:
conf = "high"
elif failed_checks <= 1 and mean_violation_rate <= 0.08:
conf = "medium"
else:
conf = "low"
out = candidates.copy()
out["confidence"] = conf
return out