6b53c239dd
Lift the duplicated cost-node skeleton — shared verbatim by ConstantCost and VolSlippageCost and locked by convention against CostSum — into one abstraction: - A new aura-std/src/cost.rs owns the cost-record contract (COST_WIDTH, COST_FIELD_NAMES, GEOMETRY_WIDTH), the CostNode factor trait (one hook: cost_numerator, in price units), the generic CostRunner<F> adapter that holds the shared state (cum, out) and the co-temporality skeleton (geometry gating, numerator/latched R-normalization, closed/open charge, 3-field emit), and a cost_node_builder schema assembler. - ConstantCost and VolSlippageCost become thin CostNode factors; their new() returns CostRunner<Self>, so every existing call site and unit test binds transparently and stays green verbatim. - CostSum and main.rs drop their local triple consts for the shared COST_FIELD_NAMES — the cycle-2 audit-flagged by-convention 3-field lockstep is now a structural single-source contract (the four copies of the triple collapse to one). main.rs also reads COST_WIDTH in place of the literal slot stride. Behaviour-preserving: the builders emit unchanged schemas (same port names), so the wiring, the net_r_equity seam, and summarize_r are untouched; the numerator/latched token form is preserved verbatim (IEEE-754 byte-identity). The existing suite is the regression net — all green: the per-node unit tests pass verbatim (0.5/1.5, 0.375/1.375), the composition E2E exact, the C18 no-cost golden byte-identical. Two new CLI characterization goldens pin the exact net_expectancy_r of the flat and composed cost paths (the prior tests only asserted net < gross, which a numerator drift would pass silently). New cost.rs runner/contract tests + the HalfSpreadCost author doctest cover the new surface. Deviation from the plan: CostNode::name() was dropped (the plan over-specified it) — it is dead surface, nothing consumes it (CostRunner forwards label(); cost_node_builder takes the name as an explicit arg). Removed from the trait, both impls, the doctest, and the test stubs; build + suite + clippy green. Verified: cargo build --workspace --all-targets clean; cargo test --workspace 0 failures; cargo clippy --workspace --all-targets -- -D warnings clean; doctest green. refs #148
144 lines
5.4 KiB
Rust
144 lines
5.4 KiB
Rust
//! `ConstantCost` — a flat round-trip cost charged once per closed trade, in R.
|
|
//! The simplest cost node of the C10 cost-model graph: a stateless [`CostNode`]
|
|
//! factor whose price-unit numerator is a flat `cost_per_trade`. The shared
|
|
//! [`CostRunner`] supplies the co-temporality skeleton (geometry gating, the
|
|
//! `cost_per_trade / |entry - stop|` R-normalization, the closed/open charge, the
|
|
//! running `cum`, the 3-field emit). R-pure: notional cancels (C10).
|
|
|
|
use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};
|
|
|
|
use crate::cost::{cost_node_builder, CostNode, CostRunner};
|
|
|
|
/// A flat per-trade cost in price units (`cost_per_trade`), emitted in R via the
|
|
/// shared [`CostRunner`].
|
|
pub struct ConstantCost {
|
|
cost_per_trade: f64,
|
|
}
|
|
|
|
impl ConstantCost {
|
|
/// A flat per-trade cost node: the factor wrapped in the shared [`CostRunner`].
|
|
pub fn new(cost_per_trade: f64) -> CostRunner<ConstantCost> {
|
|
assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
|
|
CostRunner::new(ConstantCost { cost_per_trade })
|
|
}
|
|
|
|
/// The param-generic recipe: one `cost_per_trade` F64 knob, no extra inputs.
|
|
pub fn builder() -> PrimitiveBuilder {
|
|
cost_node_builder(
|
|
"ConstantCost",
|
|
Vec::new(),
|
|
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
|
|p| Box::new(ConstantCost::new(p[0].f64())),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl CostNode for ConstantCost {
|
|
fn label(&self) -> String {
|
|
format!("ConstantCost({})", self.cost_per_trade)
|
|
}
|
|
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
|
self.cost_per_trade
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use aura_core::{AnyColumn, Cell, Node, 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())
|
|
);
|
|
}
|
|
}
|