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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user