Final 3 inline duplicates replaced with shared function calls: - execute_trade(): notional cash model (was inline cash/position update) - check_trailing_stop(): regime-adaptive trailing stop decision - enforce_hold(): hold enforcement decision (action aliasing kept inline) Training kernel now uses ALL 10 shared functions from trade_physics.cuh: decode_exposure_index, compute_target_position, decode_order_type, execute_trade, compute_tx_cost, enforce_hold, check_trailing_stop, check_capital_floor, apply_margin_cap, update_hold_time Zero inline duplicates remain. Both training and backtest kernels use identical trade physics — no more train/eval mismatches. Smoke test: Sharpe=4.89, 443 trades, no SIGSEGV. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.8 KiB
Training Kernel Unification — Final Inline Duplicates
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: Replace the 3 remaining inline duplicates in experience_kernels.cu with shared trade_physics.cuh calls, making the training kernel fully consistent with the backtest kernel.
Architecture: Each task replaces one inline block. The hold enforcement task is the most delicate because the training kernel has extra action-aliasing logic that must be preserved.
Tech Stack: CUDA C (.cu/.cuh), build.rs precompilation
Remaining Inline Duplicates
| Block | Training (inline) | Shared header | Backtest | Status |
|---|---|---|---|---|
| Trade execution | cash -= delta * raw_close; cash -= tx_cost; position = target (line 695) |
execute_trade() |
Uses shared ✓ | INLINE |
| Trailing stop | Lines 756-784, regime-adaptive distance | check_trailing_stop() |
Uses shared ✓ | INLINE |
| Hold enforcement | Lines 795-821, action-aliasing fix | enforce_hold() |
Uses shared ✓ | INLINE (has extras) |
Task 1: Wire training kernel trade execution to execute_trade()
Files:
- Modify:
crates/ml/src/cuda_pipeline/experience_kernels.cu
The training kernel's trade block (around lines 688-700) is:
float delta = target_position - position;
float tx_cost = 0.0f;
if (delta != 0.0f) {
int order_type_idx = decode_order_type(action_idx, b1_size, b2_size);
tx_cost = compute_tx_cost(delta, raw_close, tx_cost_multiplier, 0.0f,
max_position, order_type_idx, spread_scale);
cash -= delta * raw_close;
cash -= tx_cost;
position = target_position;
}
- Step 1: Replace with
execute_trade()call
Replace the above with:
int order_type_idx = decode_order_type(action_idx, b1_size, b2_size);
float tx_cost = execute_trade(&position, &cash, target_position, raw_close,
tx_cost_multiplier, 0.0f, max_position,
order_type_idx, spread_scale);
Note: spread_scale (CUSUM-based) is already computed before this point. It gets passed as spread_scale_override to execute_trade, which forwards it to compute_tx_cost. The 0.0f is spread_cost (training doesn't use bid-ask spread, only the CUSUM-based model).
IMPORTANT: The variable delta is used later in the kernel (for P&L computation, reversal detection, etc.). After the refactor, delta is no longer computed locally. Add it back:
float delta = target_position - position; /* save before execute_trade modifies position */
int order_type_idx = decode_order_type(action_idx, b1_size, b2_size);
float tx_cost = execute_trade(&position, &cash, target_position, raw_close,
tx_cost_multiplier, 0.0f, max_position,
order_type_idx, spread_scale);
Wait — execute_trade modifies position via the pointer. If we compute delta before calling execute_trade, delta has the pre-trade value. But position after the call is already updated. Check if delta is used after the trade block. Search for uses of delta after line 700.
If delta IS used later (e.g., for raw_pnl = position * (raw_next - raw_close) — but that uses the NEW position, not delta), just ensure the refactor doesn't break the data flow.
Actually, looking at the code: delta is only used inside the if (delta != 0.0f) block and not after. The variable tx_cost IS used after (for reward shaping). So the refactor is safe:
float pre_trade_position = position; /* save for reversal detection */
int order_type_idx = decode_order_type(action_idx, b1_size, b2_size);
float tx_cost = execute_trade(&position, &cash, target_position, raw_close,
tx_cost_multiplier, 0.0f, max_position,
order_type_idx, spread_scale);
- Step 2: Verify build
Run: SQLX_OFFLINE=true cargo check -p ml
- Step 3: Verify
deltaandpositionusage after trade block
Grep for delta after line 700. If used, compute it before execute_trade. If not, the refactor is clean.
Task 2: Wire training kernel trailing stop to check_trailing_stop()
Files:
- Modify:
crates/ml/src/cuda_pipeline/experience_kernels.cu
The training kernel's trailing stop (around lines 756-784) computes vol_scale and trend_scale from ADX/CUSUM, then checks if the trade return retreated from peak.
- Step 1: Extract vol_scale and trend_scale computation (keep inline)
The ADX/CUSUM feature reads and vol/trend scale computation (lines 748-764) must stay inline because they read from kernel-specific data structures (features[], bar_idx, market_dim). Only the DECISION logic should be shared.
- Step 2: Replace the trailing stop decision logic with shared call
After computing vol_scale, trend_scale, and unrealized_trade_pnl, replace the inline check (lines 769-784):
/* Old inline logic (REMOVE):
float trail_distance = 0.005f * vol_scale * trend_scale;
if (prev_sign != 0 && hold_time >= (float)min_hold_bars && peak_equity > 1.0f) {
float peak_return = ...
if (peak_return > trail_distance) {
if (unrealized_trade_pnl < trail_floor) { ... force exit ... }
}
}
*/
/* New shared call: */
if (check_trailing_stop(hold_time, min_hold_bars, peak_equity, prev_equity,
unrealized_trade_pnl, 0.005f, vol_scale, trend_scale)) {
/* Force exit — trailing stop triggered */
position = 0.0f;
float exit_cost = compute_tx_cost(ps[0], raw_close, tx_cost_multiplier, 0.0f,
max_position, 0, -1.0f);
cash = ps[1] + ps[0] * raw_close - exit_cost;
is_flat = 1.0f;
exiting_trade = 1;
}
Note: The exit logic (force flat, compute exit cost, update cash) stays inline because it interacts with training-specific variables (ps[], is_flat, exiting_trade). Only the DECISION (should we exit?) is shared.
- Step 3: Verify build
Run: SQLX_OFFLINE=true cargo check -p ml
Task 3: Wire training kernel hold enforcement to enforce_hold()
Files:
- Modify:
crates/ml/src/cuda_pipeline/experience_kernels.cu
This is the most delicate task. The training kernel's hold enforcement (lines 795-821) has EXTRA logic that enforce_hold() doesn't handle:
- Action aliasing fix: overrides
action_idxto match the held exposure - Sign recomputation after override
- Sets
wants_exit = 0,wants_reversal = 0
- Step 1: Use
enforce_hold()for the DECISION only
Replace the hold violation detection:
/* Old (lines 795-800):
int wants_exit = (prev_sign != 0 && curr_sign == 0);
int wants_reversal = (prev_sign != 0 && curr_sign != 0 && prev_sign != curr_sign);
int hold_violation = (hold_time > 0.0f
&& hold_time < (float)min_hold_bars
&& (wants_exit || wants_reversal)
&& bar_idx < total_bars - 1);
*/
/* New: use shared enforce_hold for the decision */
int is_last_bar = (bar_idx >= total_bars - 1) ? 1 : 0;
float enforced_position = enforce_hold(ps[0], position, hold_time, min_hold_bars, is_last_bar);
int hold_violation = (enforced_position != position);
/* Still need wants_exit and wants_reversal for downstream trade lifecycle */
int wants_exit = (prev_sign != 0 && curr_sign == 0);
int wants_reversal = (prev_sign != 0 && curr_sign != 0 && prev_sign != curr_sign);
- Step 2: Keep the action aliasing fix as-is
The block inside if (hold_violation) { ... } that fixes action_idx to match the held exposure STAYS. This is training-specific logic (action replay buffer aliasing) that the backtest doesn't need.
if (hold_violation) {
position = enforced_position; /* use the shared result */
cash = ps[1];
is_flat = (fabsf(position) < 0.001f) ? 1.0f : 0.0f;
/* Action aliasing fix: override stored action to match held exposure */
float prev_exposure_frac = ps[0] / (max_position > 0.0f ? max_position : 1.0f);
int held_exposure = (int)roundf((prev_exposure_frac + 1.0f) * 0.5f * (float)(b0_size - 1));
/* ... rest of aliasing logic stays ... */
curr_sign = prev_sign;
wants_exit = 0;
wants_reversal = 0;
}
- Step 3: Verify build and test
Run: SQLX_OFFLINE=true cargo check -p ml
Then run smoke test:
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
Expected: Sharpe > 0, trades > 0, no SIGSEGV.
Validation
After all 3 tasks, run the full suite:
# Unit tests
SQLX_OFFLINE=true cargo test -p ml --lib -- hyperopt::adapters::dqn::tests --nocapture
# Smoke test (training path)
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 (backtest path)
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
Success criteria:
- All unit tests pass
- Smoke test: Sharpe > 0, no SIGSEGV
- Hyperopt: 2 trials complete, max_dd < 30%, finite metrics
grep -c "execute_trade\|enforce_hold\|check_trailing_stop\|check_capital_floor\|decode_exposure_index\|compute_target_position\|decode_order_type\|compute_tx_cost\|apply_margin_cap\|update_hold_time" crates/ml/src/cuda_pipeline/experience_kernels.cushows ALL shared functions called- Zero inline duplicates of shared logic remain