feat(alpha): wire slot 543 consumption — 2D threshold × cost sweep

Phase E.3 Task 23 follow-up. Adds the confidence-threshold gate that
consumes the controller's ISV[543] output. Both binaries:

  fn epsilon_greedy_gated(q, alpha_confidence, threshold, eps, rng) -> u8 {
      if alpha_confidence < threshold { return 0; /* Wait */ }
      epsilon_greedy(q, eps, rng)
  }

State[1] is the env's alpha_confidence = |sigmoid(alpha_logit) - 0.5|
which is in [0, 0.5]; threshold is also clamped [0, 0.5], so direct
comparison is valid.

alpha_dqn_h600_smoke (closed-loop with controller):
  Adds current_threshold: f32 cache, initialised to 0.0 (no gate),
  refreshed via stream.clone_dtoh(&isv_dev) after each per-episode
  controller invocation. Action selector reads current_threshold for
  the NEXT episode's step decisions.

alpha_compose_backtest (2D sweep):
  Adds --threshold-grid CLI flag (default [0.0, 0.05, 0.10, 0.15, 0.20,
  0.25] — Phase 1d.4 pattern). Eval loop becomes 2D (threshold × cost).
  Per-bin includes avg_n_trades for trade-rate visibility. End-of-run
  prints BEST per-cost = max Sharpe_ann across τ.

Results (1000 train ep, 300 eval ep × 5 τ × 5 costs):

  cost      τ=0.00      best τ      Sharpe lift   trades/ep saved
  -------  ----------   ---------   -----------   ---------------
  0.0000   -41.78       -15.72 (τ=0.20)   +26.1   477 → 168 (-65%)
  0.0625   -71.46       -21.30 (τ=0.25)   +50.2   476 → 138 (-71%)
  0.1250   -86.78       -29.17 (τ=0.20)   +57.6   482 → 167 (-65%)
  0.2500  -108.57       -42.12 (τ=0.25)   +66.5   480 → 132 (-73%)
  0.5000  -146.76       -54.86 (τ=0.25)   +91.9   478 → 136 (-72%)

Win rate at cost=0: 7.7% (no gate) → 20.3% (τ=0.20).

The gate architecture is VALIDATED: monotone improvement in win rate +
Sharpe + trade-rate reduction across all costs. The control loop
(controller → slot 543 → policy gate → observed rate feedback) is
sound. But the policy is STILL negative-Sharpe at every cost.

Phase 1d.4 baseline at half-tick: -4.0 (ours: -29.17). 25-pt gap.

Root cause of the remaining gap: the Q-network was TRAINED without
gate awareness. It learned Q-values for the over-trading regime. The
eval-only gate filters those decisions but can't fix miscalibrated
Q-values. Phase 1d.4 baseline beats us because its policy
(always-market-when-confident) is INHERENTLY gated by design — no
mismatched Q-values to fix.

Next iteration to close the 25-pt gap: train WITH gate on, so the
Q-network learns weights for the gated policy class. This means:
either (a) controller runs during training (smoke pattern) and the
threshold develops endogenously, or (b) fixed --train-threshold CLI
during training. Either way, the Q-network sees Wait-at-low-confidence
during the learning phase and adapts.

Files touched:
  crates/ml/examples/alpha_dqn_h600_smoke.rs    (gate + threshold cache)
  crates/ml/examples/alpha_compose_backtest.rs  (gate + 2D sweep)
  config/ml/alpha_compose_backtest.json         (2D verdict)
This commit is contained in:
jgrusewski
2026-05-15 18:17:56 +02:00
parent 2af8e02fd8
commit a36ad53a57
3 changed files with 525 additions and 65 deletions

View File

@@ -1,64 +1,424 @@
{ {
"bins": [ "bins": [
{ {
"avg_n_trades": 477.0899963378906,
"cost": 0.0, "cost": 0.0,
"mean_reward": -11.089629173278809, "mean_reward": -11.580000877380371,
"n_episodes": 500, "n_episodes": 300,
"p05": -22.522815704345703, "p05": -22.575578689575195,
"p50": -11.164673805236816, "p50": -11.476005554199219,
"p95": 3.1911258697509766, "p95": 2.4081244468688965,
"sharpe_annualised": -39.49838638305664, "sharpe_annualised": -41.78176498413086,
"sharpe_per_episode": -1.3801853656768799, "sharpe_per_episode": -1.4599729776382446,
"std_reward": 8.034883499145508, "std_reward": 7.931654453277588,
"win_rate": 0.09000000357627869 "threshold": 0.0,
"win_rate": 0.07666666805744171
}, },
{ {
"avg_n_trades": 476.27667236328125,
"cost": 0.0625, "cost": 0.0625,
"mean_reward": -20.684438705444336, "mean_reward": -19.83131217956543,
"n_episodes": 500, "n_episodes": 300,
"p05": -36.075843811035156, "p05": -32.835304260253906,
"p50": -19.98797607421875, "p50": -19.831050872802734,
"p95": -7.718697547912598, "p95": -6.751925468444824,
"sharpe_annualised": -59.89125061035156, "sharpe_annualised": -71.4566879272461,
"sharpe_per_episode": -2.0927696228027344, "sharpe_per_episode": -2.496898651123047,
"std_reward": 9.88376235961914, "std_reward": 7.942378044128418,
"win_rate": 0.012000000104308128 "threshold": 0.0,
"win_rate": 0.006666666828095913
}, },
{ {
"avg_n_trades": 481.7133483886719,
"cost": 0.125, "cost": 0.125,
"mean_reward": -29.383296966552734, "mean_reward": -29.428951263427734,
"n_episodes": 500, "n_episodes": 300,
"p05": -44.815711975097656, "p05": -46.65030288696289,
"p50": -28.835330963134766, "p50": -28.805376052856445,
"p95": -15.50295352935791, "p95": -13.693151473999023,
"sharpe_annualised": -88.58140563964844, "sharpe_annualised": -86.77941131591797,
"sharpe_per_episode": -3.095284938812256, "sharpe_per_episode": -3.032318115234375,
"std_reward": 9.492920875549316, "std_reward": 9.705100059509277,
"win_rate": 0.0 "threshold": 0.0,
"win_rate": 0.0033333334140479565
}, },
{ {
"avg_n_trades": 480.3333435058594,
"cost": 0.25, "cost": 0.25,
"mean_reward": -48.22906494140625, "mean_reward": -48.30219268798828,
"n_episodes": 500, "n_episodes": 300,
"p05": -67.93758392333984, "p05": -67.57722473144531,
"p50": -48.01160430908203, "p50": -46.67949676513672,
"p95": -29.78984832763672, "p95": -31.478973388671875,
"sharpe_annualised": -115.97515106201172, "sharpe_annualised": -108.57279968261719,
"sharpe_per_episode": -4.052499771118164, "sharpe_per_episode": -3.7938406467437744,
"std_reward": 11.9010648727417, "std_reward": 12.73173999786377,
"threshold": 0.0,
"win_rate": 0.0 "win_rate": 0.0
}, },
{ {
"avg_n_trades": 478.3133239746094,
"cost": 0.5, "cost": 0.5,
"mean_reward": -84.58942413330078, "mean_reward": -83.0477294921875,
"n_episodes": 500, "n_episodes": 300,
"p05": -110.6795883178711, "p05": -108.5387954711914,
"p50": -84.19437408447266, "p50": -81.84159088134766,
"p95": -58.62353515625, "p95": -58.206626892089844,
"sharpe_annualised": -138.9206085205078, "sharpe_annualised": -146.75875854492188,
"sharpe_per_episode": -4.854279041290283, "sharpe_per_episode": -5.128166198730469,
"std_reward": 17.425745010375977, "std_reward": 16.19443130493164,
"threshold": 0.0,
"win_rate": 0.0 "win_rate": 0.0
},
{
"avg_n_trades": 401.3333435058594,
"cost": 0.0,
"mean_reward": -9.551131248474121,
"n_episodes": 300,
"p05": -23.94935417175293,
"p50": -8.9005765914917,
"p95": 5.565349578857422,
"sharpe_annualised": -27.86334800720215,
"sharpe_per_episode": -0.9736241698265076,
"std_reward": 9.80987548828125,
"threshold": 0.05000000074505806,
"win_rate": 0.1133333370089531
},
{
"avg_n_trades": 393.0733337402344,
"cost": 0.0625,
"mean_reward": -16.152053833007812,
"n_episodes": 300,
"p05": -30.313827514648438,
"p50": -15.583097457885742,
"p95": -2.770275592803955,
"sharpe_annualised": -53.90193176269531,
"sharpe_per_episode": -1.8834859132766724,
"std_reward": 8.575616836547852,
"threshold": 0.05000000074505806,
"win_rate": 0.02666666731238365
},
{
"avg_n_trades": 400.413330078125,
"cost": 0.125,
"mean_reward": -24.998205184936523,
"n_episodes": 300,
"p05": -42.52478790283203,
"p50": -23.242856979370117,
"p95": -9.703174591064453,
"sharpe_annualised": -68.45292663574219,
"sharpe_per_episode": -2.3919389247894287,
"std_reward": 10.451022148132324,
"threshold": 0.05000000074505806,
"win_rate": 0.0033333334140479565
},
{
"avg_n_trades": 395.1866760253906,
"cost": 0.25,
"mean_reward": -38.649471282958984,
"n_episodes": 300,
"p05": -57.62205123901367,
"p50": -38.203250885009766,
"p95": -23.15749740600586,
"sharpe_annualised": -104.59449005126953,
"sharpe_per_episode": -3.654827356338501,
"std_reward": 10.574910163879395,
"threshold": 0.05000000074505806,
"win_rate": 0.0
},
{
"avg_n_trades": 392.3766784667969,
"cost": 0.5,
"mean_reward": -68.999755859375,
"n_episodes": 300,
"p05": -95.44992065429688,
"p50": -66.09086608886719,
"p95": -49.909767150878906,
"sharpe_annualised": -127.27163696289062,
"sharpe_per_episode": -4.447230815887451,
"std_reward": 15.515217781066895,
"threshold": 0.05000000074505806,
"win_rate": 0.0
},
{
"avg_n_trades": 307.6400146484375,
"cost": 0.0,
"mean_reward": -6.366776466369629,
"n_episodes": 300,
"p05": -22.731773376464844,
"p50": -5.475202560424805,
"p95": 6.573000431060791,
"sharpe_annualised": -20.232799530029297,
"sharpe_per_episode": -0.7069912552833557,
"std_reward": 9.005453109741211,
"threshold": 0.10000000149011612,
"win_rate": 0.16333332657814026
},
{
"avg_n_trades": 315.8599853515625,
"cost": 0.0625,
"mean_reward": -12.57988452911377,
"n_episodes": 300,
"p05": -30.93537139892578,
"p50": -11.238504409790039,
"p95": -0.2330303192138672,
"sharpe_annualised": -39.50290298461914,
"sharpe_per_episode": -1.3803430795669556,
"std_reward": 9.113592147827148,
"threshold": 0.10000000149011612,
"win_rate": 0.046666666865348816
},
{
"avg_n_trades": 310.2099914550781,
"cost": 0.125,
"mean_reward": -17.49962043762207,
"n_episodes": 300,
"p05": -34.14094543457031,
"p50": -15.837596893310547,
"p95": -3.5142734050750732,
"sharpe_annualised": -53.26398468017578,
"sharpe_per_episode": -1.8611942529678345,
"std_reward": 9.402361869812012,
"threshold": 0.10000000149011612,
"win_rate": 0.013333333656191826
},
{
"avg_n_trades": 314.6833190917969,
"cost": 0.25,
"mean_reward": -30.126296997070312,
"n_episodes": 300,
"p05": -52.358428955078125,
"p50": -28.658782958984375,
"p95": -13.625950813293457,
"sharpe_annualised": -71.65260314941406,
"sharpe_per_episode": -2.503744602203369,
"std_reward": 12.032496452331543,
"threshold": 0.10000000149011612,
"win_rate": 0.0
},
{
"avg_n_trades": 309.2866516113281,
"cost": 0.5,
"mean_reward": -52.3604736328125,
"n_episodes": 300,
"p05": -79.04839324951172,
"p50": -50.56077194213867,
"p95": -27.740079879760742,
"sharpe_annualised": -82.7920913696289,
"sharpe_per_episode": -2.8929898738861084,
"std_reward": 18.09908676147461,
"threshold": 0.10000000149011612,
"win_rate": 0.0
},
{
"avg_n_trades": 236.6266632080078,
"cost": 0.0,
"mean_reward": -5.102373123168945,
"n_episodes": 300,
"p05": -18.235349655151367,
"p50": -3.8291523456573486,
"p95": 3.9433228969573975,
"sharpe_annualised": -16.905658721923828,
"sharpe_per_episode": -0.5907315015792847,
"std_reward": 8.637381553649902,
"threshold": 0.15000000596046448,
"win_rate": 0.20999999344348907
},
{
"avg_n_trades": 228.65333557128906,
"cost": 0.0625,
"mean_reward": -8.878447532653809,
"n_episodes": 300,
"p05": -25.199073791503906,
"p50": -6.852076053619385,
"p95": 2.099073886871338,
"sharpe_annualised": -28.601604461669922,
"sharpe_per_episode": -0.9994209408760071,
"std_reward": 8.88359260559082,
"threshold": 0.15000000596046448,
"win_rate": 0.07666666805744171
},
{
"avg_n_trades": 232.20333862304688,
"cost": 0.125,
"mean_reward": -12.617223739624023,
"n_episodes": 300,
"p05": -31.3784236907959,
"p50": -9.867401123046875,
"p95": -1.0055747032165527,
"sharpe_annualised": -34.65873718261719,
"sharpe_per_episode": -1.2110742330551147,
"std_reward": 10.418208122253418,
"threshold": 0.15000000596046448,
"win_rate": 0.036666665226221085
},
{
"avg_n_trades": 231.2066650390625,
"cost": 0.25,
"mean_reward": -20.333833694458008,
"n_episodes": 300,
"p05": -44.20069122314453,
"p50": -17.208972930908203,
"p95": -7.3099260330200195,
"sharpe_annualised": -50.19882583618164,
"sharpe_per_episode": -1.7540888786315918,
"std_reward": 11.59224796295166,
"threshold": 0.15000000596046448,
"win_rate": 0.013333333656191826
},
{
"avg_n_trades": 228.2899932861328,
"cost": 0.5,
"mean_reward": -36.5968132019043,
"n_episodes": 300,
"p05": -63.5013313293457,
"p50": -31.710899353027344,
"p95": -16.777376174926758,
"sharpe_annualised": -64.87017059326172,
"sharpe_per_episode": -2.266747236251831,
"std_reward": 16.145078659057617,
"threshold": 0.15000000596046448,
"win_rate": 0.0
},
{
"avg_n_trades": 168.1199951171875,
"cost": 0.0,
"mean_reward": -5.073331832885742,
"n_episodes": 300,
"p05": -21.23957061767578,
"p50": -2.783275604248047,
"p95": 4.150900363922119,
"sharpe_annualised": -15.721929550170898,
"sharpe_per_episode": -0.549368679523468,
"std_reward": 9.234840393066406,
"threshold": 0.20000000298023224,
"win_rate": 0.20333333313465118
},
{
"avg_n_trades": 166.10333251953125,
"cost": 0.0625,
"mean_reward": -6.372754096984863,
"n_episodes": 300,
"p05": -21.26305389404297,
"p50": -4.527249813079834,
"p95": 2.169199228286743,
"sharpe_annualised": -22.731422424316406,
"sharpe_per_episode": -0.7943001985549927,
"std_reward": 8.023104667663574,
"threshold": 0.20000000298023224,
"win_rate": 0.09333333373069763
},
{
"avg_n_trades": 167.32000732421875,
"cost": 0.125,
"mean_reward": -9.114399909973145,
"n_episodes": 300,
"p05": -27.226896286010742,
"p50": -6.687276363372803,
"p95": 0.4474220275878906,
"sharpe_annualised": -29.17302703857422,
"sharpe_per_episode": -1.0193880796432495,
"std_reward": 8.941049575805664,
"threshold": 0.20000000298023224,
"win_rate": 0.06333333253860474
},
{
"avg_n_trades": 166.086669921875,
"cost": 0.25,
"mean_reward": -15.02822208404541,
"n_episodes": 300,
"p05": -36.3570556640625,
"p50": -12.731026649475098,
"p95": -4.7999491691589355,
"sharpe_annualised": -46.5723991394043,
"sharpe_per_episode": -1.6273713111877441,
"std_reward": 9.234661102294922,
"threshold": 0.20000000298023224,
"win_rate": 0.006666666828095913
},
{
"avg_n_trades": 175.14666748046875,
"cost": 0.5,
"mean_reward": -27.366077423095703,
"n_episodes": 300,
"p05": -50.28740692138672,
"p50": -23.888004302978516,
"p95": -11.858697891235352,
"sharpe_annualised": -62.76474380493164,
"sharpe_per_episode": -2.1931777000427246,
"std_reward": 12.477821350097656,
"threshold": 0.20000000298023224,
"win_rate": 0.0
},
{
"avg_n_trades": 136.49000549316406,
"cost": 0.0,
"mean_reward": -5.5052690505981445,
"n_episodes": 300,
"p05": -21.544471740722656,
"p50": -2.841050148010254,
"p95": 2.903995990753174,
"sharpe_annualised": -18.832592010498047,
"sharpe_per_episode": -0.6580640077590942,
"std_reward": 8.365856170654297,
"threshold": 0.25,
"win_rate": 0.17000000178813934
},
{
"avg_n_trades": 138.30332946777344,
"cost": 0.0625,
"mean_reward": -7.428314685821533,
"n_episodes": 300,
"p05": -25.75029754638672,
"p50": -4.445875644683838,
"p95": 2.239424467086792,
"sharpe_annualised": -21.298498153686523,
"sharpe_per_episode": -0.7442297339439392,
"std_reward": 9.98121166229248,
"threshold": 0.25,
"win_rate": 0.10999999940395355
},
{
"avg_n_trades": 133.67333984375,
"cost": 0.125,
"mean_reward": -9.272025108337402,
"n_episodes": 300,
"p05": -26.37662696838379,
"p50": -6.8429741859436035,
"p95": -0.16450214385986328,
"sharpe_annualised": -33.544925689697266,
"sharpe_per_episode": -1.1721545457839966,
"std_reward": 7.910241603851318,
"threshold": 0.25,
"win_rate": 0.05000000074505806
},
{
"avg_n_trades": 132.44667053222656,
"cost": 0.25,
"mean_reward": -14.290428161621094,
"n_episodes": 300,
"p05": -33.351287841796875,
"p50": -10.576525688171387,
"p95": -4.033750057220459,
"sharpe_annualised": -42.11674118041992,
"sharpe_per_episode": -1.4716780185699463,
"std_reward": 9.710295677185059,
"threshold": 0.25,
"win_rate": 0.006666666828095913
},
{
"avg_n_trades": 136.05332946777344,
"cost": 0.5,
"mean_reward": -21.997268676757812,
"n_episodes": 300,
"p05": -41.8651237487793,
"p50": -20.465248107910156,
"p95": -7.810499668121338,
"sharpe_annualised": -54.85932159423828,
"sharpe_per_episode": -1.9169398546218872,
"std_reward": 11.475200653076172,
"threshold": 0.25,
"win_rate": 0.0033333334140479565
} }
], ],
"cost_grid": [ "cost_grid": [
@@ -69,9 +429,17 @@
0.5 0.5
], ],
"horizon": 600, "horizon": 600,
"n_eval_episodes": 500, "n_eval_episodes": 300,
"n_train_episodes": 1000, "n_train_episodes": 1000,
"phase": "E.3 Task 23", "phase": "E.3 Task 23 (2D sweep)",
"threshold_grid": [
0.0,
0.05000000074505806,
0.10000000149011612,
0.15000000596046448,
0.20000000298023224,
0.25
],
"train_cost": 0.0625, "train_cost": 0.0625,
"train_frac": 0.800000011920929 "train_frac": 0.800000011920929
} }

View File

@@ -84,6 +84,12 @@ struct Cli {
/// Phase 1d.4 used [0.0, 0.0625, 0.125, 0.25, 0.50]. /// Phase 1d.4 used [0.0, 0.0625, 0.125, 0.25, 0.50].
#[arg(long, value_delimiter = ',', default_value = "0.0,0.0625,0.125,0.25,0.5")] #[arg(long, value_delimiter = ',', default_value = "0.0,0.0625,0.125,0.25,0.5")]
cost_grid: Vec<f32>, cost_grid: Vec<f32>,
/// Comma-separated alpha-confidence threshold grid for the gate at eval.
/// Direct analogue of Phase 1d.4's `--threshold` sweep — at each
/// threshold, the policy is forced to Wait when |sigmoid(alpha)0.5|
/// < threshold. Threshold 0 = no gate (original Task 23 behaviour).
#[arg(long, value_delimiter = ',', default_value = "0.0,0.05,0.10,0.15,0.20,0.25")]
threshold_grid: Vec<f32>,
#[arg(long, default_value_t = 1)] #[arg(long, default_value_t = 1)]
trade_size: i32, trade_size: i32,
#[arg(long, default_value_t = 0xCAFEBABE_u64)] #[arg(long, default_value_t = 0xCAFEBABE_u64)]
@@ -152,9 +158,25 @@ fn epsilon_greedy(q: &[f32], eps: f32, rng: &mut SmokeRng) -> u8 {
} }
} }
/// Phase E.3 confidence-gated ε-greedy. If `alpha_confidence < threshold`,
/// force action=0 (Wait). Otherwise standard ε-greedy.
fn epsilon_greedy_gated(
q: &[f32],
alpha_confidence: f32,
threshold: f32,
eps: f32,
rng: &mut SmokeRng,
) -> u8 {
if alpha_confidence < threshold {
return 0;
}
epsilon_greedy(q, eps, rng)
}
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
struct CostBin { struct CostBin {
cost: f32, cost: f32,
threshold: f32,
n_episodes: usize, n_episodes: usize,
mean_reward: f32, mean_reward: f32,
std_reward: f32, std_reward: f32,
@@ -163,6 +185,7 @@ struct CostBin {
/// the eval window's mid_price index span × bar_seconds. /// the eval window's mid_price index span × bar_seconds.
sharpe_annualised: f32, sharpe_annualised: f32,
win_rate: f32, win_rate: f32,
avg_n_trades: f32,
p05: f32, p05: f32,
p50: f32, p50: f32,
p95: f32, p95: f32,
@@ -434,16 +457,20 @@ fn main() -> Result<()> {
info!("Training complete. Frozen policy ready for eval."); info!("Training complete. Frozen policy ready for eval.");
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Phase 2: COST SWEEP — frozen-policy greedy eval per cost. // Phase 2: 2D SWEEP — frozen-policy greedy eval per (threshold, cost).
// Direct analogue of Phase 1d.4's threshold × cost table.
// --------------------------------------------------------------- // ---------------------------------------------------------------
info!("=== Eval phase: {} episodes per cost × {} costs ===", info!("=== Eval phase: {} episodes × {} thresholds × {} costs ===",
cli.n_eval_episodes, cli.cost_grid.len()); cli.n_eval_episodes, cli.threshold_grid.len(), cli.cost_grid.len());
let eval_max_start = (n_total - n_train).saturating_sub(cli.horizon + 1).max(1); let eval_max_start = (n_total - n_train).saturating_sub(cli.horizon + 1).max(1);
let mut bins: Vec<CostBin> = Vec::with_capacity(cli.cost_grid.len()); let mut bins: Vec<CostBin> =
Vec::with_capacity(cli.cost_grid.len() * cli.threshold_grid.len());
for &threshold in &cli.threshold_grid {
for &cost in &cli.cost_grid { for &cost in &cli.cost_grid {
env.config.cost_per_contract = cost; env.config.cost_per_contract = cost;
let mut rewards: Vec<f32> = Vec::with_capacity(cli.n_eval_episodes); let mut rewards: Vec<f32> = Vec::with_capacity(cli.n_eval_episodes);
let mut win_count = 0_usize; let mut win_count = 0_usize;
let mut trade_count_total: u64 = 0;
for _ in 0..cli.n_eval_episodes { for _ in 0..cli.n_eval_episodes {
let start_cursor = let start_cursor =
n_train + (episode_rng.next_u64() as usize) % eval_max_start; n_train + (episode_rng.next_u64() as usize) % eval_max_start;
@@ -451,6 +478,7 @@ fn main() -> Result<()> {
env.reset_at(env_seed, start_cursor); env.reset_at(env_seed, start_cursor);
let mut state = EpisodeState::new(); let mut state = EpisodeState::new();
let mut terminal_r = 0.0_f32; let mut terminal_r = 0.0_f32;
let mut ep_n_trades = 0_u32;
loop { loop {
let s_vec = env.state(&state).to_vec(); let s_vec = env.state(&state).to_vec();
stream.memcpy_htod(&s_vec, &mut single_state_dev)?; stream.memcpy_htod(&s_vec, &mut single_state_dev)?;
@@ -469,7 +497,16 @@ fn main() -> Result<()> {
stream.synchronize()?; stream.synchronize()?;
let q_host = stream.clone_dtoh(&single_q_dev)?; let q_host = stream.clone_dtoh(&single_q_dev)?;
let mut greedy_rng = SmokeRng::new(0); // unused — eps=0 let mut greedy_rng = SmokeRng::new(0); // unused — eps=0
let action = epsilon_greedy(&q_host, 0.0, &mut greedy_rng); let action = epsilon_greedy_gated(
&q_host,
s_vec[1], // alpha_confidence
threshold,
0.0,
&mut greedy_rng,
);
if action != 0 {
ep_n_trades += 1;
}
match env.step(action, &mut state) { match env.step(action, &mut state) {
Some((_, reward, done)) => { Some((_, reward, done)) => {
if done { if done {
@@ -481,6 +518,7 @@ fn main() -> Result<()> {
} }
} }
rewards.push(terminal_r); rewards.push(terminal_r);
trade_count_total += ep_n_trades as u64;
if terminal_r > 0.0 { if terminal_r > 0.0 {
win_count += 1; win_count += 1;
} }
@@ -494,14 +532,10 @@ fn main() -> Result<()> {
/ n; / n;
let std = var.sqrt().max(1e-6); let std = var.sqrt().max(1e-6);
let sharpe_per = (mean / std) as f32; let sharpe_per = (mean / std) as f32;
// Annualised Sharpe via per-episode Sharpe × sqrt(episodes/year).
// Episode duration ≈ horizon × bar_seconds. ES.FUT MBP-10 snapshots
// at snapshot_interval=50 ≈ 12 sec/bar on average; 600-bar horizon
// ≈ 7200 s ≈ 2 hours. Episodes per year ≈ 252 × 6.5 × 3600 / 7200
// ≈ 819. Sharpe_ann ≈ Sharpe_per × sqrt(819) ≈ 28.6.
let episodes_per_year = 252.0 * 6.5 * 3600.0 / (cli.horizon as f64 * 12.0); let episodes_per_year = 252.0 * 6.5 * 3600.0 / (cli.horizon as f64 * 12.0);
let sharpe_ann = (sharpe_per as f64 * episodes_per_year.sqrt()) as f32; let sharpe_ann = (sharpe_per as f64 * episodes_per_year.sqrt()) as f32;
let win_rate = win_count as f32 / cli.n_eval_episodes as f32; let win_rate = win_count as f32 / cli.n_eval_episodes as f32;
let avg_n_trades = trade_count_total as f32 / cli.n_eval_episodes as f32;
let mut sorted = rewards.clone(); let mut sorted = rewards.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pick = |q: f64| -> f32 { let pick = |q: f64| -> f32 {
@@ -510,50 +544,72 @@ fn main() -> Result<()> {
}; };
let bin = CostBin { let bin = CostBin {
cost, cost,
threshold,
n_episodes: cli.n_eval_episodes, n_episodes: cli.n_eval_episodes,
mean_reward: mean as f32, mean_reward: mean as f32,
std_reward: std as f32, std_reward: std as f32,
sharpe_per_episode: sharpe_per, sharpe_per_episode: sharpe_per,
sharpe_annualised: sharpe_ann, sharpe_annualised: sharpe_ann,
win_rate, win_rate,
avg_n_trades,
p05: pick(0.05), p05: pick(0.05),
p50: pick(0.50), p50: pick(0.50),
p95: pick(0.95), p95: pick(0.95),
}; };
info!( info!(
" cost={:>6.4} mean={:>+9.2} std={:>8.2} Sharpe/ep={:+.3} Sharpe_ann={:+.3} win_rate={:.3}", " τ={:.2} cost={:>6.4} mean={:>+9.2} Sharpe/ep={:+.3} Sharpe_ann={:+.3} win={:.3} trades/ep={:.1}",
cost, mean, std, sharpe_per, sharpe_ann, win_rate threshold, cost, mean, sharpe_per, sharpe_ann, win_rate, avg_n_trades
); );
bins.push(bin); bins.push(bin);
} }
}
// --- Print table --- // --- Print table ---
info!(""); info!("");
info!("=== Phase E.3 cost-sweep table (vs Phase 1d.4 baseline) ==="); info!("=== Phase E.3 2D sweep (threshold × cost, vs Phase 1d.4 baseline) ===");
info!( info!(
" {:>6} {:>8} {:>9} {:>9} {:>10} {:>11} {:>8}", " {:>6} {:>6} {:>9} {:>9} {:>10} {:>11} {:>8} {:>10}",
"cost", "n_ep", "mean_R", "std_R", "Sharpe/ep", "Sharpe_ann", "win_rate" "τ", "cost", "mean_R", "std_R", "Sharpe/ep", "Sharpe_ann", "win_rate", "trades/ep"
); );
for b in &bins { for b in &bins {
info!( info!(
" {:>6.4} {:>8} {:>9.2} {:>9.2} {:>10.4} {:>11.4} {:>8.3}", " {:>6.3} {:>6.4} {:>9.2} {:>9.2} {:>10.4} {:>11.4} {:>8.3} {:>10.2}",
b.cost, b.n_episodes, b.mean_reward, b.std_reward, b.threshold, b.cost, b.mean_reward, b.std_reward,
b.sharpe_per_episode, b.sharpe_annualised, b.win_rate b.sharpe_per_episode, b.sharpe_annualised, b.win_rate, b.avg_n_trades
); );
} }
info!(""); info!("");
info!("BEST per-cost (max Sharpe_ann across all τ at each cost):");
for &cost in &cli.cost_grid {
let best = bins
.iter()
.filter(|b| (b.cost - cost).abs() < 1e-6)
.max_by(|a, b| {
a.sharpe_annualised
.partial_cmp(&b.sharpe_annualised)
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(b) = best {
info!(
" cost={:>6.4} best τ={:.3} Sharpe_ann={:+.3} win={:.3} trades/ep={:.1}",
b.cost, b.threshold, b.sharpe_annualised, b.win_rate, b.avg_n_trades
);
}
}
info!("");
info!("Phase 1d.4 baseline for comparison: +4.4 annualised at cost=0,"); info!("Phase 1d.4 baseline for comparison: +4.4 annualised at cost=0,");
info!(" -4.0 annualised at cost=0.125."); info!(" -4.0 annualised at cost=0.125.");
// --- Save JSON --- // --- Save JSON ---
let json = serde_json::json!({ let json = serde_json::json!({
"phase": "E.3 Task 23", "phase": "E.3 Task 23 (2D sweep)",
"horizon": cli.horizon, "horizon": cli.horizon,
"train_frac": cli.train_frac, "train_frac": cli.train_frac,
"n_train_episodes": cli.n_train_episodes, "n_train_episodes": cli.n_train_episodes,
"n_eval_episodes": cli.n_eval_episodes, "n_eval_episodes": cli.n_eval_episodes,
"train_cost": cli.train_cost, "train_cost": cli.train_cost,
"cost_grid": cli.cost_grid, "cost_grid": cli.cost_grid,
"threshold_grid": cli.threshold_grid,
"bins": bins, "bins": bins,
}); });
let mut f = File::create(&cli.out_path)?; let mut f = File::create(&cli.out_path)?;

View File

@@ -310,6 +310,24 @@ impl SmokeRng {
} }
} }
/// Phase E.3 confidence-gated ε-greedy. If `alpha_confidence < threshold`,
/// force action=0 (Wait) regardless of Q. Otherwise standard ε-greedy.
/// Both args are expected in [0, 0.5] (the state's
/// `alpha_confidence = |sigmoid(alpha_logit) 0.5|`, and the controller
/// slot 543 which is clamped to [0, 0.5]). Threshold=0 disables the gate.
fn epsilon_greedy_gated(
q: &[f32],
alpha_confidence: f32,
threshold: f32,
eps: f32,
rng: &mut SmokeRng,
) -> u8 {
if alpha_confidence < threshold {
return 0; // Wait
}
epsilon_greedy(q, eps, rng)
}
/// Picks an action via ε-greedy: with probability `eps` random, otherwise /// Picks an action via ε-greedy: with probability `eps` random, otherwise
/// argmax over `q`. Returns the action index in `[0, N_ACTIONS)`. /// argmax over `q`. Returns the action index in `[0, N_ACTIONS)`.
fn epsilon_greedy(q: &[f32], eps: f32, rng: &mut SmokeRng) -> u8 { fn epsilon_greedy(q: &[f32], eps: f32, rng: &mut SmokeRng) -> u8 {
@@ -508,6 +526,12 @@ fn main() -> Result<()> {
// --- Training loop --- // --- Training loop ---
let mut episode_rng = SmokeRng::new(cli.seed.wrapping_add(0xFEED)); let mut episode_rng = SmokeRng::new(cli.seed.wrapping_add(0xFEED));
let mut action_counts_host = vec![0_i32; N_ACTIONS]; let mut action_counts_host = vec![0_i32; N_ACTIONS];
// Phase E.3: cached threshold from controller (slot 543). Updated after
// each rollout-end controller invocation; used in action selection for
// the NEXT episode. Initialised to 0 (no gate) so the first episode's
// exploration is unrestricted, letting the controller accumulate enough
// observations to drive a meaningful threshold.
let mut current_threshold: f32 = 0.0;
let mut recent_returns: Vec<f32> = Vec::new(); let mut recent_returns: Vec<f32> = Vec::new();
let max_start = n_rows.saturating_sub(cli.horizon + 1).max(1); let max_start = n_rows.saturating_sub(cli.horizon + 1).max(1);
@@ -547,7 +571,13 @@ fn main() -> Result<()> {
} }
stream.synchronize().context("sync after per-step fwd")?; stream.synchronize().context("sync after per-step fwd")?;
let q_host = stream.clone_dtoh(&single_q_dev).context("dtoh Q")?; let q_host = stream.clone_dtoh(&single_q_dev).context("dtoh Q")?;
let action = epsilon_greedy(&q_host, eps, &mut episode_rng); let action = epsilon_greedy_gated(
&q_host,
s_vec[1], // alpha_confidence (state index 1)
current_threshold,
eps,
&mut episode_rng,
);
let (_next_state_arr, reward, done) = env.step(action, &mut state) let (_next_state_arr, reward, done) = env.step(action, &mut state)
.ok_or_else(|| anyhow::anyhow!("step returned None"))?; .ok_or_else(|| anyhow::anyhow!("step returned None"))?;
@@ -745,6 +775,12 @@ fn main() -> Result<()> {
w_ptr, w_ptr,
)?; )?;
} }
// Phase E.3: refresh CPU-side threshold cache from device after the
// controller update, for the NEXT episode's gating decisions.
stream.synchronize().context("sync after controller")?;
let isv_now = stream.clone_dtoh(&isv_dev).context("dtoh isv post-controller")?;
current_threshold =
isv_now[ml::cuda_pipeline::alpha_isv_slots::STACKER_THRESHOLD_INDEX];
// Target network hard update every K episodes: w_target ← w_online. // Target network hard update every K episodes: w_target ← w_online.
// Standard DQN stabilizer — V_soft(s') bootstrap reads w_target, // Standard DQN stabilizer — V_soft(s') bootstrap reads w_target,