feat(analysis,campaign,registry,cli): bootstrap net R through the process pipeline

The OOS bootstrap conduit RMetrics.trade_rs becomes net_trade_rs and
carries the COST-NETTED per-trade R (r − cost_in_r, trade order). Every
conduit consumer is thereby net-when-costed: the monte-carlo pooled-OOS
and per-survivor bootstraps, the walk-forward oos_r pooling, and the
deflation null-max — which previously compared a net observed statistic
against a gross-resampled null whenever a costed campaign selected on
net_expectancy_r. An uncosted run is bit-identical (empty cost stream
⇒ cost 0.0 per trade), so every existing golden pin stays green.

Design: one conduit, no knob — an explicit net: knob would let a costed
campaign silently produce a gross headline again (the exact misreading
of the issue's evidence). Per-member RMetrics scalar fields stay gross;
net_expectancy_r keeps its meaning. Wire shape unchanged (serde(skip)).
The net series is materialized as a separate expression in summarize_r;
the pinned-float expressions (net_sum, the SQN pair, the lockstep
r_metrics_from_rs copies) keep their tokens verbatim. Fork decisions
and rationales: issue #259 comments.

New coverage, both hostless over the synthetic SYMA archive: a costed
campaign twin shifts the pooled-OOS bootstrap (sweep→gate→wf→mc) and
every per-survivor bootstrap (sweep→mc) below its gross sibling, with
the trade population unchanged; a unit test pins the conduit as the
cost-netted series with the empty-stream degeneracy. Existing conduit
tests renamed with the field.

Verified: full workspace suite green (71 result blocks), clippy clean,
zero bare trade_rs tokens remain, ledger conduit prose updated in place.

closes #259
This commit is contained in:
2026-07-13 21:21:41 +02:00
parent 1ccce9ad32
commit bb0b0aeac2
9 changed files with 283 additions and 79 deletions
+6 -6
View File
@@ -875,11 +875,11 @@ fn select_winner(
/// (window order, then within-window trade order). Windows with no `r` block
/// contribute nothing. The single home of the pooling-in-roll-order semantics —
/// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this.
fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
fn pooled_oos_net_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
result
.windows
.iter()
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.trade_rs.clone()).unwrap_or_default())
.flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.net_trade_rs.clone()).unwrap_or_default())
.collect()
}
@@ -888,14 +888,14 @@ fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
/// (C14).
fn walkforward_summary_json(result: &WalkForwardResult) -> String {
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0);
let pooled_rs = pooled_oos_trade_rs(result);
let pooled_rs = pooled_oos_net_trade_rs(result);
let mut obj = serde_json::json!({
"windows": result.windows.len(),
"stitched_total_pips": total,
"param_stability": param_stability(result),
});
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) {
// RMetrics serializes its scalar fields (trade_rs is serde-skipped, so the
// RMetrics serializes its scalar fields (net_trade_rs is serde-skipped, so the
// oos_r block is the clean R-metric summary of the pooled series).
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
.expect("RMetrics serializes");
@@ -914,7 +914,7 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String {
/// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while
/// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match
/// would miss the wrapped ones) through the same `MetricStats::from_values`; the
/// `oos_r` block pools the per-window `trade_rs` through `r_metrics_from_rs`.
/// `oos_r` block pools the per-window `net_trade_rs` through `r_metrics_from_rs`.
/// Canonical JSON (C14).
///
/// `axes` carries the invocation's raw axis names in argv order, followed by the
@@ -954,7 +954,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String])
.collect();
let pooled_rs: Vec<f64> = reports
.iter()
.flat_map(|r| r.metrics.r.as_ref().map(|m| m.trade_rs.clone()).unwrap_or_default())
.flat_map(|r| r.metrics.r.as_ref().map(|m| m.net_trade_rs.clone()).unwrap_or_default())
.collect();
let mut obj = serde_json::json!({
"windows": reports.len(),