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>
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:
- It's more realistic (cash reflects actual trade cost, not entry-price-reset)
- It handles partial fills correctly (L50→L100 doesn't reset entry_price)
- 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_tradeto 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