Skip to main content

portfolio_analyzer/strategy/
book.rs

1//! # Book: trading a full month or a full year, the way a person would
2//!
3//! `sim.rs` grades strategies one trade at a time.  That answers "is this
4//! trade a good bet?", but not "what would my account have looked like if I
5//! had run this for all of 2022?".  This file answers the second question.
6//!
7//! ## What "trade a full period" means here
8//!
9//! 1. **Start flat**: cash only, `capital` dollars, at the close of the last
10//!    trading day *before* the period.
11//! 2. **Every close, decide**: SD is read from bars up to and including that
12//!    close, and nothing later.  The strategy says what it wants to hold.
13//! 3. **Fill at the next open** (stocks) or **at that close** (options, the
14//!    option simulator's rule, since option prices come from the close).
15//!    Every fill pays costs.
16//! 4. **Mark to market every close**: the equity curve is cash plus what
17//!    every open position is worth that day.
18//! 5. **End flat**: everything still open is closed at the period's last
19//!    close, at marked value minus closing costs.
20//!
21//! Nothing in the period can see the period's future: SD's models for
22//! year `Y` are refitted on bars whose outcomes were known before `Y` began
23//! (see `sd_year`).
24//!
25//! ## Two kinds of strategy
26//!
27//! * [`Allocator`]: stock strategies.  Each close they return target
28//!   weights (negative = short); the book trades the difference.
29//! * Option strategies from `strategy/mod.rs`: each symbol's trades come
30//!   from [`super::sim::run_window`], and the book sizes each one at a fixed
31//!   fraction of equity ("risk 2% of the account per trade").
32
33use super::sim::Trade;
34use crate::sd2::engine::Sd2Reading;
35use chrono::NaiveDate;
36use std::collections::HashMap;
37
38/// One symbol, lined up on the shared calendar.
39pub struct Lane {
40    pub symbol: String,
41    /// `None` on calendar days the symbol did not trade (not yet listed, halted).
42    pub open: Vec<Option<f64>>,
43    pub close: Vec<Option<f64>>,
44    pub sd: Vec<Option<Sd2Reading>>,
45}
46
47/// What an allocator may look at on one close.
48pub struct DayView<'a> {
49    pub day: usize,
50    pub date: NaiveDate,
51    pub lanes: &'a [Lane],
52}
53
54impl DayView<'_> {
55    /// Symbols with a usable SD reading and price today.
56    pub fn live(&self) -> impl Iterator<Item = (usize, &Sd2Reading)> + '_ {
57        self.lanes.iter().enumerate().filter_map(move |(k, l)| {
58            let r = l.sd[self.day].as_ref()?;
59            l.close[self.day]?;
60            Some((k, r))
61        })
62    }
63}
64
65/// A stock strategy: target weights each close.
66pub trait Allocator: Sync {
67    fn name(&self) -> &'static str;
68    /// One line for the book: what it believes.
69    fn thesis(&self) -> &'static str;
70    /// Target weights (fraction of equity; negative = short).  `None` keeps
71    /// the current holdings unchanged (no trading today).
72    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>>;
73}
74
75/// Buy one symbol on day one, hold to the end.
76pub struct HoldOne(pub &'static str);
77impl Allocator for HoldOne {
78    fn name(&self) -> &'static str {
79        "SPY buy & hold"
80    }
81    fn thesis(&self) -> &'static str {
82        "The benchmark: own the market, do nothing."
83    }
84    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>> {
85        (days_in == 0).then(|| v.lanes.iter().position(|l| l.symbol == self.0).map(|k| vec![(k, 1.0)]).unwrap_or_default())
86    }
87}
88
89/// Equal weight in every symbol that trades on day one, held.
90pub struct EqualHold;
91impl Allocator for EqualHold {
92    fn name(&self) -> &'static str {
93        "Equal-weight hold"
94    }
95    fn thesis(&self) -> &'static str {
96        "The universe benchmark: 1/N of every name, never rebalanced."
97    }
98    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>> {
99        if days_in > 0 {
100            return None;
101        }
102        let k: Vec<usize> = v.live().map(|(k, _)| k).collect();
103        let w = 1.0 / k.len().max(1) as f64;
104        Some(k.into_iter().map(|k| (k, w)).collect())
105    }
106}
107
108/// Hold every name SD puts above 0.5, equal weight, rebalanced weekly.
109pub struct SdSide;
110impl Allocator for SdSide {
111    fn name(&self) -> &'static str {
112        "SD side"
113    }
114    fn thesis(&self) -> &'static str {
115        "Own what SD calls up, hold cash (or nothing) for what it calls down."
116    }
117    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>> {
118        if days_in % 5 != 0 {
119            return None;
120        }
121        let k: Vec<usize> = v.live().filter(|(_, r)| r.sd > 0.5).map(|(k, _)| k).collect();
122        let w = 1.0 / k.len().max(1) as f64;
123        Some(k.into_iter().map(|k| (k, w)).collect())
124    }
125}
126
127/// Trade fresh squeeze fires in the direction of momentum.
128pub struct FireMomentum {
129    pub min_lean: f64,
130    pub shorts: bool,
131    pub slot: f64,
132    pub max_names: usize,
133}
134impl Allocator for FireMomentum {
135    fn name(&self) -> &'static str {
136        if self.shorts { "SD fire long/short" } else { "SD fire long" }
137    }
138    fn thesis(&self) -> &'static str {
139        "The classic squeeze trade: when a coil fires, ride momentum while the fire is alive."
140    }
141    fn targets(&self, v: &DayView, _days_in: usize) -> Option<Vec<(usize, f64)>> {
142        let mut longs: Vec<(usize, f64)> = Vec::new();
143        let mut shorts: Vec<(usize, f64)> = Vec::new();
144        for (k, r) in v.live() {
145            if !r.is_active_fire {
146                continue;
147            }
148            if r.lean >= self.min_lean {
149                longs.push((k, r.lean));
150            } else if self.shorts && r.lean <= -self.min_lean {
151                shorts.push((k, r.lean));
152            }
153        }
154        longs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
155        shorts.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
156        let mut out: Vec<(usize, f64)> = longs.into_iter().take(self.max_names).map(|(k, _)| (k, self.slot)).collect();
157        out.extend(shorts.into_iter().take(self.max_names).map(|(k, _)| (k, -self.slot)));
158        Some(out)
159    }
160}
161
162/// Rank by SD's expected move and hold the top (or bottom) names, weekly.
163pub struct MoveTilt {
164    pub biggest: bool,
165    pub names: usize,
166}
167impl Allocator for MoveTilt {
168    fn name(&self) -> &'static str {
169        if self.biggest { "SD big-move tilt" } else { "SD calm tilt" }
170    }
171    fn thesis(&self) -> &'static str {
172        if self.biggest {
173            "Own the names SD expects to move most (on the side SD calls)."
174        } else {
175            "Own the names SD expects to move least: the low-volatility effect."
176        }
177    }
178    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>> {
179        if days_in % 5 != 0 {
180            return None;
181        }
182        let mut c: Vec<(usize, f64)> =
183            v.live().filter(|(_, r)| r.side > 0.0).map(|(k, r)| (k, r.expected_move_sigma * r.vol20)).collect();
184        c.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
185        if self.biggest {
186            c.reverse();
187        }
188        let n = c.len().min(self.names);
189        Some(c.into_iter().take(n).map(|(k, _)| (k, 1.0 / n.max(1) as f64)).collect())
190    }
191}
192
193/// Few-day swing: buy fresh fires (first half of their expected life) with
194/// a clear upward lean; sell as soon as the fire is no longer fresh.
195pub struct SwingFire;
196impl Allocator for SwingFire {
197    fn name(&self) -> &'static str {
198        "SD swing long"
199    }
200    fn thesis(&self) -> &'static str {
201        "A squeeze that has just fired, with momentum up: hold for a few days while the fire is young."
202    }
203    fn targets(&self, v: &DayView, _days_in: usize) -> Option<Vec<(usize, f64)>> {
204        let mut c: Vec<(usize, f64)> = v
205            .live()
206            .filter(|(_, r)| r.is_active_fire && r.phase.is_some_and(|p| p <= 0.5) && r.lean >= 0.3)
207            .map(|(k, r)| (k, r.lean))
208            .collect();
209        c.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
210        Some(c.into_iter().take(10).map(|(k, _)| (k, 0.1)).collect())
211    }
212}
213
214/// Few-day mean reversion: own the names stretched furthest *below* their
215/// 21-bar average (on the side SD calls up), re-picked every 5 days.
216pub struct StretchReversion;
217impl Allocator for StretchReversion {
218    fn name(&self) -> &'static str {
219        "SD stretch reversion"
220    }
221    fn thesis(&self) -> &'static str {
222        "Short-term overreactions tend to partly undo themselves: buy the most stretched-down names, hold about a week."
223    }
224    fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>> {
225        if days_in % 5 != 0 {
226            return None;
227        }
228        let mut c: Vec<(usize, f64)> = v.live().filter(|(_, r)| r.side > 0.0 && r.stretch < 0.0).map(|(k, r)| (k, r.stretch)).collect();
229        c.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
230        let n = c.len().min(10);
231        Some(c.into_iter().take(n).map(|(k, _)| (k, 1.0 / n.max(1) as f64)).collect())
232    }
233}
234
235/// Every stock strategy the year runs include, in report order.
236pub fn allocators() -> Vec<Box<dyn Allocator>> {
237    vec![
238        Box::new(HoldOne("SPY")),
239        Box::new(EqualHold),
240        Box::new(SdSide),
241        Box::new(FireMomentum { min_lean: 0.3, shorts: false, slot: 0.05, max_names: 20 }),
242        Box::new(FireMomentum { min_lean: 0.3, shorts: true, slot: 0.05, max_names: 10 }),
243        Box::new(MoveTilt { biggest: true, names: 20 }),
244        Box::new(MoveTilt { biggest: false, names: 20 }),
245        Box::new(SwingFire),
246        Box::new(StretchReversion),
247    ]
248}
249
250/// One finished position, for win ratio and expectancy.
251#[derive(Debug, Clone, serde::Serialize)]
252pub struct Closed {
253    pub symbol: String,
254    pub entry: NaiveDate,
255    pub exit: NaiveDate,
256    pub days: usize,
257    /// Profit as a fraction of the capital committed to the position.
258    pub ret: f64,
259    pub pnl: f64,
260    pub side: &'static str,
261}
262
263/// An event a live runner would announce.
264#[derive(Debug, Clone, serde::Serialize)]
265pub struct Event {
266    pub date: NaiveDate,
267    pub action: &'static str,
268    pub symbol: String,
269    pub price: f64,
270    pub dollars: f64,
271}
272
273/// The result of trading one strategy over one period.
274pub struct Run {
275    pub name: String,
276    pub dates: Vec<NaiveDate>,
277    pub equity: Vec<f64>,
278    pub exposure: Vec<f64>,
279    pub closed: Vec<Closed>,
280    pub events: Vec<Event>,
281    /// Option trades skipped because one contract's max loss was more than
282    /// the per-trade budget (account too small for that underlying).
283    pub unaffordable: usize,
284}
285
286/// Stock costs: half-spread + slippage per side, as a fraction of notional.
287pub const STOCK_COST: f64 = 0.0005;
288
289/// Trade an allocator over calendar days `start..=end` (`start` = the close
290/// before the period, where the first decision is made).
291pub fn run_allocator(a: &dyn Allocator, lanes: &[Lane], calendar: &[NaiveDate], start: usize, end: usize, capital: f64) -> Run {
292    let mut cash = capital;
293    let mut pos: HashMap<usize, f64> = HashMap::new();
294    // symbol -> (entry date, entry day, committed dollars, running pnl, side)
295    let mut book: HashMap<usize, (NaiveDate, usize, f64, f64, &'static str)> = HashMap::new();
296    let mut closed = Vec::new();
297    let mut events = Vec::new();
298    let mut eq_curve = vec![capital];
299    let mut exposure = vec![0.0];
300    let mut dates = vec![calendar[start]];
301    let mut pending = a.targets(&DayView { day: start, date: calendar[start], lanes }, 0);
302    for day in start + 1..=end {
303        // Overnight: positions move from yesterday's close to today's open.
304        for (&k, p) in pos.iter_mut() {
305            let l = &lanes[k];
306            if let (Some(o), Some(c0)) = (l.open[day], last_close(l, day - 1)) {
307                let move_ = *p * (o / c0 - 1.0);
308                *p += move_;
309                if let Some(b) = book.get_mut(&k) {
310                    b.3 += move_;
311                }
312            }
313        }
314        // At the open: trade towards yesterday's targets.
315        if let Some(t) = pending.take() {
316            let equity = cash + pos.values().sum::<f64>();
317            let target: HashMap<usize, f64> = t.into_iter().filter(|(k, _)| lanes[*k].open[day].is_some()).collect();
318            let mut keys: Vec<usize> = pos.keys().chain(target.keys()).copied().collect();
319            keys.sort_unstable();
320            keys.dedup();
321            for k in keys {
322                let Some(price) = lanes[k].open[day] else { continue };
323                let cur = pos.get(&k).copied().unwrap_or(0.0);
324                let want = target.get(&k).copied().unwrap_or(0.0) * equity;
325                // Ignore tiny drifts: rebalance only if off by more than 10% of the target or fully exiting.
326                if (want - cur).abs() < 1e-9 || (want != 0.0 && cur != 0.0 && want.signum() == cur.signum() && (want - cur).abs() < 0.1 * want.abs()) {
327                    continue;
328                }
329                let cost = (want - cur).abs() * STOCK_COST;
330                cash -= want - cur + cost;
331                let date = calendar[day];
332                if cur == 0.0 {
333                    book.insert(k, (date, day, want.abs(), -cost, if want > 0.0 { "long" } else { "short" }));
334                    events.push(Event { date, action: "open", symbol: lanes[k].symbol.clone(), price, dollars: want });
335                } else if let Some(b) = book.get_mut(&k) {
336                    b.3 -= cost;
337                    b.2 = b.2.max(want.abs());
338                }
339                if cur != 0.0 && (want == 0.0 || want.signum() != cur.signum()) {
340                    if let Some((entry, d0, committed, pnl, side)) = book.remove(&k) {
341                        closed.push(Closed { symbol: lanes[k].symbol.clone(), entry, exit: date, days: day - d0, ret: pnl / committed, pnl, side });
342                        events.push(Event { date, action: "close", symbol: lanes[k].symbol.clone(), price, dollars: cur });
343                    }
344                    if want != 0.0 {
345                        book.insert(k, (date, day, want.abs(), 0.0, if want > 0.0 { "long" } else { "short" }));
346                        events.push(Event { date, action: "open", symbol: lanes[k].symbol.clone(), price, dollars: want });
347                    }
348                }
349                if want == 0.0 {
350                    pos.remove(&k);
351                } else {
352                    pos.insert(k, want);
353                }
354            }
355        }
356        // During the day: open to close.
357        for (&k, p) in pos.iter_mut() {
358            let l = &lanes[k];
359            if let (Some(o), Some(c)) = (l.open[day], l.close[day]) {
360                let move_ = *p * (c / o - 1.0);
361                *p += move_;
362                if let Some(b) = book.get_mut(&k) {
363                    b.3 += move_;
364                }
365            }
366        }
367        if day == end {
368            // End flat at the last close.
369            let mut keys: Vec<usize> = pos.keys().copied().collect();
370            keys.sort_unstable();
371            for k in keys {
372                let p = pos.remove(&k).unwrap();
373                let cost = p.abs() * STOCK_COST;
374                cash += p - cost;
375                if let Some((entry, d0, committed, pnl, side)) = book.remove(&k) {
376                    closed.push(Closed { symbol: lanes[k].symbol.clone(), entry, exit: calendar[day], days: day - d0, ret: (pnl - cost) / committed, pnl: pnl - cost, side });
377                }
378                events.push(Event { date: calendar[day], action: "close", symbol: lanes[k].symbol.clone(), price: last_close(&lanes[k], day).unwrap_or(0.0), dollars: p });
379            }
380        } else {
381            pending = a.targets(&DayView { day, date: calendar[day], lanes }, day - start);
382        }
383        let gross: f64 = pos.values().map(|p| p.abs()).sum();
384        let equity = cash + pos.values().sum::<f64>();
385        eq_curve.push(equity);
386        exposure.push(gross / equity.max(1e-9));
387        dates.push(calendar[day]);
388    }
389    Run { name: a.name().to_string(), dates, equity: eq_curve, exposure, closed, events, unaffordable: 0 }
390}
391
392fn last_close(l: &Lane, day: usize) -> Option<f64> {
393    (0..=day).rev().find_map(|d| l.close[d])
394}
395
396/// Size option trades into an account: each trade may lose at most
397/// `risk_frac` of equity at entry, at most `max_open` at once (later ones
398/// are skipped).
399///
400/// `contract_loss`, when given, is the real-dollar maximum loss of **one
401/// contract** (100 shares at the price actually printed that day).  Then
402/// the book buys whole contracts only: `floor(budget / contract_loss)`,
403/// and a trade it cannot afford a single contract of is skipped.  This is
404/// what makes a $5,000 account different from a $1,000,000 one.
405#[allow(clippy::too_many_arguments)]
406pub fn run_options(
407    name: &str,
408    trades: &[Trade],
409    calendar: &[NaiveDate],
410    start: usize,
411    end: usize,
412    capital: f64,
413    risk_frac: f64,
414    max_open: usize,
415    contract_loss: Option<&(dyn Fn(&Trade) -> f64 + Sync)>,
416) -> Run {
417    let mut unaffordable = 0;
418    let mut order: Vec<&Trade> = trades.iter().collect();
419    order.sort_by(|a, b| (a.entry, &a.symbol).cmp(&(b.entry, &b.symbol)));
420    let mut next = 0;
421    let mut cash = capital;
422    // (trade, committed dollars, path cursor, last mark)
423    let mut open: Vec<(&Trade, f64, usize, f64)> = Vec::new();
424    let mut closed = Vec::new();
425    let mut events = Vec::new();
426    let mut equity = vec![capital];
427    let mut exposure = vec![0.0];
428    let mut dates = vec![calendar[start]];
429    for day in start..=end {
430        let d = calendar[day];
431        // Marks first (so today's entries are sized off yesterday's equity).
432        let eq_before = cash + open.iter().map(|o| o.1 * (1.0 + o.3)).sum::<f64>();
433        while next < order.len() && order[next].entry <= d {
434            let t = order[next];
435            next += 1;
436            if t.entry < d || open.len() >= max_open {
437                continue;
438            }
439            let budget = risk_frac * eq_before;
440            let alloc = match contract_loss {
441                Some(f) => {
442                    let per = f(t);
443                    let n = if per.is_finite() && per > 0.0 { (budget / per).floor() } else { 0.0 };
444                    if n < 1.0 {
445                        unaffordable += 1;
446                        continue;
447                    }
448                    n * per
449                }
450                None => budget,
451            };
452            cash -= alloc;
453            open.push((t, alloc, 0, 0.0));
454            events.push(Event { date: d, action: "open", symbol: format!("{} {}", t.symbol, t.label), price: t.risk, dollars: alloc });
455        }
456        for o in open.iter_mut() {
457            while o.2 < o.0.path.len() && o.0.path[o.2].0 <= d {
458                o.3 = o.0.path[o.2].1;
459                o.2 += 1;
460            }
461        }
462        let mut still = Vec::new();
463        for o in open.drain(..) {
464            if o.0.exit <= d {
465                let value = o.1 * (1.0 + o.0.ret);
466                cash += value;
467                closed.push(Closed { symbol: format!("{} {}", o.0.symbol, o.0.label), entry: o.0.entry, exit: o.0.exit, days: o.0.days, ret: o.0.ret, pnl: value - o.1, side: "option" });
468                events.push(Event { date: d, action: "close", symbol: format!("{} {}", o.0.symbol, o.0.label), price: o.0.ret, dollars: value });
469            } else {
470                still.push(o);
471            }
472        }
473        open = still;
474        if day > start {
475            let committed: f64 = open.iter().map(|o| o.1).sum();
476            let eq = cash + open.iter().map(|o| o.1 * (1.0 + o.3)).sum::<f64>();
477            equity.push(eq);
478            exposure.push(committed / eq.max(1e-9));
479            dates.push(d);
480        }
481    }
482    Run { name: name.to_string(), dates, equity, exposure, closed, events, unaffordable }
483}
484
485/// The standard scorecard.
486#[derive(Debug, Clone, Default, serde::Serialize)]
487pub struct Metrics {
488    pub strategy: String,
489    pub period: String,
490    pub total_return: f64,
491    pub cagr: f64,
492    pub vol: f64,
493    pub sharpe: f64,
494    pub sortino: f64,
495    pub max_drawdown: f64,
496    pub calmar: f64,
497    pub exposure: f64,
498    pub trades: usize,
499    pub win_ratio: f64,
500    pub avg_win: f64,
501    pub avg_loss: f64,
502    pub ev_per_trade: f64,
503    pub profit_factor: f64,
504    pub avg_days: f64,
505    pub best_trade: f64,
506    pub worst_trade: f64,
507}
508
509/// Annual risk-free rate subtracted in Sharpe and Sortino.
510pub const RISK_FREE: f64 = 0.0;
511
512pub fn metrics(r: &Run, period: &str) -> Metrics {
513    let e = &r.equity;
514    let n = e.len().saturating_sub(1).max(1) as f64;
515    let rets: Vec<f64> = e.windows(2).map(|w| w[1] / w[0] - 1.0).collect();
516    let mean = rets.iter().sum::<f64>() / n;
517    let sd = (rets.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n).sqrt();
518    let rf = RISK_FREE / 252.0;
519    let down = (rets.iter().map(|x| (x - rf).min(0.0).powi(2)).sum::<f64>() / n).sqrt();
520    let (mut peak, mut dd) = (e[0], 0.0f64);
521    for &v in e {
522        peak = peak.max(v);
523        dd = dd.max(1.0 - v / peak);
524    }
525    let total = e[e.len() - 1] / e[0] - 1.0;
526    let cagr = (1.0 + total).powf(252.0 / n) - 1.0;
527    let wins: Vec<f64> = r.closed.iter().filter(|c| c.pnl > 0.0).map(|c| c.ret).collect();
528    let losses: Vec<f64> = r.closed.iter().filter(|c| c.pnl <= 0.0).map(|c| c.ret).collect();
529    let gp: f64 = r.closed.iter().filter(|c| c.pnl > 0.0).map(|c| c.pnl).sum();
530    let gl: f64 = -r.closed.iter().filter(|c| c.pnl <= 0.0).map(|c| c.pnl).sum::<f64>();
531    let nt = r.closed.len();
532    let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
533    Metrics {
534        strategy: r.name.clone(),
535        period: period.to_string(),
536        total_return: total,
537        cagr,
538        vol: sd * 252f64.sqrt(),
539        sharpe: if sd > 0.0 { (mean - rf) / sd * 252f64.sqrt() } else { 0.0 },
540        sortino: if down > 0.0 { (mean - rf) / down * 252f64.sqrt() } else { 0.0 },
541        max_drawdown: dd,
542        calmar: if dd > 0.0 { cagr / dd } else { 0.0 },
543        exposure: avg(&r.exposure[1..]),
544        trades: nt,
545        win_ratio: if nt > 0 { wins.len() as f64 / nt as f64 } else { 0.0 },
546        avg_win: avg(&wins),
547        avg_loss: avg(&losses),
548        ev_per_trade: avg(&r.closed.iter().map(|c| c.ret).collect::<Vec<_>>()),
549        profit_factor: if gl > 0.0 { gp / gl } else if gp > 0.0 { f64::INFINITY } else { 0.0 },
550        avg_days: avg(&r.closed.iter().map(|c| c.days as f64).collect::<Vec<_>>()),
551        best_trade: r.closed.iter().map(|c| c.ret).fold(f64::NAN, f64::max),
552        worst_trade: r.closed.iter().map(|c| c.ret).fold(f64::NAN, f64::min),
553    }
554}
555
556// ───────────────────────── charts (plain SVG, no dependencies) ─────────────────────────
557
558/// A colour per series that reads on light and dark pages.
559pub const PALETTE: &[&str] = &[
560    "#6b7280", "#9ca3af", "#2563eb", "#16a34a", "#0d9488", "#d97706", "#7c3aed", "#dc2626", "#db2777", "#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#a16207",
561];
562
563/// Charts are shown as `<img>`, which cannot inherit the page's text colour,
564/// so each SVG carries its own light/dark text colour.
565const SVG_STYLE: &str = "<style>svg{color:#1f2937}@media (prefers-color-scheme:dark){svg{color:#e5e7eb}}</style>";
566
567fn esc(s: &str) -> String {
568    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
569}
570
571/// Line chart of equity curves, each starting at 100.
572pub fn svg_equity(title: &str, runs: &[&Run]) -> String {
573    let (w, h, l, r, t, b) = (960.0, 460.0, 56.0, 270.0, 36.0, 34.0);
574    let log = runs.iter().any(|x| x.equity.iter().any(|v| v / x.equity[0] > 4.0));
575    let series: Vec<Vec<f64>> = runs
576        .iter()
577        .map(|x| x.equity.iter().map(|v| { let v = 100.0 * v / x.equity[0]; if log { v.max(1.0).log10() } else { v } }).collect())
578        .collect();
579    let base = if log { 2.0 } else { 100.0 };
580    let n = series.iter().map(|s| s.len()).max().unwrap_or(2).max(2);
581    let lo = series.iter().flatten().cloned().fold(f64::INFINITY, f64::min).min(base);
582    let hi = series.iter().flatten().cloned().fold(f64::NEG_INFINITY, f64::max).max(base);
583    let pad = (hi - lo).max(1.0) * 0.06;
584    let (lo, hi) = (lo - pad, hi + pad);
585    let x = |i: usize| l + (w - l - r) * i as f64 / (n - 1) as f64;
586    let y = |v: f64| t + (h - t - b) * (1.0 - (v - lo) / (hi - lo));
587    let mut s = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" aria-label="{}" font-family="system-ui,sans-serif" font-size="12">"#, esc(title)) + SVG_STYLE;
588    s += &format!(r#"<text x="{l}" y="20" font-size="15" font-weight="600" fill="currentColor">{}</text>"#, esc(title));
589    for k in 0..=5 {
590        let v = lo + (hi - lo) * k as f64 / 5.0;
591        let shown = if log { 10f64.powf(v) } else { v };
592        s += &format!(r#"<line x1="{l}" x2="{}" y1="{y:.1}" y2="{y:.1}" stroke="currentColor" stroke-opacity="0.12"/><text x="{}" y="{:.1}" text-anchor="end" fill="currentColor" fill-opacity="0.7">{shown:.0}</text>"#, w - r, l - 6.0, y(v) + 4.0, y = y(v));
593    }
594    s += &format!(r#"<line x1="{l}" x2="{}" y1="{y:.1}" y2="{y:.1}" stroke="currentColor" stroke-opacity="0.45" stroke-dasharray="4 3"/>"#, w - r, y = y(base));
595    if let Some(first) = runs.first() {
596        let ds = &first.dates;
597        // Month ticks for a year or less, year ticks (every other year) for longer spans.
598        let long = ds.len() > 400;
599        for (i, d) in ds.iter().enumerate() {
600            let (key, prev) = if long { ("%Y", ds.get(i.wrapping_sub(1))) } else { ("%m", ds.get(i.wrapping_sub(1))) };
601            let new = i == 0 || prev.is_some_and(|p| p.format(key).to_string() != d.format(key).to_string());
602            if new && (!long || d.format("%Y").to_string().parse::<i32>().unwrap_or(0) % 2 == 0) {
603                let label = if long { d.format("%Y").to_string() } else { d.format("%b").to_string() };
604                s += &format!(r#"<text x="{:.1}" y="{}" text-anchor="middle" fill="currentColor" fill-opacity="0.7">{}</text>"#, x(i), h - 12.0, label);
605            }
606        }
607    }
608    for (j, (ser, run)) in series.iter().zip(runs).enumerate() {
609        let c = PALETTE[j % PALETTE.len()];
610        let pts: Vec<String> = ser.iter().enumerate().map(|(i, v)| format!("{:.1},{:.1}", x(i), y(*v))).collect();
611        let dash = if j < 2 { r#" stroke-dasharray="5 3""# } else { "" };
612        s += &format!(r#"<polyline fill="none" stroke="{c}" stroke-width="1.8"{dash} points="{}"/>"#, pts.join(" "));
613        let last = ser.last().map(|v| if log { 10f64.powf(*v) } else { *v }).unwrap_or(100.0);
614        s += &format!(r#"<rect x="{}" y="{}" width="12" height="3" fill="{c}"/><text x="{}" y="{}" fill="currentColor">{} ({:+.1}%)</text>"#, w - r + 12.0, t + 6.0 + 18.0 * j as f64, w - r + 30.0, t + 11.0 + 18.0 * j as f64, esc(&run.name), last - 100.0);
615    }
616    s + "</svg>\n"
617}
618
619/// Grouped bars: one group per bucket label, one bar per series (values 0..1).
620pub fn svg_bars(title: &str, labels: &[String], series: &[(String, Vec<Option<f64>>)], note: &str) -> String {
621    let (w, h, l, t, b) = (760.0, 340.0, 50.0, 44.0, 56.0);
622    let gw = (w - l - 20.0) / labels.len().max(1) as f64;
623    let bw = gw * 0.8 / series.len().max(1) as f64;
624    let y = |v: f64| t + (h - t - b) * (1.0 - v);
625    let mut s = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" aria-label="{}" font-family="system-ui,sans-serif" font-size="12">"#, esc(title)) + SVG_STYLE;
626    s += &format!(r#"<text x="{l}" y="20" font-size="15" font-weight="600" fill="currentColor">{}</text>"#, esc(title));
627    for k in 0..=4 {
628        let v = k as f64 / 4.0;
629        s += &format!(r#"<line x1="{l}" x2="{}" y1="{yy:.1}" y2="{yy:.1}" stroke="currentColor" stroke-opacity="0.12"/><text x="{}" y="{:.1}" text-anchor="end" fill="currentColor" fill-opacity="0.7">{:.0}%</text>"#, w - 20.0, l - 6.0, y(v) + 4.0, 100.0 * v, yy = y(v));
630    }
631    s += &format!(r#"<line x1="{l}" x2="{}" y1="{yy:.1}" y2="{yy:.1}" stroke="currentColor" stroke-opacity="0.45" stroke-dasharray="4 3"/>"#, w - 20.0, yy = y(0.5));
632    for (i, lab) in labels.iter().enumerate() {
633        let gx = l + gw * i as f64 + gw * 0.1;
634        for (j, (_, vals)) in series.iter().enumerate() {
635            if let Some(Some(v)) = vals.get(i) {
636                let c = PALETTE[(j + 2) % PALETTE.len()];
637                s += &format!(r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{c}"/><text x="{:.1}" y="{:.1}" text-anchor="middle" fill="currentColor" font-size="10">{:.0}</text>"#, gx + bw * j as f64, y(*v), bw - 2.0, y(0.0) - y(*v), gx + bw * (j as f64 + 0.5), y(*v) - 3.0, 100.0 * v);
638            }
639        }
640        s += &format!(r#"<text x="{:.1}" y="{}" text-anchor="middle" fill="currentColor" fill-opacity="0.8">{}</text>"#, gx + gw * 0.4, h - b + 16.0, esc(lab));
641    }
642    for (j, (name, _)) in series.iter().enumerate() {
643        let c = PALETTE[(j + 2) % PALETTE.len()];
644        s += &format!(r#"<rect x="{}" y="{}" width="12" height="10" fill="{c}"/><text x="{}" y="{}" fill="currentColor">{}</text>"#, l + 220.0 * j as f64, h - 22.0, l + 16.0 + 220.0 * j as f64, h - 13.0, esc(name));
645    }
646    s += &format!(r#"<text x="{l}" y="{}" fill="currentColor" fill-opacity="0.7" font-size="11">{}</text>"#, h - 2.0, esc(note));
647    s + "</svg>\n"
648}
649
650/// Heat map: one row per strategy, one column per period, cell = return.
651pub fn svg_heatmap(title: &str, rows: &[String], cols: &[String], cell: &[Vec<Option<f64>>]) -> String {
652    let (lw, cw, ch, top) = (230.0, 44.0, 24.0, 52.0);
653    let w = lw + cw * cols.len() as f64 + 10.0;
654    let h = top + ch * rows.len() as f64 + 30.0;
655    let mut s = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" aria-label="{}" font-family="system-ui,sans-serif" font-size="11">"#, esc(title)) + SVG_STYLE;
656    s += &format!(r#"<text x="4" y="20" font-size="15" font-weight="600" fill="currentColor">{}</text>"#, esc(title));
657    for (j, c) in cols.iter().enumerate() {
658        s += &format!(r#"<text x="{:.1}" y="{}" text-anchor="middle" fill="currentColor" fill-opacity="0.75">{}</text>"#, lw + cw * (j as f64 + 0.5), top - 8.0, esc(c));
659    }
660    for (i, r) in rows.iter().enumerate() {
661        let yy = top + ch * i as f64;
662        s += &format!(r#"<text x="{}" y="{:.1}" text-anchor="end" fill="currentColor">{}</text>"#, lw - 8.0, yy + ch * 0.65, esc(r));
663        for (j, v) in cell[i].iter().enumerate() {
664            let xx = lw + cw * j as f64;
665            let Some(v) = v else { continue };
666            let a = (v.abs() / 0.30).min(1.0) * 0.85 + 0.08;
667            let col = if *v >= 0.0 { "22,163,74" } else { "220,38,38" };
668            s += &format!(r#"<rect x="{xx:.1}" y="{yy:.1}" width="{}" height="{}" fill="rgba({col},{a:.2})"/><text x="{:.1}" y="{:.1}" text-anchor="middle" fill="currentColor">{:+.0}</text>"#, cw - 2.0, ch - 2.0, xx + cw / 2.0 - 1.0, yy + ch * 0.65, 100.0 * v);
669        }
670    }
671    s += &format!(r#"<text x="4" y="{}" fill="currentColor" fill-opacity="0.7">Cells: total return of that period, %.  Green up, red down.</text>"#, h - 8.0);
672    s + "</svg>\n"
673}