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