1use super::pricing::{bs_price, skewed_iv, Kind, ATM_FROM_INDEX};
21use super::{Context, OpenView, Strategy};
22use crate::sd2::engine::Sd2Reading;
23use chrono::NaiveDate;
24
25pub const RATE: f64 = 0.03;
28
29#[derive(Debug, Clone, Copy)]
37pub struct Costs {
38 pub spread_frac: f64,
40 pub min_spread_of_spot: f64,
42 pub commission_of_spot: f64,
44}
45
46impl Costs {
47 pub const LIQUID: Costs = Costs { spread_frac: 0.02, min_spread_of_spot: 0.0001, commission_of_spot: 0.000065 };
49 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 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
61pub struct Underlying<'a> {
63 pub symbol: &'a str,
64 pub dates: &'a [NaiveDate],
65 pub close: &'a [f64],
67 pub iv_index: &'a [f64],
69 pub real_iv: bool,
71 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 #[serde(skip)]
91 pub path: Vec<(NaiveDate, f64)>,
92 pub legs: String,
94 pub entry_spot: f64,
95 pub exit_spot: f64,
96 pub entry_iv: f64,
98 pub dte: usize,
100 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
120pub fn run(strategy: &dyn Strategy, u: &Underlying) -> Vec<Trade> {
122 run_window(strategy, u, 0, u.close.len(), false)
123}
124
125pub 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
134pub 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 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 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 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 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 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 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#[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 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}