0e17fd4f2dbeb9fbebcbc0f3fb702bfe0eade078
59 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0e17fd4f2d |
diag(crt-2): per-horizon alpha-input EMA test — hypothesis investigation
Driven by ffr59 (commit
|
||
|
|
b44a97ff94 |
diag(crt): empirical measurement battery for signal × controller × cost analysis
Pure-instrumentation commit. Zero behavior change. The Gate CRT.1 smoke p9cnk (commit |
||
|
|
1e656948b3 |
arch(crt-1): composite exit_signal safety circuit-breaker (C1.4)
Per spec §4.3 + plan C1.4. Safety backup for the primary exit path
(target → 0 from collapsed multi-horizon conviction, C1.2). Catches
the edge case where conviction stays bounded above zero but the trade
is deep in unrealized loss, or short/long horizons disagree for many
events while threshold-gate logic still permits sizing.
Three terms, max-of-three composite:
degradation_term = (1 − conv_ema_now / conviction_at_entry) / 2.0
disagreement_term = disagree_consecutive_events / 32.0
pnl_decay_term = max(−pnl_adjusted_conv_ema, 0) / 1.0
exit_signal = max(deg, dis, pnl_decay)
Fires force-flat (market_targets = (3, 0)) when exit_signal >= 1.0.
State-update flow:
- pnl_track open branch: write conviction_at_entry (offset 20),
conviction_ema (offset 24, initialised to entry value),
pnl_adjusted_conviction_ema (offset 44, = 0),
peak_unrealized_pnl (offset 48, = 0). Takes new conviction_ema_per_b
read-only arg to snapshot at open.
- decision kernel intra-event (when pos open): update offsets
24/44/48/56 (disagreement counter) via update_open_trade_trajectory.
- composite_exit_check device helper: reads offsets 20/24/44/56,
writes (3, 0) on fire. Sibling of stop_check_isv with same return
semantics; called after trajectory update so all four reads see
the freshly-written values.
Fields not read in CRT.1 (offsets 28-43 per-horizon EMA, 52 degradation
counter, 60 horizon_idx_dominant) are NOT written by the open branch —
alloc_zeros covers init. Per feedback_no_hiding: don't populate unused
fields. Also fixes a latent C1.1 bug: pnl_track close branch read
horizon_idx from offset 20 (now conviction_at_entry, f32); moved the
read to offset 60 per the documented layout.
Threshold-bootstrap defaults (will be ISV-derived in CRT.2 alongside
the rest of the controller anchors per pearl_controller_anchors_isv_driven):
degradation_factor = 2.0 (50% conviction decay vs entry → ≤0.5 term)
disagreement_factor = 32.0 (32 events of short/long disagreement)
pnl_decay_factor = 1.0 (pnl_adj_conv structurally ∈ [-1, +1])
Under these defaults disagreement_term is the only term that can fire
on its own — degradation_term caps at 0.5 (conv_ema/conviction_at_entry
≥ 0) and pnl_decay_term caps at 1.0 in the limit. The three terms still
add evidence in superposition; CRT.2 will retune factors against ISV
percentiles. Unit-test coverage validates the disagreement path
(composite_exit_signal_fires_on_disagreement).
Per pearl_no_host_branches_in_captured_graph — all composite logic
lives inside the decision kernel; no host roundtrip in the per-event
hot path. Per feedback_no_htod_htoh_only_mapped_pinned — no new
memcpy or stream.synchronize() in step_pnl_track or
dispatch_latent_market_orders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7c850a6c02 |
arch(crt-1): no-trade band in seed_inflight — sparse action at signal cadence (C1.3)
Per spec §4.0 + plan C1.3. Without this gate, every fractional change in target_lots seeds an order via target-delta semantics; combined with the continuous controller invocation (every event after A1) this generates hyperactivity even with the multi-horizon §4.4 conviction formula (C1.2). v2 Gate-1 failures confirmed: scalar EMA + amplification clamp + per-event controller = 155k trades on a 2M-event smoke. The band absorbs fractional target adjustments so most events produce no order. When the signal genuinely shifts and target moves enough to cross the band, the kernel seeds. Continuous evaluation, sparse action. Greenfields atomic refactor — single config knob threaded through every layer in one commit: SweepBase.delta_floor: f32 (YAML, default 1.0 = 1 lot) SimVariant.delta_floor: Option<f32> (per-variant override) ResolvedSimVariant.delta_floor: f32 UniformSimParams.delta_floor: f32 BatchedSimConfig.delta_floor: Vec<f32> LobSimCuda.delta_floor_d: CudaSlice<f32> seed_inflight_limits_batched kernel param + skip-if-below-floor logic New accessor: LobSimCuda::read_inflight_count(b) — counts active != 0 limit slots for backtest b. Not cfg(test); follows existing read_limit_slot pattern. Test: no_trade_band_blocks_micro_delta verifies that a second decision with the same strong-bullish alpha (same target, effective = in-flight lots) produces delta=0 and does not re-seed. max_lots=1 keeps the arithmetic unambiguous. Existing tests: all UniformSimParams struct literals updated with delta_floor=0.0 (band disabled) so existing behaviour is preserved. harness.rs from_uniform path uses delta_floor=1.0 (production default). Hot-path discipline: the delta_floor_d upload happens once per run in the existing config-upload block (alongside threshold, cost, max_hold_ns); the kernel reads the device slot per-event without any host roundtrip. No memcpy_htod / dtoh / dtov / synchronize introduced on the per-event path. Per pearl_controller_anchors_isv_driven, this floor is currently a config constant; CRT.2 (Phase 2) makes it ISV-derived from rolling spread cost / signal volatility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
632296021c |
arch(crt-1): multi-horizon ISV-weighted conviction replaces scalar EMA + rescale (C1.2)
Per spec §4.4 + plan C1.2. Replaces v2 A2 ( |
||
|
|
e27fc90b9b |
arch(crt-1): open_trade_state 24→64 byte expansion — atomic refactor (C1.1)
Per spec §7 (v3 architecture reset, promoted from v2 Phase B into CRT.1).
Single-cache-line layout. Every reader and writer of open_trade_state
migrates in this commit per feedback_no_partial_refactor.
New 64-byte layout (existing 24-byte fields preserved at original offsets):
Offset Size Field
0 8 entry_ts_ns (u64)
8 4 entry_px_x100 (i32)
12 4 entry_size (i32)
16 4 realized_at_open (f32)
20 4 conviction_at_entry (f32) ← NEW
24 4 conviction_ema (f32) ← NEW
28 16 conviction_per_horizon_ema [4 × f32] ← NEW
44 4 pnl_adjusted_conviction_ema (f32) ← NEW
48 4 peak_unrealized_pnl (f32) ← NEW
52 4 degradation_consecutive_events (u32) ← NEW
56 4 disagreement_consecutive_events (u32) ← NEW
60 1 horizon_idx_dominant_at_entry (u8) ← NEW
61 3 pad
The 24-byte prefix is unchanged so downstream readers (stop_check_isv in
decision_policy.cu, max_hold event-rate check in resting_orders.cu) need
only update the stride constant. The new fields at offsets 20..63 are
populated by subsequent CRT.1 tasks (C1.2 multi-horizon conviction, C1.4
composite exit signal).
Open branch in pnl_track.cu zeros the new fields implicitly because
alloc_zeros on the device slot zero-initialises everything; subsequent
writes only touch the 0-23 byte range (existing fields). Close branch
reset-loop iterates OPEN_TRADE_STATE_BYTES which now zeros all 64.
Files changed:
crates/ml-backtesting/src/lob/mod.rs — pub const OPEN_TRADE_STATE_BYTES: usize = 64
crates/ml-backtesting/cuda/pnl_track.cu — #define OPEN_TRADE_STATE_BYTES 64
crates/ml-backtesting/cuda/resting_orders.cu — comment + stride literal 24→64
crates/ml-backtesting/tests/stop_controller.rs — open_trade_state_64_byte_layout test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1d889d2de9 |
arch(crt-a): Wiener-α conviction-EMA smoothing in decision_policy
After A1 (commit |
||
|
|
045850e8f3 |
arch(crt-a): delete decision_stride field — greenfields atomic refactor
Per spec 2026-05-20-continuous-reasoning-trader-design.md §3.3 and §8:
decision_stride is REMOVED, not deprecated. No backwards-compat shim,
no fallback. Every consumer migrates in this commit per
feedback_no_partial_refactor.
Removed from:
- bin/fxt-backtest: RunArgs CLI flag, SweepBase field, SweepCell
override field, default_decision_stride() function, all three
BacktestHarnessConfig and RunArgs construction sites
- crates/ml-backtesting/src/harness.rs: BacktestHarnessConfig field,
MultiHorizonLoaderConfig decision_stride initializer, `let stride`
local, `if event_count % stride == 0` gate around
step_decision_with_latency; forward_step_into + step_decision now
share a single window-full guard (merged into one `if` block)
- crates/ml-alpha/src/data/loader.rs: MultiHorizonLoaderConfig field,
next_sequence stride logic simplified to stride=1 (consecutive
snapshots only)
- crates/ml-alpha/src/trainer/perception.rs: PerceptionTrainerConfig
field and Default impl; all four dt_s locals replaced with 1.0_f32
(training K-loop, graph-capture K-loop, forward_step_into CfC step,
eval K-loop)
- crates/ml-alpha/examples/alpha_train.rs: CLI flag, trainer_cfg and
both loader configs
- crates/ml/examples/alpha_baseline.rs: CLI flag, train + eval stride
gates replaced with unconditional read_all()
- config/ml/*.yaml: decision_stride: lines removed from
sweep_smoke, sweep_threshold_tuning, sweep_deployability,
sweep_decision_stride_example (file repurposed as generic example)
- tests: forward_step_golden, perception_overfit (×7 structs including
the stride=4 smoke repurposed as a second convergence check),
multi_horizon_loader (stride=4 spacing test repurposed as
ts_ns monotonicity check), ring3_replay, trainer_parity
Harness loop now invokes BOTH forward_step_into AND
step_decision_with_latency on every event whenever the snapshot window
is full. forward_step_into advances SSM state and writes alpha_probs_d;
step_decision_with_latency reads alpha_probs_d immediately after —
no CPU roundtrip, no stride gate.
n_decisions ≈ events_processed - seq_len + 1 after this commit
(vs ~9999 at stride=200 in the S2 baseline).
cargo check --workspace: clean
cargo test -p ml-backtesting --lib: 33 passed
cargo test -p ml-alpha --lib: 33 passed (6 ignored)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
92f8b10ed2 |
fix(crt-a): forward_step_into eliminates GPU↔CPU roundtrip + bit-identical seed
Corrective commit on top of
|
||
|
|
a0e81fbdfc |
arch(crt-a): forward_step incremental SSM state — enables event-rate trunk forward
Per A0 investigation memo (commit
|
||
|
|
b92bd72c72 |
fix(ml-backtesting): move max_hold force-close from decision-rate to event-rate — actual cap enforcement
S2.1 instrumentation (smoke cqpph @
|
||
|
|
95a77c4ac6 |
diag(ml-backtesting): max_hold enforcement counters localize why 86.5% of trades exceed the 60s cap
S2 cluster smoke ppcfk (
|
||
|
|
76ec68c1c9 |
fix(ml-backtesting): per-level price-range validation in walk_ask_for_buy/walk_bid_for_sell — eliminates sized-but-bad-priced sentinel propagation
v2 NaN instrumentation (S1.21) localized the bug to apply_fill_to_pos's open-from-flat branch writing pos.vwap_entry = avg_px where avg_px was 0 (194 cases) or > 21M finite (216 cases). The arithmetic was clean — root cause is walk_ask_for_buy/walk_bid_for_sell consuming size from deep levels (k=1..9) that have lvl_sz > 0 but lvl_px = 0 (or huge sentinel). MBP-10 fills empty depth slots beyond available levels with these sentinels. Existing per-level filter checked lvl_sz <= 0, !isfinite(lvl_sz), !isfinite(lvl_px) — but allowed lvl_px = 0 and lvl_px > max range. Fix: thread per-backtest min_reasonable_px / max_reasonable_px (already uploaded for the top-of-book skip in book_update_apply_snapshot via S1.19) through to walk_*, and reject any level whose price falls outside [min_px, max_px]. Same fix shape as Bug C-b (top-of-book skip), now applied at all 10 depths. Changes: resting_orders.cu (walk_* signatures + kernel param + 2 call sites), order_match.cu (walk_* signatures + kernel param + 1 call site), sim/mod.rs (submit_market + step_resting_orders launch args). 19 CUDA tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fc41440dc9 |
diag(ml-backtesting): finer NaN instrumentation — per-vwap-write-site + last-bad-vwap capture
v1 instrumentation (S1.20) eliminated 3 of 6 hypotheses: apply_fill_to_pos arithmetic is fully clean (avg_px=0 realised=0 realized_pnl=0). But pnl_track open branch still saw vwap_entry=0 (194 times) or > 21M (216 times) at the open transition. v2 adds per-vwap-write-site counters so we can pinpoint which apply_fill branch produced the bad vwap, plus captures the actual last-bad-vwap value and the path id (1..6 = open-flat, scale-in zero/huge, flip-beyond zero/huge, session-gap-saw-stale). The 7th counter (vwap_session_gap_was_bad) fires when the session-gap force-close path sees pos.vwap_entry already in a corrupt state pre-gap. Logged at end of smoke as `nan_counters_v2: zero_flat=N huge_flat=N zero_scale=N huge_scale=N zero_flip=N huge_flip=N session_gap_was_bad=N | b0_last_bad_vwap=X b0_last_bad_path=N`. 19/19 stop_controller CUDA tests pass; 5/5 decision_floor_coldstart pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
025554afcd |
diag(ml-backtesting): kernel-side NaN instrumentation for residual sentinel root-cause
Cluster smoke 88dbp (post-Fix-S1.19 parameterized price range) still produces 97 zero + 85 i32::MAX sentinel entry_px values despite source data being fully filtered. Sentinels originate in kernel arithmetic paths post-sanitization, not from book input. Adds 6 per-backtest u32 counters incremented at NaN-producing sites: - nan_avg_px: avg_px = total_cost/filled_lots -> NaN/Inf - nan_realised: (avg_px - vwap_entry) * dir * unwind -> NaN/Inf - nan_realized_pnl: pos.realized_pnl becomes non-finite after += or -= - zero_vwap_at_open: pnl_track open branch saw vwap_entry == 0 - saturated_vwap_at_open: pnl_track open saw |vwap_entry| > 21M or NaN/Inf - defensive_exit_clamp: pnl_track close defensive clamp fired Each counter printed at end of smoke via nan_counters: log line. apply_fill_to_pos (resting_orders.cu) gains 3 counter args; NaN-producing paths return early WITHOUT propagating into pos state. pnl_track_step gains 3 counter args. All call sites threaded through sim/mod.rs launches. NanCounters struct + read_nan_counters() accessor added to LobSimCuda. Unit test nan_counters_initialize_to_zero confirms zero-init and accessor compile. 18/18 stop_controller, 5/5 decision_floor_coldstart pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c03cf9aa38 |
fix(ml-backtesting): parameterized price-range sanitization replaces hardcoded 1e8
Audit (fxt-data-audit on ES.FUT_2024-Q1) revealed real source-data outliers that the hardcoded < 1e8 threshold didn't catch: - bid_min = -$4.85 (negative bid) - bid_p1 = $64.15 (1% of bids in sub-$100 range, far below ES) - ask_max = $53,012 (10x above any plausible ES price) The < 1e8 threshold = $100M was useless: never triggered on real ES. Fix: parameterize the range. min_reasonable_px / max_reasonable_px as per-backtest fields in UniformSimParams, ResolvedSimVariant, and BatchedSimConfig (defaults 0.0 / f32::INFINITY = no-check, preserves existing test fixtures). Sweep_smoke.yaml sets 1000/20000 for ES futures — catches all observed outliers without rejecting any plausible price. BacktestHarness calls upload_price_range() once after creating the sim so the bounds are active before the first apply_snapshot. CUDA kernel book_update_apply_snapshot gains two new args (min_reasonable_px[n_backtests], max_reasonable_px[n_backtests]) that replace the hardcoded > 0.0f && < 1.0e8f checks at both the top-of-book gate and the per-level sanitization pass. Test price_range_rejection_skips_snapshot validates: sub-$1000 snapshot, super-$20000 snapshot, and negative bid all skip with snapshots_skipped counter increment; valid ES snapshot passes through. 17/17 stop_controller tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a85f38e97a |
fix(ml-backtesting): three residual sentinel + session-gap fixes
Three orthogonal fixes for residual issues in smoke stx9p (97 zero sentinels, 85 i32::MAX sentinels, 840/1024 over-60s holds): Fix 1 — zero-sentinel residue (px > 0): - Per-level sanitization in apply_snapshot_kernel only checked sz > 0. - A book level with px=0, sz>0 passed → walk_* computed total_cost=0 → avg_px=0 → vwap_entry=0. Trade record reported entry_px=0. - Add px > 0.0f to bid_ok/ask_ok conditions. Fix 2 — i32::MAX upper bound (px < 1e8): - Post-Bug-D (1e9 nanoprice scaling) some boundary events carried prices larger than any plausible instrument. Saturating cast to i32 produced the 21474836 sentinel even when isfinite() passed. - Add px < 1.0e8f upper bound to per-level AND top-of-book validation. Fix 3 — session-gap force-close: - max_hold check fires at decision_stride frequency, but during weekend halts no events advance current_ts → max_hold never fires until next session. Result: 49h holds in stx9p (840/1024 over 60s threshold). - Detect ts gap > 1 hour in resting_orders_step. If position is open, zero position_lots directly. pnl_track_step's existing close branch emits the TradeRecord on the next call. No synthetic P&L added — records show realised_pnl from whatever was accumulated before the gap (honest: cannot fill across a halt). - New per-backtest last_event_ts_d slot tracks the previous event ts. - Test session_gap_force_closes_open_positions: 2-hour ts jump after open verifies force-flat fires and exactly 1 TradeRecord is emitted. All 16 stop_controller tests pass. All 5 decision_floor_coldstart pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5235b4515b |
fix(data,ml-backtesting): DBN nanoprice scaling + skip-on-corrupt-top-of-book
Two root-cause fixes surfaced by cluster smoke v74v4 (sweep_smoke-a2dfc6d99): Bug D — DBN parser price scaling: - BidAskPair::price_to_f64/price_from_f64 used /1e12 / *1e12 from test-data calibration. DBN production uses 1e-9 nanoprice (the DBN standard). ES at 5500 raw 5_500_000_000_000 → 5.5 instead of 5500. Smoke trade records showed entry_px=5.24 instead of expected ~5240 ES index points (1000× too small). Fix: 1e12 → 1e9 in both functions. Round-trip symmetric; tests updated. Bug C-b — corrupt top-of-book sentinel: - Per-level sanitization (Task 15) zeros each unhealthy MBP-10 level individually. At session-boundary events with all 10 levels invalid, the book becomes uniformly zero. apply_fill_to_pos then reads bid_px[0]=0 / ask_px[0]=0 → vwap_entry=0 → trade record entry_px=0 (zero sentinel in v74v4 CSV, 162/1024 trades in n59t4). - Fix: pre-validate top-of-book in apply_snapshot_kernel. If bid_px[0]/ask_px[0]/bid_sz[0]/ask_sz[0] are non-finite or bid_px[0]<=0/ask_px[0]<=0/bid_sz[0]<=0/ask_sz[0]<=0, atomically skip the entire snapshot (book/prev_mid/atr_mid_ema unchanged). Add per-backtest snapshots_skipped_d counter for observability. Test corrupt_top_of_book_skips_snapshot_and_increments_counter validates NaN top-price + zero top-size cases both increment the counter without mutating state, and that a subsequent valid snapshot updates normally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bf619a2e7b |
feat(ml-backtesting): max-hold + exit_px defensive + spec criteria revision
Three follow-ups from cluster smoke gp74n (trade_vol pearl validation): 1. Max-hold force-close: max_hold_ns added as per-backtest config (default 0 = disabled). Fires force-flat (3, 0) when current_ts - entry_ts >= max_hold_ns, BEFORE SL/trail check. Tested via max_hold_forces_close. Sweep YAML sets 60s cap to bound the long tail observed in gp74n (263985s pathological hold). 2. exit_px defensive sanity check: 500/1024 gp74n trades reported exit_px = ±i32::MAX/100 (float→int saturation sentinel from likely NaN segment_realized). Defensive fix in pnl_track_step: if exit_px is non-finite or diverges from entry_px by >10%, fall back to entry_px with zero realised_pnl. Root cause to be traced separately. 3. Spec §9.2 criteria revision: mean_hold<30s replaced with p95<600s + max<=max_hold_ns. The 30s threshold reflected the pre-pearl sub-cost churning bug, not a real feature criterion. p95 catches long-tail pathology while letting alpha-driven exit timing breathe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
da6e887174 |
feat(ml-backtesting): cost-floor in stop_check_isv prevents sub-cost churning
Cluster smoke w7b4p showed 44864 closed trades in 1M events at 200ms latency — physically impossible without sub-cost churning. Root cause: sl_distance = max(pnl_ema_loss, atr) ≈ 0.05 at cold-start, but round-trip cost = 2 × 0.125 = 0.25. Every stop-out guaranteed loss > 5× distance; EMA converges sub-cost. Amendment: triple-max sl_distance = max(pnl_ema_loss, atr, 2*cost); trail_distance = max(pnl_ema_win, atr, 2*cost). cost is an ISV-discipline strategy anchor (already per-backtest from P4 — no new state slot, no tuned constants). Added cost_per_lot_per_side_per_b param to both decision kernel signatures and threaded cost_per_lot_per_side_d through both launch sites in step_decision_with_latency. Test: cost_floor_prevents_sub_cost_stops validates unrealized in (ATR, 0.25) does NOT fire SL; unrealized > 0.25 DOES fire. Spec amended (§10, §12). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4101796ad9 |
refactor(ml-backtesting): delete StopRules + use_cold_start_stopgap atomically
- StopRules struct + sl_tp_rules field + all literals deleted; the ISV stop controller (Tasks 2-9) replaces this dead data path. - use_cold_start_stopgap propagation deleted across harness, batched_config, fxt-backtest, sweep_smoke.yaml, and 5 test files. - Q1 stopgap branch in harness.rs deleted; replaced with the original simple strategy-upload loop. - decision_floor_coldstart test retargeted: cold_start_persistent_ bullish_with_default_stops_never_closes -> ..._now_closes, asserts trades > 0 AND |pos| <= max_lots (99 trades, pos=1 on local GPU). - CBSW spec + plan prefixed with SUPERSEDED headers. Per feedback_single_source_of_truth_no_duplicates + feedback_no_partial_refactor: contract change migrates every consumer in one commit. No legacy wrappers; no version suffixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0c4b106394 |
feat(ml-backtesting): pnl_track_step resets trail_hwm on close transition
Two-instruction addition to the existing close-emission branch (prev != 0 && now == 0). Without the reset, the next entry inherits a stale HWM that could arm the trail spuriously on event 0 of the new trade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1d852a994d |
feat(ml-backtesting): wire stop_check_isv into decision_policy_program
Both decision kernels now call the same __device__ helper at the top of per-backtest dispatch (after the program_lens early-return). Single source of truth for stop logic across default and bytecode-VM paths. Parity test ensures they produce identical market_target output for matched scenarios. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2cb4dfed28 |
feat(ml-backtesting): stop_check_isv __device__ helper + gate-on-flat
Skeleton helper called from decision_policy_default at top of
per-backtest dispatch. Returns 0 (no-op) on flat positions; trigger
logic for open positions added in Tasks 4-6. Single source of truth
for stop logic across decision_policy_{default,program}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
24a8b4c621 |
chore(ml-backtesting): allow(unsafe_code) on sim/mod.rs for cudarc launches
cudarc kernel launches require `unsafe` blocks — the driver API has no way to type-check kernel args against the cubin signature. The workspace-wide `-W unsafe-code` lint produces noise on every launch site (10+ blocks); suppressing at the module level keeps the lint useful elsewhere in the crate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dc8c37e11e |
feat(ml-backtesting): ATR-EMA on mid-price in book_update_apply_snapshot
Per-event Wiener-α=0.4 EMA on |Δmid| with first-observation bootstrap. Floor source for the SL/trail-distance controller (spec §6). Thread 0 of the broadcast-snapshot kernel handles the update per backtest; threads 1..9 still handle the 10 level writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5b5292aecd |
feat(ml-backtesting): add stop-controller state slots + accessors
Three CudaSlice<f32> per-backtest slots (prev_mid_d, atr_mid_ema_d, trail_hwm_d) plus test-only accessors. Foundation for the ISV-driven stop controller; no behavioral change until subsequent tasks wire them into kernels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ef831ba598 |
obs(ml-backtesting): trades=N on progress line for intra-run firing visibility
Adds LobSimCuda::read_total_trade_count (cheap n_backtests*4 byte DtoH of the trade-log head counters) and emits the sum on each PROGRESS_EVERY eprintln. Lets future smokes (Q2 CBSW validation, P7 sweep) see trade firing mid-run instead of waiting for write_artifacts at harness end. Heads count kernel writes (clamped to ring cap), so the sum is a monotone trade counter across the sweep — drops back only on harness reset which doesn't happen mid-run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fef5939556 |
feat(ml-backtesting): cold-start stopgap — max-confidence bytecode policy (Q1/Tier1)
The threshold-tuning smoke at
|
||
|
|
81decf40f8 |
feat(ml-backtesting): conviction_log side-channel + threshold-tuning smoke
P4 plumbed the threshold-gate kernel side but deferred the side-channel that captures observed max_conviction per decision. Wire it now so the threshold pre-registration step (spec §3.4) can compute the calibrated p60-p95 absolute threshold values from a real model-on-data run. Harness changes: - BacktestHarness gains conviction_log: Vec<f32>. Per decision, computes max_h |alpha[h] - 0.5| * 2 from the SAME probs that go into broadcast_ alpha (same value the threshold gate would compare against), pushes to the log. One shared vec — batched cells broadcast the same probs to every backtest, so per-backtest is redundant. - write_artifacts emits convictions.bin (raw little-endian f32) + conviction_percentiles.json with pre-computed p10/p25/p50/p60/p70/ p80/p90/p95/p99 + mean/min/max. Also eprintln-prints the summary line for at-a-glance log inspection. Smoke YAML switched to the threshold-tuning configuration: threshold=0 (no gate, full distribution captured), cost=0.125 (1-tick realistic anchor so the observed Sharpe is the no-gate net-of-cost floor for the sweep's deployability story). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7b96268efc |
fix(ml-backtesting): aggregator supports P6 batched output layout
Legacy fan-out writes <sweep-dir>/<cell>/summary.json (one cell per Argo task). P6 batched flow writes <sweep-dir>/<cell>/sim_<variant>/ summary.json (one Argo task → run_batched_cell → harness with variant_names → sim_<name>/ subdirs per spec §3.3). The aggregator was looking only at <sweep-dir>/<cell>/summary.json, so the realistic batched smoke completed the actual backtest fine (2M events, 500k decisions, real artifacts written) but the end-of-sweep aggregate step errored with "no cell directories with summary.json". Walk both layouts: directories containing summary.json directly are flat cells (legacy); directories one level deeper that contain summary.json are batched-cell variants. Cell labels become "<cell>/<variant>" so the aggregate.parquet rows distinguish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b1f8cd4389 |
chore(ml-backtesting): drop dead snapshot helpers from P3 migration
snapshot_realized_pnl / snapshot_position_lots / snapshot_open_horizon_mask
were the host-side read paths for the close-detection loop that P3
(
|
||
|
|
9d4fda36ab |
feat(ml-backtesting): batched-cell sweep schema + 140-variant runner (P6)
Sweep YAML now supports the batched flow per spec §3.3 + Task 6:
- SweepBase.sim_variants: Vec<SimVariant> — list of (cost, latency,
threshold, ...) variants. When non-empty, each cell runs ONE harness
at n_parallel=variants.len() with BatchedSimConfig::from_grid instead
of the legacy one-harness-per-cell fan-out.
- SweepBase.data_template: Option<String> — when set with `{window}`
placeholder, each cell's `window` field interpolates the per-cell
data path. Replaces single scalar `data` for the windowed flow.
- SweepCell.window: Option<String> — window identifier (e.g., "2025-Q2").
- SimVariant: threshold + cost_per_lot_per_side required (the spec's
primary axes); other fields optional overrides on top of SweepBase
scalars.
New runner pieces:
- BatchedSimConfig::from_grid(&[ResolvedSimVariant]) in
crates/ml-backtesting/src/sim/batched_config.rs.
- ResolvedSimVariant — per-variant fully-resolved sim params.
- resolve_sim_variants(&SweepBase) in main.rs — layers per-variant
overrides over base scalars.
- run_batched_cell() in main.rs — builds the harness with
sim_config_override + variant_names plumbed through. Writes per-
backtest artifacts to sim_<variant_name>/ subdirs (spec §3.3).
Harness side:
- BacktestHarnessConfig gains variant_names + sim_config_override
Option fields. When sim_config_override is Some, harness uses that
directly instead of building from_uniform off scalar cfg. When
variant_names is Some, write_artifacts uses sim_<name>/ instead of
cell_NNNN/ subdirs. Both None preserve legacy single-cell behaviour
(smoke, fixtures unchanged).
YAML configs:
- config/ml/sweep_threshold_tuning.yaml: 1 cell (W0) × 8 sim_variants
(p60-p95 in 5pt steps) with cost=0.125 (1-tick anchor). Threshold
pre-registration pass.
- config/ml/sweep_deployability.yaml: 4 cells (W1-W4) × 140 variants
each (7 costs × 4 latencies × 5 thresholds). Generated by
scripts/generate_sweep_variants.py — placeholder threshold values
(p60-p95) until threshold-tuning publishes calibrated absolutes to
config/ml/v2_prod_thresholds.json.
Deferred to P7 (operational glue):
- argo-lob-sweep.sh adaptation for the batched flow (cells = windows,
not sim-variants; one Argo task per window invokes `fxt-backtest sweep`
end-to-end inside the pod rather than `fxt-backtest run`).
Regression: all 7 existing CUDA tests pass through the new harness
construction path (sim_config_override = None → from_uniform fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
cd82f9a4a0 |
feat(ml-backtesting): threshold gate + per-fill cost integration (P4)
Adds the two sweep axes that the spec's deployability grid needs but were missing from the kernels: Threshold gate (decision_policy.cu, both kernels): - New per-backtest `threshold_per_b` array kernel arg. - Pre-Kelly prelude: if max_h |alpha[h] - 0.5| * 2 < threshold[b], emit noop and return. Kept deterministic from alpha alone so the threshold pre-registration step (p60-p95 absolute calibration on a validation window, future P6) reflects exactly what gets gated in deployment. Per-fill cost integration (resting_orders.cu / apply_fill_to_pos): - apply_fill_to_pos signature grows three args: b, cost_per_lot_per_side_per_b, total_fees_per_b. Single insertion point at line 90. - After the close-leg realized_pnl math runs (so the gross unwind P&L is preserved), deduct fill_cost = filled_lots * cost_per_lot_per_side[b] from pos.realized_pnl AND accumulate into total_fees_per_b[b]. - Net-of-cost semantics: isv_kelly_update_on_close reads realized_pnl delta which is now net of cost — Kelly state learns from realistic return distribution. - All 3 apply_fill_to_pos call sites in step_resting_orders updated. order_match.cu's submit_market_immediate path is dead code in the post-P1 flow (everything routes through seed_inflight_limits_batched → step_resting_orders → apply_fill_to_pos) so not touched here. BatchedSimConfig + UniformSimParams + BacktestHarnessConfig gain threshold + cost_per_lot_per_side fields. All UniformSimParams constructors in tests and main.rs updated with defaults (0.0, 0.0 = gate disabled, frictionless). Regression: - threshold_gate_skips_low_conviction (p=0.51 + threshold=0.10 → noop) - threshold_gate_allows_high_conviction (p=0.8 + threshold=0.10 → buy 1+) - threshold_zero_is_passthrough (sanity) - All P1+P2+P3 tests continue to pass via the new ABI. cost_deducted_at_each_fill + kelly_state_sees_net_return end-to-end tests deferred — they require a full submit_market → fill → close sequence, which the production smoke exercises. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3836e25783 |
feat(ml-backtesting): detect_close_transitions_batched kernel (P3)
Replaces TWO host loops in step_decision_with_latency with two GPU kernels: 1. snapshot_pos_state — replaces the host-side snapshot_realized_pnl + snapshot_position_lots + snapshot_open_horizon_mask trio (3 separate memcpy_dtoh per decision). Now one kernel launch writes prev_pos_lots_d / prev_realized_pnl_d / prev_open_horizon_mask_d directly on the device. 2. detect_close_transitions_batched — replaces the host close-detect loop that called read_pos per close-eligible backtest (up to n_backtests memcpy_dtoh per decision). Now one kernel writes closed_horizon_mask_d + realised_return_d on the device, and isv_kelly_update_on_close consumes them with no host roundtrip. At n_parallel=140 these two loops together accounted for ~350M+ small host roundtrips per quarter. Combined with P2 the latency-path of step_decision_with_latency is now fully GPU-resident. isv_kelly_update_on_close kernel always launches (skips backtests with mask=0 internally) rather than gating via a host any_close check. All P1+P2 regression tests pass through the new GPU close-detect path (at n=1 with uniform config + immediate-fill latency, no close happens in the cold-start tests since they only check market_target; the close path is exercised indirectly). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b741f0c5ce |
feat(ml-backtesting): seed_inflight_limits_batched kernel (P2)
Replaces the host roundtrip loop at the latency path of step_decision_ with_latency with one GPU kernel launch. At n_parallel=140 the host loop did up to 140 memcpy_dtoh + 140 seed_limit_order calls per decision (~210M roundtrips per quarter at the threshold-tuning load). The new kernel does the same work in one launch. Per-backtest single-writer (threadIdx.x==0). Each backtest scans its own MAX_LIMITS=32 slot range for an `active==0` slot. Slot allocation is per-backtest (no cross-backtest atomics needed). Overflow path increments pos.submission_overflow. dispatch_latent_market_orders now takes &BatchedSimConfig (unused inside — the latency_ns_d device buffer is already populated by step_decision_with_latency's upload block from P1). All 3 decision_floor_coldstart tests still pass via the new GPU path (at latency_ns=0 the in-flight slot's arrival_ts==current_ts and gets promoted on the next step_resting_orders, functionally equivalent to the legacy submit_market_immediate kernel). Independence test (different per-backtest latencies → different arrival_ts) deferred to a later step where a read_first_inflight_arrival_ts helper is added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b8966fb1a6 |
feat(ml-backtesting): per-backtest sim parameter arrays (P1)
Migrates target_annual_vol_units, annualisation_factor, max_lots, latency_ns, kelly_frac_floor, sharpe_weight_floor from scalar-broadcast kernel args to per-backtest device arrays via BatchedSimConfig. Atomic contract change per feedback_no_partial_refactor — kernel + sim + harness + all 3 existing test files migrate in this commit. - crates/ml-backtesting/src/sim.rs → sim/mod.rs (directory module) - crates/ml-backtesting/src/sim/batched_config.rs (NEW): BatchedSimConfig + UniformSimParams + validate(). from_uniform rebuilds the legacy uniform-broadcast behaviour at n=1 (smoke/fixtures). from_grid lands in P6 for the 140-variant sweep packing. - LobSimCuda gains 6 per-backtest device buffers (target_annual_vol_units_d, annualisation_factor_d, max_lots_d, latency_ns_d, kelly_frac_floor_d, sharpe_weight_floor_d). step_decision_with_latency uploads from BatchedSimConfig each call; both decision kernel launches now pass per-backtest array pointers. - decision_policy_default + decision_policy_program: scalar args become const float* / const int* per_b arrays; first lines of each kernel index by `b` into the arrays. Behaviour preserved at n=1 uniform. - dispatch_latent_market_orders: reads latency per-backtest from &BatchedSimConfig (host loop stays for P1; P2 replaces with kernel). - step_decision_with_latency now ALWAYS dispatches through the latency path; when cfg.latency_ns[b]=0 the in-flight slot's arrival_ts equals current_ts and gets promoted immediately on next snapshot. Eliminates the if/else branch and consolidates the launch path. - harness.rs: BacktestHarness gains a sim_config field, built via BatchedSimConfig::from_uniform at new() from the harness cfg's scalar fields. The run loop passes &self.sim_config to step_decision_with_latency. Regression coverage: - parallel_sim_correctness::parallel_sim_equivalence_with_uniform_config — n=8 with uniform config produces 8 bit-identical market_targets (proves per-backtest indexing reduces correctly). - Existing decision_floor_coldstart tests (3) all pass through the new ABI — proves cold-start floor + variance-cap gate behaviour preserved. - parallel_sim_independence_per_backtest deferred to P2 (needs read_first_inflight_arrival_ts helper that depends on LimitSlot layout). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
28d3ea57b7 |
fix(ml-backtesting): smoke stride 4 + harness progress log line
After widening the smoke to max_events=0 (full quarter), the inherited decision_stride=1 became 10M+ forward passes — hours of GPU per cell, indistinguishable from a deadlock in pod logs. Two operational fixes: - sweep_smoke.yaml: decision_stride 1 -> 4 (matches the sweep template's own default). - harness.rs: emit a `progress: events=... decisions=... elapsed=... rate=...ev/s` line every 1M events on stderr so multi-million-event cells are observable from kubectl logs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
da21feb1b1 |
fix(ml-backtesting): cold-start Kelly + Sharpe-weight floors in decision kernel
Production smoke completed end-to-end but produced n_trades=0 across 99,969 decisions — `decision_policy_default` and `decision_policy_program` both applied a sentinel-skip pattern: if `isv_kelly_d` had not been seeded (pnl_ema_win == 0), each horizon's signed-size stayed zero, AND each horizon's aggregation weight (= recent_sharpe) also stayed zero. The cross-horizon w_sum was therefore 0, final_size was 0, every market_target was noop. State only updates on trade close → no trade ever fires → infinite cold-start. Per pearl_blend_formulas_must_have_permanent_floor (`max(real, floor)`, not blend) and pearl_kelly_cap_signal_driven_floors, replace the sentinel- skip with a two-layer floor on each kernel: 1. Kelly fraction: `max(kelly_frac_floor, computed_kelly)` — when state is sentinel, falls back to the floor directly. Cap_lots falls back to `max_lots` when realised_return_var is sentinel. 2. Aggregation weight: `max(sharpe_weight_floor, recent_sharpe)` — lets cross-horizon sum produce a non-zero size before recent_sharpe is populated. Once a horizon shows positive sharpe it dominates. Plumbed through `step_decision_with_latency` / `step_decision` as two new f32 args (atomic contract change, every caller migrated). Defaults 0.20 / 0.10 chosen so a strong-conviction signal (sig_mag ≥ 0.5) fires 1 lot at cold-start under max_lots=5 while weaker signals stay flat (see `default_kelly_frac_floor` comment for the arithmetic). Exposed as CLI flags + sweep-grid base/cell overrides. Regression test `decision_floor_coldstart` proves: - default floors (0.20/0.10) fire a 1-lot buy with p_h=0.8 and zero state - zero floors reproduce the original noop bug Also moves `aggregate` step to the GPU pool because fxt-backtest is dynamically linked against libcuda.so.1 (the ci-compile-cpu hosts don't expose CUDA driver libs). Verified locally on RTX 3050 Ti — workspace cargo check passes, both regression tests pass, trunk save/load roundtrip still passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
395e0d3000 |
refactor(ml-backtesting): drive forward via PerceptionTrainer.forward_only
BacktestHarness now owns a PerceptionTrainer (in inference role) instead of a raw CfcTrunk. The sliding K-window of recent snapshots accumulates in the harness; at each decision-stride boundary (and only once the window has reached cfg.seq_len), the harness calls trainer.forward_only(&window) and broadcasts the last K position's per-horizon probs to the LobSim. fxt-backtest's main.rs constructs the trainer via PerceptionTrainer::from_checkpoint when --checkpoint is supplied (else random init for noise baseline). Why this shape: PerceptionTrainer's evaluate_batched already runs the full inference chain (snap → vsn → mamba2 → ln → mamba2 → ln → attn_pool → cfc K-loop → grn heads) correctly. Duplicating that 400-line forward chain on CfcTrunk would double the surface area for the same result — the trunk's role is weight-source-of-truth (achieved in X1-X9), not kernel-launch orchestration. End-to-end status: alpha_train emits Checkpoint files via X14 wiring; fxt-backtest now loads those Checkpoints via from_checkpoint and drives forward via forward_only. Phase 2 (Argo runtime: training → smoke → threshold pre-reg → 560-cell deployability sweep → verdict) is unblocked. Adds PerceptionTrainer::config() accessor so the harness can read seq_len. Verification: ml-alpha + ml-backtesting + fxt-backtest all build clean. |
||
|
|
0ddc861708 |
feat(ml-backtesting): max_drawdown_pct + deployability verdict emitter (X16+X17)
X16: Adds Summary.max_drawdown_pct field as |max_drawdown_usd| / STARTING_CAPITAL_USD (pinned at $35k per project_ml_alpha_starting_capital memory — realistic ES single-contract small-account anchor). Used by the verdict emitter as the capital-deployability hard gate (median across windows < 20%). X17: Adds VerdictTier enum (Pass-robust / Pass-nominal / Fail-inconclusive / Fail / Fail-degenerate), AnchorSpec / AnchorReport / DeployabilityVerdict types, classify_verdict (tiered logic per spec §3.5), and emit_deployability_verdict that reads per-cell summary.json files at both realistic (1.0 tick, 200 ms) and stress (1.5 tick, 400 ms) anchors, computes median Sharpe / Sortino / max_dd_pct / profit_factor across walk-forward windows, and applies the gates. Per spec §3.2 (X16), §3.5 (X17). |
||
|
|
19986c8d96 |
feat(ml-alpha): tick-rule signed trade-flow inference at L1 (C16)
Replaces the placeholder `trade_signed_vol = trade_count_delta` (always
non-negative, sign-neutral) with a proper Databento-standard tick-rule
inference applied to L1 size + price deltas across consecutive MBP-10
snapshots:
• ask_px[0] unchanged AND ask_sz[0] decreased → aggressive buys
consumed ask depth; add (prev.ask_sz − cur.ask_sz).
• bid_px[0] unchanged AND bid_sz[0] decreased → aggressive sells hit
the bid; subtract (prev.bid_sz − cur.bid_sz).
• ask_px[0] moved up → previous best ask cleared; add prev.ask_sz.
• bid_px[0] moved down → previous best bid hit; subtract prev.bid_sz.
• Pure cancellations (size shrank but price moved AWAY from us) =
ambiguous; ignore.
Convention matches `Mbp10RawInput::trade_signed_vol`: positive =
buyer-initiated, negative = seller-initiated. This is a LOWER-BOUND
estimator — won't catch trades that cleared multiple levels (those
manifest only via deeper-level deltas) or trades against hidden /
off-book liquidity. Acceptable for v1 queue-decay signal; production
deployments can layer the trades-stream loader for ground-truth flow.
Wired into BacktestHarness::run() → sim.step_resting_orders(ts, vol)
so the queue-decay branch of resting_orders.cu finally fires with
non-zero input. Previously the harness passed 0.0 unconditionally,
which meant resting limits could only fill via the price-cross
marketability branch — same-price queue-decay was inert.
Six new unit tests cover each branch of the inference (pure cancel,
ask-shrank, bid-shrank, ask-px-up, bid-px-down, mixed both-sides).
34 ml-alpha lib tests + 33 ml-backtesting lib + 12 GPU fixtures + 3
fuzz still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
344d7a67d7 |
feat(ml-backtesting): wire --latency-ns end-to-end + fixture (C12)
The in-flight machinery from C11 is now reachable from the harness +
CLI. New step_decision_with_latency on LobSimCuda:
latency_ns == 0 → existing immediate-match path (submit_market_immediate
kernel fills against current book).
latency_ns > 0 → host reads market_targets, converts each non-noop
into seed_limit_order(active=2, price=very-aggressive,
arrival_ts_ns = current + latency_ns). The
resting_orders kernel promotes + fills at arrival
time, so the order sees whatever book exists then —
not the book at decision time.
Aggressive-price heuristic: buy at 1e9 / sell at 0 — the marketability
check (price ≥ best ask for buy, ≤ best bid for sell) unconditionally
crosses at arrival; the kernel then walks book levels for the actual
fill price. This models "market order with latency" correctly because
slippage emerges from the book-walking at arrival, not from a limit
price boundary.
BacktestHarness gains a latency_ns field; harness::run() always calls
step_decision_with_latency now. Also calls sim.step_resting_orders
per event (with trade_signed_vol=0.0 — the Mbp10RawInput trade-flow
hookup is deferred to a trades-feed integration commit) so in-flight
orders get a chance to promote on every snapshot.
bin/fxt-backtest no longer ignores --latency-ns; the flag value
propagates through BacktestHarnessConfig.latency_ns into the kernel
path. Default stays at 100_000_000 (IBKR + Scaleway baseline per
spec §4). Setting --latency-ns 0 selects the legacy immediate path.
Adds latency_in_flight_miss GPU fixture: limit at 5510 submitted
active=2 at T=1s with arrival_ts=T+100ms. step_resting at T+50ms
sees no promotion (still in-flight). Book moves +5 against buyer
during the 100ms window. step_resting at T+150ms promotes the
limit and fills it at the WORSE post-move book (vwap=5505 vs the
original 5500 it would have hit without latency). Slot ends active=0.
11/11 GPU fixtures green on RTX 3050. 33 lib tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1fb2decb79 |
feat(ml-backtesting): resting orders + stops + OCO + audit ring (C11)
Picks up the deferred work from C5/C7 trim notes. Adds:
cuda/lob_state.cuh — LimitSlot (32 B) + StopSlot (32 B) + Orders
(limits[32] + stops[16] = 1536 B/backtest). u64-aligned with
explicit _pad[6] on both slot structs so the Rust mirrors
(LimitSlotFlat / StopSlotFlat) bytemuck::Pod-derive without
panics about implicit padding.
cuda/resting_orders.cu — three kernels:
resting_orders_step (runs per event after book_update):
1. In-flight → resting promotion at arrival_ts_ns. Queue position
initialised pessimistically (full level depth ahead) per spec §9.
2. Queue decay against same-side trade_signed_vol at level: ahead-
of-us first then us; partial-fill emits OrderEvent + pos update.
3. Marketability check: book moved through our price → cross at
the just-arrived book (slippage-aware fill walks levels).
IOC cancels remainder; FOK does too (partial-fill not allowed).
4. Stop trigger detection: best ask up for buy-stop, best bid
down for sell-stop. StopMarket walks book; StopLimit converts
to a new LimitSlot (overflow → submission_overflow++).
5. OCO mutual exclusion: oco_pair byte (0..31 = limit, 32..47 =
stop) — when one leg fills/triggers, the paired slot is freed.
seed_limit_slot / seed_stop_slot — host-side single-slot seeders
for fixture testing + as a v1 entry point until the decision
kernel learns to emit resting orders. Both set an overflow_flag
if the target slot is already in use (gen_counter bumped on
successful seed for SlotTag freshness).
src/sim.rs — wires three new methods:
seed_limit_order(b, slot, side, kind, active_state, oco_pair,
price, size, queue_position_init, arrival_ts_ns)
seed_stop_order(b, slot, side, kind, active_state, oco_pair,
trigger_price, limit_price, size, arrival_ts_ns)
step_resting_orders(current_ts_ns, trade_signed_vol)
→ launches resting_orders_step kernel + chains step_pnl_track
so any fill that closes a position emits a TradeRecord.
Plus read_limit_slot / read_stop_slot / read_audit_records for
fixture inspection.
Audit-ring buffers (deferred from C2 originally) now allocated:
audit_d (n × 256 × 24 B) + audit_head_d (n × u32). Both
resting_orders.cu and the decision kernel's WriteOrder path
populate it via the inlined pack_slot_tag + emit_audit helpers.
Four new GPU fixtures:
limit_rest_marketable_fill — resting buy at 5500 with book at
ask 5500 fills immediately on step_resting (3 lots, vwap=5500).
stop_trigger — buy 4 lots @ 5500, seed sell-stop at trigger=5495,
book moves so bid=5494.50: stop triggers, walks book, position
closes flat, TradeRecord emitted, stop slot active=0.
oco_one_cancels_other — pair {buy@5495, sell@5505} with oco_pair
cross-linked: book moves so bid=5505.50, sell leg fills (pos=-2),
buy leg auto-cancelled. Both slots end active=0.
submission_overflow — host loop fills all 32 limit slots, then 33rd
seed_limit_order call must return Err (slot 0 already in use).
All 10 GPU fixtures green on RTX 3050 (6 original + 4 new). Ring 2
fuzz at N ∈ {1, 16, 256} still green. 33 lib unit tests still green.
The decision kernel (C7) and submit_market_immediate (C5) paths
are unchanged — they continue to use the "immediate fill" route.
A follow-up commit can teach the decision kernel to emit resting
limits via submit_limit_alloc (already defined in resting_orders.cu).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
63ed6d0217 |
feat(ml-backtesting): artifacts + sweep aggregate + fxt-backtest CLI (C9)
artifacts.rs:
- Summary struct (total_pnl_usd, sharpe_ann, sortino_ann,
max_drawdown_usd, calmar, n_trades, win_rate, avg_win/avg_loss,
profit_factor, total_fees_usd, exposure_pct,
kelly_cap_history_sample).
- compute_summary(records, pnl_curve_usd) — non-overlapping
annualisation × √825 per pearl_phase1d4_backtest_cost_edge_frontier
(K=6000 holding × 250 trading days ≈ 825 trades/year).
Sharpe + Sortino + max drawdown + Calmar.
- write_summary (JSON pretty-printed), write_trades_csv (with USD
conversion from fp ×100), write_pnl_curve_bin (bytemuck-cast f32
slice). 5 unit tests with tempdir.
aggregate.rs:
- aggregate_sweep_dir walks <root>/<cell>/summary.json, builds an
arrow RecordBatch (cell name + 9 stats columns), writes
SNAPPY-compressed aggregate.parquet.
- pareto_frontier: cells are kept unless another cell weakly
dominates on all three of (sharpe_ann maxed, max_drawdown_usd
minimised, total_fees_usd minimised) AND strictly improves on one.
Written to pareto_frontier.json (Vec<cell-name>).
- 2 unit tests (3-cell mutual-non-dominance; B-dominates-A).
harness.rs:
- run() now samples Pos.realized_pnl × $50/index-pt per event into
self.pnl_curves[b], so the per-cell P&L curve is ready for
write_artifacts() without an extra sim pass.
- write_artifacts(out_dir) — per-cell <out>/cell_NNNN/{summary.json,
trades.csv, pnl_curve.bin}.
bin/fxt-backtest:
- clap-derive CLI with two subcommands:
run --data <dir> [--predecoded-dir <dir>] [--policy-grid <yaml>]
[--n-parallel N] [--decision-stride S] [--latency-ns N]
[--target-annual-vol-units F] [--annualisation-factor F]
[--max-lots N] [--max-events N] [--seed N] --out <dir>
aggregate <sweep_dir>
- Constructs MlDevice::cuda(0) + CfcTrunk::new_random for the trunk
(v1 — ml-alpha has no checkpoint format yet; --seed gates init).
- Parses --policy-grid YAML if given but doesn't yet plumb to the
LobSimCuda decision kernel (the v1 kernel hardcodes the
Strategy::default_for path; bytecode VM is C7's deferred follow-up).
Parse step kept end-to-end so the YAML format is validated now.
- --latency-ns parsed but not consumed — reserved for follow-up
resting-order in-flight promotion (deferred from C5).
Adds parquet + arrow + arrow-array + arrow-schema + serde_yaml to
ml-backtesting deps; bin/fxt-backtest added to workspace members.
All 33 lib tests + 6 GPU fixture tests green. CLI --help renders both
subcommands correctly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a4127935a8 |
feat(ml-backtesting): BacktestHarness orchestrator + Ring 1b parity (C8)
BacktestHarness::new(cfg, &dev, trunk) constructs a MultiHorizonLoader
(inference_only=true), captures the trunk's perception Graph A using
loader.peek_first() as the template, then allocates a LobSimCuda.
run() walks the chronological snapshot stream, calls apply_snapshot on
every event, and at decision-stride boundaries:
trunk.update_input_buffers(raw)
→ trunk.perception_forward_captured() → (probs[N_HORIZONS], proj)
→ sim.broadcast_alpha(&probs)
→ sim.step_decision(ts, target_vol, ann_factor, max_lots)
Returns RunStats { events_processed, decisions_taken }. The harness
deliberately accepts an externally-built CfcTrunk (random-init in v1)
because a checkpoint format isn't pinned in ml-alpha yet; when one
lands, the binary CLI (C9) can switch from new_random to load_checkpoint.
trainer_parity.rs Ring 1b — two ignored tests:
peek_first_byte_equal_across_modes
Verifies the Mbp10RawInput produced by the loader path used by the
backtest harness (inference_only=true) is BYTE-EQUAL to what the
trainer's loader (inference_only=false) produces from the same
source. Guards against any future refactor accidentally diverging
the two paths (e.g. someone special-casing inference path to skip
regime feature computation). All 50 f32 fields + scalars compared
via .to_bits() equality.
inference_iteration_matches_chronological_snapshots
Verifies next_inference_input() yields monotone-ts snapshots with
correct cur==prev semantics on the first read and prev_ts==prior_ts
afterwards.
Both tests skip gracefully when FOXHUNT_TEST_DATA fixtures lack
populated sidecars (the placeholder-empty bins committed in the
test_data/ tree).
GPU-side inference parity (probs[N_HORIZONS] bit-equal across loader
modes) is deferred until ml-alpha pins a checkpoint format and we have
a small checkpoint fixture to gate it on FOXHUNT_TEST_CKPT.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
25f43a4268 |
feat(ml-backtesting): decision_policy + per-horizon ISV-Kelly (C7)
Two new kernels in cuda/decision_policy.cu:
decision_policy_default — alpha[N_HORIZONS] × per-horizon IsvKellyState
→ market target. Per-horizon Kelly fraction × signal magnitude × ISV-cap
(target_annual_vol / sqrt(realised_return_var × annualisation_factor)),
aggregated via WeightedByRealizedSharpe (weights = max(0, recent_sharpe)
/ Σ; auto-shifts capital toward empirically winning horizons per spec §5
+ §6). No floor — sub-1-lot intents become no-op. Round-to-nearest on
the final lot count to dodge an f32-truncation off-by-one where
0.8(f32) * 0.75 * 5.0 ≈ 2.99999976 → trunc-int 2.
isv_kelly_update_on_close — for every horizon flagged in
closed_horizon_mask[b], updates pnl_ema_{win,loss} via Wiener-α (floor
0.4 per pearl_wiener_alpha_floor_for_nonstationary), win_rate_ema,
Welford-ish realised_return_var, and the recent_sharpe composite.
First-observation bootstrap (per pearl_first_observation_bootstrap):
sentinel n_trades_seen=0 → direct EMA replacement, no zero-bias warmup.
The full bytecode VM from spec §6 is NOT in this commit — the default
policy is hardcoded in the kernel as the path Strategy::default_for()
already produces. Bytecode plumbing in src/policy/mod.rs stays put for
v2 expansion (custom RegimeSwitch / Portfolio compositions).
IsvKellyState struct added to lob_state.cuh (24 bytes per horizon × 5
per backtest); host mirror IsvKellyStateHost from C3 cast-compatible
via bytemuck::Pod. LobSimCuda gains broadcast_alpha + step_decision +
read_isv_kelly + write_isv_kelly (warm-start). step_decision chains:
decision_policy → merge_open_mask → submit_market_immediate
→ pnl_track_step → host close-detect → isv_kelly_update_on_close.
PRE-submit pos/pnl/mask snapshots feed the host close-detection;
captured via three small DtoH copies (cold path, 24 bytes × n_backtests).
decision_alpha_buy_close fixture: warm-start h4 with positive Kelly
state (n=50, recent_sharpe=1.0), broadcast alpha[4]=0.9 → buy 3 lots
@ ask top 5500.00. Snapshot moves to bid 5505.00, broadcast alpha[4]=0.1
→ sell 3 lots → close at +15 P&L. Verify ISV-Kelly h4: n_trades 50→51
exactly, others unchanged. PASS — all 6 Ring 1 fixtures green on RTX 3050.
Out-of-scope for this commit (defer to follow-ups, per plan trim notes):
- stop_trigger / oco_one_cancels_other / submission_overflow fixtures
(need resting-order LimitSlot[32] machinery deferred from C5)
- Bytecode VM dispatch (RegimeSwitch / Portfolio compositions)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1b679b5e40 |
feat(ml-backtesting): pnl_track kernel + segment_complete TradeRecord emission
pnl_track_step runs after each matching pass, compares per-block
position-state-now against persisted OpenTradeState (24 B scratch)
and either:
- records entry context (entry_ts_ns, entry_px_x100, entry_size,
realised_at_open) on open transition (prev==0, now!=0); or
- emits a 40-byte TradeRecord into the per-block trade-log buffer
on close transition (prev!=0, now==0), reconstructing implied
exit_px from the realized P&L delta and converting to USD ×100
fixed-point ($50/index-point × 100 = ×5000 multiplier).
Multi-fill averaging (scale-in then partial close) deferred to v2 —
v1 covers the clean open→close case the spec calls out as primary.
LobSimCuda owns three new buffers: open_trade_state_d (n × 24),
trade_log_d (n × TRADE_LOG_CAP × 40), trade_log_head_d (n × u32).
submit_market now takes current_ts_ns and chains pnl_track_step
internally; step_pnl_track() exposed for caller-driven orchestration.
read_trade_records(backtest_idx) drains the per-block ring as
Vec<TradeRecord>; LSP-pinned 40-byte Pod struct from C2 lines up
1:1 with the kernel's hand-rolled byte writes.
pnl_accounting_buy_close fixture: buy 4 lots @ ask top (5500.00),
book moves +5 to bid top 5505.00, sell 4 to close. Expected
realized_pnl = (5505 − 5500.00) × 4 = $20 in price-units, which is
$20 × $50/contract × 100 = 100000 USD ×100 fixed-point. PASS within
$1 fixed-point tolerance.
All 5 Ring 1 fixtures green on RTX 3050.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
78f0618294 |
feat(ml-backtesting): order_match immediate market-fill kernel + Pos accounting
submit_market_immediate walks the ask/bid book for a buy/sell market
order, computes notional cost + filled lots across up to 10 levels,
and updates per-backtest Pos { position_lots, vwap_entry, realized_pnl }
with correct VWAP scale-in + counter-direction realized-P&L accounting.
Single-writer (thread 0) per block — no atomics per
feedback_no_atomicadd.md. Each backtest gets its own target slot
{ side, size } in the device-global targets array (side=2 = no-op).
Pos struct added to lob_state.cuh (24 bytes); host mirror PosFlat in
src/lob/mod.rs is repr(C) bytemuck::Pod for direct host↔device cast.
LobSimCuda gains submit_market(backtest_idx, side, size) +
read_pos(backtest_idx) -> PosFlat. Fixture harness extended with the
SubmitMarket event variant + ExpectedPos check (vwap_tol + realized_pnl_tol
for FP tolerance).
market_order_consumes_top fixture verifies a buy of 5 lots into
ask[3@5500.00, 10@5500.25] fills 3+2 → position_lots=5,
vwap_entry=5500.10 within 1e-3 tolerance. All 4 Ring 1 fixtures
(book_update × 3 + market_order × 1) green on RTX 3050.
Scope deliberately trimmed from plan C5: this commit lands market-fill
matching only. Queue decay on resting limits, in-flight latency
promotion, stop-trigger detection, and OCO leg-cancellation will land
in follow-up commits — each in its own kernel addition. This keeps the
incremental delta reviewable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|