From 2957561c308602dc4a97b00cb55b59a50c6f0c31 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 22 Jun 2026 14:34:21 +0200 Subject: [PATCH] fix(chart): mean-decimate the bounded exposure series instead of a min/max band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serve-time decimation (#108) reduced every series by per-bucket min/max. That is the right envelope for a cumulative equity curve but wrong for the bounded exposure stream (C10, f64 ∈ [-1,+1]): over a multi-year window each ~hundreds-of-bars bucket straddles sign flips, so min/max is ±1 in nearly every bucket and the exposure collapses to a solid -1..+1 band that reads as per-point oscillation — even for a calm strategy (a 50/200 member flips only 0.81% of bars yet still bands out). Make decimation tap-aware (RED-first, #111): a new `ReduceKind {MinMax, Mean}` on each Series, set by the chart builders via `reduce_for_tap` (exposure -> Mean, else MinMax). `decimate` honours it — a Mean series emits the per-bucket mean (its net/duty-cycle level) in both spine slots, a MinMax series keeps min/max. The shared spine, the equity rendering, and the page payload are unchanged (reduce is server-side only, `#[serde(skip)]`). Verified on real GER40 1y M1: the served exposure series goes from a ±1 band to a smooth net-level line in [-0.38, +0.34]. Rendering the min/max envelope honestly (range bars / OHLC, vs today's polyline) is the deferred other half — filed as #112. Verified: cargo test --workspace = 447 passed / 0 failed (incl. the RED-then-GREEN decimate_mean_reduces_a_bipolar_series_to_its_bucket_level); clippy -D warnings clean. closes #111 refs #112 --- crates/aura-cli/src/main.rs | 128 ++++++++++++++++++++++++---------- crates/aura-cli/src/render.rs | 30 ++++++-- 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0e73a71..aac560b 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -12,7 +12,7 @@ //! first real-data backtest from the CLI. mod render; -use render::{ChartData, ChartMeta, ChartMode, Series}; +use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ @@ -245,14 +245,29 @@ fn member_key(named: &[(String, Scalar)], varying: &HashSet) -> String { /// underlying multi-year M1 point count. const CHART_DECIMATE_BUCKETS: usize = 2000; -/// Serve-time min-max decimation on the aligned `ChartData` (#108). Partition the -/// shared `xs` into at most `buckets` contiguous index ranges; per non-empty bucket -/// emit the bucket's first (and, if it spans >1 index, last) timestamp as shared -/// spine slots, and for each series the min then max of its non-null values in that -/// range (sub-pixel ordering — the drawn vertical extent of the column is preserved). -/// An all-null bucket emits null. `meta` passes through unchanged. Deterministic -/// (C1). Full data stays on disk; only the served page is thinned. No-op when -/// `xs.len() <= 2 * buckets`. +/// Per-tap decimation kind (#111): the bounded exposure stream (C10, f64 ∈ [-1,+1]) +/// reduces by per-bucket mean, so its net/duty-cycle level survives decimation +/// instead of collapsing to a -1..+1 band (every bucket of a multi-year exposure +/// straddles many sign flips, so min/max would be ±1 everywhere). An unbounded +/// cumulative curve (equity) keeps the min/max envelope so drawdowns survive. Keyed +/// on the tap name — `exposure` is the only bounded level tap today. +fn reduce_for_tap(tap: &str) -> ReduceKind { + if tap == "exposure" { + ReduceKind::Mean + } else { + ReduceKind::MinMax + } +} + +/// Serve-time decimation on the aligned `ChartData` (#108). Partition the shared +/// `xs` into at most `buckets` contiguous index ranges; per non-empty bucket emit the +/// bucket's first (and, if it spans >1 index, last) timestamp as shared spine slots, +/// and reduce each series per its [`ReduceKind`] (#111): a `MinMax` series emits min +/// then max (the envelope — equity drawdowns survive), a `Mean` series emits the +/// per-bucket mean in both slots (the net level — a bounded exposure shows its +/// duty-cycle instead of a -1..+1 band). An all-null bucket emits null. `meta` passes +/// through unchanged. Deterministic (C1). Full data stays on disk; only the served +/// page is thinned. No-op when `xs.len() <= 2 * buckets`. fn decimate(data: ChartData, buckets: usize) -> ChartData { let buckets = buckets.max(1); let n = data.xs.len(); @@ -285,31 +300,43 @@ fn decimate(data: ChartData, buckets: usize) -> ChartData { .map(|s| { let mut points: Vec> = Vec::with_capacity(out_xs.len()); for &(lo, hi, two) in &bounds { - let mut mn = f64::INFINITY; - let mut mx = f64::NEG_INFINITY; - let mut any = false; - for v in s.points[lo..hi].iter().flatten() { - any = true; - if *v < mn { - mn = *v; + let (first, second) = match s.reduce { + ReduceKind::MinMax => { + // envelope: min at the first slot, max at the second. + let mut mn = f64::INFINITY; + let mut mx = f64::NEG_INFINITY; + let mut any = false; + for v in s.points[lo..hi].iter().flatten() { + any = true; + if *v < mn { + mn = *v; + } + if *v > mx { + mx = *v; + } + } + if any { (Some(mn), Some(mx)) } else { (None, None) } } - if *v > mx { - mx = *v; - } - } - if any { - points.push(Some(mn)); - if two { - points.push(Some(mx)); - } - } else { - points.push(None); - if two { - points.push(None); + ReduceKind::Mean => { + // net level: the per-bucket mean written to both slots (a flat + // step), so a bounded high-flip series shows its duty-cycle + // instead of a -1..+1 band (#111). + let mut sum = 0.0; + let mut cnt = 0u32; + for v in s.points[lo..hi].iter().flatten() { + sum += *v; + cnt += 1; + } + let m = if cnt > 0 { Some(sum / cnt as f64) } else { None }; + (m, m) } + }; + points.push(first); + if two { + points.push(second); } } - Series { name: s.name, y_scale_id: s.y_scale_id, points } + Series { name: s.name, y_scale_id: s.y_scale_id, points, reduce: s.reduce } }) .collect(); @@ -341,7 +368,7 @@ fn build_chart_data(name: &str, traces: RunTraces) -> ChartData { let y_scale_id = format!("y_{}", series.len()); let points: Vec> = joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[c].as_f64())).collect(); - series.push(Series { name, y_scale_id, points }); + series.push(Series { name, y_scale_id, points, reduce: reduce_for_tap(&tap.tap) }); } } @@ -405,7 +432,7 @@ fn build_comparison_chart_data( // A future multi-column tap selection would need a column index here. let points: Vec> = joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect(); - series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points }); + series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points, reduce: reduce_for_tap(tap) }); } // member_rows is non-empty here (checked above) => members is non-empty, so // members[0] is safe. commit/broker ARE shared across a family (one frozen @@ -1829,7 +1856,7 @@ mod tests { let points: Vec> = (0..n).map(|i| Some(i as f64)).collect(); let data = ChartData { xs, - series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }], + series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2000); @@ -1847,7 +1874,7 @@ mod tests { let points: Vec> = pv.into_iter().map(Some).collect(); let data = ChartData { xs, - series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }], + series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2); @@ -1863,7 +1890,7 @@ mod tests { points.extend(std::iter::repeat_n(None, 5)); let data = ChartData { xs, - series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }], + series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2); @@ -1874,7 +1901,7 @@ mod tests { fn decimate_is_a_noop_within_budget() { let data = ChartData { xs: vec![1, 2, 3], - series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(1.0), Some(2.0), Some(3.0)] }], + series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(1.0), Some(2.0), Some(3.0)], reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2000); @@ -1888,12 +1915,39 @@ mod tests { let xs: Vec = (0..n as i64).collect(); let points: Vec> = (0..n).map(|i| Some(i as f64)).collect(); let meta = ChartMeta { name: "keep-me".into(), ..Default::default() }; - let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }], meta }; + let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta }; let out = decimate(data, 2000); assert_eq!(out.meta.name, "keep-me", "meta must pass through decimation"); assert!(out.xs.windows(2).all(|w| w[0] < w[1]), "decimated spine must stay strictly increasing"); } + /// #111: a bounded *level* series with `reduce = Mean` decimates to each bucket's + /// MEAN, not its min/max envelope — so a high-flip bipolar exposure shows its + /// net/duty-cycle level instead of collapsing to a -1..+1 band. RED under the + /// shipped min/max-only decimation (any bucket holding a +1 emits +1); GREEN once + /// `decimate` honours `ReduceKind::Mean`. + #[test] + fn decimate_mean_reduces_a_bipolar_series_to_its_bucket_level() { + // 10 points, 2 buckets. Bucket 0 (idx 0..5) = [+1,+1,-1,+1,+1] -> mean +0.6; + // bucket 1 (idx 5..10) = all -1 -> mean -1.0. + let xs: Vec = (0..10).collect(); + let pv = vec![1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0]; + let points: Vec> = pv.into_iter().map(Some).collect(); + let data = ChartData { + xs, + series: vec![Series { name: "exposure".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::Mean }], + meta: ChartMeta::default(), + }; + let out = decimate(data, 2); + let got = out.series[0].points.clone(); + // No -1..+1 envelope: bucket 0 is its mean (+0.6), not a min/max pair. + assert!(!got.contains(&Some(1.0)), "mean reduce must not emit a +1 envelope point: {got:?}"); + assert!(got.contains(&Some(0.6)), "bucket-0 duty-cycle mean (+0.6) missing: {got:?}"); + // bucket 0 spans two slots, both = the mean (a flat step, not a -1->+1 ramp). + assert_eq!(got[0], Some(0.6), "first slot must be the bucket mean"); + assert_eq!(got[1], Some(0.6), "second slot must also be the bucket mean"); + } + /// #102 single-run meta wiring: `build_chart_data` maps the `RunManifest` into /// `ChartData.meta` — kind "run", the name arg, the manifest window/broker, the /// charted taps, and the bound params stringified (each typed `Scalar` rendered diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index 80c7faf..9d1b10d 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -123,14 +123,36 @@ pub enum ChartMode { Panels, } +/// How `decimate` reduces a series within each bucket (#111). An *envelope* series +/// (a cumulative equity curve) keeps its min+max so drawdown spikes survive; a +/// *level* series (the bounded exposure, f64 ∈ [-1,+1]) takes the per-bucket mean, +/// so a high-flip signal shows its net/duty-cycle level instead of collapsing to a +/// -1..+1 band (every bucket of a multi-year exposure straddles many flips, so +/// min/max would be ±1 everywhere). Server-side only — never serialized into the +/// page (the viewer reads decimated points, not how they were reduced). Rendering +/// min/max as range bars / OHLC is a separate follow-up; this only fixes the +/// reduction. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ReduceKind { + /// Per-bucket min and max — the envelope (equity, unbounded cumulative). + #[default] + MinMax, + /// Per-bucket mean — the net level (exposure, bounded ∈ [-1,+1]). + Mean, +} + /// One chart series: a name, its own y-scale id (so disparate magnitudes stay -/// legible), and one `Option` per shared-x slot (`null` = a gap where the tap -/// did not fire at that timestamp). +/// legible), one `Option` per shared-x slot (`null` = a gap where the tap did +/// not fire at that timestamp), and how `decimate` reduces it per bucket. #[derive(serde::Serialize)] pub struct Series { pub name: String, pub y_scale_id: String, pub points: Vec>, + /// Per-bucket reduction for `decimate` (#111); server-side only, not part of the + /// served payload. + #[serde(skip)] + pub reduce: ReduceKind, } /// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta` @@ -253,8 +275,8 @@ mod tests { ChartData { xs: vec![1, 2, 3], series: vec![ - Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)] }, - Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)] }, + Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)], reduce: ReduceKind::MinMax }, + Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)], reduce: ReduceKind::Mean }, ], meta: ChartMeta { kind: "run".into(),