Root cause: backtest windows of 300K bars produced ±billions% returns via multiplicative compounding, and sqrt(252) annualization was wrong for 1-minute bars. Fixes: - Window size capped to 10K bars (~25 trading days), evenly distributed across the full validation set (was clustered in first 6%) - Annualization: configurable bars_per_day field in GpuBacktestConfig (default 390.0 for 1-min), produces sqrt(98280) ≈ 313.5 - tanh normalization recalibrated: Sharpe/5, Sortino/8 (was /2, /3) - CVaR threshold scaled to per-bar: 0.003 with slope 1400 (was 0.05/200) - VaR/CVaR strided sampling covers full window (was first 4096 only) - financials.rs + ab_testing.rs: sqrt(252) → sqrt(98280) for consistency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
19 KiB
Boundary-Aware Trade Counting + Dead Code Cleanup — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix fragmented trade counting across GPU thread chunk boundaries, remove 3 dead parallel reduction arrays + dead per-thread drawdown variables, and extract BARS_PER_YEAR constant in coordinator_extended.rs.
Architecture: The backtest metrics kernel uses 256 threads processing consecutive chunks. Trades spanning chunk boundaries were fragmented (returns lost, counts wrong). Replace with hybrid: parallel per-thread boundary metadata export + thread-0 O(256) stitching pass. Simultaneously remove dead code from prior exact-drawdown fix (s_max_dd, s_trades, s_wins arrays + local_peak/local_max_dd per-thread vars). Shared memory layout shrinks from 9 to 6 reduction arrays (25KB → 22KB).
Tech Stack: CUDA kernel (C), Rust (cudarc 0.19.3), nvcc PTX compilation.
Task 1: Dead code cleanup — remove 3 dead reduction arrays and per-thread drawdown vars
Files:
- Modify:
crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu - Modify:
crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs:1795-1798
This task removes the dead code FIRST so that Task 2 starts from a clean baseline. After this task, the kernel still has the OLD (broken) trade counting, but the dead drawdown/trades/wins reduction arrays are gone.
- Step 1: Update kernel header comment
In backtest_metrics_kernel.cu, replace lines 1-18 (the header comment) with:
// Per-window metrics reduction kernel.
// One block per window. Threads cooperate to reduce step_returns.
//
// Output per window [14 floats]:
// [0] sharpe_ratio (annualized via configurable annualization_factor)
// [1] total_pnl (multiplicatively compounded fractional return, e.g. 0.05 = +5%)
// [2] max_drawdown (exact sequential scan, worst peak-to-trough fraction)
// [3] sortino_ratio
// [4] win_rate (winning_trades / total_trades, exact via boundary stitching)
// [5] total_trades (exact position changes, boundary-aware)
// [6] var_95 (5th-percentile return — Value at Risk at 95% confidence)
// [7] cvar_95 (mean of returns below VaR — Expected Shortfall)
// [8] calmar_ratio (annualized mean return / max drawdown)
// [9] omega_ratio (sum of gains / sum of losses)
// [10] buy_count (exposure_idx 5-8 — Long25..Long100)
// [11] sell_count (exposure_idx 0-3 — Short100..Short25)
// [12] hold_count (action 4 — Flat)
// [13] unique_action_count (popcount of 9-bit mask — how many exposure levels 0..8 were used)
- Step 2: Update shared memory layout comment and pointers
Replace lines 38-61 (shared memory layout comment + pointer declarations) with:
// Shared memory layout (6 reduction arrays + sort scratch):
// [0 .. stride) : s_sum
// [stride .. 2*stride) : s_sq_sum
// [2*stride..3*stride) : s_down_sq
// [3*stride..4*stride) : s_buys
// [4*stride..5*stride) : s_sells
// [5*stride..6*stride) : s_holds
// [6*stride .. 6*stride + 4096) : s_sorted (bitonic sort scratch + boundary data)
extern __shared__ float shmem[];
float* s_sum = shmem; // [blockDim.x]
float* s_sq_sum = shmem + stride; // [blockDim.x]
float* s_down_sq = shmem + 2*stride; // [blockDim.x] (downside deviation)
// Action distribution: exposure 0-3=sell, 4=flat, 5-8=buy
float* s_buys = shmem + 3*stride; // [blockDim.x]
float* s_sells = shmem + 4*stride; // [blockDim.x]
float* s_holds = shmem + 5*stride; // [blockDim.x]
float* s_sorted = shmem + 6*stride; // [4096] for bitonic sort + boundary stitching
- Step 3: Remove dead per-thread variables
Replace lines 63-71 (per-thread local accumulators) with:
// Pass 1: per-thread local accumulators
float local_sum = 0.0f, local_sq = 0.0f, local_down = 0.0f;
// Multiplicative compounding for equity product reduction (total_pnl)
float local_cum = 1.0f;
int local_buys = 0, local_sells = 0, local_holds = 0;
int local_action_mask = 0; // 9-bit bitmask: bit i set if exposure_idx i was seen
Note: local_peak, local_max_dd, local_wins, local_trades, prev_action, trade_cum_return are all removed. local_cum is kept for the equity product reduction.
- Step 4: Remove dead per-bar drawdown tracking and old trade counting from loop body
Replace lines 82-129 (the entire for loop body + post-loop trade close) with:
for (int i = chunk_start; i < chunk_end; i++) {
float r = step_returns[base + i];
// Sharpe/Sortino use arithmetic mean of per-bar returns
local_sum += r;
local_sq += r * r;
if (r < 0.0f) local_down += r * r;
// Equity product for total_pnl (drawdown computed exactly by thread 0 later)
local_cum *= (1.0f + r);
// Action distribution: decode exposure from factored action.
// Factored action = exposure * (b1*b2) + order * b2 + urgency.
// exposure_idx: 0-3=sell, 4=flat, 5-8=buy (for 9-action space)
int act = actions_history[base + i];
int exp_idx = act / (DQN_ORDER_ACTIONS * DQN_URGENCY_ACTIONS);
if (exp_idx < DQN_NUM_ACTIONS / 2) local_sells++;
else if (exp_idx == DQN_NUM_ACTIONS / 2) local_holds++;
else local_buys++;
// Unique action tracking: set bit per exposure index (0..8)
if (exp_idx >= 0 && exp_idx < DQN_NUM_ACTIONS)
local_action_mask |= (1 << exp_idx);
}
Note: the entire drawdown tracking block (lines 90-95: local_cum *= ..., local_peak = fmaxf(...), dd = ..., local_max_dd = ...) is removed. Only local_cum *= (1.0f + r) is kept for the equity product. The entire old trade counting block (lines 97-106 + 120-129) is removed — will be replaced by boundary tracking in Task 2.
- Step 5: Update shared memory stores
Replace lines 131-141 (store to shmem) with:
// Store local values to shared memory for parallel reduction
s_sum[tid] = local_sum;
s_sq_sum[tid] = local_sq;
s_down_sq[tid] = local_down;
s_buys[tid] = (float)local_buys;
s_sells[tid] = (float)local_sells;
s_holds[tid] = (float)local_holds;
__syncthreads();
- Step 6: Update block reduction from 9 arrays to 6
Replace lines 143-157 (block reduction loop) with:
// Block-level parallel reduction for 6 arrays
for (int s = stride / 2; s > 0; s >>= 1) {
if (tid < s) {
s_sum[tid] += s_sum[tid + s];
s_sq_sum[tid] += s_sq_sum[tid + s];
s_down_sq[tid] += s_down_sq[tid + s];
s_buys[tid] += s_buys[tid + s];
s_sells[tid] += s_sells[tid + s];
s_holds[tid] += s_holds[tid + s];
}
__syncthreads();
}
- Step 7: Update thread-0 metrics output (remove s_trades/s_wins references)
In the thread-0 section (lines 176-214), replace lines 207-213 with:
// Trade count and win rate: placeholder zeros (replaced by boundary stitching in next commit)
metrics_out[out_base + 4] = 0.0f; // win_rate (TODO: boundary stitching)
metrics_out[out_base + 5] = 0.0f; // total_trades (TODO: boundary stitching)
metrics_out[out_base + 10] = s_buys[0]; // buy action count
metrics_out[out_base + 11] = s_sells[0]; // sell action count
metrics_out[out_base + 12] = s_holds[0]; // hold action count
- Step 8: Update shmem_bytes in gpu_backtest_evaluator.rs
In gpu_backtest_evaluator.rs:1795-1798, replace:
// Shared memory: 9 reduction arrays × 256 threads × 4 bytes = 9 KB
// + 4096 floats for bitonic sort scratch = 16 KB
// Total = 25 KB (well within the 48 KB L1/shmem limit)
let shmem_bytes = (256_u32 * 9 + 4096) * std::mem::size_of::<f32>() as u32;
with:
// Shared memory: 6 reduction arrays × 256 threads × 4 bytes = 6 KB
// + 4096 floats for sort scratch / boundary data = 16 KB
// Total = 22 KB (well within the 48 KB L1/shmem limit)
let shmem_bytes = (256_u32 * 6 + 4096) * std::mem::size_of::<f32>() as u32;
- Step 9: Build + test
Run: SQLX_OFFLINE=true cargo check -p ml --lib
Expected: compiles clean
Run: SQLX_OFFLINE=true cargo test -p ml --lib -- backtest
Expected: all backtest tests pass (trade count/win rate will be 0 temporarily — that's expected, fixed in Task 2)
- Step 10: Commit
git add crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs
git commit -m "refactor: remove 3 dead reduction arrays + per-thread drawdown vars from metrics kernel
s_max_dd, s_trades, s_wins parallel reductions were made redundant by
the exact sequential drawdown scan and upcoming boundary stitching.
Reduces shared memory from 25KB to 22KB and removes 3 dead ops/bar
from the per-thread loop. Trade count/win rate temporarily zeroed —
restored by boundary stitching in next commit."
Task 2: Boundary-aware parallel trade counting with stitching
Files:
- Modify:
crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu
This task adds the hybrid parallel trade counting: per-thread boundary metadata + thread-0 stitching. It replaces the temporary zeros from Task 1 with exact values.
- Step 1: Add boundary-tracking variables to per-thread accumulators
After the existing per-thread variables (from Task 1), add:
// Boundary-aware trade tracking: each thread exports metadata about
// trades at chunk boundaries. Thread 0 stitches these to produce
// exact trade counts and win rates across the full window.
int bnd_first_action = -1; // -1 signals empty chunk
int bnd_last_action = -1;
float bnd_prefix_return = 0.0f;
float bnd_suffix_return = 0.0f;
int bnd_complete_trades = 0;
int bnd_complete_wins = 0;
int bnd_num_changes = 0;
int bnd_cur_action = -1;
float bnd_cur_return = 0.0f;
- Step 2: Add boundary tracking to the per-bar loop
Inside the for (int i = chunk_start; i < chunk_end; i++) loop, AFTER the action distribution block and BEFORE the closing brace, add:
// Boundary-aware trade tracking
if (i == chunk_start) {
bnd_first_action = act;
bnd_cur_action = act;
}
if (act != bnd_cur_action) {
bnd_num_changes++;
if (bnd_num_changes == 1) {
// First change: everything accumulated so far is the prefix
bnd_prefix_return = bnd_cur_return;
} else {
// Interior change: segment since last change is a complete trade
bnd_complete_trades++;
if (bnd_cur_return > 0.0f) bnd_complete_wins++;
}
bnd_cur_return = 0.0f;
bnd_cur_action = act;
}
bnd_cur_return += r;
bnd_last_action = act;
- Step 3: Add post-loop suffix/prefix assignment
After the closing brace of the for loop, add:
// Post-loop: assign suffix and handle no-change edge case
bnd_suffix_return = bnd_cur_return;
if (bnd_num_changes == 0) {
// Entire chunk is one partial trade segment (prefix == suffix)
bnd_prefix_return = bnd_cur_return;
}
- Step 4: Export boundary data to shared memory
After the equity product reduction (after the // s_sorted[0] now holds the full-window equity comment and its __syncthreads()), add:
// ── Export boundary trade data to shared memory ──────────────────────
// Reuse s_sorted[stride..8*stride] for 7 boundary arrays (1792 floats).
// This region is free between the equity product reduction (which only
// uses s_sorted[0]) and the bitonic sort (which starts later).
// Int values stored via __int_as_float for bit-exact round-trip.
{
float* s_bnd = &s_sorted[stride];
s_bnd[0 * stride + tid] = __int_as_float(bnd_first_action);
s_bnd[1 * stride + tid] = __int_as_float(bnd_last_action);
s_bnd[2 * stride + tid] = bnd_prefix_return;
s_bnd[3 * stride + tid] = bnd_suffix_return;
s_bnd[4 * stride + tid] = __int_as_float(bnd_complete_trades);
s_bnd[5 * stride + tid] = __int_as_float(bnd_complete_wins);
s_bnd[6 * stride + tid] = __int_as_float(bnd_num_changes);
}
__syncthreads();
- Step 5: Add stitching pass in thread-0 section
In the thread-0 section (if (tid == 0)), AFTER the exact drawdown sequential scan and BEFORE the metrics output writes, add the stitching code:
// ── Boundary stitching: exact trade count + win rate ─────────────
// Sum interior complete trades from all threads, then stitch
// boundary trades by walking the per-thread metadata left-to-right.
float* s_bnd = &s_sorted[stride];
int total_trades = 0;
int total_wins = 0;
// Sum interior trades (parallel-counted, always correct)
for (int t = 0; t < stride; t++) {
total_trades += __float_as_int(s_bnd[4 * stride + t]);
total_wins += __float_as_int(s_bnd[5 * stride + t]);
}
// Stitch boundary trades
float open_return = 0.0f;
int open_action = -1;
for (int t = 0; t < stride; t++) {
int fa = __float_as_int(s_bnd[0 * stride + t]);
int la = __float_as_int(s_bnd[1 * stride + t]);
float pr = s_bnd[2 * stride + t];
float sr = s_bnd[3 * stride + t];
int nc = __float_as_int(s_bnd[6 * stride + t]);
if (fa < 0) continue; // empty chunk
// Step 1: connect this chunk's start to the open trade
if (open_action < 0) {
// First valid chunk — open the first trade (no closure yet)
open_action = fa;
open_return = 0.0f;
} else if (fa != open_action) {
// Action changed at chunk boundary — close the open trade
total_trades++;
if (open_return > 0.0f) total_wins++;
open_action = fa;
open_return = 0.0f;
}
// Step 2: process this chunk's contribution
if (nc == 0) {
// No action changes — entire chunk extends the open trade
open_return += pr;
} else {
// Prefix extends and closes the open trade
open_return += pr;
total_trades++;
if (open_return > 0.0f) total_wins++;
// Interior trades already counted above
// Suffix becomes the new open trade
open_action = la;
open_return = sr;
}
}
// Close the final open trade (window end)
if (open_action >= 0) {
total_trades++;
if (open_return > 0.0f) total_wins++;
}
- Step 6: Update metrics output to use stitched values
Replace the temporary trade count/win rate output (from Task 1 Step 7) with:
float total_trades_f = (float)total_trades;
metrics_out[out_base + 4] = (total_trades_f > 0.0f) ? (float)total_wins / total_trades_f : 0.0f;
metrics_out[out_base + 5] = total_trades_f;
metrics_out[out_base + 10] = s_buys[0];
metrics_out[out_base + 11] = s_sells[0];
metrics_out[out_base + 12] = s_holds[0];
- Step 7: Build + test
Run: SQLX_OFFLINE=true cargo check -p ml --lib
Expected: compiles clean
Run: SQLX_OFFLINE=true cargo test -p ml --lib -- backtest
Expected: all backtest tests pass
Run: SQLX_OFFLINE=true cargo test -p ml --lib -- objective
Expected: all objective function tests pass
Run: SQLX_OFFLINE=true cargo test -p ml --lib
Expected: 887 pass, 0 fail
- Step 8: Commit
git add crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu
git commit -m "feat: boundary-aware parallel trade counting with exact stitching
Hybrid approach: each of 256 threads exports boundary metadata (first/last
action, prefix/suffix return, interior trade count). Thread 0 stitches
boundaries in O(256) to produce exact trade count and win rate.
Fixes: trades spanning chunk boundaries were fragmented (returns lost,
counts incorrect). Now every trade's cumulative return is tracked exactly
regardless of which thread chunks it spans.
7 boundary values stored in s_sorted[stride..8*stride] — zero extra
shared memory (reuses sort scratch between equity reduction and bitonic sort)."
Task 3: coordinator_extended.rs — extract BARS_PER_YEAR constant
Files:
-
Modify:
crates/ml-ensemble/src/coordinator_extended.rs:338-340 -
Step 1: Replace inline formula with named constant
In coordinator_extended.rs, replace lines 338-340:
// Annualize assuming 252 trading days, 6.5 hours per day, predictions every minute
let sharpe = if std_dev > 1e-10 {
let annualization_factor = (252.0 * 6.5 * 60.0_f64).sqrt();
with:
// Annualize: 1-minute bars, 390 bars/day × 252 days/year
const BARS_PER_YEAR: f64 = 390.0 * 252.0;
let sharpe = if std_dev > 1e-10 {
let annualization_factor = BARS_PER_YEAR.sqrt();
- Step 2: Build + test
Run: SQLX_OFFLINE=true cargo check -p ml-ensemble --lib
Expected: compiles clean
Run: SQLX_OFFLINE=true cargo test -p ml-ensemble --lib
Expected: 118 pass, 0 fail
- Step 3: Commit
git add crates/ml-ensemble/src/coordinator_extended.rs
git commit -m "refactor: extract BARS_PER_YEAR constant in coordinator_extended.rs
Replaces inline (252.0 * 6.5 * 60.0).sqrt() with named constant
matching the pattern used in financials.rs and ab_testing.rs."
Task 4: Full integration test
Files:
-
No new files — validation only
-
Step 1: Run full ml test suite
Run: SQLX_OFFLINE=true cargo test -p ml --lib
Expected: 887 pass, 0 fail
- Step 2: Run full ml-dqn test suite
Run: SQLX_OFFLINE=true cargo test -p ml-dqn --lib
Expected: 359 pass, 0 fail
- Step 3: Run full ml-ensemble test suite
Run: SQLX_OFFLINE=true cargo test -p ml-ensemble --lib
Expected: 118 pass, 0 fail
- Step 4: Workspace compilation check
Run: SQLX_OFFLINE=true cargo check --workspace
Expected: compiles clean, no errors
- Step 5: Verify PTX compilation test
Run: SQLX_OFFLINE=true cargo test -p ml --lib -- test_metrics_ptx_compilation
Expected: passes (verifies the modified kernel compiles to valid PTX)
Execution Order
Task 1 (dead code cleanup) → Task 2 (boundary stitching) → Task 3 (coordinator constant) → Task 4 (integration test)
Tasks 1 and 2 MUST be sequential (Task 2 builds on Task 1's cleaned-up kernel). Task 3 is independent but ordered last for clean git history. Task 4 validates everything.