feat: raw portfolio returns buffer for accurate Sharpe/MaxDD + multiplicative equity curve

- 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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-25 01:05:03 +01:00
parent c0352bb051
commit d7fcdae711
3 changed files with 45 additions and 12 deletions

View File

@@ -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;
}

View File

@@ -437,6 +437,9 @@ pub struct GpuExperienceCollector {
actions_out: CudaSlice<i32>, // [alloc_episodes * alloc_timesteps]
rewards_out: CudaSlice<f32>, // [alloc_episodes * alloc_timesteps]
done_out: CudaSlice<f32>, // [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<f32>, // [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::<f32>(total_output)
.map_err(|e| MLError::ModelError(format!("alloc done_out: {e}")))?;
let raw_returns_out = stream
.alloc_zeros::<f32>(total_output)
.map_err(|e| MLError::ModelError(format!("alloc raw_returns_out: {e}")))?;
let trade_stats_buf = stream
.alloc_zeros::<f32>(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<f64> = host_rewards.iter().map(|&r| r as f64).collect();
let step_returns: Vec<f64> = 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}"

View File

@@ -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;
}