File size: 8,479 Bytes
8f1601b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
from __future__ import annotations

from dataclasses import asdict, dataclass
from datetime import timedelta
from pathlib import Path

import pandas as pd

from .vol_backtest import max_drawdown


REQUIRED_QUOTE_COLUMNS = {
    "date",
    "underlying_symbol",
    "underlying_price",
    "contract_symbol",
    "option_type",
    "expiration",
    "strike",
    "bid",
    "ask",
}


@dataclass
class OptionBacktestTrade:
    entry_date: str
    exit_date: str
    contract_symbol: str
    option_type: str
    strike: float
    expiration: str
    quantity: int
    entry_price: float
    exit_price: float
    fees: float
    pnl: float

    def to_dict(self) -> dict:
        return asdict(self)


def validate_quote_frame(quotes: pd.DataFrame) -> None:
    missing = REQUIRED_QUOTE_COLUMNS - set(quotes.columns)
    if missing:
        raise ValueError(f"Historical option quotes missing required columns: {sorted(missing)}")


def prepare_quotes(quotes: pd.DataFrame) -> pd.DataFrame:
    validate_quote_frame(quotes)
    frame = quotes.copy()
    frame["date"] = pd.to_datetime(frame["date"]).dt.normalize()
    frame["expiration"] = pd.to_datetime(frame["expiration"]).dt.normalize()
    frame["option_type"] = frame["option_type"].str.lower()
    quoted_mid = (frame["bid"] + frame["ask"]) / 2
    if "mid" not in frame.columns:
        frame["mid"] = quoted_mid
    else:
        frame["mid"] = frame["mid"].where(frame["mid"].notna(), quoted_mid)
    frame["dte"] = (frame["expiration"] - frame["date"]).dt.days
    frame = frame[(frame["bid"] >= 0) & (frame["ask"] > 0) & (frame["dte"] >= 0)]
    return frame.sort_values(["date", "expiration", "strike", "option_type"]).reset_index(drop=True)


def load_option_quotes_csv(path: str | Path) -> pd.DataFrame:
    return prepare_quotes(pd.read_csv(path))


def available_exit_date(
    quotes: pd.DataFrame,
    entry_date: pd.Timestamp,
    target_exit_date: pd.Timestamp,
    contract_symbol: str,
) -> pd.Timestamp | None:
    contract_quotes = quotes[
        (quotes["contract_symbol"] == contract_symbol)
        & (quotes["date"] >= target_exit_date)
    ]
    if contract_quotes.empty:
        contract_quotes = quotes[quotes["contract_symbol"] == contract_symbol]
        contract_quotes = contract_quotes[
            (contract_quotes["date"] > entry_date)
            & (contract_quotes["date"] < target_exit_date)
        ]
        if contract_quotes.empty:
            return None
        return contract_quotes["date"].max()
    if contract_quotes.empty:
        return None
    return contract_quotes["date"].min()


def quote_price(row: pd.Series, side: str, price_field: str) -> float:
    if price_field == "mid":
        return float(row["mid"])
    if price_field != "trade":
        raise ValueError("price_field must be 'trade' or 'mid'.")
    if side == "buy":
        return float(row["ask"])
    return float(row["bid"])


def select_expiration_slice(day_quotes: pd.DataFrame, target_dte: int) -> pd.DataFrame:
    candidates = day_quotes[day_quotes["dte"] > 0]
    if candidates.empty:
        return candidates
    expiration = candidates.assign(dte_error=(candidates["dte"] - target_dte).abs()).sort_values("dte_error").iloc[0]["expiration"]
    return candidates[candidates["expiration"] == expiration]


def select_atm_contract(expiration_slice: pd.DataFrame, option_type: str) -> pd.Series | None:
    contracts = expiration_slice[expiration_slice["option_type"] == option_type]
    if contracts.empty:
        return None
    spot = float(expiration_slice["underlying_price"].iloc[0])
    return contracts.assign(strike_error=(contracts["strike"] - spot).abs()).sort_values("strike_error").iloc[0]


def backtest_long_straddle_from_quotes(
    quotes: pd.DataFrame,
    symbol: str,
    target_dte: int = 30,
    holding_days: int = 5,
    entry_every_days: int = 5,
    contract_multiplier: int = 100,
    fee_per_contract: float = 0.65,
    price_field: str = "trade",
) -> dict:
    frame = prepare_quotes(quotes)
    frame = frame[frame["underlying_symbol"].str.upper() == symbol.upper()]
    if frame.empty:
        raise ValueError(f"No historical option quotes found for {symbol}.")

    trades: list[OptionBacktestTrade] = []
    trade_groups = []
    equity = [0.0]
    dates = sorted(frame["date"].unique())
    next_entry_date = dates[0]

    for entry_date in dates:
        entry_date = pd.Timestamp(entry_date)
        if entry_date < next_entry_date:
            continue

        day_quotes = frame[frame["date"] == entry_date]
        expiration_slice = select_expiration_slice(day_quotes, target_dte)
        if expiration_slice.empty:
            continue

        call = select_atm_contract(expiration_slice, "call")
        put = select_atm_contract(expiration_slice, "put")
        if call is None or put is None:
            continue

        target_exit_date = entry_date + timedelta(days=holding_days)
        pending_group_trades = []
        group_pnl = 0.0
        for leg in [call, put]:
            exit_date = available_exit_date(frame, entry_date, target_exit_date, str(leg["contract_symbol"]))
            if exit_date is None:
                continue
            exit_quote = frame[
                (frame["date"] == exit_date)
                & (frame["contract_symbol"] == leg["contract_symbol"])
            ].iloc[0]

            entry_price = quote_price(leg, "buy", price_field)
            exit_price = quote_price(exit_quote, "sell", price_field)
            fees = fee_per_contract * 2
            pnl = (exit_price - entry_price) * contract_multiplier - fees
            trade = OptionBacktestTrade(
                entry_date=str(entry_date.date()),
                exit_date=str(pd.Timestamp(exit_date).date()),
                contract_symbol=str(leg["contract_symbol"]),
                option_type=str(leg["option_type"]),
                strike=float(leg["strike"]),
                expiration=str(pd.Timestamp(leg["expiration"]).date()),
                quantity=1,
                entry_price=round(entry_price, 4),
                exit_price=round(exit_price, 4),
                fees=round(fees, 2),
                pnl=round(pnl, 2),
            )
            pending_group_trades.append(trade)
            group_pnl += pnl

        if len(pending_group_trades) == 2:
            trades.extend(pending_group_trades)
            equity.append(equity[-1] + group_pnl)
            trade_groups.append(
                {
                    "entry_date": str(entry_date.date()),
                    "exit_date": pending_group_trades[0].exit_date,
                    "strategy": "long_straddle",
                    "pnl": round(group_pnl, 2),
                    "legs": [trade.to_dict() for trade in pending_group_trades],
                }
            )
            next_entry_date = entry_date + timedelta(days=entry_every_days)

    equity_series = pd.Series(equity)
    group_pnls = [group["pnl"] for group in trade_groups]
    wins = [pnl for pnl in group_pnls if pnl > 0]
    losses = [pnl for pnl in group_pnls if pnl <= 0]

    return {
        "strategy": "long_straddle",
        "symbol": symbol.upper(),
        "target_dte": target_dte,
        "holding_days": holding_days,
        "entry_every_days": entry_every_days,
        "contract_multiplier": contract_multiplier,
        "fee_per_contract": fee_per_contract,
        "price_field": price_field,
        "trade_count": len(trade_groups),
        "leg_trade_count": len(trades),
        "total_pnl": round(float(equity_series.iloc[-1]), 2) if not equity_series.empty else 0.0,
        "max_drawdown": round(max_drawdown(equity_series + 100000), 6),
        "win_rate": len(wins) / len(group_pnls) if group_pnls else 0.0,
        "avg_win": round(sum(wins) / len(wins), 2) if wins else 0.0,
        "avg_loss": round(sum(losses) / len(losses), 2) if losses else 0.0,
        "trades": trade_groups[:200],
        "data_requirements": [
            "Historical option quotes with date, expiration, strike, bid, ask, and underlying_price.",
            "For production-grade backtests, include deltas, IV, volume, open interest, and corporate action adjusted symbols.",
        ],
        "limitations": [
            "No early assignment model yet.",
            "No margin model yet.",
            "No intraday fills; entry and exit use the daily quote row.",
            "Results are only as good as the historical option quote data supplied.",
        ],
    }