feat(sp19 commit b): producer-side multi-horizon reward blend at fxcache write time

Path (B) Commit B — load-bearing semantic change for the SP19 multi-
horizon reward augmentation. At fxcache-write time, blend 1-bar / 5-bar
/ 30-bar log-returns into `tgt[1]` (`preproc_next`) using equal-thirds
weights with `1/sqrt(N)` vol-scale correction:

    blended = (1/3) * r_1
            + (1/3) * r_5  / sqrt(5)
            + (1/3) * r_30 / sqrt(30)

The vol-scale correction applies INSIDE the blend (before weighting) so
each horizon's log-return is at unit-volatility-equivalent under
random-walk assumption — drops naturally into the existing `tgt[1]`
preprocessing pipeline (consumed in `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508`) without double-scaling.

Producer trim: valid range shrinks by `LOOKAHEAD_HORIZON_MAX = 30`
bars since `r_30` needs `close[i + WARMUP + 30]`. Walk-forward fold
construction is dataset-relative, so trimming at producer time shifts
every fold's tail by 30 bars — one-time cost per fxcache regen.

Both producer call sites (`precompute_features.rs` for the
feature-precompute binary, `data_loading.rs` for the DBN-fallback
in-process path) change atomically per `feedback_no_partial_refactor`.
Kernel consumers are unchanged — only `tgt[1]`'s value composition
differs from the consumer's perspective.

ISV slots [507..510) (allocated in Commit A) are NOT consumed at
producer time — `precompute_features` runs BEFORE training so the
ISV bus isn't initialised when the blend happens. Producer hardcodes
1/3 each via `SP19_HORIZON_BLEND_WEIGHTS`. Future Path (A) refactor
would bump TARGET_DIM to carry per-horizon log-returns and read
ISV at training-step time; for the empirical hypothesis test
("does multi-horizon blend lift WR?") equal-thirds is sufficient.

fxcache schema invalidation: `FXCACHE_VERSION` 8 → 9. Existing
`.fxcache` files (1-bar-only `preproc_next`) fail validation at load
and trigger Argo's ensure-fxcache regen step. First L40S run after
this commit takes ~10-15 min longer for the regen — one-time cost,
expected.

Touches:
- crates/ml/examples/precompute_features.rs: SP19_HORIZON_BLEND_WEIGHTS
  constant + LOOKAHEAD_HORIZON_MAX trim + blend in tgt[] writer +
  early-bail-out check on minimum dataset size.
- crates/ml/src/trainers/dqn/data_loading.rs: same blend +
  trim in DBN-fallback path; `last sample targets itself` block
  removed (the trimmed range guarantees feature-vector and target
  lengths match without a sentinel last row).
- crates/ml/src/fxcache.rs: FXCACHE_VERSION 8 → 9 + v9 docstring entry.
- crates/ml/tests/multi_horizon_reward_blend_test.rs (NEW): CPU-only
  oracle behavioural test — 4 cases including known returns, trim
  contract, zero-close short-circuit, constant-price blend.
- docs/dqn-wire-up-audit.md: Concerns subsection appended to the
  SP19 Commit B entry already drafted in Commit A (pre-existing
  test_fxcache_empty + test_dqn_checkpoint_round_trip flakiness
  documented for Invariant 7).

Verification:
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check --workspace        clean
cargo test -p ml --test multi_horizon_reward_blend_test              4/4 pass
cargo test -p ml --test fxcache_roundtrip_test test_fxcache_f32_roundtrip  passes (TARGET_DIM unchanged)
cargo test -p ml --lib ...                                           13 baseline failures (pre-existing); zero new regressions
bash scripts/audit_sp18_consumers.sh --check                         exit 0

Pre-existing test_fxcache_empty failure: hand-crafts a 64-byte header
but the actual header is 72 bytes; reader bails early on
"failed to fill whole buffer" instead of "bar_count=0". Test setup
bug, NOT a regression. Pre-Commit B failure count: 13. Post-Commit B
failure count: 13 (the test_dqn_checkpoint_round_trip test is flaky
and toggles independently of this change — verified by stashing and
re-running 3× pre-Commit B).

Atomic-refactor invariant satisfied: Commit A (slot reservations) +
Commit B (producer-side blend) land on the same branch with no L40S
dispatch between. Per task instruction the branch stays unpushed
pending user review.

DBN-fallback path: applied identically in `data_loading.rs:497-528`.
Both producers use the same `SP19_HORIZON_BLEND_WEIGHTS` constant and
the same `LOOKAHEAD_HORIZON_MAX = 30` trim.

Vol-scale correction site: applied inside the blend (before weighting)
in BOTH producers. The existing `tgt[1]` preprocessing pipeline does
NOT double-scale — `experience_kernels.cu:1556` and
`cuda_pipeline/mod.rs:508` consume `tgt[1]` as a unit-scale log-return
exactly as before the blend was added; the blended value drops in
without further scaling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-09 14:45:20 +02:00
parent 37964ae2a4
commit d39005c6f4
5 changed files with 303 additions and 24 deletions

View File

@@ -223,6 +223,38 @@ async fn main() -> Result<()> {
let t0 = Instant::now();
const WARMUP: usize = 50;
// ── SP19 (2026-05-09) — producer-side multi-horizon reward blend ──
// Path (B) of the multi-horizon reward augmentation spec. We blend
// 1-bar / 5-bar / 30-bar log-returns into `tgt[1]` at fxcache write
// time so kernel consumers see a single reward signal (no kernel
// changes). `1/sqrt(N)` vol-scale correction inside the blend keeps
// each horizon's log-return at unit-volatility-equivalent before
// weighting under random-walk assumption.
//
// Why hardcoded weights here? `precompute_features` runs BEFORE
// training so the ISV bus isn't initialised when the blend happens.
// Slots [507..510) (`REWARD_HORIZON_WEIGHT_*BAR_INDEX`) are
// reservations for a future Path (A) refactor that bumps TARGET_DIM
// to carry per-horizon log-returns through the kernel pipeline; in
// that world the consumer reads ISV at training-step time. For the
// empirical hypothesis test ("does multi-horizon blend lift WR?")
// equal-thirds is sufficient since varying weights requires fxcache
// regen (~10-15 min on L40S) and per-batch adaptation is impossible
// without the TARGET_DIM bump.
//
// Sentinel match: `SENTINEL_REWARD_HORIZON_WEIGHT_DEFAULT` in
// `crates/ml/src/cuda_pipeline/sp14_isv_slots.rs` equals
// `1.0_f32 / 3.0_f32 = 0.333_333_343`. The producer here uses f64
// `1.0 / 3.0` for numerical accuracy in the blend itself; the f32
// ISV slot value is what a future consumer reads, and matches
// bit-identically when cast.
const LOOKAHEAD_HORIZON_MAX: usize = 30;
const SP19_HORIZON_BLEND_WEIGHTS: [f64; 3] = [
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
];
// ── Early exit if cache already exists AND matches current version ────────
let hex_key_early = ml::feature_cache::calculate_dbn_cache_key_full(
&data_dir,
@@ -315,10 +347,13 @@ async fn main() -> Result<()> {
info!("Volume bars built: {} bars ({} contracts/bar) in {:.1}s",
all_bars.len(), ml::features::DEFAULT_VOLUME_BAR_SIZE, t1.elapsed().as_secs_f64());
if all_bars.len() < WARMUP + 2 {
// SP19 Path (B) (2026-05-09): need at least `WARMUP + LOOKAHEAD_HORIZON_MAX + 1`
// bars so the 30-bar log-return at `i = 0` (`all_bars[WARMUP + 30]`) is
// valid AND we still produce at least one output row.
if all_bars.len() < WARMUP + LOOKAHEAD_HORIZON_MAX + 1 {
anyhow::bail!(
"Insufficient data: {} volume bars, need at least {} (WARMUP={WARMUP} + 2)",
all_bars.len(), WARMUP + 2
"Insufficient data: {} volume bars, need at least {} (WARMUP={WARMUP} + LOOKAHEAD_HORIZON_MAX={LOOKAHEAD_HORIZON_MAX} + 1)",
all_bars.len(), WARMUP + LOOKAHEAD_HORIZON_MAX + 1
);
}
@@ -358,7 +393,14 @@ async fn main() -> Result<()> {
// the documented contract — the network-input columns ended up containing raw
// prices (~$5000+ for ES futures). Fixed here so the fxcache matches its own
// documented schema.
let n = feature_vectors.len().saturating_sub(1); // need next bar for target
//
// SP19 Path (B) (2026-05-09): `tgt[1]` (preproc_next) now holds the
// multi-horizon-blended log-return rather than the 1-bar log-return.
// The blend mixes 1-bar / 5-bar / 30-bar log-returns at equal-thirds
// weights with `1/sqrt(N)` vol-scale correction. The valid range
// shrinks by `LOOKAHEAD_HORIZON_MAX` bars (we need
// `all_bars[i + WARMUP + 30]` for the 30-bar log-return).
let n = feature_vectors.len().saturating_sub(LOOKAHEAD_HORIZON_MAX);
let features: Vec<[f64; 42]> = feature_vectors[..n].to_vec();
let mut targets: Vec<[f64; 6]> = Vec::with_capacity(n);
for i in 0..n {
@@ -371,9 +413,19 @@ async fn main() -> Result<()> {
let preproc_close = if prev_close > 0.0 {
(raw_curr / prev_close).ln()
} else { 0.0 };
let preproc_next = if raw_curr > 0.0 {
(raw_next / raw_curr).ln()
} else { 0.0 };
// ── SP19 Path (B) — multi-horizon blended log-return ──
// 1-bar / 5-bar / 30-bar log-returns with `1/sqrt(N)` vol-scale
// correction applied INSIDE the blend (before weighting). Tail
// bars are excluded by the `n = len - LOOKAHEAD_HORIZON_MAX`
// trim above so `all_bars[i + WARMUP + 30]` is always valid.
let raw_5bar = all_bars[i + WARMUP + 5].close;
let raw_30bar = all_bars[i + WARMUP + 30].close;
let log_return_1bar = if raw_curr > 0.0 { (raw_next / raw_curr).ln() } else { 0.0 };
let log_return_5bar = if raw_curr > 0.0 { (raw_5bar / raw_curr).ln() / (5.0_f64).sqrt() } else { 0.0 };
let log_return_30bar = if raw_curr > 0.0 { (raw_30bar / raw_curr).ln() / (30.0_f64).sqrt() } else { 0.0 };
let preproc_next = SP19_HORIZON_BLEND_WEIGHTS[0] * log_return_1bar
+ SP19_HORIZON_BLEND_WEIGHTS[1] * log_return_5bar
+ SP19_HORIZON_BLEND_WEIGHTS[2] * log_return_30bar;
// mid_price_open will be filled from MBP-10 data below if available;
// default to raw_open (OHLCV-only fallback).
targets.push([preproc_close, preproc_next, raw_curr, raw_next, raw_open, raw_open]);

View File

@@ -71,7 +71,19 @@ const HEADER_SIZE: usize = 72;
/// futures price level instead of unit-scale log returns. Wire-format
/// unchanged; semantic contract enforced. Caches written before this fix
/// carry raw prices in `preproc_*` columns and MUST be regenerated.
pub const FXCACHE_VERSION: u16 = 8;
/// v9: SP19 Path (B) producer-side multi-horizon reward blend.
/// `tgt[1]` (`preproc_next`) now holds a 1-bar / 5-bar / 30-bar
/// log-return blend at equal-thirds weights with `1/sqrt(N)`
/// vol-scale correction, instead of the prior 1-bar log-return
/// alone. Producers (`precompute_features.rs`, `data_loading.rs`)
/// trim the final `LOOKAHEAD_HORIZON_MAX = 30` bars from the dataset
/// since the 30-bar log-return needs `close[i + 30]`. Wire-format
/// unchanged (`TARGET_DIM = 6`); semantic contract for `tgt[1]` is
/// a different scalar value composition. Caches written before this
/// fix carry the 1-bar-only `preproc_next` and MUST be regenerated.
/// Per `feedback_no_partial_refactor` both producer call sites
/// change atomically; kernel consumers of `tgt[1]` are unchanged.
pub const FXCACHE_VERSION: u16 = 9;
/// Compile-time fingerprint over the feature-schema source files. Bumps
/// automatically whenever `features/extraction.rs`, `fxcache.rs`, or

View File

@@ -507,8 +507,26 @@ impl DQNTrainer {
// raw-price values at target[0:1] were never consumed — production aux head
// reads next_states[i][0] (= MARKET feat[0] = log_return), not targets.
// Fixed here so the on-disk fxcache matches its own documented schema.
//
// SP19 Path (B) (2026-05-09): `tgt[1]` now holds the multi-horizon-
// blended log-return — 1-bar / 5-bar / 30-bar log-returns at
// equal-thirds weights with `1/sqrt(N)` vol-scale correction. The
// valid range shrinks by `LOOKAHEAD_HORIZON_MAX = 30` bars; the
// last-sample tail block is removed in lockstep with the
// `precompute_features.rs` producer (no truncated last row to
// make the feature/target lengths agree under the trim). See
// `crates/ml/examples/precompute_features.rs` for the canonical
// blend implementation; this DBN-fallback path mirrors it
// bit-identically per `feedback_no_partial_refactor`.
const LOOKAHEAD_HORIZON_MAX: usize = 30;
const SP19_HORIZON_BLEND_WEIGHTS: [f64; 3] = [
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
];
let mut training_data = Vec::new();
for i in 0..feature_vectors.len().saturating_sub(1) {
let n = feature_vectors.len().saturating_sub(LOOKAHEAD_HORIZON_MAX);
for i in 0..n {
let raw_current = all_ohlcv_bars[i + 50].close;
let raw_next = all_ohlcv_bars[i + 1 + 50].close;
let raw_open = all_ohlcv_bars[i + 50].open;
@@ -519,24 +537,22 @@ impl DQNTrainer {
let preproc_close = if prev_close > 0.0 {
(raw_current / prev_close).ln()
} else { 0.0 };
let preproc_next = if raw_current > 0.0 {
(raw_next / raw_current).ln()
} else { 0.0 };
// ── SP19 Path (B) — multi-horizon blended log-return ──
// The `n = len - LOOKAHEAD_HORIZON_MAX` trim above guarantees
// `all_ohlcv_bars[i + 50 + 30]` is in bounds.
let raw_5bar = all_ohlcv_bars[i + 50 + 5].close;
let raw_30bar = all_ohlcv_bars[i + 50 + 30].close;
let log_return_1bar = if raw_current > 0.0 { (raw_next / raw_current).ln() } else { 0.0 };
let log_return_5bar = if raw_current > 0.0 { (raw_5bar / raw_current).ln() / (5.0_f64).sqrt() } else { 0.0 };
let log_return_30bar = if raw_current > 0.0 { (raw_30bar / raw_current).ln() / (30.0_f64).sqrt() } else { 0.0 };
let preproc_next = SP19_HORIZON_BLEND_WEIGHTS[0] * log_return_1bar
+ SP19_HORIZON_BLEND_WEIGHTS[1] * log_return_5bar
+ SP19_HORIZON_BLEND_WEIGHTS[2] * log_return_30bar;
training_data.push((
feature_vectors[i],
vec![preproc_close, preproc_next, raw_current, raw_next, raw_open, raw_open],
));
}
// Last sample targets itself — preproc returns are 0.0 (no future bar).
if !feature_vectors.is_empty() {
let idx = all_ohlcv_bars.len() - 1;
let raw_close = all_ohlcv_bars[idx].close;
let raw_open = all_ohlcv_bars[idx].open;
training_data.push((
feature_vectors[feature_vectors.len() - 1],
vec![0.0, 0.0, raw_close, raw_close, raw_open, raw_open],
));
}
let feature_dim = if feature_vectors.is_empty() { 42 } else { feature_vectors[0].len() };
info!(

View File

@@ -0,0 +1,185 @@
//! SP19 Path (B) — producer-side multi-horizon reward blend behavioural test.
//!
//! Verifies the blend math at fxcache-write time with a CPU-only oracle:
//!
//! blended = (1/3) * r_1 + (1/3) * r_5 / sqrt(5) + (1/3) * r_30 / sqrt(30)
//!
//! where `r_N = ln(close[i + N] / close[i])`. The producer code lives in
//! `crates/ml/examples/precompute_features.rs` and
//! `crates/ml/src/trainers/dqn/data_loading.rs`; this test re-implements the
//! blend with the SAME `SP19_HORIZON_BLEND_WEIGHTS` constant and asserts
//! match to ε=1e-9 across a synthetic 100-bar trajectory.
//!
//! Edge case: bars within `LOOKAHEAD_HORIZON_MAX = 30` of the dataset end
//! are EXCLUDED — the trim is `n = len - LOOKAHEAD_HORIZON_MAX`. We assert
//! the trim is exact by checking output length and bar-index range.
//!
//! Per `feedback_no_partial_refactor`, the test asserts the producer math
//! AND the trim contract simultaneously, so a regression in either dimension
//! fires the same gate.
#![allow(clippy::float_cmp)]
const WARMUP: usize = 50;
const LOOKAHEAD_HORIZON_MAX: usize = 30;
const SP19_HORIZON_BLEND_WEIGHTS: [f64; 3] = [
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
1.0_f64 / 3.0_f64,
];
/// Mirror of the producer-side blend in `precompute_features.rs` /
/// `data_loading.rs`. Returns the blended log-return at bar `i`.
///
/// Pre-condition: `i + WARMUP + LOOKAHEAD_HORIZON_MAX < closes.len()`.
fn blended_log_return(closes: &[f64], i: usize) -> f64 {
let raw_curr = closes[i + WARMUP];
let raw_next = closes[i + WARMUP + 1];
let raw_5bar = closes[i + WARMUP + 5];
let raw_30bar = closes[i + WARMUP + 30];
let log_return_1bar = if raw_curr > 0.0 { (raw_next / raw_curr).ln() } else { 0.0 };
let log_return_5bar = if raw_curr > 0.0 { (raw_5bar / raw_curr).ln() / (5.0_f64).sqrt() } else { 0.0 };
let log_return_30bar = if raw_curr > 0.0 { (raw_30bar / raw_curr).ln() / (30.0_f64).sqrt() } else { 0.0 };
SP19_HORIZON_BLEND_WEIGHTS[0] * log_return_1bar
+ SP19_HORIZON_BLEND_WEIGHTS[1] * log_return_5bar
+ SP19_HORIZON_BLEND_WEIGHTS[2] * log_return_30bar
}
/// Build a deterministic synthetic close-price trajectory. Geometric
/// drift `μ_per_bar = 0.001` plus 5-bar / 30-bar cyclic perturbation
/// keeps the 1-bar / 5-bar / 30-bar log-returns measurably distinct so
/// the blend's contribution is non-degenerate.
fn synthetic_closes(n: usize) -> Vec<f64> {
let mu = 0.001_f64;
(0..n)
.map(|i| {
let drift = (i as f64) * mu;
let cyc5 = ((i as f64) * std::f64::consts::PI / 5.0).sin() * 0.01;
let cyc30 = ((i as f64) * std::f64::consts::PI / 30.0).cos() * 0.005;
5000.0_f64 * (drift + cyc5 + cyc30).exp()
})
.collect()
}
#[test]
fn sp19_blend_matches_oracle_for_known_returns() {
// Engineered close prices producing exact integer-ratio log-returns
// at bar 0 (after WARMUP). With WARMUP = 50 and a 100-bar trajectory
// the valid output range is `[0, 100 - WARMUP - 30) = [0, 20)`.
let n_bars = WARMUP + 50; // 100 total bars
let closes = synthetic_closes(n_bars);
let n_outputs = closes.len() - WARMUP - LOOKAHEAD_HORIZON_MAX;
assert!(n_outputs > 0, "synthetic dataset too small to produce any blended output");
// Hand-checked oracle for bar i = 0
let p_curr = closes[WARMUP];
let p_1 = closes[WARMUP + 1];
let p_5 = closes[WARMUP + 5];
let p_30 = closes[WARMUP + 30];
let oracle_1 = (p_1 / p_curr).ln();
let oracle_5 = (p_5 / p_curr).ln() / (5.0_f64).sqrt();
let oracle_30 = (p_30 / p_curr).ln() / (30.0_f64).sqrt();
let oracle_blend =
SP19_HORIZON_BLEND_WEIGHTS[0] * oracle_1
+ SP19_HORIZON_BLEND_WEIGHTS[1] * oracle_5
+ SP19_HORIZON_BLEND_WEIGHTS[2] * oracle_30;
let producer = blended_log_return(&closes, 0);
let diff = (producer - oracle_blend).abs();
assert!(
diff < 1.0e-9,
"SP19 blend mismatch at bar 0: producer={} oracle={} diff={}",
producer, oracle_blend, diff
);
// Sweep all valid output bars to catch indexing drift.
for i in 0..n_outputs {
let producer = blended_log_return(&closes, i);
// Rebuild the oracle at this bar inline (no helper to avoid
// tautological self-reference — recompute from raw closes).
let p_curr = closes[i + WARMUP];
let p_1 = closes[i + WARMUP + 1];
let p_5 = closes[i + WARMUP + 5];
let p_30 = closes[i + WARMUP + 30];
let r1 = (p_1 / p_curr).ln();
let r5 = (p_5 / p_curr).ln() / (5.0_f64).sqrt();
let r30 = (p_30 / p_curr).ln() / (30.0_f64).sqrt();
let oracle = (r1 + r5 + r30) / 3.0;
let diff = (producer - oracle).abs();
assert!(
diff < 1.0e-12,
"SP19 blend mismatch at bar {}: producer={} oracle={} diff={}",
i, producer, oracle, diff
);
}
}
#[test]
fn sp19_trim_excludes_last_lookahead_horizon_max_bars() {
// n_outputs = feature_vectors.len() - LOOKAHEAD_HORIZON_MAX
// = (closes.len() - WARMUP) - LOOKAHEAD_HORIZON_MAX
let closes_lens = [
WARMUP + LOOKAHEAD_HORIZON_MAX + 1, // boundary: exactly 1 output
WARMUP + LOOKAHEAD_HORIZON_MAX + 10, // 10 outputs
WARMUP + LOOKAHEAD_HORIZON_MAX + 100, // 100 outputs
];
for &n_bars in &closes_lens {
let feature_vectors_len = n_bars - WARMUP;
let expected_outputs = feature_vectors_len.saturating_sub(LOOKAHEAD_HORIZON_MAX);
// Producer trim formula: `n = feature_vectors.len() - LOOKAHEAD_HORIZON_MAX`.
let producer_trim = feature_vectors_len.saturating_sub(LOOKAHEAD_HORIZON_MAX);
assert_eq!(
producer_trim, expected_outputs,
"producer trim mismatch at n_bars={}: producer={} expected={}",
n_bars, producer_trim, expected_outputs
);
// Last valid bar index `i = expected_outputs - 1` accesses
// `all_bars[i + WARMUP + 30] = all_bars[n_bars - 1]` — the last
// bar in the dataset, in bounds by construction.
if expected_outputs > 0 {
let last_i = expected_outputs - 1;
let last_access_idx = last_i + WARMUP + LOOKAHEAD_HORIZON_MAX;
assert!(
last_access_idx < n_bars,
"trim contract violated at n_bars={}: last_access_idx={} >= n_bars",
n_bars, last_access_idx
);
// The next-bar-after-last-output access would go out of bounds —
// confirms the trim is tight (no off-by-one slack).
let next_i = expected_outputs;
let next_access_idx = next_i + WARMUP + LOOKAHEAD_HORIZON_MAX;
assert!(
next_access_idx >= n_bars,
"trim contract too loose at n_bars={}: next_access_idx={} should be >= n_bars",
n_bars, next_access_idx
);
}
}
}
#[test]
fn sp19_blend_zero_when_close_is_zero() {
// Degenerate close = 0.0 at bar `i + WARMUP` short-circuits the blend
// to 0.0 (the producer guards `if raw_curr > 0.0` per
// `precompute_features.rs:374` analogue). This is the same guard the
// 1-bar-only `preproc_next` had pre-SP19.
let mut closes = vec![5000.0_f64; WARMUP + LOOKAHEAD_HORIZON_MAX + 5];
closes[WARMUP] = 0.0;
let blended = blended_log_return(&closes, 0);
assert_eq!(blended, 0.0, "zero close must short-circuit blend to 0.0");
}
#[test]
fn sp19_blend_constant_price_yields_zero_blend() {
// Constant price ⇒ all log-returns are zero ⇒ blend is zero.
let closes = vec![5000.0_f64; WARMUP + LOOKAHEAD_HORIZON_MAX + 5];
for i in 0..(closes.len() - WARMUP - LOOKAHEAD_HORIZON_MAX) {
let blended = blended_log_return(&closes, i);
assert!(
blended.abs() < 1.0e-15,
"constant price must blend to ≈0 at bar {}: got {}", i, blended
);
}
}