Files
foxhunt/crates/ml-backtesting/cuda/decision_policy.cu
jgrusewski ed19985c95 feat(ml-backtesting): bytecode VM dispatcher for Strategy compositions (C13)
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>
2026-05-18 09:26:22 +02:00

381 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}