Files
foxhunt/docs/superpowers/plans/2026-03-25-boundary-trade-counting.md
jgrusewski 3bd339b5a3 fix: backtest evaluator — correct annualization, window sizing, objective calibration
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>
2026-03-25 19:43:26 +01:00

19 KiB
Raw Blame History

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.