Skip to main content

portfolio_analyzer/strategy/
sim.rs

1//! # Simulator: the honest bookkeeper
2//!
3//! The simulator walks one underlying forward a day at a time.  Each day it
4//! either asks the strategy whether to open a trade, or marks the open trade
5//! to market and asks whether to close it.  Rules it enforces so strategies
6//! cannot cheat by accident:
7//!
8//! * **Decisions use the close of day `t`; fills happen at that close.**  SD
9//!   is computed from bars up to and including `t` only.
10//! * **Every leg pays a spread on the way in and the way out**, plus
11//!   commission.  Wider for names priced off the IV proxy (single stocks have
12//!   wider option markets than SPY).
13//! * **Returns are on capital at risk**: the debit for bought structures,
14//!   (widest wing โˆ’ credit) for sold ones.  A 50% return means "made half of
15//!   what could have been lost".
16//! * **Noise only touches SD's eyes.**  When the backtest adds Gaussian noise,
17//!   SD sees the noisy history, but trades are priced and settled on the real
18//!   prices and real implied volatility.
19
20use super::pricing::{bs_price, skewed_iv, Kind, ATM_FROM_INDEX};
21use super::{Context, OpenView, Strategy};
22use crate::sd2::engine::Sd2Reading;
23use chrono::NaiveDate;
24
25/// Interest rate used for pricing.  A constant 3% is a simplification; at
26/// one-month horizons its effect on straddles and spreads is tiny.
27pub const RATE: f64 = 0.03;
28
29/// Trading costs per share, per leg, per side.
30///
31/// Histories are dividend-adjusted, so a 2003 share of AAPL "costs" $0.30.
32/// Fixed-cent costs would be absurd on that scale, so every cost is a
33/// fraction of either the option's price or the stock's price.  The
34/// defaults match a $100 stock: a 1-cent (ETF) or 3-cent (single stock)
35/// minimum half-spread and $0.65 per contract.
36#[derive(Debug, Clone, Copy)]
37pub struct Costs {
38    /// Half-spread as a fraction of the option's price.
39    pub spread_frac: f64,
40    /// Minimum half-spread as a fraction of the *stock* price.
41    pub min_spread_of_spot: f64,
42    /// Commission per share as a fraction of the stock price.
43    pub commission_of_spot: f64,
44}
45
46impl Costs {
47    /// Index ETFs with deep option markets.
48    pub const LIQUID: Costs = Costs { spread_frac: 0.02, min_spread_of_spot: 0.0001, commission_of_spot: 0.000065 };
49    /// Single stocks: wider markets.
50    pub const SINGLE: Costs = Costs { spread_frac: 0.04, min_spread_of_spot: 0.0003, commission_of_spot: 0.000065 };
51    fn per_leg(&self, price: f64, spot: f64) -> f64 {
52        (self.spread_frac * price).max(self.min_spread_of_spot * spot) + self.commission_of_spot * spot
53    }
54    /// Closing cost: a leg worth less than the minimum spread is simply left
55    /// to expire (or abandoned), so it costs nothing to "close".
56    fn close_leg(&self, price: f64, spot: f64) -> f64 {
57        if price < self.min_spread_of_spot * spot { 0.0 } else { self.per_leg(price, spot) }
58    }
59}
60
61/// One underlying's inputs to the simulator.
62pub struct Underlying<'a> {
63    pub symbol: &'a str,
64    pub dates: &'a [NaiveDate],
65    /// Real closes (what trades settle on).
66    pub close: &'a [f64],
67    /// Implied vol *index level* per bar (annualized fraction, e.g. 0.18).
68    pub iv_index: &'a [f64],
69    /// True if `iv_index` is a real Cboe index rather than the proxy.
70    pub real_iv: bool,
71    /// SD readings as SD saw them (possibly from noisy data).
72    pub sd: &'a [Option<Sd2Reading>],
73    pub costs: Costs,
74}
75
76#[derive(Debug, Clone, serde::Serialize)]
77pub struct Trade {
78    pub symbol: String,
79    pub strategy: &'static str,
80    pub label: &'static str,
81    pub entry: NaiveDate,
82    pub exit: NaiveDate,
83    pub days: usize,
84    pub risk: f64,
85    pub pnl: f64,
86    pub ret: f64,
87    pub real_iv: bool,
88    /// Return on risk at each close the trade was open (entry day = cost only),
89    /// for marking a portfolio to market day by day.
90    #[serde(skip)]
91    pub path: Vec<(NaiveDate, f64)>,
92    /// Human-readable legs, e.g. "+1 C 412.50, +1 P 412.50".
93    pub legs: String,
94    pub entry_spot: f64,
95    pub exit_spot: f64,
96    /// ATM implied vol at entry (annualized).
97    pub entry_iv: f64,
98    /// Trading days from entry to expiry.
99    pub dte: usize,
100    /// SD's expected move รท the implied move, at entry.
101    pub entry_edge: f64,
102}
103
104struct Leg {
105    kind: Kind,
106    strike: f64,
107    qty: f64,
108}
109
110fn mark(legs: &[Leg], spot: f64, atm: f64, t: f64) -> f64 {
111    legs.iter().map(|l| l.qty * bs_price(l.kind, spot, l.strike, t, skewed_iv(atm, spot, l.strike), RATE)).sum()
112}
113
114fn exit_cost(legs: &[Leg], spot: f64, atm: f64, t: f64, c: &Costs) -> f64 {
115    legs.iter()
116        .map(|l| c.close_leg(bs_price(l.kind, spot, l.strike, t, skewed_iv(atm, spot, l.strike), RATE), spot))
117        .sum()
118}
119
120/// Run one strategy over one underlying, over its whole history.
121pub fn run(strategy: &dyn Strategy, u: &Underlying) -> Vec<Trade> {
122    run_window(strategy, u, 0, u.close.len(), false)
123}
124
125/// Run one strategy over bars `start..end` only ("trade a full period").
126///
127/// New trades open only inside the window.  With `force_close`, a trade
128/// still open on the last bar of the window is closed there at its marked
129/// value minus closing costs, so the period ends flat.
130pub fn run_window(strategy: &dyn Strategy, u: &Underlying, start: usize, end: usize, force_close: bool) -> Vec<Trade> {
131    run_window_full(strategy, u, start, end, force_close, false).0
132}
133
134/// Live replay: like [`run_window`], but trades may open even though their
135/// expiry lies beyond the last bar (that is what "today" looks like), and
136/// the trade still open on the last bar is returned too (marked, not
137/// closed): what a live runner holds right now.
138pub fn run_window_open(strategy: &dyn Strategy, u: &Underlying, start: usize, end: usize, force_close: bool) -> (Vec<Trade>, Option<Trade>) {
139    run_window_full(strategy, u, start, end, force_close, true)
140}
141
142fn run_window_full(strategy: &dyn Strategy, u: &Underlying, start: usize, end: usize, force_close: bool, live: bool) -> (Vec<Trade>, Option<Trade>) {
143    let n = u.close.len();
144    let end = end.min(n);
145    let mut trades = Vec::new();
146    // (legs, entry index, expiry index, risk, entry value, max profit, label, lean)
147    let mut open: Option<(Vec<Leg>, usize, usize, f64, f64, f64, &'static str, f64)> = None;
148    let mut edge_at_entry = f64::NAN;
149    let mut last_mark = 0.0;
150    let mut path: Vec<(NaiveDate, f64)> = Vec::new();
151    for i in start.max(1)..end {
152        let Some(sd) = u.sd[i].as_ref() else { continue };
153        let atm = u.iv_index[i] * ATM_FROM_INDEX;
154        if !atm.is_finite() || atm <= 0.0 {
155            continue;
156        }
157        let spot = u.close[i];
158        let ctx = Context { spot, iv: atm, sd, prev: u.sd[i - 1].as_ref() };
159        if let Some((legs, start, expiry, risk, entry_value, max_profit, label, lean)) = &open {
160            let days_left = expiry.saturating_sub(i);
161            let t = days_left as f64 / 252.0;
162            let value = mark(legs, spot, atm, t);
163            let pnl_now = value - entry_value;
164            last_mark = pnl_now;
165            let view = OpenView {
166                label,
167                days_held: i - start,
168                days_left,
169                return_on_risk: pnl_now / risk,
170                fraction_of_max: if *max_profit > 0.0 { pnl_now / max_profit } else { 0.0 },
171                entry_lean: *lean,
172            };
173            let last = force_close && i + 1 == end;
174            if days_left == 0 || last || strategy.exit(&ctx, &view) {
175                let pnl = pnl_now - exit_cost(legs, spot, atm, t, &u.costs);
176                path.push((u.dates[i], pnl / risk));
177                trades.push(Trade {
178                    symbol: u.symbol.to_string(),
179                    strategy: strategy.name(),
180                    label,
181                    entry: u.dates[*start],
182                    exit: u.dates[i],
183                    days: i - start,
184                    risk: *risk,
185                    pnl,
186                    ret: pnl / risk,
187                    real_iv: u.real_iv,
188                    path: std::mem::take(&mut path),
189                    legs: describe(legs),
190                    entry_spot: u.close[*start],
191                    exit_spot: spot,
192                    entry_iv: u.iv_index[*start] * ATM_FROM_INDEX,
193                    dte: expiry - start,
194                    entry_edge: edge_at_entry,
195                });
196                open = None;
197            } else {
198                path.push((u.dates[i], pnl_now / risk));
199            }
200            continue;
201        }
202        if force_close && i + 1 >= end {
203            continue;
204        }
205        let Some(plan) = strategy.enter(&ctx) else { continue };
206        // A backtest needs the whole life of the trade in the data; live does not.
207        if !live && i + plan.dte >= n {
208            continue;
209        }
210        let t = plan.dte as f64 / 252.0;
211        let legs: Vec<Leg> =
212            plan.legs.iter().map(|l| Leg { kind: l.kind, strike: spot * l.moneyness, qty: l.qty }).collect();
213        let value = mark(&legs, spot, atm, t);
214        let entry_cost: f64 = legs
215            .iter()
216            .map(|l| u.costs.per_leg(bs_price(l.kind, spot, l.strike, t, skewed_iv(atm, spot, l.strike), RATE), spot))
217            .sum();
218        // Widest distance between a bought and a sold strike of the same kind.
219        let width = |k: Kind| {
220            let s: Vec<&Leg> = legs.iter().filter(|l| l.kind == k).collect();
221            let longs = s.iter().filter(|l| l.qty > 0.0).map(|l| l.strike);
222            let shorts: Vec<f64> = s.iter().filter(|l| l.qty < 0.0).map(|l| l.strike).collect();
223            longs.flat_map(|a| shorts.iter().map(move |b| (a - b).abs())).fold(0.0, f64::max)
224        };
225        let (risk, max_profit) = if value > 0.0 {
226            // Debit: lose at most what was paid.  Vertical max profit = width โˆ’ debit.
227            let w = width(Kind::Call).max(width(Kind::Put));
228            (value + entry_cost, if w > 0.0 { w - value } else { 0.0 })
229        } else {
230            // Credit: lose at most widest wing โˆ’ credit.
231            let w = width(Kind::Call).max(width(Kind::Put));
232            ((w + value) + entry_cost, -value)
233        };
234        if risk <= 0.0 || !risk.is_finite() {
235            continue;
236        }
237        // Entry costs are paid up front: book them into the starting value.
238        path.clear();
239        path.push((u.dates[i], -entry_cost / risk));
240        last_mark = -entry_cost;
241        edge_at_entry = ctx.vol_edge();
242        open = Some((legs, i, i + plan.dte, risk, value + entry_cost, max_profit, plan.label, sd.lean));
243    }
244    let still_open = open.map(|(legs, start, expiry, risk, _, _, label, _)| {
245        let last = end.saturating_sub(1).max(start);
246        Trade {
247            symbol: u.symbol.to_string(),
248            strategy: strategy.name(),
249            label,
250            entry: u.dates[start],
251            exit: u.dates[last],
252            days: last - start,
253            risk,
254            pnl: last_mark,
255            ret: last_mark / risk,
256            real_iv: u.real_iv,
257            path,
258            legs: describe(&legs),
259            entry_spot: u.close[start],
260            exit_spot: u.close[last],
261            entry_iv: u.iv_index[start] * ATM_FROM_INDEX,
262            dte: expiry - start,
263            entry_edge: edge_at_entry,
264        }
265    });
266    (trades, still_open)
267}
268
269fn describe(legs: &[Leg]) -> String {
270    legs.iter()
271        .map(|l| format!("{:+} {} {:.2}", l.qty, if l.kind == Kind::Call { "C" } else { "P" }, l.strike))
272        .collect::<Vec<_>>()
273        .join(", ")
274}
275
276/// Summary of a list of trades.
277#[derive(Debug, Clone, Default, serde::Serialize)]
278pub struct Summary {
279    pub trades: usize,
280    pub win_rate: f64,
281    pub mean_ret: f64,
282    pub median_ret: f64,
283    pub total_ret: f64,
284    pub sharpe_per_trade: f64,
285    pub worst: f64,
286    pub max_drawdown: f64,
287    pub mean_days: f64,
288}
289
290pub fn summarize(trades: &[&Trade]) -> Summary {
291    if trades.is_empty() {
292        return Summary::default();
293    }
294    let n = trades.len() as f64;
295    let mut r: Vec<f64> = trades.iter().map(|t| t.ret).collect();
296    let mean = r.iter().sum::<f64>() / n;
297    let sd = (r.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n).sqrt();
298    // Drawdown on the chronological equity curve (sum of returns on risk).
299    let mut chrono: Vec<&&Trade> = trades.iter().collect();
300    chrono.sort_by_key(|t| t.exit);
301    let (mut eq, mut peak, mut dd) = (0.0f64, 0.0f64, 0.0f64);
302    for t in chrono {
303        eq += t.ret;
304        peak = peak.max(eq);
305        dd = dd.max(peak - eq);
306    }
307    r.sort_by(|a, b| a.partial_cmp(b).unwrap());
308    Summary {
309        trades: trades.len(),
310        win_rate: trades.iter().filter(|t| t.pnl > 0.0).count() as f64 / n,
311        mean_ret: mean,
312        median_ret: r[r.len() / 2],
313        total_ret: mean * n,
314        sharpe_per_trade: if sd > 0.0 { mean / sd } else { 0.0 },
315        worst: r[0],
316        max_drawdown: dd,
317        mean_days: trades.iter().map(|t| t.days as f64).sum::<f64>() / n,
318    }
319}