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:
2026-06-28 14:41:45 +02:00
parent 725545cbde
commit 3fe4684190
8 changed files with 539 additions and 89 deletions
+189
View File
@@ -0,0 +1,189 @@
//! `ConstantCost` — a flat round-trip cost charged once per closed trade, in R.
//! The first cost node of the C10 cost-model graph: an ordinary downstream
//! node (C9) that reads the executor's trade-geometry and emits a cost-in-R record
//! isomorphic to `PositionManagement`'s R-triple — `cost_in_r` (charged on a close)
//! / `cum_cost_in_r` (its running sum) / `open_cost_in_r` (the open trade's would-be
//! cost, for the window-end trade `summarize_r` synthesises). R-pure: with flat-1R
//! sizing the cost is `cost_per_trade / |entry - stop|`; notional cancels (C10).
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// A flat per-trade cost in price units (`cost_per_trade`), emitted in R. Inputs are
/// four executor-exposed fields: `closed` (closed_this_cycle), `open`, `entry_price`,
/// `stop_price`. Emits `None` until the executor has produced a record this cycle.
pub struct ConstantCost {
cost_per_trade: f64,
cum: f64,
out: [Cell; 3],
}
impl ConstantCost {
pub fn new(cost_per_trade: f64) -> Self {
assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
Self { cost_per_trade, cum: 0.0, out: [Cell::from_f64(0.0); 3] }
}
/// The param-generic recipe: one `cost_per_trade` F64 knob.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"ConstantCost",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() },
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() },
],
output: vec![
FieldSpec { name: "cost_in_r".into(), kind: ScalarKind::F64 },
FieldSpec { name: "cum_cost_in_r".into(), kind: ScalarKind::F64 },
FieldSpec { name: "open_cost_in_r".into(), kind: ScalarKind::F64 },
],
params: vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(ConstantCost::new(p[0].f64())),
)
}
}
impl Node for ConstantCost {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
// Withhold until the executor's full geometry record is present this cycle
// (all four fields fire together; before warm-up none of them do).
let closed_w = ctx.bool_in(0);
let open_w = ctx.bool_in(1);
let entry_w = ctx.f64_in(2);
let stop_w = ctx.f64_in(3);
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_w.is_empty() {
return None;
}
let closed = closed_w[0];
let open = open_w[0];
let entry = entry_w[0];
let stop = stop_w[0];
let latched = (entry - stop).abs();
// A zero latched distance is a flat/degenerate cycle (no valid 1R denominator):
// it contributes no cost rather than dividing by zero — matching summarize_r's guard.
let per = if latched > 0.0 { self.cost_per_trade / latched } else { 0.0 };
let cost_in_r = if closed { per } else { 0.0 };
let open_cost_in_r = if open { per } else { 0.0 };
self.cum += cost_in_r;
self.out = [
Cell::from_f64(cost_in_r),
Cell::from_f64(self.cum),
Cell::from_f64(open_cost_in_r),
];
Some(&self.out)
}
fn label(&self) -> String {
format!("ConstantCost({})", self.cost_per_trade)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn cols() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
]
}
#[test]
fn no_geometry_yet_withholds() {
let mut c = ConstantCost::new(2.0);
let inputs = cols(); // entry column empty
assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn closed_charges_cost_per_trade_over_latched() {
let mut c = ConstantCost::new(2.0);
let mut inputs = cols();
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
inputs[1].push(Scalar::bool(false)).unwrap(); // not open
inputs[2].push(Scalar::f64(100.0)).unwrap(); // entry
inputs[3].push(Scalar::f64(96.0)).unwrap(); // stop -> latched 4.0
// cost_in_r = 2.0/4.0 = 0.5; cum = 0.5; open_cost_in_r = 0.0
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.5), Cell::from_f64(0.5), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn open_emits_would_be_cost_not_charged_to_cum() {
let mut c = ConstantCost::new(2.0);
let mut inputs = cols();
inputs[0].push(Scalar::bool(false)).unwrap(); // not closed
inputs[1].push(Scalar::bool(true)).unwrap(); // open
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
// cost_in_r = 0 (no close); cum stays 0; open_cost_in_r = 0.5
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
);
}
#[test]
fn zero_latched_contributes_no_cost() {
let mut c = ConstantCost::new(2.0);
let mut inputs = cols();
inputs[0].push(Scalar::bool(true)).unwrap();
inputs[1].push(Scalar::bool(false)).unwrap();
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0 -> no divide
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn label_carries_the_cost() {
// Parameterised sibling convention: the identifying param is in the label,
// so two differently-priced cost nodes disambiguate in a trace.
assert_eq!(ConstantCost::new(2.0).label(), "ConstantCost(2)");
assert_eq!(ConstantCost::new(0.5).label(), "ConstantCost(0.5)");
}
#[test]
#[should_panic(expected = "cost_per_trade must be >= 0")]
fn new_panics_on_negative_cost() {
let _ = ConstantCost::new(-1.0);
}
#[test]
fn cum_accumulates_across_closes() {
let mut c = ConstantCost::new(2.0);
let mut a = cols();
a[0].push(Scalar::bool(true)).unwrap();
a[1].push(Scalar::bool(false)).unwrap();
a[2].push(Scalar::f64(100.0)).unwrap();
a[3].push(Scalar::f64(96.0)).unwrap(); // 0.5
let _ = c.eval(Ctx::new(&a, Timestamp(0)));
let mut b = cols();
b[0].push(Scalar::bool(true)).unwrap();
b[1].push(Scalar::bool(false)).unwrap();
b[2].push(Scalar::f64(100.0)).unwrap();
b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2.0 -> 1.0; cum 1.5
assert_eq!(
c.eval(Ctx::new(&b, Timestamp(1))),
Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
);
}
}
+2
View File
@@ -18,6 +18,7 @@
mod add;
mod and;
mod bias;
mod constant_cost;
mod delay;
mod ema;
mod eqconst;
@@ -43,6 +44,7 @@ mod sub;
pub use add::Add;
pub use and::And;
pub use bias::Bias;
pub use constant_cost::ConstantCost;
pub use delay::Delay;
pub use ema::Ema;
pub use eqconst::EqConst;