Files
foxhunt/docs/superpowers/plans/2026-03-27-hive-findings-fixes.md
jgrusewski be65d8e5fc fix: hive findings — capital floor, dueling centering, metrics, CVaR objective
From 5-agent deep analysis:

Kernel fixes:
- Capital floor penalty: -1.0 → -10.0 (match reward range for C51)
- Add pre-trade capital floor check to training kernel (match backtest)
- Add tx cost to forced liquidation in both kernels
- Fix expected_q dueling centering: per-atom mean (was scalar mean)
- Fix Calmar: use exact full-window mean (was sampled for >4096 bars)
- Fix CVaR off-by-one: include VaR observation in Expected Shortfall
- Trailing stop: add vol_scale/trend_scale params to shared function
- Update reward v5 → v6 docstrings (12 occurrences)
- Fix annualization comment (sqrt(98280) not sqrt(252))

Rust fixes:
- CVaR penalty: additive → multiplicative discount on composite
  (prevents CVaR from dominating objective for undertrained models)
- Fix dd_threshold default mismatch (0.01 → 0.02 matching Default)
- Fix action entropy max_entropy (log2(3) → log2(9) for 9 actions)
- Fix Sortino denominator in financials.rs (N_negative → N_total)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:56:50 +01:00

13 KiB

Hive Findings Fixes — All Issues Resolution 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 ALL findings from the 5-agent hive analysis: 2 critical, 4 high, 6 medium issues. Unify the portfolio simulation model between training and backtest kernels.

Architecture: Quick wins first (1-line fixes), then the deep refactor (unified cash/MtM model in trade_physics.cuh). Each task is independent except Task 7 (unification) which depends on Tasks 1-6.

Tech Stack: CUDA C (.cu/.cuh), Rust (cudarc), build.rs precompilation


File Structure

File Responsibility
trade_physics.cuh Shared trade physics — add unified trade execution function
experience_kernels.cu Training env_step — call shared trade execution
backtest_env_kernel.cu Backtest env_step — call shared trade execution
expected_q_kernel.cu Fix dueling centering
backtest_metrics_kernel.cu Fix Calmar mean, CVaR off-by-one
dqn.rs (hyperopt adapter) Fix CVaR cap, dd_threshold, entropy logging, capital floor penalty
financials.rs Fix Sortino denominator

Task 1: Quick wins — 1-line fixes in dqn.rs

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs

  • Step 1: Reduce CVaR penalty cap from 3.0 to 1.0

Find * 100.0).min(3.0) in extract_objective and change to .min(1.0).

This prevents CVaR from dominating the composite score (composite max = 0.60, CVaR was capped at 3.0 = 5x the composite).

  • Step 2: Fix dd_threshold default mismatch

In from_continuous(), find dd_threshold: 0.01 and change to dd_threshold: 0.02 to match Default::default().

  • Step 3: Fix action entropy logging — use 9 actions not 3

In extract_objective, find (3.0_f64).log2() and change to (9.0_f64).log2(). Also update the action_distribution to use the 9 exposure actions from backtest metrics when available, not the 3-bucket BUY/SELL/HOLD approximation.

  • Step 4: Compile check

Run: SQLX_OFFLINE=true cargo check -p ml


Task 2: Capital floor penalty — training kernel

Files:

  • Modify: crates/ml/src/cuda_pipeline/experience_kernels.cu

  • Step 1: Increase capital floor penalty from -1.0 to -10.0

Find reward = -1.0f; in the capital floor block (near check_capital_floor) and change to reward = -10.0f;.

This matches the tanh reward range [-10, +10] and gives C51 a proper distributional signal for catastrophic loss.

  • Step 2: Add pre-trade capital floor check

Before the action decode block (after reading portfolio state), add:

/* Pre-trade capital floor: don't execute trades on a blown account.
 * Matches backtest_env_kernel which checks floor at both pre-trade and post-trade. */
if (check_capital_floor(ps[2], peak_equity)) {
    position = 0.0f;
    cash = ps[2];
    reward = -10.0f;
    out_rewards[out_off] = reward;
    out_dones[out_off] = 1.0f;
    /* Write flat portfolio state */
    ps[0] = 0.0f;   /* position */
    ps[1] = cash;
    ps[2] = cash;    /* portfolio_value */
    current_timesteps[i] = 0;  /* reset episode */
    return;
}
  • Step 3: Add tx cost to capital floor liquidation

In the post-trade capital floor block, before position = 0.0f, add:

float exit_cost = compute_tx_cost(position, raw_close, tx_cost_multiplier, 0.0f,
                                  max_position, 0, -1.0f);
cash -= exit_cost;

Do the same in backtest_env_kernel.cu's two capital floor blocks.

  • Step 4: Compile check

Run: SQLX_OFFLINE=true cargo check -p ml


Task 3: Fix expected_q dueling centering

Files:

  • Modify: crates/ml/src/cuda_pipeline/expected_q_kernel.cu

  • Step 1: Replace scalar mean with per-atom mean

Find the block (around lines 41-49):

float mean_adv_sum = 0.0f;
for (int aa = 0; aa < bd; aa++) {
    for (int j = 0; j < num_atoms; j++) {
        mean_adv_sum += adv_aa[j];
    }
}
float mean_adv_per_atom = mean_adv_sum / (float)(bd * num_atoms);

Replace the inner combined logit computation with per-atom centering:

/* Per-atom advantage centering: mean_a(A[a,j]) for each atom j independently.
 * Matches the dueling formulation in c51_loss_kernel.cu and mse_loss_kernel.cu. */
for (int j = 0; j < num_atoms; j++) {
    float a_mean = 0.0f;
    for (int aa = 0; aa < bd; aa++) {
        const float* adv_aa = b_logits + (long long)i * total_actions * num_atoms
                             + (long long)(adv_offset + aa) * num_atoms;
        a_mean += adv_aa[j];
    }
    a_mean /= (float)bd;
    float combined = val[j] + adv[j] - a_mean;
    /* ... rest of log_softmax computation uses combined per-atom ... */
}

The key change: move the mean computation INSIDE the atom loop so each atom j gets its own mean across actions, not a single scalar mean across all atoms and actions.

  • Step 2: Rebuild cubins and test

Run: cargo clean -p ml --profile release-test && FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true cargo test -p ml --lib --profile release-test -- smoke_tests::training_stability::test_gpu_collector_auto_initializes --ignored --nocapture


Task 4: Fix financial metrics

Files:

  • Modify: crates/ml/src/trainers/dqn/financials.rs

  • Modify: crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu

  • Step 1: Fix Sortino denominator in financials.rs

Find the Sortino computation and change the denominator from downside.len() to n (total number of returns), matching the kernel and textbook definition (Sortino & Price 1994).

  • Step 2: Fix Calmar to use full-window mean

In backtest_metrics_kernel.cu, the Calmar computation (around line 393-397) re-sums returns from the sorted array. Replace with the already-computed mean from the parallel reduction:

float calmar = (max_dd > 0.001f)
    ? (mean * annualization_factor * annualization_factor) / max_dd
    : 0.0f;
calmar = fmaxf(-100.0f, fminf(100.0f, calmar));

This eliminates sampling error for windows > 4096 bars.

  • Step 3: Fix CVaR off-by-one

Change int cvar_count = (var_idx > 0) ? var_idx : 1; to int cvar_count = var_idx + 1; to include the VaR observation itself in the Expected Shortfall average.

  • Step 4: Compile check

Task 5: Fix trailing stop divergence

Files:

  • Modify: crates/ml/src/cuda_pipeline/trade_physics.cuh

  • Modify: crates/ml/src/cuda_pipeline/backtest_env_kernel.cu

  • Step 1: Add vol/trend scaling params to check_trailing_stop

Update the shared function signature to accept vol_scale and trend_scale:

__device__ __forceinline__ int check_trailing_stop(
    float hold_time, int min_hold_bars,
    float peak_equity, float prev_equity,
    float current_trade_return,
    float base_trail_distance,
    float vol_scale,       /* 1.0 = no scaling, >1 = widen in volatile markets */
    float trend_scale      /* 1.0 = no scaling, >1 = widen in trending markets */
) {
    if (hold_time < (float)min_hold_bars || peak_equity <= 1.0f) return 0;
    float trail_distance = base_trail_distance * vol_scale * trend_scale;
    float peak_return = (peak_equity - prev_equity) / fmaxf(prev_equity, 1.0f);
    if (peak_return > trail_distance) {
        if (current_trade_return < peak_return - trail_distance) return 1;
    }
    return 0;
}
  • Step 2: Update backtest kernel to pass vol/trend = 1.0

The backtest doesn't have ADX/CUSUM features, so pass 1.0f, 1.0f for default scaling.

  • Step 3: Update training kernel to call shared function

Replace the inline trailing stop (lines ~755-770) with check_trailing_stop(), passing the computed vol_scale and trend_scale from ADX/CUSUM features.

  • Step 4: Compile check

Task 6: Stale docstring cleanup

Files:

  • Modify: crates/ml/src/cuda_pipeline/experience_kernels.cu

  • Modify: crates/ml/src/cuda_pipeline/backtest_metrics_kernel.cu

  • Step 1: Update reward v5 → v6 references

Find all comments referencing "reward v5" and update to "reward v6: Sparse Trade-Completion Only".

  • Step 2: Fix metrics kernel sqrt(252) comment

Change "sqrt(252) for daily" to "sqrt(bars_per_day * 252), e.g. sqrt(98280) for 1-min bars".


Task 7: Unify portfolio simulation model (CRITICAL)

Files:

  • Modify: crates/ml/src/cuda_pipeline/trade_physics.cuh
  • Modify: crates/ml/src/cuda_pipeline/backtest_env_kernel.cu

This is the deepest fix. The training kernel uses a notional cash model and marks to raw_next. The backtest uses entry-price-reset and marks to close. They must match.

Design decision: Adopt the training kernel's model (notional cash + mark-to-next) because:

  1. It's more realistic (cash reflects actual trade cost, not entry-price-reset)
  2. It handles partial fills correctly (L50→L100 doesn't reset entry_price)
  3. MtM to next bar is standard for futures (daily settlement)

The backtest kernel needs access to the NEXT bar's close price for MtM. Currently it only has the current bar's OHLC. The evaluator's step loop processes bars sequentially, so we can pass next_close as an additional price.

  • Step 1: Add execute_trade to trade_physics.cuh
/* Unified trade execution — single source of truth for both kernels.
 * Handles: delta computation, tx cost, cash update, position update.
 * Returns: tx_cost charged (for logging). */
__device__ __forceinline__ float execute_trade(
    float* position,      /* in/out: current position */
    float* cash,          /* in/out: cash balance */
    float target_position,
    float close_price,
    float tx_cost_bps,
    float spread_cost,
    float max_position,
    int   order_type_idx,
    float spread_scale_override
) {
    float delta = target_position - *position;
    if (fabsf(delta) <= 0.001f || close_price <= 0.0f) return 0.0f;
    float cost = compute_tx_cost(delta, close_price, tx_cost_bps, spread_cost,
                                 max_position, order_type_idx, spread_scale_override);
    *cash -= delta * close_price;  /* notional: buy costs cash, sell adds */
    *cash -= cost;
    *position = target_position;
    return cost;
}

/* Mark-to-market: compute portfolio value from cash + unrealized.
 * Uses next_close for consistent forward-looking MtM (futures settlement). */
__device__ __forceinline__ float mark_to_market(
    float cash, float position, float next_close
) {
    return cash + position * next_close;
}
  • Step 2: Refactor backtest_env_kernel.cu to use unified model

Replace the entry-price-reset trade execution with execute_trade(). Remove entry_price from the portfolio state — it's no longer needed (notional model tracks P&L through cash directly).

Add next_close access: the evaluator's step loop at step t can pass prices[(w * max_len + t + 1) * 4 + 3] as the next close. For the last bar, use current close.

Replace the mark-to-market with mark_to_market(cash, position, next_close).

The portfolio state layout changes:

[0] value        → mark_to_market(cash, position, next_close)
[1] position     → unchanged
[2] cash         → notional cash (no entry_price needed)
[3] entry_price  → REMOVED (repurpose for peak_trade_return for trailing stop)
[4] max_equity   → unchanged
[5] hold_time    → unchanged
[6] cum_return   → unchanged
[7] step_count   → unchanged
  • Step 3: Update backtest_env_kernel signature

Add next_prices buffer or pass max_len so the kernel can read the next bar's close:

float next_close = (current_step + 1 < wlen)
    ? prices[(w * max_len + current_step + 1) * 4 + 3]
    : close;  /* last bar: use current close */
  • Step 4: Update GpuBacktestEvaluator launch site

No new buffers needed — the kernel already has access to all prices. The next_close read is within the existing prices_buf bounds.

  • Step 5: Update backtest_metrics_kernel.cu if needed

The metrics kernel reads step_returns which are computed by env_step. With the unified model, step_ret = (new_value - old_value) / old_value where new_value uses mark_to_market(cash, position, next_close). This is now consistent with training.

  • Step 6: Full test suite
# Unit tests
SQLX_OFFLINE=true cargo test -p ml --lib -- hyperopt::adapters::dqn::tests --nocapture

# Smoke test
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true cargo test -p ml --lib --profile release-test -- smoke_tests::training_stability::test_gpu_collector_auto_initializes --ignored --nocapture

# Hyperopt integration
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true cargo test -p ml --lib --profile release-test -- hyperopt::campaign::tests::test_local_hyperopt --ignored --nocapture

Validation

Success criteria:

  • All unit tests pass (25+)
  • Smoke test: Sharpe > 0, trades > 0
  • Hyperopt: 2 trials complete, max_dd < 30%, finite metrics
  • No SIGSEGV
  • Training and backtest produce consistent P&L for the same actions on the same data