feat(0081): cost-model graph cycle 1 — ConstantCost node + net-R seam
Lands the first cycle of the "Cost-model graph (in R)" milestone: cost is
now a composable in-graph node, not a post-run scalar (C10).
- ConstantCost (aura-std): a flat round-trip cost per trade, emitted in R
as a 3-field record {cost_in_r, cum_cost_in_r, open_cost_in_r} isomorphic
to PositionManagement's R-triple. R-pure: cost_in_r = cost_per_trade /
|entry-stop|; notional cancels, no equity held.
- summarize_r folds the cost stream into net_expectancy_r, replacing its
scalar round_trip_cost (one home for cost, no double-count). Positional
co-temporal join; an empty stream is the gross-R baseline.
- net_r_equity tap (stage1_r_graph): LinComb(4) over [cum_realized_r,
unrealized_r, -cum_cost_in_r, -open_cost_in_r] -> Recorder, a sibling of
r_equity; --cost-per-trade on the run path wires the node + tap + fold.
- A no-cost run is byte-identical: no net_r_equity tap, net == gross, the
C18 golden unchanged.
Tests: exact-subsumption (the node-stream net == mean(r_i - cost_i) over
ALL trades incl. the window-end open one), the in-graph net_r_equity tap +
net < gross E2E, the no-cost golden held. Full suite green; clippy clean.
Implement-time decisions ratified (recorded on #148): the cost stream is
recorded 3-wide (the node's full output; summarize_r reads col 0/2, col 1
feeds the in-graph net curve) — resolving a 2-wide/3-wide inconsistency in
the plan's Step 3; Trade drops its `latched` field (cost now arrives in R),
so r_col ENTRY_PRICE/STOP_PRICE become #[allow(dead_code)] layout contract.
Scoped to the run path (non-reduce); sweep/walkforward/mc pass None — cost
on the reduce-mode sweep path and OOS-pooling cost are a deferred follow-up.
Known pre-existing concern (not this cycle): aura-std lib.rs module doc
still names "realistic broker profiles", retired by the C10 rework.
refs #148
This commit is contained in:
+113
-35
@@ -135,7 +135,13 @@ mod r_col {
|
||||
pub const CLOSED: usize = 0;
|
||||
pub const REALIZED_R: usize = 1;
|
||||
pub const DIRECTION: usize = 4;
|
||||
// entry/stop define the latched distance (1R). Since the net-of-cost fold reads the
|
||||
// cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers
|
||||
// the distance), this crate's reductions no longer touch these two columns; they stay in
|
||||
// the documented layout contract and are exercised by the test fixtures.
|
||||
#[allow(dead_code)]
|
||||
pub const ENTRY_PRICE: usize = 6;
|
||||
#[allow(dead_code)]
|
||||
pub const STOP_PRICE: usize = 7;
|
||||
pub const CONVICTION_AT_ENTRY: usize = 9;
|
||||
pub const SIZE: usize = 10;
|
||||
@@ -143,6 +149,17 @@ mod r_col {
|
||||
pub const UNREALIZED_R: usize = 12;
|
||||
}
|
||||
|
||||
// Cost-model record column indices — the lockstep contract with aura-std's
|
||||
// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`).
|
||||
// Read positionally across the crate boundary as a type-erased `Scalar` vector
|
||||
// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the
|
||||
// window-end open trade's would-be cost (col 2); the running sum (col 1) is not
|
||||
// folded, so it is intentionally not named here.
|
||||
mod cost_col {
|
||||
pub const COST_IN_R: usize = 0;
|
||||
pub const OPEN_COST_IN_R: usize = 2;
|
||||
}
|
||||
|
||||
/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN
|
||||
/// score"): capping n stops the single-number objective rewarding sheer turnover.
|
||||
const SQN_CAP: u64 = 100;
|
||||
@@ -151,23 +168,34 @@ const SQN_CAP: u64 = 100;
|
||||
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
|
||||
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
|
||||
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
|
||||
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) -> RMetrics {
|
||||
pub fn summarize_r(
|
||||
record: &[(Timestamp, Vec<Scalar>)],
|
||||
cost: &[(Timestamp, Vec<Scalar>)],
|
||||
) -> RMetrics {
|
||||
// Collect one entry per trade: its realised R, the entry-conviction |bias|, and the
|
||||
// latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the
|
||||
// record. The ledger is the closed rows; a position still open on the last row is
|
||||
// force-closed at its unrealized R (a window-end trade).
|
||||
// per-trade cost-in-R the cost-model graph supplied (read positionally from the
|
||||
// co-temporal `cost` stream). The ledger is the closed rows; a position still open on
|
||||
// the last row is force-closed at its unrealized R (a window-end trade).
|
||||
struct Trade {
|
||||
r: f64,
|
||||
bias_abs: f64,
|
||||
latched: f64,
|
||||
cost: f64,
|
||||
}
|
||||
// Positional join: the cost stream is co-temporal 1:1 with the record (both recorded
|
||||
// per cycle off the same executor cadence — a cost node emits exactly when PM emits),
|
||||
// so `record[i]` and `cost[i]` are the same cycle. A closed row `i` reads `cost[i]`'s
|
||||
// `cost_in_r` (col 0); the window-end open last row reads its `open_cost_in_r` (col 2).
|
||||
// An empty `cost` slice ⇒ every `.get(i)` is `None` ⇒ cost `0.0` (the gross-R baseline).
|
||||
// Positional avoids the shared-timestamp collision a `ts`-keyed map would have (C4:
|
||||
// same `ts` can be two cycles).
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
for (_, row) in record {
|
||||
for (i, (_, row)) in record.iter().enumerate() {
|
||||
if row[r_col::CLOSED].as_bool() {
|
||||
let c = cost.get(i).map(|(_, cr)| cr[cost_col::COST_IN_R].as_f64()).unwrap_or(0.0);
|
||||
trades.push(Trade {
|
||||
r: row[r_col::REALIZED_R].as_f64(),
|
||||
bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(),
|
||||
latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(),
|
||||
cost: c,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -175,10 +203,14 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
if let Some((_, last)) = record.last()
|
||||
&& last[r_col::OPEN].as_bool()
|
||||
{
|
||||
let c = cost
|
||||
.get(record.len() - 1)
|
||||
.map(|(_, cr)| cr[cost_col::OPEN_COST_IN_R].as_f64())
|
||||
.unwrap_or(0.0);
|
||||
trades.push(Trade {
|
||||
r: last[r_col::UNREALIZED_R].as_f64(),
|
||||
bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(),
|
||||
latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(),
|
||||
cost: c,
|
||||
});
|
||||
n_open_at_end = 1;
|
||||
}
|
||||
@@ -241,12 +273,9 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
(0.0, 0.0)
|
||||
}
|
||||
};
|
||||
// net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R
|
||||
// by dividing by that trade's latched R-distance (a zero distance contributes no cost).
|
||||
let net_sum: f64 = trades
|
||||
.iter()
|
||||
.map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 })
|
||||
.sum();
|
||||
// net-of-cost: subtract the cost-model's per-trade cost-in-R (from the cost stream;
|
||||
// an empty stream is the gross-R baseline — every trade's cost is 0.0).
|
||||
let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
|
||||
let net_expectancy_r = net_sum / n as f64;
|
||||
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
|
||||
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
|
||||
@@ -604,9 +633,26 @@ mod tests {
|
||||
v[r_col::OPEN] = Scalar::bool(false);
|
||||
(Timestamp(0), v)
|
||||
}
|
||||
|
||||
// A 14-wide PositionManagement dense row carrying BOTH the closed-trade geometry
|
||||
// (entry/stop/conviction) AND the open-at-end fields (open/unrealized_r) — the one
|
||||
// fixture the cost-stream subsumption test needs (`r_row_full` forces closed=true, so
|
||||
// it cannot build a window-end open row). Only the columns summarize_r reads are set;
|
||||
// the rest default to f64(0.0) (never read), mirroring the existing row helpers.
|
||||
fn row14(closed: bool, r: f64, entry: f64, stop: f64, conv: f64, open: bool, unreal: f64) -> Vec<Scalar> {
|
||||
let mut v = vec![Scalar::f64(0.0); 14];
|
||||
v[r_col::CLOSED] = Scalar::bool(closed);
|
||||
v[r_col::REALIZED_R] = Scalar::f64(r);
|
||||
v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
|
||||
v[r_col::STOP_PRICE] = Scalar::f64(stop);
|
||||
v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(conv);
|
||||
v[r_col::OPEN] = Scalar::bool(open);
|
||||
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
|
||||
v
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_is_zero_on_empty() {
|
||||
let m = summarize_r(&[], 0.0);
|
||||
let m = summarize_r(&[], &[]);
|
||||
assert_eq!(m.n_trades, 0);
|
||||
assert_eq!(m.expectancy_r, 0.0);
|
||||
assert_eq!(m.max_r_drawdown, 0.0);
|
||||
@@ -624,7 +670,7 @@ mod tests {
|
||||
r_row(true, -1.0, true, 0.0),
|
||||
r_row(true, 1.0, false, 0.0), // last row not open
|
||||
];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert_eq!(m.n_trades, 3);
|
||||
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
|
||||
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
|
||||
@@ -635,7 +681,7 @@ mod tests {
|
||||
fn summarize_r_force_closes_open_position_at_window_end() {
|
||||
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
|
||||
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
assert_eq!(m.n_open_at_end, 1);
|
||||
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
|
||||
@@ -644,7 +690,7 @@ mod tests {
|
||||
fn summarize_r_max_drawdown_on_by_trade_curve() {
|
||||
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
|
||||
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
|
||||
assert!((summarize_r(&rec, 0.0).max_r_drawdown - 2.0).abs() < 1e-9);
|
||||
assert!((summarize_r(&rec, &[]).max_r_drawdown - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -657,17 +703,17 @@ mod tests {
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, -1.0, false, 0.0),
|
||||
];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() {
|
||||
// n < 2 -> 0
|
||||
assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0);
|
||||
assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], &[]).sqn, 0.0);
|
||||
// identical R -> zero variance -> 0
|
||||
let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)];
|
||||
assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0);
|
||||
assert_eq!(summarize_r(&flat, &[]).sqn, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -678,7 +724,7 @@ mod tests {
|
||||
let rec: Vec<_> = (0..144)
|
||||
.map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0))
|
||||
.collect();
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert_eq!(m.n_trades, 144);
|
||||
assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized);
|
||||
assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100");
|
||||
@@ -699,7 +745,7 @@ mod tests {
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, -1.0, false, 0.0),
|
||||
];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn");
|
||||
assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here");
|
||||
}
|
||||
@@ -716,7 +762,7 @@ mod tests {
|
||||
r_row_full(1.0, 100.0, 99.0, 0.9),
|
||||
r_row_full(2.0, 100.0, 99.0, 1.0),
|
||||
];
|
||||
let m = summarize_r(&calibrated, 0.0);
|
||||
let m = summarize_r(&calibrated, &[]);
|
||||
assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r);
|
||||
assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r);
|
||||
assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated");
|
||||
@@ -731,29 +777,61 @@ mod tests {
|
||||
r_row_full(-1.0, 100.0, 99.0, 0.9),
|
||||
r_row_full(-2.0, 100.0, 99.0, 1.0),
|
||||
];
|
||||
let a = summarize_r(&anti, 0.0);
|
||||
let a = summarize_r(&anti, &[]);
|
||||
assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_conviction_terciles_zero_under_three_trades() {
|
||||
let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)];
|
||||
assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]);
|
||||
assert_eq!(summarize_r(&rec, &[]).conviction_terciles_r, [0.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() {
|
||||
// one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price
|
||||
// units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9.
|
||||
// one closed trade: R=+2, entry 100, stop 90 -> latched 10. A ConstantCost(1.0)
|
||||
// node (1.0 price units) emits cost_in_r = 1.0/10 = 0.1 on the closed row (col 0).
|
||||
// gross E[R]=2.0, net = 1.9. The empty stream is the gross baseline (net == gross).
|
||||
let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)];
|
||||
let gross = summarize_r(&rec, 0.0);
|
||||
let net = summarize_r(&rec, 1.0);
|
||||
let gross = summarize_r(&rec, &[]);
|
||||
// producer 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]: closed -> 0.1 / 0.1 / 0.
|
||||
let cost = vec![(Timestamp(0), vec![Scalar::f64(0.1), Scalar::f64(0.1), Scalar::f64(0.0)])];
|
||||
let net = summarize_r(&rec, &cost);
|
||||
assert!((gross.expectancy_r - 2.0).abs() < 1e-9);
|
||||
assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross");
|
||||
assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy");
|
||||
assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
|
||||
}
|
||||
|
||||
/// Property: `summarize_r` folding the cost stream reproduces the retired scalar
|
||||
/// `round_trip_cost` argument EXACTLY — `net_expectancy_r == mean(rᵢ − costᵢ)` over
|
||||
/// BOTH the closed trade (cost from the stream's col 0) and the window-end open trade
|
||||
/// (cost from col 2), with the cost-in-R taken straight from the co-temporal stream a
|
||||
/// `ConstantCost` node emits (not recomputed from latched). Exercises a nonzero cost,
|
||||
/// so it proves exact subsumption — not merely the cost=0 baseline.
|
||||
#[test]
|
||||
fn summarize_r_folds_cost_stream_exactly_like_the_scalar_over_all_trades() {
|
||||
// Trade 1: closed, entry 100 stop 96 (latched 4), realized R = 1.0.
|
||||
// Trade 2: still open at window end, entry 100 stop 98 (latched 2), unrealized R = 0.5.
|
||||
let c = 2.0_f64;
|
||||
let record = vec![
|
||||
(Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)),
|
||||
(Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)),
|
||||
];
|
||||
// Cost stream the ConstantCost(2.0) node emits, co-temporal — the producer's
|
||||
// 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r].
|
||||
let cost = vec![
|
||||
// closed row: 0.5 charged on close (col 0), cum 0.5, no open cost.
|
||||
(Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]),
|
||||
// open row: no close cost, cum unchanged, would-be open cost 1.0 (col 2).
|
||||
(Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]),
|
||||
];
|
||||
let m = summarize_r(&record, &cost);
|
||||
// gross E[R] = mean(1.0, 0.5) = 0.75; net = mean(1.0-0.5, 0.5-1.0) = mean(0.5,-0.5) = 0.0
|
||||
assert!((m.expectancy_r - 0.75).abs() < 1e-12);
|
||||
assert!((m.net_expectancy_r - 0.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
// One PositionManagement dense record row for the derive tests: only the columns
|
||||
// derive_position_events reads (closed, direction, size, open) are set; the rest
|
||||
// default to 0. Distinct per-row timestamps (the derive keys events on the row's
|
||||
@@ -923,14 +1001,14 @@ mod tests {
|
||||
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
|
||||
// record; trade_rs must carry [2.0, -1.0] in trade order.
|
||||
let rec = pm_record_two_closed_trades(); // helper below
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert_eq!(m.trade_rs, vec![2.0, -1.0]);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_empty_record_has_empty_trade_rs() {
|
||||
let m = summarize_r(&[], 0.0);
|
||||
let m = summarize_r(&[], &[]);
|
||||
assert!(m.trade_rs.is_empty());
|
||||
assert_eq!(m.n_trades, 0);
|
||||
}
|
||||
@@ -945,7 +1023,7 @@ mod tests {
|
||||
#[test]
|
||||
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
|
||||
let rec = pm_record_closed_then_open_at_end();
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
let m = summarize_r(&rec, &[]);
|
||||
assert_eq!(m.trade_rs, vec![2.0, 0.5]);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
assert_eq!(m.n_open_at_end, 1);
|
||||
@@ -969,7 +1047,7 @@ mod tests {
|
||||
// (trade_rs is an in-memory conduit, excluded from equality) — this is what
|
||||
// keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped,
|
||||
// so it deserializes empty).
|
||||
let a = summarize_r(&pm_record_two_closed_trades(), 0.0);
|
||||
let a = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||||
let mut b = a.clone();
|
||||
b.trade_rs = Vec::new();
|
||||
assert_eq!(a, b);
|
||||
@@ -979,7 +1057,7 @@ mod tests {
|
||||
fn populated_trade_rs_is_absent_from_serialized_json() {
|
||||
// the in-memory conduit must never reach the wire: a populated trade_rs is
|
||||
// dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
|
||||
let m = summarize_r(&pm_record_two_closed_trades(), 0.0);
|
||||
let m = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||||
assert_eq!(m.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated");
|
||||
let json = serde_json::to_string(&m).expect("RMetrics serializes");
|
||||
assert!(!json.contains("trade_rs"), "trade_rs must not reach the wire: {json}");
|
||||
|
||||
Reference in New Issue
Block a user