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:
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user