File size: 4,942 Bytes
b6d53e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
from __future__ import annotations

from datetime import datetime

import numpy as np
import pandas as pd

from .types import DiagnosticResult, OptionSnapshot


def _validate_calls_df(calls_df: pd.DataFrame) -> pd.DataFrame:
    required = {"expiry", "strike", "mid"}
    if not required.issubset(calls_df.columns):
        missing = sorted(required - set(calls_df.columns))
        raise ValueError(f"Missing required columns: {missing}")
    return calls_df.sort_values(["expiry", "strike"]).reset_index(drop=True)


def check_monotonicity(calls_df: pd.DataFrame, tol: float = 1e-9) -> DiagnosticResult:
    df = _validate_calls_df(calls_df)
    violations = 0
    comparisons = 0
    for _, grp in df.groupby("expiry"):
        mids = grp["mid"].to_numpy(dtype=float)
        diffs = np.diff(mids)
        comparisons += int(len(diffs))
        violations += int(np.sum(diffs > tol))
    violation_rate = float(violations / comparisons) if comparisons else 0.0
    return DiagnosticResult(
        name="monotonicity",
        passed=violations == 0,
        violations=violations,
        comparisons=comparisons,
        violation_rate=violation_rate,
        details="Call prices should be non-increasing in strike.",
    )


def check_convexity(calls_df: pd.DataFrame, tol: float = 1e-8) -> DiagnosticResult:
    df = _validate_calls_df(calls_df)
    violations = 0
    comparisons = 0
    for _, grp in df.groupby("expiry"):
        g = grp.sort_values("strike")
        k = g["strike"].to_numpy(dtype=float)
        c = g["mid"].to_numpy(dtype=float)
        if len(k) < 3:
            continue
        dk = np.diff(k)
        valid = dk > 0
        if not np.all(valid):
            keep = np.concatenate(([True], valid))
            k = k[keep]
            c = c[keep]
            if len(k) < 3:
                continue
            dk = np.diff(k)

        slope_left = (c[1:-1] - c[:-2]) / (k[1:-1] - k[:-2])
        slope_right = (c[2:] - c[1:-1]) / (k[2:] - k[1:-1])
        comparisons += int(len(slope_right))
        violations += int(np.sum((slope_right - slope_left) < -tol))
    violation_rate = float(violations / comparisons) if comparisons else 0.0
    return DiagnosticResult(
        name="convexity",
        passed=violations == 0,
        violations=violations,
        comparisons=comparisons,
        violation_rate=violation_rate,
        details="Call strike slopes should be non-decreasing (uneven-grid convexity).",
    )


def check_calendar(calls_df: pd.DataFrame, tol: float = 1e-8) -> DiagnosticResult:
    df = _validate_calls_df(calls_df)
    strikes = sorted(set(df["strike"].tolist()))
    violations = 0
    comparisons = 0
    for strike in strikes:
        s = df[df["strike"] == strike].sort_values("expiry")
        if len(s) < 2:
            continue
        mids = s["mid"].to_numpy(dtype=float)
        diffs = np.diff(mids)
        comparisons += int(len(diffs))
        violations += int(np.sum(diffs < -tol))
    violation_rate = float(violations / comparisons) if comparisons else 0.0
    return DiagnosticResult(
        name="calendar",
        passed=violations == 0,
        violations=violations,
        comparisons=comparisons,
        violation_rate=violation_rate,
        details="Call prices should be non-decreasing with maturity at same strike.",
    )


def run_all_checks(snapshot: OptionSnapshot) -> list[DiagnosticResult]:
    calls = snapshot.options[snapshot.options["option_type"] == "call"].copy()
    return [
        check_monotonicity(calls),
        check_convexity(calls),
        check_calendar(calls),
    ]


def run_checks_by_expiry(snapshot: OptionSnapshot) -> pd.DataFrame:
    calls = snapshot.options[snapshot.options["option_type"] == "call"].copy()
    rows: list[dict[str, object]] = []
    for expiry, grp in calls.groupby("expiry"):
        mini = OptionSnapshot(
            ticker=snapshot.ticker,
            snapshot_time=snapshot.snapshot_time,
            spot=snapshot.spot,
            options=grp.copy(),
        )
        for d in run_all_checks(mini):
            rows.append(
                {
                    "expiry": pd.to_datetime(expiry),
                    "check": d.name,
                    "passed": d.passed,
                    "violations": d.violations,
                    "comparisons": d.comparisons,
                    "violation_rate": d.violation_rate,
                    "details": d.details,
                }
            )
    return pd.DataFrame(rows).sort_values(["expiry", "check"]).reset_index(drop=True)


def select_best_quality_expiry(snapshot: OptionSnapshot) -> datetime:
    per_expiry = run_checks_by_expiry(snapshot)
    if per_expiry.empty:
        raise ValueError("No per-expiry diagnostics available")
    scores = (
        per_expiry.groupby("expiry")["violation_rate"]
        .mean()
        .sort_values(ascending=True)
    )
    return pd.to_datetime(scores.index[0]).to_pydatetime()