fix(chart): mean-decimate the bounded exposure series instead of a min/max band

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
This commit is contained in:
2026-06-22 14:34:21 +02:00
parent e01bdc3d5e
commit 2957561c30
2 changed files with 117 additions and 41 deletions
+26 -4
View File
@@ -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<f64>` per shared-x slot (`null` = a gap where the tap
/// did not fire at that timestamp).
/// legible), one `Option<f64>` 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<Option<f64>>,
/// 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(),