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:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
185
crates/ml/tests/multi_horizon_reward_blend_test.rs
Normal file
185
crates/ml/tests/multi_horizon_reward_blend_test.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user