1use super::sim::Trade;
34use crate::sd2::engine::Sd2Reading;
35use chrono::NaiveDate;
36use std::collections::HashMap;
37
38pub struct Lane {
40 pub symbol: String,
41 pub open: Vec<Option<f64>>,
43 pub close: Vec<Option<f64>>,
44 pub sd: Vec<Option<Sd2Reading>>,
45}
46
47pub struct DayView<'a> {
49 pub day: usize,
50 pub date: NaiveDate,
51 pub lanes: &'a [Lane],
52}
53
54impl DayView<'_> {
55 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
65pub trait Allocator: Sync {
67 fn name(&self) -> &'static str;
68 fn thesis(&self) -> &'static str;
70 fn targets(&self, v: &DayView, days_in: usize) -> Option<Vec<(usize, f64)>>;
73}
74
75pub 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
89pub 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
108pub 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
127pub 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
162pub 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
193pub 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
214pub 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
235pub 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#[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 pub ret: f64,
259 pub pnl: f64,
260 pub side: &'static str,
261}
262
263#[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
273pub 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 pub unaffordable: usize,
284}
285
286pub const STOCK_COST: f64 = 0.0005;
288
289pub 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 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 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 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 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 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 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#[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 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 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#[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
509pub 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
556pub const PALETTE: &[&str] = &[
560 "#6b7280", "#9ca3af", "#2563eb", "#16a34a", "#0d9488", "#d97706", "#7c3aed", "#dc2626", "#db2777", "#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#a16207",
561];
562
563const 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('&', "&").replace('<', "<").replace('>', ">")
569}
570
571pub 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 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
619pub 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
650pub 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}