From d7fcdae71177d5fb42116346243535ccbb73f11f Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Wed, 25 Mar 2026 01:05:03 +0100 Subject: [PATCH] feat: raw portfolio returns buffer for accurate Sharpe/MaxDD + multiplicative equity curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added raw_returns_out GPU buffer alongside rewards_out in experience kernel - Portfolio return = (equity_t - equity_{t-1}) / equity_{t-1} per bar (no shaping) - collect_trade_stats() downloads raw returns (not RL rewards) for financials - MaxDD now uses multiplicative compounding: equity *= (1 + r_t) - total_return computed from compounded equity curve Before: MaxDD 94-2213% (using shaped rewards). After: MaxDD 17-32% (honest). The model shows -15% return per epoch with PF 0.7-0.9 — no edge yet, needs H100 hyperopt. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cuda_pipeline/experience_kernels.cu | 16 +++++++++++++- .../cuda_pipeline/gpu_experience_collector.rs | 21 ++++++++++++++----- crates/ml/src/trainers/dqn/financials.rs | 20 ++++++++++++------ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/crates/ml/src/cuda_pipeline/experience_kernels.cu b/crates/ml/src/cuda_pipeline/experience_kernels.cu index 008ac7f05..794d8961e 100644 --- a/crates/ml/src/cuda_pipeline/experience_kernels.cu +++ b/crates/ml/src/cuda_pipeline/experience_kernels.cu @@ -485,6 +485,7 @@ extern "C" __global__ void experience_action_select( * @param current_t timestep index (0-based) in this batch * @param cvar_scales [N] or NULL CVaR position scaling * @param q_gaps [N] or NULL Q-gap conviction scaling + * @param raw_returns_out [N, L] or NULL true per-bar portfolio return (unshaped) */ extern "C" __global__ void experience_env_step( const float* __restrict__ targets, @@ -511,7 +512,8 @@ extern "C" __global__ void experience_env_step( int b2_size, int current_t, const float* __restrict__ cvar_scales, /* [N] or NULL — CVaR position scaling */ - const float* __restrict__ q_gaps /* [N] or NULL — Q-gap conviction scaling */ + const float* __restrict__ q_gaps, /* [N] or NULL — Q-gap conviction scaling */ + float* raw_returns_out /* [N, L] output: true per-bar portfolio return (unshapen) */ ) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i >= N) return; @@ -990,6 +992,18 @@ extern "C" __global__ void experience_env_step( out_rewards[out_off] = reward; out_dones[out_off] = (float)done; + /* ---- Write RAW portfolio return (unshaped) for accurate Sharpe/MaxDD ---- */ + /* True fractional return: (equity_t - equity_{t-1}) / equity_{t-1}. + * No dense/sparse weighting, no loss_aversion, no patience multiplier. + * This is what risk management (Sharpe, Sortino, MaxDD, VaR) must use. */ + if (raw_returns_out != NULL) { + float portfolio_return = (prev_equity > 1.0f) + ? (new_portfolio_value - prev_equity) / prev_equity + : 0.0f; + if (isnan(portfolio_return) || isinf(portfolio_return)) portfolio_return = 0.0f; + raw_returns_out[out_off] = portfolio_return; + } + /* ---- Advance timestep counter ---- */ current_timesteps[i] = t + 1; } diff --git a/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs b/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs index 5d8b03129..e6f9e1e14 100644 --- a/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs +++ b/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs @@ -437,6 +437,9 @@ pub struct GpuExperienceCollector { actions_out: CudaSlice, // [alloc_episodes * alloc_timesteps] rewards_out: CudaSlice, // [alloc_episodes * alloc_timesteps] done_out: CudaSlice, // [alloc_episodes * alloc_timesteps] + /// Raw per-bar portfolio returns (unshaped) for accurate Sharpe/MaxDD/Sortino. + /// True fractional return: (equity_t - equity_{t-1}) / equity_{t-1}. + raw_returns_out: CudaSlice, // [alloc_episodes * alloc_timesteps] /// Running reward statistics for cross-epoch normalization (Welford's online algorithm) @@ -714,6 +717,9 @@ impl GpuExperienceCollector { let done_out = stream .alloc_zeros::(total_output) .map_err(|e| MLError::ModelError(format!("alloc done_out: {e}")))?; + let raw_returns_out = stream + .alloc_zeros::(total_output) + .map_err(|e| MLError::ModelError(format!("alloc raw_returns_out: {e}")))?; let trade_stats_buf = stream .alloc_zeros::(TRADE_STATS_FLOATS) .map_err(|e| MLError::ModelError(format!("alloc trade_stats_buf: {e}")))?; @@ -867,6 +873,7 @@ impl GpuExperienceCollector { actions_out, rewards_out, done_out, + raw_returns_out, epoch_state, reset_flags: 0, expert_actions_gpu: None, @@ -962,14 +969,17 @@ impl GpuExperienceCollector { let sum_sq_returns = host_stats[5] as f64; let total_trades = (win_count + loss_count) as usize; - // Download rewards_out for step_returns (once per epoch, cold path). + // Download raw_returns_out for step_returns (once per epoch, cold path). + // raw_returns_out contains TRUE per-bar portfolio returns (unshaped): + // (equity_t - equity_{t-1}) / equity_{t-1} + // NOT the shaped RL reward (which includes dense*0.1 + sparse*2.0 + loss_aversion). // Total = alloc_episodes * alloc_timesteps floats (~12KB for 3200 experiences). let total_output = self.alloc_episodes * self.alloc_timesteps; - let mut host_rewards = vec![0.0_f32; total_output]; - self.stream.memcpy_dtoh(&self.rewards_out, &mut host_rewards) - .map_err(|e| MLError::ModelError(format!("rewards_out DtoH: {e}")))?; + let mut host_raw_returns = vec![0.0_f32; total_output]; + self.stream.memcpy_dtoh(&self.raw_returns_out, &mut host_raw_returns) + .map_err(|e| MLError::ModelError(format!("raw_returns_out DtoH: {e}")))?; - let step_returns: Vec = host_rewards.iter().map(|&r| r as f64).collect(); + let step_returns: Vec = host_raw_returns.iter().map(|&r| r as f64).collect(); Ok(TradeStats { total_trades, @@ -1309,6 +1319,7 @@ impl GpuExperienceCollector { .arg(&t_i32) .arg(&self.cvar_scales_ptr) // CVaR position scaling (0 = NULL = no scaling) .arg(&self.q_gaps_buf) // Q-gap conviction scaling + .arg(&mut self.raw_returns_out) // Raw portfolio returns (unshaped) for Sharpe/MaxDD .launch(launch_cfg) .map_err(|e| MLError::ModelError(format!( "experience_env_step t={t}: {e}" diff --git a/crates/ml/src/trainers/dqn/financials.rs b/crates/ml/src/trainers/dqn/financials.rs index 3ca06a56e..26c1026db 100644 --- a/crates/ml/src/trainers/dqn/financials.rs +++ b/crates/ml/src/trainers/dqn/financials.rs @@ -52,10 +52,18 @@ pub(crate) fn compute_epoch_financials( 0.0 }; - // Total and average return from trade returns - let total_return = trade_stats.sum_returns; + // Total return: compound all step_returns into final equity, then compute return + // (sum_returns from trades is NOT the portfolio return — it's the sum of individual + // trade returns which double-counts overlapping bar returns) + let total_return = { + let mut eq = 1.0_f64; + for &ret in &trade_stats.step_returns { + eq *= 1.0 + ret; + } + eq - 1.0 // fractional total return + }; let avg_return = if total_trades > 0 { - trade_stats.sum_returns / total_trades as f64 + total_return / total_trades as f64 } else { 0.0 }; @@ -94,13 +102,13 @@ pub(crate) fn compute_epoch_financials( (0.0, 0.0) }; - // MaxDD from step_returns equity curve + // MaxDD from step_returns equity curve (multiplicative compounding) + // step_returns are fractional per-bar returns: (equity_t - equity_{t-1}) / equity_{t-1} let mut equity = initial_capital; let mut peak = equity; let mut max_dd = 0.0_f64; for &ret in returns { - // ret is a fractional per-bar return; convert to dollar PnL - equity += ret * initial_capital; + equity *= 1.0 + ret; // multiplicative: equity_t = equity_{t-1} × (1 + r_t) if equity > peak { peak = equity; }