The hardcoded WeightedByRealizedSharpe path from C7 is now joined by
a stack-based bytecode interpreter that consumes Strategy::flatten()
output, unlocking RegimeSwitch / Portfolio / non-default Ensemble /
single-Leaf compositions specified via policy-grid YAML.
cuda/decision_policy.cu — new kernel `decision_policy_program`:
Stack-based VM with parallel (value, attribution_mask) stacks.
Opcodes mirror src/policy/mod.rs::OpCode exactly:
NoOp / PushScalar / EvalRegime / BranchIfRegime
EmitPerHorizonSize (computes sized intent from alpha[h] +
IsvKellyState[h] using same Kelly + ISV-cap formula as the
hardcoded default)
AggMean / AggWeightedSharpe / AggMaxConfidence (pop n values,
push aggregated, OR attribution masks)
ApplyConflict (v1 no-op, reserved for Portfolio)
WriteOrder (terminal — converts top-of-stack to market_target +
open_horizon_masks attribution if currently flat)
AggWeightedSharpe recovers the source horizon from a single-bit
attribution mask to look up recent_sharpe; multi-bit masks (nested
aggregators that collapsed horizons) fall back to uniform weight.
decision_policy_default extended with a program_lens param: skips any
backtest whose plen > 0 (the program kernel handled it). The two
kernels run sequentially in step_decision_with_latency with mutual
exclusivity on each backtest slot.
LobSimCuda gains:
upload_program(b, &Program) — uploads a Strategy::flatten() output
to backtest b's slot in program_table_d, updates program_lens_d.
set_regime(b, regime_id) — writes regimes_d for OP_EVAL_REGIME /
OP_BRANCH_IF_REGIME consumption.
BacktestHarnessConfig.strategies (Vec<Strategy>) — empty means every
cell uses the hardcoded default; non-empty len must equal n_parallel
and each strategy is flattened + uploaded at construction.
bin/fxt-backtest --policy-grid <yaml> path now actually plumbs through
to the kernel (was parsed-but-ignored in C9). Empty grid keeps the
default behaviour.
New fixture decision_program_h4_only: uploads Strategy::Leaf(h4_only)
flattened to (EmitPerHorizonSize, WriteOrder) — 2 instructions, 16
bytes — and verifies the bytecode kernel produces equivalent end-state
to the hardcoded default (3 lot buy at vwap=5500). Proves the VM
dispatcher works end-to-end.
12/12 GPU fixtures green. 33 lib unit tests + 3 Ring 2 fuzz tests
still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
381 lines
16 KiB
Plaintext
381 lines
16 KiB
Plaintext
// decision_policy.cu — alpha → per-horizon Kelly → aggregate → market target.
|
||
//
|
||
// Hardcoded v1 policy = Strategy::default_for(max_lots) from C3:
|
||
// for each h in 0..N_HORIZONS:
|
||
// compute signed sizing from (alpha[h], IsvKellyState[h])
|
||
// aggregate via WeightedByRealizedSharpe:
|
||
// w[h] = max(0, recent_sharpe[h]) / sum_of_positive_sharpes
|
||
// final = sum_h w[h] * signed_size[h]
|
||
// final → side+size in market_targets[b]
|
||
//
|
||
// The bytecode VM from spec §6 is intentionally not implemented in v1 —
|
||
// the default Strategy::default_for produces exactly this shape and
|
||
// alternative compositions are deferred. Bytecode plumbing remains in
|
||
// src/policy/mod.rs ready for v2 expansion.
|
||
//
|
||
// Single-writer per block (thread 0). See spec §5.
|
||
|
||
#include "lob_state.cuh"
|
||
|
||
extern "C" __global__ void decision_policy_default(
|
||
const float* __restrict__ alpha_probs, // [N_HORIZONS] broadcast
|
||
const unsigned char* __restrict__ isv_kelly_base, // [n_backtests * N_HORIZONS * sizeof(IsvKellyState)]
|
||
const Pos* __restrict__ positions, // [n_backtests]
|
||
const unsigned int* __restrict__ program_lens, // [n_backtests] — non-zero ⇒ skip (program kernel handles it)
|
||
int* __restrict__ market_targets, // [n_backtests * 2] — written
|
||
unsigned int* __restrict__ open_horizon_masks, // [n_backtests] — written (entry attribution)
|
||
float target_annual_vol_units,
|
||
float annualisation_factor,
|
||
int max_lots,
|
||
int n_backtests
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
if (program_lens[b] != 0u) return; // backtest b uses a custom bytecode program; skip default
|
||
|
||
const IsvKellyState* isv = reinterpret_cast<const IsvKellyState*>(
|
||
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
|
||
);
|
||
|
||
float signed_sizes[N_HORIZONS];
|
||
float weights[N_HORIZONS];
|
||
unsigned int attribution_mask = 0;
|
||
float w_sum = 0.0f;
|
||
|
||
#pragma unroll
|
||
for (int h = 0; h < N_HORIZONS; ++h) {
|
||
const float p_h = alpha_probs[h];
|
||
const IsvKellyState& s = isv[h];
|
||
|
||
// Per-spec §5: no floor, cap derives from ISV.
|
||
// If pnl_ema_win is sentinel (0), no kelly_frac estimate available.
|
||
float ss = 0.0f;
|
||
if (s.pnl_ema_win > 1e-9f && s.realised_return_var > 1e-9f) {
|
||
const float sig_mag = fabsf(p_h - 0.5f) * 2.0f; // ∈ [0, 1]
|
||
const float dir = (p_h > 0.5f) ? 1.0f : -1.0f;
|
||
// Kelly fraction; clamp [0, 1].
|
||
float kelly_frac = (s.win_rate_ema * s.pnl_ema_win
|
||
- (1.0f - s.win_rate_ema) * s.pnl_ema_loss)
|
||
/ s.pnl_ema_win;
|
||
kelly_frac = fmaxf(0.0f, fminf(1.0f, kelly_frac));
|
||
// Cap units from realized variance + target vol budget.
|
||
const float cap_units = target_annual_vol_units
|
||
/ sqrtf(s.realised_return_var * annualisation_factor);
|
||
const float cap_lots = fminf(cap_units, (float)max_lots);
|
||
ss = dir * sig_mag * kelly_frac * cap_lots;
|
||
}
|
||
signed_sizes[h] = ss;
|
||
const float w = fmaxf(0.0f, s.recent_sharpe);
|
||
weights[h] = w;
|
||
w_sum += w;
|
||
}
|
||
|
||
float final_size = 0.0f;
|
||
if (w_sum > 1e-9f) {
|
||
#pragma unroll
|
||
for (int h = 0; h < N_HORIZONS; ++h) {
|
||
const float w_norm = weights[h] / w_sum;
|
||
final_size += w_norm * signed_sizes[h];
|
||
// Attribute the open to any horizon whose weight contributed.
|
||
if (weights[h] > 1e-9f) attribution_mask |= (1u << h);
|
||
}
|
||
}
|
||
|
||
// Round-to-nearest (truncation alone bites here: 0.8(f32) * 0.75 * 5.0
|
||
// = 2.99999976 → (int)2, off by one. Floor truncation for sub-1
|
||
// suppression is preserved via the explicit |lots| < 1 check.
|
||
int lots = (int)truncf(final_size + (final_size >= 0.0f ? 0.5f : -0.5f));
|
||
if (lots == 0) {
|
||
market_targets[b * 2 + 0] = 2; // no-op
|
||
market_targets[b * 2 + 1] = 0;
|
||
return;
|
||
}
|
||
|
||
// If currently flat, this is an opening trade: record attribution mask
|
||
// so pnl_track + isv_kelly_update can credit the right horizons on close.
|
||
if (positions[b].position_lots == 0) {
|
||
open_horizon_masks[b] = attribution_mask;
|
||
}
|
||
|
||
const int side = (lots > 0) ? 0 : 1;
|
||
const int abs_sz = (lots > 0) ? lots : -lots;
|
||
market_targets[b * 2 + 0] = side;
|
||
market_targets[b * 2 + 1] = abs_sz;
|
||
}
|
||
|
||
// Bytecode VM — interprets the flat Program emitted by Strategy::flatten()
|
||
// (Rust src/policy/mod.rs) against current alpha probs + per-horizon
|
||
// IsvKellyState. Replaces the hardcoded default policy when the host
|
||
// has uploaded a non-empty program for backtest b.
|
||
//
|
||
// Stack layout: parallel float-value + u32-attribution-mask stacks.
|
||
// Each EmitPerHorizonSize pushes its (signed_size, mask=(1<<h)). The
|
||
// aggregators pop n values + n masks, push the aggregated value with
|
||
// OR'd mask. The terminal WriteOrder pops one value+mask, converts to
|
||
// {side, abs_size} in market_targets[b] and records the attribution
|
||
// mask in open_horizon_masks[b] for entry-time crediting.
|
||
//
|
||
// Layout MUST stay in sync with src/policy/mod.rs::{OpCode, Instruction}.
|
||
|
||
#define OP_NOOP 0
|
||
#define OP_EVAL_REGIME 1
|
||
#define OP_EMIT_PER_HORIZON_SIZE 2
|
||
#define OP_AGG_WEIGHTED_SHARPE 3
|
||
#define OP_AGG_MEAN 4
|
||
#define OP_AGG_MAX_CONFIDENCE 5
|
||
#define OP_APPLY_CONFLICT 6
|
||
#define OP_WRITE_ORDER 7
|
||
#define OP_PUSH_SCALAR 8
|
||
#define OP_BRANCH_IF_REGIME 9
|
||
#define STACK_CAP 32
|
||
|
||
struct Instruction {
|
||
unsigned char op;
|
||
unsigned char arg0;
|
||
unsigned short arg1;
|
||
unsigned int arg2;
|
||
};
|
||
|
||
extern "C" __global__ void decision_policy_program(
|
||
const float* __restrict__ alpha_probs,
|
||
const unsigned char* __restrict__ isv_kelly_base,
|
||
const Pos* __restrict__ positions,
|
||
const Instruction* __restrict__ programs, // [n_backtests * max_instructions]
|
||
const unsigned int* __restrict__ program_lens, // [n_backtests]
|
||
const unsigned int* __restrict__ regimes, // [n_backtests] — current regime id
|
||
int* __restrict__ market_targets, // [n_backtests * 2]
|
||
unsigned int* __restrict__ open_horizon_masks,
|
||
float target_annual_vol_units,
|
||
float annualisation_factor,
|
||
int max_instructions,
|
||
int max_lots,
|
||
int n_backtests
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
|
||
const unsigned int plen = program_lens[b];
|
||
if (plen == 0u) {
|
||
// Skip — this backtest uses the hardcoded default kernel separately.
|
||
return;
|
||
}
|
||
const Instruction* prog = programs + (size_t)b * max_instructions;
|
||
const IsvKellyState* isv = reinterpret_cast<const IsvKellyState*>(
|
||
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
|
||
);
|
||
const unsigned int my_regime = regimes[b];
|
||
|
||
float stack_val[STACK_CAP];
|
||
unsigned int stack_mask[STACK_CAP];
|
||
int sp = 0;
|
||
|
||
unsigned int pc = 0;
|
||
while (pc < plen && pc < (unsigned int)max_instructions) {
|
||
const Instruction ins = prog[pc];
|
||
pc += 1;
|
||
switch (ins.op) {
|
||
case OP_NOOP: break;
|
||
case OP_PUSH_SCALAR: {
|
||
if (sp >= STACK_CAP) break;
|
||
const float v = __int_as_float((int)ins.arg2);
|
||
stack_val[sp] = v;
|
||
stack_mask[sp] = 0u;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_EVAL_REGIME: {
|
||
if (sp >= STACK_CAP) break;
|
||
stack_val[sp] = (my_regime == (unsigned int)ins.arg0) ? 1.0f : 0.0f;
|
||
stack_mask[sp] = 0u;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_BRANCH_IF_REGIME: {
|
||
if (my_regime != (unsigned int)ins.arg0) {
|
||
pc = ins.arg2;
|
||
}
|
||
break;
|
||
}
|
||
case OP_EMIT_PER_HORIZON_SIZE: {
|
||
if (sp >= STACK_CAP) break;
|
||
const int h = (int)ins.arg0;
|
||
const int leaf_max_lots = (int)ins.arg1;
|
||
if (h < 0 || h >= N_HORIZONS) { stack_val[sp]=0.0f; stack_mask[sp]=0u; sp+=1; break; }
|
||
const float p_h = alpha_probs[h];
|
||
const IsvKellyState& s = isv[h];
|
||
float ss = 0.0f;
|
||
if (s.pnl_ema_win > 1e-9f && s.realised_return_var > 1e-9f) {
|
||
const float sig_mag = fabsf(p_h - 0.5f) * 2.0f;
|
||
const float dir = (p_h > 0.5f) ? 1.0f : -1.0f;
|
||
float kelly_frac = (s.win_rate_ema * s.pnl_ema_win
|
||
- (1.0f - s.win_rate_ema) * s.pnl_ema_loss)
|
||
/ s.pnl_ema_win;
|
||
kelly_frac = fmaxf(0.0f, fminf(1.0f, kelly_frac));
|
||
const float cap_units = target_annual_vol_units
|
||
/ sqrtf(s.realised_return_var * annualisation_factor);
|
||
const float effective_cap = fminf((float)leaf_max_lots, (float)max_lots);
|
||
const float cap_lots = fminf(cap_units, effective_cap);
|
||
ss = dir * sig_mag * kelly_frac * cap_lots;
|
||
}
|
||
stack_val[sp] = ss;
|
||
stack_mask[sp] = 1u << h;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_AGG_MEAN: {
|
||
const int n = (int)ins.arg0;
|
||
if (n <= 0 || sp < n) break;
|
||
float sum = 0.0f;
|
||
unsigned int mask_or = 0u;
|
||
for (int i = sp - n; i < sp; ++i) {
|
||
sum += stack_val[i];
|
||
mask_or |= stack_mask[i];
|
||
}
|
||
sp -= n;
|
||
stack_val[sp] = sum / (float)n;
|
||
stack_mask[sp] = mask_or;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_AGG_WEIGHTED_SHARPE: {
|
||
const int n = (int)ins.arg0;
|
||
if (n <= 0 || sp < n) break;
|
||
float w_sum = 0.0f, wx_sum = 0.0f;
|
||
unsigned int mask_or = 0u;
|
||
for (int i = sp - n; i < sp; ++i) {
|
||
const unsigned int m = stack_mask[i];
|
||
// Look up recent_sharpe for the single horizon this slot
|
||
// attributes to. If the mask has multiple bits set (a
|
||
// nested aggregator already merged horizons) we fall back
|
||
// to uniform weight 1.0 because per-horizon attribution
|
||
// collapsed.
|
||
float w;
|
||
if (m != 0u && (m & (m - 1u)) == 0u) {
|
||
// Single-bit mask — recover horizon index.
|
||
int h = 0;
|
||
unsigned int mm = m;
|
||
while ((mm & 1u) == 0u && h < N_HORIZONS) { mm >>= 1; h += 1; }
|
||
w = fmaxf(0.0f, isv[h].recent_sharpe);
|
||
} else {
|
||
w = 1.0f;
|
||
}
|
||
w_sum += w;
|
||
wx_sum += w * stack_val[i];
|
||
mask_or |= m;
|
||
}
|
||
sp -= n;
|
||
stack_val[sp] = (w_sum > 1e-9f) ? (wx_sum / w_sum) : 0.0f;
|
||
stack_mask[sp] = mask_or;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_AGG_MAX_CONFIDENCE: {
|
||
const int n = (int)ins.arg0;
|
||
if (n <= 0 || sp < n) break;
|
||
int best = sp - n;
|
||
for (int i = sp - n + 1; i < sp; ++i) {
|
||
if (fabsf(stack_val[i]) > fabsf(stack_val[best])) best = i;
|
||
}
|
||
const float bv = stack_val[best];
|
||
const unsigned int bm = stack_mask[best];
|
||
sp -= n;
|
||
stack_val[sp] = bv;
|
||
stack_mask[sp] = bm;
|
||
sp += 1;
|
||
break;
|
||
}
|
||
case OP_APPLY_CONFLICT: {
|
||
// v1: no-op (mean of children already aggregated; conflict
|
||
// resolution only matters when multiple cells share state,
|
||
// which v1 doesn't). Preserved for v2 Portfolio mode.
|
||
break;
|
||
}
|
||
case OP_WRITE_ORDER: {
|
||
if (sp <= 0) break;
|
||
sp -= 1;
|
||
const float final_size = stack_val[sp];
|
||
const unsigned int attribution = stack_mask[sp];
|
||
int lots = (int)truncf(final_size + (final_size >= 0.0f ? 0.5f : -0.5f));
|
||
if (lots == 0) {
|
||
market_targets[b * 2 + 0] = 2; // no-op
|
||
market_targets[b * 2 + 1] = 0;
|
||
} else {
|
||
if (positions[b].position_lots == 0) {
|
||
open_horizon_masks[b] = attribution;
|
||
}
|
||
const int side = (lots > 0) ? 0 : 1;
|
||
const int abs_sz = (lots > 0) ? lots : -lots;
|
||
market_targets[b * 2 + 0] = side;
|
||
market_targets[b * 2 + 1] = abs_sz;
|
||
}
|
||
break;
|
||
}
|
||
default: break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Updates per-horizon IsvKellyState[h] for every horizon flagged in
|
||
// `closed_horizon_mask[b]`. Called by the host after pnl_track_step
|
||
// detects close transitions. `realised_return[b]` is the closed-trade
|
||
// per-lot return in price units (positive = win, negative = loss).
|
||
//
|
||
// Wiener-α floor 0.4 per pearl_wiener_alpha_floor_for_nonstationary.md.
|
||
// Welford variance updates a running estimate around mean 0 (simplified;
|
||
// v2 may switch to ema-centered).
|
||
//
|
||
// recent_sharpe composite re-computed from the freshly updated EMAs.
|
||
extern "C" __global__ void isv_kelly_update_on_close(
|
||
unsigned char* isv_kelly_base,
|
||
const unsigned int* closed_horizon_mask, // [n_backtests]
|
||
const float* realised_return, // [n_backtests]
|
||
int n_backtests
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
const unsigned int mask = closed_horizon_mask[b];
|
||
if (mask == 0u) return;
|
||
|
||
const float ret = realised_return[b];
|
||
IsvKellyState* isv = reinterpret_cast<IsvKellyState*>(
|
||
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
|
||
);
|
||
|
||
const float alpha = 0.4f; // Wiener-α floor (non-stationary policy P&L)
|
||
|
||
#pragma unroll
|
||
for (int h = 0; h < N_HORIZONS; ++h) {
|
||
if (!(mask & (1u << h))) continue;
|
||
IsvKellyState& s = isv[h];
|
||
const bool win = ret > 0.0f;
|
||
|
||
if (s.n_trades_seen == 0u) {
|
||
// First-observation bootstrap: replace EMAs directly (no zero-bias warmup).
|
||
s.pnl_ema_win = win ? ret : 0.0f;
|
||
s.pnl_ema_loss = win ? 0.0f : -ret;
|
||
s.win_rate_ema = win ? 1.0f : 0.0f;
|
||
s.realised_return_var = ret * ret; // single-sample variance proxy
|
||
} else {
|
||
if (win) {
|
||
s.pnl_ema_win = (1.0f - alpha) * s.pnl_ema_win + alpha * ret;
|
||
s.win_rate_ema = (1.0f - alpha) * s.win_rate_ema + alpha * 1.0f;
|
||
} else {
|
||
s.pnl_ema_loss = (1.0f - alpha) * s.pnl_ema_loss + alpha * (-ret);
|
||
s.win_rate_ema = (1.0f - alpha) * s.win_rate_ema + alpha * 0.0f;
|
||
}
|
||
// Welford-ish running variance around 0.
|
||
const float prev_n = (float)s.n_trades_seen;
|
||
s.realised_return_var =
|
||
(prev_n * s.realised_return_var + ret * ret) / (prev_n + 1.0f);
|
||
}
|
||
s.n_trades_seen += 1u;
|
||
|
||
// Composite recent_sharpe = (mean_winning_contribution − mean_losing_contribution)
|
||
// / sqrt(realised_return_var)
|
||
const float numerator = s.pnl_ema_win * s.win_rate_ema
|
||
- s.pnl_ema_loss * (1.0f - s.win_rate_ema);
|
||
const float denom = sqrtf(fmaxf(s.realised_return_var, 1e-9f));
|
||
s.recent_sharpe = numerator / denom;
|
||
}
|
||
}
|