refactor: split the aura-std roster into C28 layer crates

Phase 4 of the Stratification milestone. aura-std held four C28 ladder
layers in one roster; this cuts them into layer-aligned, aura-core-only
node crates so the import direction is enforced by the crate graph:

- aura-std        — engine nodes only (arithmetic/logic/rolling + sinks)
- aura-market     — session, resample
- aura-strategy   — bias, stops, sizer, cost-model machinery
- aura-backtest   — sim_broker, position_management
- aura-vocabulary — the relocated closed std_vocabulary roster

Node modules move verbatim (byte-identical renames); consumers are
rewired by import path only. A new structural test
(aura-vocabulary/tests/c28_layering.rs) asserts each node crate's
[dependencies] stay within its C28-permitted inner set, catching the
acyclic-but-outward violation the compiler misses.

Behaviour byte-identical: full workspace suite green (1448 tests), no
golden edited, clippy -D warnings clean. C28 Status block updated.

closes #288
This commit is contained in:
2026-07-19 20:28:20 +02:00
parent 34ff539143
commit b39fd63396
66 changed files with 398 additions and 152 deletions
-97
View File
@@ -1,97 +0,0 @@
//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional
//! bias (C10). The strategy's primary output: one f64 input, one f64 output
//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional)
//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer).
//! `scale` sets which signal magnitude maps to full conviction.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Emits `None` until its input is present (warm-up filter, C8).
pub struct Bias {
scale: f64,
out: [Cell; 1],
}
impl Bias {
/// Build a bias node with saturation magnitude `scale` (must be > 0).
pub fn new(scale: f64) -> Self {
assert!(scale > 0.0, "Bias scale must be > 0");
Self { scale, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe for a blueprint primitive: declares `scale` and builds
/// through `Bias::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Bias",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Bias::new(p[0].f64())),
)
}
}
impl Node for Bias {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // not yet warmed up (C8 filter)
}
self.out[0] = Cell::from_f64((w[0] / self.scale).clamp(-1.0, 1.0));
Some(&self.out)
}
fn label(&self) -> String {
format!("Bias({})", self.scale)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn bias_clamps_to_unit_band() {
let mut e = Bias::new(0.5);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
// (raw signal, expected clamped bias) for scale 0.5
let cases = [
(0.1_f64, 0.2_f64), // within band
(0.5, 1.0), // at the high edge
(1.0, 1.0), // saturates high
(-0.1, -0.2), // within band, negative
(-1.0, -1.0), // saturates low
];
for (sig, want) in cases {
inputs[0].push(Scalar::f64(sig)).unwrap();
assert_eq!(
e.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(want)].as_slice())
);
}
}
#[test]
fn bias_is_none_until_input_present() {
let mut e = Bias::new(0.5);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn input_slot_is_named_signal() {
assert_eq!(Bias::builder().schema().inputs[0].name, "signal");
}
}
-121
View File
@@ -1,121 +0,0 @@
//! `CarryCost` — a flat per-held-cycle carry/financing cost, in R. The simplest
//! ACCRUAL cost node (C10): a cost incurred for every cycle a position is held,
//! accruing over the hold rather than charged once at close. A labelled stress
//! parameter (a constant price-unit carry), the flat base case of the accrual
//! family. A `ConstantCost` twin differing only in `charge_mode() -> PerHeldCycle`;
//! the shared `CostRunner` accrues, dumps at close, and marks the open position so
//! the net_r_equity curve bleeds over the hold.
use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};
use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner};
/// A flat per-held-cycle carry in price units (`carry_per_cycle`), emitted in R via the
/// shared [`CostRunner`] in its per-held-cycle (accrual) charge mode.
pub struct CarryCost {
carry_per_cycle: f64,
}
impl CarryCost {
/// A flat per-held-cycle carry node: the factor wrapped in the shared [`CostRunner`].
pub fn new(carry_per_cycle: f64) -> CostRunner<CarryCost> {
assert!(carry_per_cycle >= 0.0, "CarryCost carry_per_cycle must be >= 0");
CostRunner::new(CarryCost { carry_per_cycle })
}
/// The param-generic recipe: one `carry_per_cycle` F64 knob, no extra inputs.
pub fn builder() -> PrimitiveBuilder {
cost_node_builder(
"CarryCost",
Vec::new(),
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|p| Box::new(CarryCost::new(p[0].f64())),
)
}
}
impl CostNode for CarryCost {
fn label(&self) -> String {
format!("CarryCost({})", self.carry_per_cycle)
}
fn charge_mode(&self) -> ChargeMode {
ChargeMode::PerHeldCycle
}
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
self.carry_per_cycle
}
}
#[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 charge_mode_is_per_held_cycle() {
assert_eq!(CarryCost { carry_per_cycle: 1.0 }.charge_mode(), ChargeMode::PerHeldCycle);
}
#[test]
fn accrues_each_held_cycle_then_dumps_at_close() {
// per = 1/4 = 0.25. open, open, close -> open_cost 0.25, 0.5, then dump 0.75.
let mut c = CarryCost::new(1.0);
let mut a = cols();
a[0].push(Scalar::bool(false)).unwrap();
a[1].push(Scalar::bool(true)).unwrap();
a[2].push(Scalar::f64(100.0)).unwrap();
a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4
assert_eq!(
c.eval(Ctx::new(&a, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.25)].as_slice())
);
let mut b = cols();
b[0].push(Scalar::bool(false)).unwrap();
b[1].push(Scalar::bool(true)).unwrap();
b[2].push(Scalar::f64(100.0)).unwrap();
b[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
c.eval(Ctx::new(&b, Timestamp(1))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
);
let mut d = cols();
d[0].push(Scalar::bool(true)).unwrap();
d[1].push(Scalar::bool(false)).unwrap();
d[2].push(Scalar::f64(100.0)).unwrap();
d[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
c.eval(Ctx::new(&d, Timestamp(2))),
Some([Cell::from_f64(0.75), Cell::from_f64(0.75), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn label_carries_the_carry() {
assert_eq!(CarryCost::new(0.5).label(), "CarryCost(0.5)");
}
#[test]
fn builder_exposes_one_param_no_extra() {
let builder = CarryCost::builder();
let schema = builder.schema();
assert_eq!(schema.params.len(), 1);
assert_eq!(schema.params[0].name, "carry_per_cycle");
// geometry-only inputs (4), no extras.
assert_eq!(schema.inputs.len(), crate::cost::GEOMETRY_WIDTH);
}
#[test]
#[should_panic(expected = "carry_per_cycle must be >= 0")]
fn new_panics_on_negative_carry() {
let _ = CarryCost::new(-1.0);
}
}
-143
View File
@@ -1,143 +0,0 @@
//! `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())
);
}
}
-547
View File
@@ -1,547 +0,0 @@
//! The cost-model-graph node contract (C10): the `CostNode` factor trait and the
//! `CostRunner<F>` adapter that wraps a factor into an engine `Node`.
//!
//! A cost node's only per-node difference is the **price-unit cost numerator** the
//! runner divides by the latched 1R distance. Everything else — gating on the PM
//! geometry (the co-temporality contract: the cost stream stays 1:1 with the
//! executor's record), the closed/open charge, the running `cum`, the 3-field
//! emit — is the runner's, written once. The 3-field cost record
//! (`COST_FIELD_NAMES`) is one source of truth, read by both the producer side
//! (`cost_node_builder`) and the `CostSum` aggregator; this mirrors the
//! `position_management::{FIELD_NAMES, WIDTH}` precedent and replaces what was a
//! by-convention triple lockstep.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
use std::collections::HashSet;
use std::sync::{LazyLock, Mutex};
/// The 3-field cost-in-R record every cost node emits, in slot order — one source
/// of truth for the producer schema and the `CostSum` aggregator.
pub const COST_WIDTH: usize = 3;
pub const COST_FIELD_NAMES: [&str; COST_WIDTH] =
["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
/// The PM-geometry input prefix every cost node gates on (`closed`, `open`,
/// `entry_price`, `stop_price`). A factor's own extra inputs are appended after
/// these, beginning at slot `GEOMETRY_WIDTH`.
pub const GEOMETRY_WIDTH: usize = 4;
/// Process-global port-name intern pool (#152): ONE `&'static str` allocation
/// per distinct name for the process lifetime — the `COL_PORTS` `LazyLock`
/// pattern generalized. Rebuilding a cost graph per member across a sweep
/// reuses these strings instead of `.leak()`ing fresh ones per build.
static PORT_NAMES: LazyLock<Mutex<HashSet<&'static str>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
/// Intern an arbitrary port name: the same input yields the same
/// `&'static str` allocation on every call. The one leak per DISTINCT name is
/// the pool's deliberate, bounded cost; per-build callers never leak.
pub fn intern_port(name: &str) -> &'static str {
let mut pool = PORT_NAMES.lock().expect("port-name intern pool lock");
match pool.get(name) {
Some(interned) => interned,
None => {
let interned: &'static str = Box::leak(name.to_string().into_boxed_str());
pool.insert(interned);
interned
}
}
}
/// The interned `cost[{k}].{name}` port/agg name — the single source of the
/// cost-vocabulary name shape, consumed by `CostSum::builder` and `cost_graph`
/// (aura-composites) (#152).
pub fn cost_port(k: usize, name: &str) -> &'static str {
intern_port(&format!("cost[{k}].{name}"))
}
fn geometry_input_ports() -> Vec<PortSpec> {
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() },
]
}
fn cost_output_fields() -> Vec<FieldSpec> {
COST_FIELD_NAMES
.iter()
.map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
.collect()
}
/// When a cost factor charges its cost. `AtClose` — once per closed trade (commission,
/// flat cost, slippage): the per-trade default, charged on the close cycle. `PerHeldCycle`
/// — every cycle the position is held (carry, funding): the cost accrues over the hold.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChargeMode {
AtClose,
PerHeldCycle,
}
/// A cost factor: the per-round-trip cost in *price units* (the numerator the
/// runner divides by the latched 1R distance). The only thing a cost node differs
/// in; the co-temporality skeleton is [`CostRunner`]'s, shared.
///
/// # Authoring a cost node
///
/// ```
/// use aura_core::Ctx;
/// use aura_std::{CostNode, CostRunner};
///
/// pub struct HalfSpreadCost {
/// half_spread: f64,
/// }
///
/// impl HalfSpreadCost {
/// pub fn new(half_spread: f64) -> CostRunner<HalfSpreadCost> {
/// assert!(half_spread >= 0.0, "HalfSpreadCost half_spread must be >= 0");
/// CostRunner::new(HalfSpreadCost { half_spread })
/// }
/// }
///
/// impl CostNode for HalfSpreadCost {
/// fn label(&self) -> String {
/// format!("HalfSpreadCost({})", self.half_spread)
/// }
/// fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
/// self.half_spread // price units; the runner divides by the latched 1R distance
/// }
/// }
///
/// let _node = HalfSpreadCost::new(0.5); // a ready-to-wire cost node
/// ```
pub trait CostNode: 'static {
/// One-line render label carrying the identifying param (C23).
fn label(&self) -> String;
/// Extra input ports beyond the 4 geometry inputs, appended at slot
/// `GEOMETRY_WIDTH`. Default: none.
fn extra_inputs(&self) -> Vec<PortSpec> {
Vec::new()
}
/// When this factor charges. Default `AtClose` (the per-trade cost shape); a
/// per-held-cycle (accrual) factor overrides this to `PerHeldCycle`.
fn charge_mode(&self) -> ChargeMode {
ChargeMode::AtClose
}
/// The round-trip cost in price units this cycle, BEFORE R-normalization and
/// BEFORE the closed/open gate. Reads its extra inputs from `ctx` at
/// `GEOMETRY_WIDTH + i`; an empty window during warm-up means 0 (the runner
/// still emits the row — co-temporality).
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
}
/// The shared co-temporality skeleton wrapping any [`CostNode`] factor into a
/// `Node`. Holds the only running state a cost node needs — `cum` and the output
/// buffer — so a factor impl stays pure.
pub struct CostRunner<F: CostNode> {
factor: F,
cum: f64,
acc: f64,
out: [Cell; COST_WIDTH],
}
impl<F: CostNode> CostRunner<F> {
pub fn new(factor: F) -> Self {
Self { factor, cum: 0.0, acc: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
}
}
impl<F: CostNode> Node for CostRunner<F> {
fn lookbacks(&self) -> Vec<usize> {
vec![1; GEOMETRY_WIDTH + self.factor.extra_inputs().len()]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
// Gate ONLY on the PM geometry (co-temporality): the cost stream stays 1:1
// with the executor's record. A factor's not-yet-warm input contributes 0
// (handled in `cost_numerator`), it does not withhold the row.
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 latched = (entry_w[0] - stop_w[0]).abs();
let numerator = self.factor.cost_numerator(&ctx);
// Zero latched distance = no valid 1R denominator -> no cost. The
// `numerator / latched` token form is preserved verbatim from the
// pre-migration nodes for byte-identity (IEEE-754).
let per = if latched > 0.0 { numerator / latched } else { 0.0 };
let (cost_in_r, open_cost_in_r) = match self.factor.charge_mode() {
// At-close (per-trade): charged once on the close cycle. VERBATIM the
// pre-cycle-5 tokens — IEEE-754 byte-identity with the existing goldens.
ChargeMode::AtClose => {
let cost_in_r = if closed { per } else { 0.0 };
let open_cost_in_r = if open { per } else { 0.0 };
(cost_in_r, open_cost_in_r)
}
// Per-held-cycle (accrual): the carry accrues every cycle the position is
// held; the accrued total realizes into `cum` at close, and marks the open
// position (grows each held cycle) so the net_r_equity curve bleeds over
// the hold rather than stepping at close.
ChargeMode::PerHeldCycle => {
if open || closed {
self.acc += per;
}
let cost_in_r = if closed { self.acc } else { 0.0 };
let open_cost_in_r = if open { self.acc } else { 0.0 };
if closed {
self.acc = 0.0;
}
(cost_in_r, open_cost_in_r)
}
};
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 {
self.factor.label()
}
}
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
/// extra inputs, the standard 3-field cost output, the given params, and a build
/// closure. The single home for the cost-node schema shape.
pub fn cost_node_builder(
name: &'static str,
extra_inputs: Vec<PortSpec>,
params: Vec<ParamSpec>,
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
) -> PrimitiveBuilder {
let mut inputs = geometry_input_ports();
inputs.extend(extra_inputs);
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params }, build)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ConstantCost, CostSum};
use aura_core::{AnyColumn, Scalar, Timestamp};
/// A test-only factor: a constant numerator, no extra inputs.
struct StubCost(f64);
impl CostNode for StubCost {
fn label(&self) -> String {
format!("StubCost({})", self.0)
}
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
self.0
}
}
/// A test-only factor with one extra f64 input, read at `GEOMETRY_WIDTH`.
struct StubExtra(f64);
impl CostNode for StubExtra {
fn label(&self) -> String {
"StubExtra".into()
}
fn extra_inputs(&self) -> Vec<PortSpec> {
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "x".into() }]
}
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
let w = ctx.f64_in(GEOMETRY_WIDTH);
let x = if w.is_empty() { 0.0 } else { w[0] };
self.0 * x
}
}
/// A test-only factor charging per held cycle (accrual), constant numerator.
struct StubPerHeld(f64);
impl CostNode for StubPerHeld {
fn label(&self) -> String {
format!("StubPerHeld({})", self.0)
}
fn charge_mode(&self) -> ChargeMode {
ChargeMode::PerHeldCycle
}
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
self.0
}
}
fn geom_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 withholds_until_geometry_present() {
let mut r = CostRunner::new(StubCost(2.0));
let inputs = geom_cols(); // empty
assert_eq!(r.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn charges_numerator_over_latched_on_close() {
let mut r = CostRunner::new(StubCost(2.0));
let mut inputs = geom_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(96.0)).unwrap(); // latched 4 -> 2/4 = 0.5
assert_eq!(
r.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_in_cum() {
let mut r = CostRunner::new(StubCost(2.0));
let mut inputs = geom_cols();
inputs[0].push(Scalar::bool(false)).unwrap();
inputs[1].push(Scalar::bool(true)).unwrap(); // open
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
r.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_no_cost() {
let mut r = CostRunner::new(StubCost(2.0));
let mut inputs = geom_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
assert_eq!(
r.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 cum_accumulates() {
let mut r = CostRunner::new(StubCost(2.0));
let mut a = geom_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 _ = r.eval(Ctx::new(&a, Timestamp(0)));
let mut b = geom_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 -> 1.0; cum 1.5
assert_eq!(
r.eval(Ctx::new(&b, Timestamp(1))),
Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn per_held_cycle_accrues_open_cost_and_dumps_at_close() {
// per = 2/4 = 0.5 each cycle. A hold of open, open, close.
let mut r = CostRunner::new(StubPerHeld(2.0));
// Cycle 1: held open -> accrue 0.5 into open_cost; nothing into cum yet.
let mut a = geom_cols();
a[0].push(Scalar::bool(false)).unwrap(); // not closed
a[1].push(Scalar::bool(true)).unwrap(); // open
a[2].push(Scalar::f64(100.0)).unwrap();
a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 -> per 0.5
assert_eq!(
r.eval(Ctx::new(&a, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
);
// Cycle 2: still held open -> open_cost GROWS to 1.0 (the accrual bleed), cum still 0.
let mut b = geom_cols();
b[0].push(Scalar::bool(false)).unwrap();
b[1].push(Scalar::bool(true)).unwrap();
b[2].push(Scalar::f64(100.0)).unwrap();
b[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
r.eval(Ctx::new(&b, Timestamp(1))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(1.0)].as_slice())
);
// Cycle 3: close -> accrue the closing cycle, DUMP the total 1.5 into cost_in_r,
// cum steps to 1.5, open_cost back to 0.
let mut c = geom_cols();
c[0].push(Scalar::bool(true)).unwrap(); // closed
c[1].push(Scalar::bool(false)).unwrap(); // not open
c[2].push(Scalar::f64(100.0)).unwrap();
c[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
r.eval(Ctx::new(&c, Timestamp(2))),
Some([Cell::from_f64(1.5), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn per_held_cycle_resets_accrual_between_trades() {
// Two trades (open, close each). acc must reset at the first close so the
// second trade's dumped total is its OWN accrual, not cumulative.
let mut r = CostRunner::new(StubPerHeld(2.0)); // per 0.5
let mut o1 = geom_cols();
o1[0].push(Scalar::bool(false)).unwrap();
o1[1].push(Scalar::bool(true)).unwrap();
o1[2].push(Scalar::f64(100.0)).unwrap();
o1[3].push(Scalar::f64(96.0)).unwrap();
let _ = r.eval(Ctx::new(&o1, Timestamp(0))); // open_cost 0.5
let mut c1 = geom_cols();
c1[0].push(Scalar::bool(true)).unwrap();
c1[1].push(Scalar::bool(false)).unwrap();
c1[2].push(Scalar::f64(100.0)).unwrap();
c1[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
r.eval(Ctx::new(&c1, Timestamp(1))),
Some([Cell::from_f64(1.0), Cell::from_f64(1.0), Cell::from_f64(0.0)].as_slice())
); // trade 1 total 1.0, cum 1.0, acc reset
let mut o2 = geom_cols();
o2[0].push(Scalar::bool(false)).unwrap();
o2[1].push(Scalar::bool(true)).unwrap();
o2[2].push(Scalar::f64(100.0)).unwrap();
o2[3].push(Scalar::f64(96.0)).unwrap();
let _ = r.eval(Ctx::new(&o2, Timestamp(2))); // fresh open_cost 0.5
let mut c2 = geom_cols();
c2[0].push(Scalar::bool(true)).unwrap();
c2[1].push(Scalar::bool(false)).unwrap();
c2[2].push(Scalar::f64(100.0)).unwrap();
c2[3].push(Scalar::f64(96.0)).unwrap();
assert_eq!(
r.eval(Ctx::new(&c2, Timestamp(3))),
Some([Cell::from_f64(1.0), Cell::from_f64(2.0), Cell::from_f64(0.0)].as_slice())
); // trade 2's OWN total 1.0 (acc reset proved), cum running 2.0
}
#[test]
fn extra_input_cold_contributes_zero_but_row_emits() {
// Co-temporality: a not-yet-warm factor input -> 0 cost, but a row IS emitted.
let mut r = CostRunner::new(StubExtra(0.5));
let mut inputs = geom_cols();
inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1)); // x, empty
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(96.0)).unwrap();
assert_eq!(
r.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 extra_input_warm_scales_numerator() {
let mut r = CostRunner::new(StubExtra(0.5));
let mut inputs = geom_cols();
inputs.push(AnyColumn::with_capacity(ScalarKind::F64, 1));
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(96.0)).unwrap(); // latched 4
inputs[4].push(Scalar::f64(3.0)).unwrap(); // x=3 -> 0.5*3=1.5 -> 1.5/4 = 0.375
assert_eq!(
r.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn lookbacks_count_geometry_plus_extra() {
assert_eq!(CostRunner::new(StubCost(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH]);
assert_eq!(CostRunner::new(StubExtra(1.0)).lookbacks(), vec![1; GEOMETRY_WIDTH + 1]);
}
#[test]
fn runner_label_delegates_to_factor() {
assert_eq!(CostRunner::new(StubCost(2.0)).label(), "StubCost(2)");
}
#[test]
fn geometry_width_matches_port_count() {
assert_eq!(GEOMETRY_WIDTH, geometry_input_ports().len());
}
#[test]
fn cost_output_fields_are_the_triple() {
let names: Vec<String> = cost_output_fields().into_iter().map(|f| f.name).collect();
assert_eq!(names, COST_FIELD_NAMES.to_vec());
}
#[test]
fn cost_node_builder_assembles_geometry_prefix_then_extras() {
// The builder's input-assembly contract, at the schema level: the 4 geometry
// ports first (in slot order), the factor's extra inputs appended beginning
// at GEOMETRY_WIDTH, the shared 3-field cost output, and the given params
// passed through verbatim.
let extra = vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "vol".into() }];
let params = vec![ParamSpec { name: "k".into(), kind: ScalarKind::F64 }];
let builder = cost_node_builder(
"Probe",
extra.clone(),
params.clone(),
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
);
let schema = builder.schema();
// The first GEOMETRY_WIDTH inputs are the geometry prefix verbatim...
assert_eq!(schema.inputs[..GEOMETRY_WIDTH], geometry_input_ports()[..]);
// ...and the factor's extras begin exactly at slot GEOMETRY_WIDTH.
assert_eq!(schema.inputs[GEOMETRY_WIDTH..], extra[..]);
assert_eq!(schema.inputs.len(), GEOMETRY_WIDTH + extra.len());
// The output is the shared 3-field cost triple; params pass through.
assert_eq!(schema.output, cost_output_fields());
assert_eq!(schema.params, params);
}
#[test]
fn cost_port_interns_one_static_name_per_distinct_pair() {
// Same (k, name) -> the SAME allocation (pointer equality), process-wide;
// a distinct pair -> a distinct interned name. The no-per-build-leak
// property #152 asks for: rebuilding a cost graph per member reuses these.
let a = cost_port(0, "volatility");
let b = cost_port(0, "volatility");
assert!(std::ptr::eq(a, b), "same pair must return the same interned str");
assert_eq!(a, "cost[0].volatility");
let c = cost_port(1, "volatility");
assert_eq!(c, "cost[1].volatility");
let d = cost_port(0, "cost_in_r");
assert_eq!(d, "cost[0].cost_in_r");
}
#[test]
fn intern_port_dedups_bare_names() {
let a = intern_port("volatility");
let b = intern_port("volatility");
assert!(std::ptr::eq(a, b), "the pool must dedup bare names too");
assert_eq!(a, "volatility");
}
#[test]
fn producer_and_aggregator_share_the_triple() {
// The structural lockstep: producer output and aggregator output/inputs all
// read COST_FIELD_NAMES (one source), replacing the by-convention triple.
let prod: Vec<String> =
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
let agg_out: Vec<String> =
CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect();
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
let agg_in: Vec<String> =
CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
let expected: Vec<String> =
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
assert_eq!(agg_in, expected);
}
}
-163
View File
@@ -1,163 +0,0 @@
//! `CostSum` — the output node of a C10 cost-model graph: it sums `n_costs`
//! cost-in-R records per-field into one aggregate record, so any number of cost
//! nodes collapses to the single 3-field cost stream the net-R seam already
//! consumes (`summarize_r` + the `net_r_equity` tap stay unchanged). Each cost
//! node contributes the 3-field `{cost_in_r, cum_cost_in_r, open_cost_in_r}`
//! record; the aggregate is the per-field sum. `n_costs = 1` is the identity, so
//! the cost path is uniform whether one or several cost nodes are wired.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
};
/// The cost-record field triple and its width come from the shared cost contract
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name
/// loop, the output schema, the lookback vector, and the eval accumulator all read
/// it, so the producer↔aggregator field match is structural, not by-convention.
use crate::cost::{cost_port, COST_FIELD_NAMES, COST_WIDTH};
/// Per-field sum of `n_costs` cost-in-R records. Inputs are
/// `cost[k].{cost_in_r,cum_cost_in_r,open_cost_in_r}` for `k in 0..n_costs`, in
/// slot order (3 per cost node); the 3-field output mirrors a single cost record.
/// Emits `None` until every input leg is present (mode-A as-of join, like LinComb).
pub struct CostSum {
n_costs: usize,
out: [Cell; COST_WIDTH],
}
impl CostSum {
pub fn new(n_costs: usize) -> Self {
assert!(n_costs >= 1, "CostSum needs at least one cost input");
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
}
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
/// captured by the build closure (no per-build params). The input names are a
/// lockstep contract with the connect side (`cost[k].<field>`).
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
for k in 0..n_costs {
for field in COST_FIELD_NAMES {
inputs.push(PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
// Owned String (PortSpec.name is String); the NAME comes from
// the shared cost_port helper so the aggregator's input names
// and cost_graph's agg-side names share ONE shape definition.
name: cost_port(k, field).to_string(),
});
}
}
PrimitiveBuilder::new(
"CostSum",
NodeSchema {
inputs,
output: COST_FIELD_NAMES
.iter()
.map(|n| FieldSpec { name: (*n).into(), kind: ScalarKind::F64 })
.collect(),
params: vec![],
},
move |_| Box::new(CostSum::new(n_costs)),
)
}
}
impl Node for CostSum {
fn lookbacks(&self) -> Vec<usize> {
vec![1; self.n_costs * COST_WIDTH]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let mut acc = [0.0_f64; COST_WIDTH]; // per-field accumulator, COST_FIELD_NAMES order
for k in 0..self.n_costs {
for (f, slot) in acc.iter_mut().enumerate() {
let w = ctx.f64_in(k * COST_WIDTH + f);
if w.is_empty() {
return None;
}
*slot += w[0];
}
}
self.out = [Cell::from_f64(acc[0]), Cell::from_f64(acc[1]), Cell::from_f64(acc[2])];
Some(&self.out)
}
fn label(&self) -> String {
format!("CostSum({})", self.n_costs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn f64_cols(n: usize) -> Vec<AnyColumn> {
(0..n).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect()
}
#[test]
fn two_records_sum_per_field() {
let mut s = CostSum::new(2);
let mut inputs = f64_cols(6);
// cost[0] = [0.5, 0.5, 0.0]; cost[1] = [0.375, 1.0, 0.2]
for (i, v) in [0.5, 0.5, 0.0, 0.375, 1.0, 0.2].into_iter().enumerate() {
inputs[i].push(Scalar::f64(v)).unwrap();
}
// per-field sum: [0.875, 1.5, 0.2]
assert_eq!(
s.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.875), Cell::from_f64(1.5), Cell::from_f64(0.2)].as_slice())
);
}
#[test]
fn n_one_is_identity() {
let mut s = CostSum::new(1);
let mut inputs = f64_cols(3);
for (i, v) in [0.5, 1.25, 0.3].into_iter().enumerate() {
inputs[i].push(Scalar::f64(v)).unwrap();
}
assert_eq!(
s.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.5), Cell::from_f64(1.25), Cell::from_f64(0.3)].as_slice())
);
}
#[test]
fn withholds_until_every_leg_present() {
let mut s = CostSum::new(2);
let mut inputs = f64_cols(6);
// only the first cost node's three fields present -> withhold
for col in inputs.iter_mut().take(3) {
col.push(Scalar::f64(1.0)).unwrap();
}
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn input_slots_are_named_cost_index_field() {
let s = CostSum::builder(2);
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(
names,
[
"cost[0].cost_in_r", "cost[0].cum_cost_in_r", "cost[0].open_cost_in_r",
"cost[1].cost_in_r", "cost[1].cum_cost_in_r", "cost[1].open_cost_in_r",
]
);
}
#[test]
fn label_carries_the_arity() {
assert_eq!(CostSum::new(2).label(), "CostSum(2)");
}
#[test]
#[should_panic(expected = "CostSum needs at least one cost input")]
fn new_panics_on_zero() {
let _ = CostSum::new(0);
}
}
-36
View File
@@ -20,12 +20,7 @@
mod abs;
mod add;
mod and;
mod bias;
mod carry_cost;
mod const_node;
mod constant_cost;
mod cost;
mod cost_sum;
mod cumsum;
mod delay;
mod div;
@@ -35,42 +30,24 @@ mod gated_recorder;
mod gt;
mod latch;
mod lincomb;
mod longonly;
mod max;
mod min;
mod mul;
mod position_management;
mod recorder;
mod resample;
mod rolling_max;
mod rolling_min;
mod scale;
mod select;
mod series_reducer;
mod session;
mod sign;
mod sim_broker;
mod sizer;
mod sma;
mod sqrt;
mod stop_rule;
mod sub;
mod vocabulary;
mod vol_slippage_cost;
mod vol_tf_stop;
mod when;
pub use abs::Abs;
pub use add::Add;
pub use and::And;
pub use bias::Bias;
pub use carry_cost::CarryCost;
pub use const_node::Const;
pub use constant_cost::ConstantCost;
pub use cost::{
cost_node_builder, cost_port, intern_port, ChargeMode, CostNode, CostRunner,
COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH,
};
pub use cost_sum::CostSum;
pub use cumsum::CumSum;
pub use delay::Delay;
pub use div::Div;
@@ -80,30 +57,17 @@ pub use gated_recorder::GatedRecorder;
pub use gt::Gt;
pub use latch::Latch;
pub use lincomb::LinComb;
pub use longonly::LongOnly;
pub use max::Max;
pub use min::Min;
pub use mul::Mul;
pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS,
WIDTH as PM_WIDTH,
};
pub use recorder::Recorder;
pub use resample::Resample;
pub use rolling_max::RollingMax;
pub use rolling_min::RollingMin;
pub use scale::Scale;
pub use select::Select;
pub use series_reducer::SeriesReducer;
pub use session::{Session, SessionFrankfurt};
pub use sign::Sign;
pub use sim_broker::SimBroker;
pub use sizer::Sizer;
pub use sma::Sma;
pub use sqrt::Sqrt;
pub use stop_rule::FixedStop;
pub use sub::Sub;
pub use vocabulary::{std_vocabulary, std_vocabulary_types};
pub use vol_slippage_cost::VolSlippageCost;
pub use vol_tf_stop::VolTfStop;
pub use when::When;
-108
View File
@@ -1,108 +0,0 @@
//! `LongOnly` — a long-only exposure gate. The bool param `enabled`: when true,
//! short (negative) exposure is clamped to 0 (a long-only constraint); when
//! false, exposure passes through unchanged. One f64 input, one f64 output.
//! Emits `None` until its input is present (warm-up filter, C8).
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Long-only exposure gate driven by a bool param. `enabled=true` →
/// `max(exposure, 0.0)`; `enabled=false` → `exposure` unchanged.
pub struct LongOnly {
enabled: bool,
out: [Cell; 1],
}
impl LongOnly {
/// Build a long-only gate. `enabled` toggles the short-clamp.
pub fn new(enabled: bool) -> Self {
Self { enabled, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe: declares the bool param `enabled` and builds
/// through `LongOnly::new` (the slice is kind-checked before `build`, so the
/// typed `p[0].bool()` read is total). First aura-std node with a bool param.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"LongOnly",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() }],
output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "enabled".into(), kind: ScalarKind::Bool }],
},
|p| Box::new(LongOnly::new(p[0].bool())),
)
}
}
impl Node for LongOnly {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // not yet warmed up (C8 filter)
}
self.out[0] = Cell::from_f64(if self.enabled { w[0].max(0.0) } else { w[0] });
Some(&self.out)
}
fn label(&self) -> String {
format!("LongOnly({})", self.enabled)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn long_only_gates_negatives_when_enabled() {
let mut g = LongOnly::new(true);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
// (input exposure, expected output) for enabled=true: negatives clamp to 0.
let cases = [(0.6_f64, 0.6_f64), (-0.6, 0.0), (1.0, 1.0), (-1.0, 0.0)];
for (sig, want) in cases {
inputs[0].push(Scalar::f64(sig)).unwrap();
assert_eq!(
g.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(want)].as_slice())
);
}
}
#[test]
fn long_only_passes_through_when_disabled() {
let mut g = LongOnly::new(false);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let cases = [(0.6_f64, 0.6_f64), (-0.6, -0.6)];
for (sig, want) in cases {
inputs[0].push(Scalar::f64(sig)).unwrap();
assert_eq!(
g.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(want)].as_slice())
);
}
}
#[test]
fn long_only_is_none_until_input_present() {
let mut g = LongOnly::new(true);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(g.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn long_only_param_is_a_bool_named_enabled() {
let builder = LongOnly::builder();
let schema = builder.schema();
assert_eq!(schema.params.len(), 1);
assert_eq!(schema.params[0].name, "enabled");
assert_eq!(schema.params[0].kind, ScalarKind::Bool);
}
}
-410
View File
@@ -1,410 +0,0 @@
//! `PositionManagement` — the stateful heart of feed-forward risk-based execution. Turns a
//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes.
//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the
//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the
//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end
//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY:
//! `realized_r = direction * (exit - entry) / latched_distance`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
Timestamp,
};
/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r`
/// matches the raw i64 across the crate boundary (the record streams as type-erased
/// `Scalar`s, C4 SoA, not as this enum), so the encoding is the load-bearing contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i64)]
pub enum ExitReason {
Stop = 0,
BiasFlip = 1,
ReversalLeg = 2,
WindowEnd = 3,
}
/// Dense record column layout — the lockstep contract shared (by convention) with the
/// `Recorder` tap kinds and `summarize_r`'s column reads.
pub const WIDTH: usize = 14;
pub const FIELD_NAMES: [&str; WIDTH] = [
"closed_this_cycle",
"realized_r",
"exit_reason",
"was_stopped",
"direction",
"entry_ts",
"entry_price",
"stop_price",
"exit_price",
"conviction_at_entry",
"size",
"open",
"unrealized_r",
"cum_realized_r",
];
pub const RECORD_KINDS: [ScalarKind; WIDTH] = {
use ScalarKind::*;
[
Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64,
]
};
// The frozen-R latch: an open position records its entry, its latched (frozen at
// entry) R-distance, the protective stop level, and entry metadata. R is computed
// against `latched_dist`, never a re-read distance — the R-denominator is frozen.
struct Open {
dir: i64,
entry: f64,
latched_dist: f64,
stop_level: f64,
entry_ts: Timestamp,
bias_abs: f64,
}
pub struct PositionManagement {
pos: Option<Open>,
cum_realized_r: f64,
out: [Cell; WIDTH],
}
impl PositionManagement {
pub fn new() -> Self {
Self {
pos: None,
cum_realized_r: 0.0,
out: [Cell::from_f64(0.0); WIDTH],
}
}
pub fn builder() -> PrimitiveBuilder {
let inputs = vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() },
];
let output = FIELD_NAMES
.iter()
.zip(RECORD_KINDS)
.map(|(n, k)| FieldSpec { name: (*n).into(), kind: k })
.collect();
PrimitiveBuilder::new(
"PositionManagement",
NodeSchema { inputs, output, params: vec![] },
|_| Box::new(PositionManagement::new()),
)
}
}
impl Default for PositionManagement {
fn default() -> Self {
Self::new()
}
}
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
impl Node for PositionManagement {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let pw = ctx.f64_in(1);
if pw.is_empty() {
return None; // no price yet — nothing to do
}
let price = pw[0];
let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired
let now = ctx.now();
let mut closed = false;
let mut realized = 0.0;
let mut reason = ExitReason::Stop as i64;
let mut was_stopped = false;
let mut ex_dir = 0i64;
let mut ex_entry_ts = Timestamp(0);
let mut ex_entry = 0.0;
let mut ex_stop = 0.0;
let mut ex_exit = 0.0;
let mut ex_bias_abs = 0.0;
// (A) Maintain/exit the position held into this cycle, marked to `price`.
if let Some(p) = &self.pos {
let stop_hit = (p.dir > 0 && price <= p.stop_level)
|| (p.dir < 0 && price >= p.stop_level);
let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0
if stop_hit {
let fill = if p.dir > 0 {
price.min(p.stop_level)
} else {
price.max(p.stop_level)
};
// size-invariant: R is a pure stop-distance ratio (feed-forward).
realized = p.dir as f64 * (fill - p.entry) / p.latched_dist;
was_stopped = true;
reason = ExitReason::Stop as i64;
ex_exit = fill;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
} else if bias_exit {
// size-invariant: R is a pure stop-distance ratio (feed-forward).
realized = p.dir as f64 * (price - p.entry) / p.latched_dist;
reason = if sign0(bias) != 0.0 {
ExitReason::ReversalLeg as i64
} else {
ExitReason::BiasFlip as i64
};
ex_exit = price;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
}
}
// (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available.
if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 {
let dir = sign0(bias) as i64;
let stop_level = if dir > 0 { price - dist } else { price + dist };
self.pos = Some(Open {
dir,
entry: price,
latched_dist: dist,
stop_level,
entry_ts: now,
bias_abs: bias.abs(),
});
}
// (C) Open-position fields (carry for window-end) + unrealized mark.
let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos {
Some(p) => (
true,
p.dir as f64 * (price - p.entry) / p.latched_dist,
p.dir,
p.entry_ts,
p.entry,
p.stop_level,
p.bias_abs,
),
None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0),
};
// Trade-detail columns describe the CLOSED trade if closed, else the OPEN one.
let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed {
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs)
} else {
(o_dir, o_ets, o_entry, o_stop, 0.0, o_babs)
};
// `direction` (col 4) tracks the position OPEN at cycle end — it is carried for
// window-end synthesis and names the reopened leg on a reversal. It falls back
// to the closed trade's direction only when flat at cycle end. The other
// trade-detail columns (and the debug_assert below) stay keyed to the CLOSED
// trade's geometry via `d_*`.
let out_dir = if open { o_dir } else { d_dir };
self.out = [
Cell::from_bool(closed),
Cell::from_f64(realized),
Cell::from_i64(reason),
Cell::from_bool(was_stopped),
Cell::from_i64(out_dir),
Cell::from_ts(d_ets),
Cell::from_f64(d_entry),
Cell::from_f64(d_stop),
Cell::from_f64(d_exit),
Cell::from_f64(d_babs),
Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant
Cell::from_bool(open),
Cell::from_f64(unrealized),
Cell::from_f64(self.cum_realized_r),
];
// Scale-robust tolerance: the column re-derivation reconstructs the R-denominator
// via `d_entry - d_stop`, a catastrophic-cancellation subtraction at tiny-pip price
// scale (entry ~1.1, dist ~1e-6) that drifts from the cancellation-free frozen
// `latched_dist` `realized` was computed with. A relative tolerance (floored at the
// historical absolute 1e-9 for |R| <= 1) keeps the guard meaningful while letting the
// two build profiles agree on tiny-pip instruments — `realized` itself is exact.
debug_assert!(
!closed
|| (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs()
< 1e-9 * realized.abs().max(1.0)
);
Some(&self.out)
}
fn label(&self) -> String {
"PositionManagement".into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar};
// Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row.
fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec<Cell> {
cols[0].push(Scalar::f64(bias)).unwrap();
cols[1].push(Scalar::f64(price)).unwrap();
cols[2].push(Scalar::f64(dist)).unwrap();
cols[3].push(Scalar::f64(size)).unwrap();
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
}
// size defaults to 1.0 (the iter-1 placeholder value), so every existing case is
// behaviour-identical under the new 4-input shape.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
step_sized(n, cols, bias, price, dist, 1.0)
}
fn cols() -> Vec<AnyColumn> { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
#[test]
fn emits_one_dense_record_per_cycle() {
let mut n = PositionManagement::new();
let mut c = cols();
let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
assert_eq!(row.len(), WIDTH);
assert!(!row[0].bool()); // closed_this_cycle = false
assert!(!row[11].bool()); // open = false
}
// (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R.
// Scaling size leaves every realized_r identical (the property that keeps the research loop
// feed-forward); col 10 reflects the size so we know it actually flowed end-to-end.
#[test]
fn realized_r_is_invariant_under_size() {
let run = |size: f64| {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100
step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110
};
let small = run(1.0);
let big = run(7.0);
assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant");
assert_eq!(small[1].f64(), 1.0); // (110-100)/10
assert_eq!(small[10].f64(), 1.0); // size column reflects the input
assert_eq!(big[10].f64(), 7.0);
}
// (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
// 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
// move to 110; the flip closes it THERE, never retroactively flattening it).
#[test]
fn no_lookahead_bias_exit_realises_the_held_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit
assert!(row[0].bool()); // closed_this_cycle
assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0
assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64);
assert!(!row[3].bool()); // not stopped
}
// A position opened this cycle never earns the pre-entry move: entered @110, its
// own-cycle unrealized R is 0.
#[test]
fn entry_does_not_earn_pre_entry_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle
}
// (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1.
#[test]
fn stop_out_is_not_capped_at_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90)
assert!(row[0].bool());
assert!(row[3].bool()); // was_stopped
assert_eq!(row[2].i64(), ExitReason::Stop as i64);
assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail
}
// A no-gap stop fills exactly at the stop -> exactly -1R.
#[test]
fn no_gap_stop_is_exactly_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly
assert_eq!(row[1].f64(), -1.0);
}
// (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg
// (one record, ReversalLeg) and reopens short as state (open=true, dir=-1).
#[test]
fn reversal_closes_one_leg_and_reopens() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110
assert!(row[0].bool()); // one close
assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10
assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64);
assert!(row[11].bool()); // reopened: open
assert_eq!(row[4].i64(), -1); // new direction short
}
// Property: the realized_r <-> dense-record-columns redundancy guard holds for a
// tiny-pip instrument (EURUSD-scale entry, a sub-pip stop distance). `eval` stores
// `realized` from the cancellation-free frozen `latched_dist`; the in-`eval`
// redundancy guard RE-derives R from the record columns as
// `direction * (exit - entry) / |entry - stop|`, reconstructing the R-denominator
// by the subtraction `entry - stop`. At a price scale ~1.1 with a stop distance
// ~1e-6, that subtraction loses ~6 significant digits relative to the magnitude of
// `entry` (~2e-16 ULP / 1e-6 dist), so the recomputed denominator drifts from the
// frozen `latched_dist`; with a deep gap-through-stop realising a large |R|, the
// recomputed R diverges from the (correct) stored `realized` by more than the
// guard's ABSOLUTE 1e-9 tolerance, panicking a debug build while a release build
// (assert compiled out) returns the correct R. The stored `realized` is correct;
// the guard must use a scale-robust (relative) tolerance so the two build profiles
// agree. A no-op for large-pip instruments (GER40), where the cancellation is
// negligible.
#[test]
fn tiny_pip_gap_stop_does_not_trip_the_redundancy_guard() {
let entry = 1.10000_f64; // EURUSD-scale price
let dist = 0.000001_f64; // sub-pip vol-stop distance (a quiet M1 bar)
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, entry, dist); // open long @1.10000, stop @1.099999
// A fast gap straight through the stop: price 1.09990, ~100e-6 below entry ->
// a ~-100R loss (stop-outs are NOT capped at -1R; the honest tail). In a debug
// build the current absolute-tolerance guard PANICS here.
let row = step(&mut n, &mut c, 1.0, 1.09990_f64, dist);
assert!(row[0].bool(), "closed_this_cycle");
assert!(row[3].bool(), "was_stopped");
// The stored realized_r and the record-column re-derivation must agree within a
// tolerance robust to the tiny price scale (relative, not absolute 1e-9).
let realized = row[1].f64();
let (dir, d_entry, d_stop, d_exit) =
(row[4].i64() as f64, row[6].f64(), row[7].f64(), row[8].f64());
let recomputed = dir * (d_exit - d_entry) / (d_entry - d_stop).abs();
assert!(
(realized - recomputed).abs() <= 1e-9 * realized.abs().max(1.0),
"realized_r ({realized}) must match the column re-derivation ({recomputed}) \
within a scale-robust tolerance",
);
}
// (4) Open at window end is carried on the last row: open=true, the unrealized R the
// post-run fold will force-close, and the direction for window-end synthesis.
#[test]
fn open_at_window_end_is_carried_on_the_last_row() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close
assert!(!row[0].bool()); // not closed
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10
assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis
}
}
-277
View File
@@ -1,277 +0,0 @@
//! `Resample` — aggregates a fine OHLC stream (e.g. M1) into coarser OHLC bars
//! (e.g. 15-minute), emitting a **completed** bar ONLY on rollover (C2: a bar is
//! actionable only once complete — never a partial bar).
//!
//! **Schema.** FOUR `f64` inputs `open, high, low, close`, each
//! `Firing::Barrier(0)`: they are co-fresh, fed by four separate M1 sources at
//! the same M1 timestamp, so `Barrier(0)` makes the node fire exactly once when
//! all four carry that timestamp (the `Ohlcv` test fixture in `harness.rs` is the
//! precedent — the first multi-field-output + Barrier group in the engine).
//! Output is a 4-field record `open, high, low, close` (each `f64`). One param
//! `period_minutes: i64`.
//!
//! **Accumulator over the current bucket.** `open` = first M1 open of the window,
//! `high` = running max of M1 highs, `low` = running min of M1 lows, `close` =
//! last M1 close.
//!
//! **Emit-on-rollover via `ctx.now()`.** With
//! `period_ns = period_minutes * 60 * 1_000_000_000`, the bucket of an M1 instant
//! is `ctx.now().0 / period_ns` (integer division on the i64 epoch-ns). Per fired
//! eval (all 4 inputs present, Barrier-gated):
//! - **first ever sample:** start the accumulator, return `None`.
//! - **same bucket:** fold the M1 into the accumulator, return `None`.
//! - **rollover (`bucket > current_bucket`):** the accumulator is a COMPLETE bar
//! → emit `[open, high, low, close]`; THEN restart the accumulator from this
//! M1 and adopt the new bucket; return `Some(the completed bar)`.
//!
//! **Timestamp (C4).** The completed bar carries no timestamp of its own — the
//! engine stamps the emission cycle's `ctx.now()` (the new bucket's first M1
//! instant, which is the close instant of the bar just emitted).
//! The node only returns the 4 OHLC values.
//!
//! **Partial last bar is DROPPED.** There is no end-of-stream flush: a bucket with
//! no following rollover tick never emits. This is C2 again — an incomplete bar is
//! not actionable.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Open/high/low/close accumulator for the current bucket, plus the bucket index
/// it belongs to. Held across cycles in node state (like `SimBroker`/`Latch`).
struct Acc {
bucket: i64,
open: f64,
high: f64,
low: f64,
close: f64,
}
/// Resamples a fine 4-field OHLC stream into coarser OHLC bars, emitting a
/// completed bar only on bucket rollover (C2). The first multi-field-output node
/// in `aura-std`.
pub struct Resample {
period_ns: i64,
acc: Option<Acc>,
out: [Cell; 4],
}
impl Resample {
/// Build a `Resample` aggregating into `period_minutes`-wide buckets
/// (must be >= 1).
pub fn new(period_minutes: i64) -> Self {
assert!(period_minutes >= 1, "Resample period_minutes must be >= 1");
Self {
period_ns: period_minutes * 60 * 1_000_000_000,
acc: None,
out: [Cell::from_f64(0.0); 4],
}
}
/// The param-generic recipe for a blueprint primitive: declares
/// `period_minutes` and builds through `Resample::new`.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Resample",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "open".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "high".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "low".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "close".into() },
],
output: vec![
FieldSpec { name: "open".into(), kind: ScalarKind::F64 },
FieldSpec { name: "high".into(), kind: ScalarKind::F64 },
FieldSpec { name: "low".into(), kind: ScalarKind::F64 },
FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Resample::new(p[0].i64())),
)
}
}
impl Node for Resample {
fn lookbacks(&self) -> Vec<usize> {
// each of the four inputs is read at depth 1 (newest M1 sample only); the
// window-in-progress lives in node state, not in the input columns.
vec![1, 1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
// Barrier(0) guarantees all four arrive co-fresh; read the newest M1.
let (o, h, l, c) = (ctx.f64_in(0)[0], ctx.f64_in(1)[0], ctx.f64_in(2)[0], ctx.f64_in(3)[0]);
let bucket = ctx.now().snap_to_nearest_minute().0 / self.period_ns;
match self.acc.take() {
// first ever sample: start the accumulator, nothing complete yet.
None => {
self.acc = Some(Acc { bucket, open: o, high: h, low: l, close: c });
None
}
// same bucket: fold this M1 into the in-progress bar (still partial).
Some(mut acc) if bucket == acc.bucket => {
acc.high = acc.high.max(h);
acc.low = acc.low.min(l);
acc.close = c; // last close wins
self.acc = Some(acc);
None
}
// rollover: the accumulated bar is COMPLETE -> emit it, then restart
// the accumulator from this M1 in the new bucket (C2 emit-on-rollover).
Some(acc) => {
self.out = [
Cell::from_f64(acc.open),
Cell::from_f64(acc.high),
Cell::from_f64(acc.low),
Cell::from_f64(acc.close),
];
self.acc = Some(Acc { bucket, open: o, high: h, low: l, close: c });
Some(&self.out)
}
}
}
fn label(&self) -> String {
format!("Resample({}m)", self.period_ns / (60 * 1_000_000_000))
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
/// Four depth-1 f64 input columns, as bootstrap sizes them from `lookbacks`
/// for the Barrier(0) OHLC group.
fn ohlc_inputs() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
]
}
/// Drive one fired eval: populate all four co-fresh inputs (Barrier-gated, so
/// in the real graph they always arrive together) and step the node at the M1
/// instant `ns`. Each input is a capacity-1 ring, so the push overwrites the
/// single slot — `f64_in(i)[0]` reads exactly this M1 (mirrors the engine
/// re-presenting the newest sample each cycle, as `sma.rs`'s test does).
fn step(node: &mut Resample, inputs: &mut [AnyColumn], ns: i64, o: f64, h: f64, l: f64, c: f64) -> Option<Vec<f64>> {
for (col, v) in inputs.iter_mut().zip([o, h, l, c]) {
col.push(Scalar::f64(v)).unwrap();
}
node.eval(Ctx::new(inputs, Timestamp(ns)))
.map(|r| r.iter().map(|c| c.f64()).collect())
}
#[test]
fn resample_emits_completed_bar_only_on_rollover_open_first_high_max_low_min_close_last() {
// THE HEADLINE PROPERTY: a coarse bar is emitted ONLY when the cursor
// rolls into the next bucket, and it carries open=first M1 open,
// high=max M1 high, low=min M1 low, close=last M1 close of the WINDOW —
// C2 (a bar is actionable only once complete; partials are never emitted).
let period_minutes = 15;
let period_ns = period_minutes * 60 * 1_000_000_000; // 900_000_000_000
assert_eq!(period_ns, 900_000_000_000);
let mut node = Resample::new(period_minutes);
let mut inputs = ohlc_inputs();
// --- bucket 0: three M1 ticks, each accumulating, NONE emitted ---
// t=0 : first sample of bucket 0 -> starts the accumulator.
assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);
// t=60e9: same bucket -> high climbs to 103, low to 100, close to 102.
assert_eq!(step(&mut node, &mut inputs, 60_000_000_000, 100.5, 103.0, 100.0, 102.0), None);
// t=120e9: same bucket -> high stays 103, low drops to 99? no: low=101 > 99
// so running low stays 99; close becomes 101.5 (last).
assert_eq!(step(&mut node, &mut inputs, 120_000_000_000, 102.0, 102.5, 101.0, 101.5), None);
// --- first tick of bucket 1 (t=900e9): ROLLOVER -> emit the COMPLETED
// bucket-0 bar: open=100.0 (first), high=103.0 (max), low=99.0 (min),
// close=101.5 (last). THE load-bearing assertion. ---
let emitted = step(&mut node, &mut inputs, 900_000_000_000, 101.5, 104.0, 101.0, 103.0);
assert_eq!(emitted, Some(vec![100.0, 103.0, 99.0, 101.5]));
}
#[test]
fn resample_drops_the_partial_last_bar_and_resets_the_accumulator() {
// PARTIAL-DROP + clean reset: after bucket 0 rolls over (emitting), the
// accumulator restarts from bucket 1's first M1. With no further rollover,
// bucket 1's bar is incomplete and is NEVER emitted (no EOF flush). Feeding
// a bucket-2 tick THEN emits bucket 1 — proving open=bucket-1's first open
// (101.5), i.e. the accumulator reset cleanly rather than carrying bucket 0.
let mut node = Resample::new(15);
let mut inputs = ohlc_inputs();
// bucket 0: two ticks -> open=100.0 (first), high=103.0 (max 101,103),
// low=99.0 (min 99,100), close=102.0 (last close).
assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);
assert_eq!(step(&mut node, &mut inputs, 60_000_000_000, 100.5, 103.0, 100.0, 102.0), None);
// bucket 1's first tick rolls over bucket 0 (emits) and starts bucket 1.
assert_eq!(
step(&mut node, &mut inputs, 900_000_000_000, 101.5, 104.0, 101.0, 103.0),
Some(vec![100.0, 103.0, 99.0, 102.0])
);
// another bucket-1 tick: still accumulating bucket 1, NO emission.
assert_eq!(step(&mut node, &mut inputs, 960_000_000_000, 103.0, 105.0, 102.5, 104.5), None);
// bucket 2's first tick rolls over bucket 1: open=101.5 (bucket 1's FIRST),
// high=105.0 (max of 104,105), low=101.0 (min of 101,102.5), close=104.5.
assert_eq!(
step(&mut node, &mut inputs, 1_800_000_000_000, 104.5, 106.0, 104.0, 105.5),
Some(vec![101.5, 105.0, 101.0, 104.5])
);
// ...and bucket 2 (the new partial) is NOT flushed: no further emission.
}
#[test]
fn resample_buckets_a_boundary_bar_by_its_nominal_minute_despite_subsecond_jitter() {
// PROPERTY: bucket membership follows an M1 bar's NOMINAL minute, not the
// raw provider stamp. Provider M1 stamps carry sub-second jitter around
// minute boundaries (#280: on ~55% of days the 09:00 bar is stamped
// 08:59:59.xxx). A bar whose nominal minute *is* a bucket boundary must
// open the new bucket — and thereby complete the previous one — even when
// its stamp lands a fraction of a second BEFORE that boundary. It must not
// be folded back into the previous bucket.
//
// 5m buckets: boundaries at 0, 5min, 10min, ... (multiples of period_ns).
let period_ns = 5 * 60 * 1_000_000_000_i64; // 300_000_000_000
let mut node = Resample::new(5);
let mut inputs = ohlc_inputs();
// Bucket 0 opens with one M1 bar at a clean minute stamp (t=0).
assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);
// The next bar's NOMINAL minute is 05:00 — exactly the bucket-0/bucket-1
// boundary — but the provider stamped it 600ms early at 04:59:59.400
// (= 299_400_000_000 ns, 0.6s before the 300_000_000_000 boundary). By its
// nominal minute it is bucket 1's FIRST bar, so feeding it must ROLL OVER
// bucket 0 and emit the completed bucket-0 bar [100, 101, 99, 100.5].
let jittered_boundary_stamp = period_ns - 600_000_000; // 299_400_000_000
let emitted = step(&mut node, &mut inputs, jittered_boundary_stamp, 105.0, 106.0, 104.0, 105.5);
assert_eq!(
emitted,
Some(vec![100.0, 101.0, 99.0, 100.5]),
"the jitter-early boundary bar must open the new bucket and complete bucket 0, \
not fold back into it"
);
}
#[test]
fn output_is_a_four_field_ohlc_record_barrier_gated() {
// schema shape: four Barrier(0) f64 inputs named o/h/l/c, four f64 output
// fields named o/h/l/c, one i64 param. The first multi-field output node.
let s = Resample::builder().schema().clone();
let in_names: Vec<&str> = s.inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(in_names, ["open", "high", "low", "close"]);
assert!(s.inputs.iter().all(|p| p.kind == ScalarKind::F64 && p.firing == Firing::Barrier(0)));
let out_names: Vec<&str> = s.output.iter().map(|f| f.name.as_str()).collect();
assert_eq!(out_names, ["open", "high", "low", "close"]);
assert!(s.output.iter().all(|f| f.kind == ScalarKind::F64));
assert_eq!(s.params, vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }]);
}
}
-245
View File
@@ -1,245 +0,0 @@
//! `Session` — maps the current cycle's UTC instant to a **Frankfurt session bar
//! index**, emitting `bars_since_open: i64` = the count of completed bar-periods
//! since the session open, in **LOCAL (tz-aware, DST-correct) time**.
//!
//! This is the one node in the session-breakout vocabulary that carries domain
//! knowledge (the Frankfurt open + timezone + DST). It admits `chrono` /
//! `chrono-tz` to `aura-std` — a vetted standard crate for wall-clock/DST math,
//! never hand-rolled (C16 per-case policy).
//!
//! **Schema.** ONE input `trigger: f64, Firing::Any` whose **value is ignored**:
//! it is wired from the resampler's `close15` only so the node fires exactly once
//! per completed 15m bar. `Session` reads only `ctx.now()` + its baked config.
//! Output ONE `i64` field `bars_since_open`. **No scalar params** — the open
//! time, timezone, and period are *structural* construction config, baked at
//! build (like `SimBroker::builder(fee)` bakes its fee); a timezone is not a
//! scalar, so it is captured by the closure, not declared as a param.
//!
//! **Semantics.** Let `local = <ctx.now() epoch-ns UTC> converted
//! to tz` (chrono-tz, DST-correct). Let
//! `mins = (local.hour()*60 + local.minute()) - (open_hour*60 + open_minute)`.
//! Emit `bars_since_open = mins / period_minutes` (i64 division). The resampler
//! buckets land on exact :00/:15/:30/:45 boundaries, so in-session bar closes are
//! exact positive multiples: the 09:0009:15 bar closes at **09:15 → 1**,
//! 09:3009:45 closes at **09:45 → 3**, 10:0010:15 closes at **10:15 → 5**.
//! Pre-open instants give `<= 0` (non-actionable — the downstream `EqConst(==3)`
//! / `EqConst(==5)` gates simply never match; there is no separate in-session
//! bool gate, `bars_since_open` alone is the contract).
//!
//! **Off-by-one (close-instant indexing).** `ctx.now()` at a
//! resampler emission is the **just-closed** bar's *close instant*, so "the bar
//! that closed at 09:45" reads `bars_since_open == 3`.
//!
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
};
use chrono::{TimeZone, Timelike};
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
/// midnight), the IANA timezone, and the bar period — baked at construction,
/// never streamed and never a swept param (a timezone is not a scalar).
pub struct Session {
open_minutes: i64, // open_hour*60 + open_minute, minutes past local midnight
tz: chrono_tz::Tz,
period_minutes: i64,
out: [Cell; 1],
}
impl Session {
/// Build a `Session` for an open at `open_hour:open_minute` local `tz` time,
/// indexing bars of `period_minutes` width (must be >= 1).
pub fn new(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> Self {
assert!(period_minutes >= 1, "Session period_minutes must be >= 1");
Self {
open_minutes: open_hour as i64 * 60 + open_minute as i64,
tz,
period_minutes,
out: [Cell::from_i64(0)],
}
}
/// The param-generic recipe for a blueprint primitive. The open time,
/// timezone, and period are **structural** config baked into the closure
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
/// declared param list is empty and the `build_fn` ignores its slice (like
/// `Gt` / `Latch`).
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Session",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![],
},
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
)
}
}
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
/// (09:00 `Europe/Berlin`) baked as a literal, so the closed `std_vocabulary`
/// roster can reach a `Session` without a structural construction argument
/// (`Session::builder` itself stays absent from the roster — #155 scope).
/// The one remaining knob, `period_minutes` (i64, conventionally 15 — the
/// resampler's bar width), is declared as the node's sole `ParamSpec` and
/// bound through it via the single-knob `CarryCost`/`ConstantCost`/
/// `VolSlippageCost` pattern. This is additive: `Session::builder` is
/// untouched and stays the vehicle for other timezones/opens.
pub struct SessionFrankfurt;
impl SessionFrankfurt {
/// The param-generic recipe: one `period_minutes` I64 knob, the Frankfurt
/// open/timezone baked in the closure.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"SessionFrankfurt",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
)
}
}
impl Node for Session {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() {
return None; // cold: trigger has not fired yet
}
// Convert the engine's epoch-ns UTC instant (close-instant) into
// tz-aware LOCAL wall-clock — chrono-tz applies the correct
// DST offset for this date, so the same local minute reads the same bar
// index in summer (CEST) and winter (CET).
let local = self.tz.timestamp_nanos(ctx.now().snap_to_nearest_minute().0);
let mins = local.hour() as i64 * 60 + local.minute() as i64 - self.open_minutes;
// i64 division: in-session closes land on exact period boundaries, so the
// value is the bar index; pre-open is <= 0 (non-actionable).
self.out[0] = Cell::from_i64(mins / self.period_minutes);
Some(&self.out)
}
fn label(&self) -> String {
format!("Session({}m@{})", self.period_minutes, self.tz)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
/// One depth-1 f64 trigger column, as bootstrap sizes it from `lookbacks`.
fn trigger_input() -> Vec<AnyColumn> {
vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]
}
/// Drive one fired eval: push an (ignored-value) trigger into slot 0 and step
/// the node at the cycle instant built from Berlin **local wall-clock**
/// `(year, month, day, hour, minute)` —
/// `Berlin.with_ymd_and_hms(...).timestamp_nanos_opt()` converts the local
/// instant to the epoch-ns UTC the engine streams. Returns `bars_since_open`.
fn step_local(
node: &mut Session,
inputs: &mut [AnyColumn],
trigger: f64,
local: (i32, u32, u32, u32, u32),
) -> Option<i64> {
let (y, mo, d, h, mi) = local;
let ns = Berlin.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap().timestamp_nanos_opt().unwrap();
inputs[0].push(Scalar::f64(trigger)).unwrap();
node.eval(Ctx::new(inputs, Timestamp(ns))).map(|r| r[0].i64())
}
#[test]
fn session_indexes_bars_since_frankfurt_open_in_local_dst_aware_time() {
// THE HEADLINE PROPERTY: bars_since_open = (local wall-clock minutes past
// the 09:00 Frankfurt open) / period, in tz-aware LOCAL time. Because the
// close instant is the bucket boundary (:00/:15/:30/:45),
// in-session bar closes are exact positive multiples of the bar index.
//
// Built from Berlin local wall-clock via chrono-tz so the assertion is
// self-checking across DST — the SAME local 09:45 must read 3 in both the
// CEST summer (UTC+2) and the CET winter (UTC+1), even though the raw
// epoch-ns differs by an hour. That equality is what proves the node uses
// tz-aware local time, not a fixed UTC offset.
let mut node = Session::new(9, 0, Berlin, 15);
let mut inputs = trigger_input();
// --- (1) Summer (CEST, UTC+2), 2024-07-01 — the in-session sequence.
// 09:15→1, 09:30→2, 09:45→3, 10:00→4, 10:15→5. The trigger VALUE is
// arbitrary (0.0) — proving it is ignored. ---
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 15)), Some(1));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 30)), Some(2));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 9, 45)), Some(3));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 0)), Some(4));
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 10, 15)), Some(5));
// --- (2) DST-CORRECTNESS (load-bearing): Winter (CET, UTC+1),
// 2024-01-02, local 09:45 → 3. The UTC offset is one hour LESS than
// summer, so the raw epoch-ns for the same wall-clock differs — yet
// local 09:45 still yields 3. A fixed-offset implementation would be
// off by one bar here. ---
assert_eq!(step_local(&mut node, &mut inputs, 0.0, (2024, 1, 2, 9, 45)), Some(3));
// --- (3) Trigger value is ignored: a WILD trigger (999.0) at summer
// local 09:45 still reads 3 — the output depends only on ctx.now(). ---
assert_eq!(step_local(&mut node, &mut inputs, 999.0, (2024, 7, 1, 9, 45)), Some(3));
// --- (4) Pre-open: summer local 08:45 is BEFORE the 09:00 open, so
// bars_since_open <= 0 — non-actionable; it must be neither 3 nor 5,
// so the downstream EqConst gates never match out of session. ---
let pre = step_local(&mut node, &mut inputs, 0.0, (2024, 7, 1, 8, 45)).unwrap();
assert!(pre <= 0, "pre-open bars_since_open must be <= 0, got {pre}");
assert_ne!(pre, 3);
assert_ne!(pre, 5);
}
#[test]
fn session_indexes_a_boundary_bar_by_its_nominal_minute_despite_subsecond_jitter() {
// PROPERTY (#280): the bar index follows an M1 bar's NOMINAL minute, not
// the raw provider stamp. Provider stamps carry sub-second jitter around
// minute boundaries, and the node derives wall-clock from the raw stamp
// (dropping the seconds entirely: hour*60 + minute). The first in-session
// bar (nominal 09:15 → bars_since_open = 1) must not be demoted to 0
// (at-open, non-actionable — "pre-open") just because its stamp landed a
// fraction of a second early at 09:14:59.4.
let mut node = Session::new(9, 0, Berlin, 15);
let mut inputs = trigger_input();
// Nominal 09:15 Berlin (summer/CEST), stamped 600ms early at 09:14:59.400.
// Raw wall-clock reads minute 14 (mins=14, 14/15=0 → at-open); the nominal
// minute is 09:15 → 1.
let ns = Berlin
.with_ymd_and_hms(2024, 7, 1, 9, 14, 59)
.unwrap()
.timestamp_nanos_opt()
.unwrap()
+ 400_000_000;
inputs[0].push(Scalar::f64(0.0)).unwrap();
let got = node.eval(Ctx::new(&inputs, Timestamp(ns))).map(|r| r[0].i64());
assert_eq!(
got,
Some(1),
"the jitter-early first in-session bar (nominal 09:15) must read \
bars_since_open = 1, not be demoted to 0 by its raw sub-second stamp"
);
}
#[test]
fn session_is_none_until_trigger_fires() {
// Cold guard (C8): an empty trigger column means the resampler has not yet
// emitted a completed bar — the node has nothing to index, so it filters.
let mut node = Session::new(9, 0, Berlin, 15);
let inputs = trigger_input();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
}
-172
View File
@@ -1,172 +0,0 @@
//! `SimBroker` — the sim-optimal broker (class (a) of C10): deterministic,
//! frictionless, perfect-fill. Consumes an exposure stream (slot 0) + a price
//! stream (slot 1) and integrates the return earned by the exposure held INTO
//! each cycle (decided at t-1) into a cumulative synthetic pip-equity output.
//! Measures signal quality, not execution-modelled P&L.
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
/// Integrates `exposure * price-return` into cumulative pips. `pip_size` is
/// per-instrument reference metadata (beside the hot path, C7/C15), held here,
/// never streamed.
///
/// # Input slots
///
/// Two `f64` inputs, **order is load-bearing** (both are `f64`, so a swapped
/// wiring is *not* caught at bootstrap — it would silently produce a wrong
/// equity curve):
///
/// - **slot 0 — exposure** ∈ [-1, +1] (the strategy's intent, e.g. from
/// [`Bias`](crate::Bias)).
/// - **slot 1 — price** (the instrument price the exposure is marked against).
///
/// # Firing and warm-up
///
/// Both inputs are [`Firing::Any`](aura_core::Firing::Any), so the broker fires
/// on **every price-fresh cycle** (price is typically the source tick). A
/// not-yet-warmed exposure leg is read as flat `0.0`, so the broker emits an
/// equity row from the **first** price tick, reading `0.0` until the exposure
/// leg produces its first value — the recorded curve therefore has one row per
/// price cycle, with leading `0.0`s during warm-up, not one row per exposure.
///
/// # Output
///
/// One `f64` column, the cumulative pip equity (a producer; tap it with a
/// recording sink to persist the curve).
pub struct SimBroker {
pip_size: f64,
prev_price: Option<f64>,
prev_exposure: f64, // exposure held into this cycle (decided at t-1); 0.0 = flat
cum: f64, // cumulative pips
out: [Cell; 1],
}
impl SimBroker {
/// Build a sim-optimal broker for an instrument whose pip is `pip_size`
/// (price units per pip; must be > 0).
pub fn new(pip_size: f64) -> Self {
assert!(pip_size > 0.0, "SimBroker pip_size must be > 0");
Self {
pip_size,
prev_price: None,
prev_exposure: 0.0,
cum: 0.0,
out: [Cell::from_f64(0.0)],
}
}
/// The param-generic recipe for a blueprint primitive. `pip_size` is per-instrument
/// metadata (C10/C15), not a tunable param — it is captured by the closure, not
/// injected; the node declares no params.
pub fn builder(pip_size: f64) -> PrimitiveBuilder {
PrimitiveBuilder::new(
"SimBroker",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
],
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
params: vec![],
},
move |_| Box::new(SimBroker::new(pip_size)),
)
}
}
impl Node for SimBroker {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let price = ctx.f64_in(1);
if price.is_empty() {
return None; // no price yet — nothing to mark
}
let price = price[0];
let expo = ctx.f64_in(0).get(0).unwrap_or(0.0); // flat until exposure warms up
if let Some(pp) = self.prev_price {
self.cum += self.prev_exposure * (price - pp) / self.pip_size;
}
self.prev_price = Some(price);
self.prev_exposure = expo; // update AFTER taking PnL — no look-ahead (C2)
self.out[0] = Cell::from_f64(self.cum);
Some(&self.out)
}
fn label(&self) -> String {
format!("SimBroker({})", self.pip_size)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn two_f64_inputs() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
]
}
// Drive one broker cycle by hand: optionally push an exposure into slot 0
// (None models a cycle where the exposure chain has not warmed up — slot 0
// stays empty) and a price into slot 1, then eval and return the equity.
fn step(b: &mut SimBroker, inputs: &mut [AnyColumn], expo: Option<f64>, price: f64) -> f64 {
if let Some(e) = expo {
inputs[0].push(Scalar::f64(e)).unwrap();
}
inputs[1].push(Scalar::f64(price)).unwrap();
match b.eval(Ctx::new(inputs, Timestamp(0))) {
Some([c]) => c.f64(),
other => panic!("expected Some([F64]), got {other:?}"),
}
}
#[test]
fn sim_broker_integrates_lagged_exposure_times_return() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 100.0), 0.0); // no prev price
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 110.0), 5.0); // 0.5*(110-100)
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 108.0), 4.0); // +0.5*(108-110) = -1
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 100.0), 12.0); // +(-1)*(100-108) = +8
}
#[test]
fn sim_broker_no_lookahead() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
step(&mut b, &mut inputs, Some(1.0), 100.0);
// exposure flips to 0.0 THIS cycle, but the PnL must use the 1.0 held
// into it: 1.0*(110-100) = 10. Using the fresh 0.0 would give 0.
assert_eq!(step(&mut b, &mut inputs, Some(0.0), 110.0), 10.0);
}
#[test]
fn sim_broker_is_flat_during_warmup() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
// exposure never pushed (slot 0 empty) -> treated as flat; equity stays 0
assert_eq!(step(&mut b, &mut inputs, None, 100.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 110.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 90.0), 0.0);
}
#[test]
fn sim_broker_first_cycle_has_no_pnl() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(1.0), 100.0), 0.0); // no prev price to mark
}
#[test]
fn input_slots_are_named_exposure_price() {
let b = SimBroker::builder(1.0);
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["exposure", "price"]);
}
}
-132
View File
@@ -1,132 +0,0 @@
//! `Sizer` — the flat-1R sizing seam (C10). Turns a protective-stop distance
//! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is
//! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the
//! stop — NOT a constant. The `bias` input gates firing (a size is only meaningful when a
//! strategy is taking a position) and is the live/deploy-edge seam: fixed-fractional
//! sizing swaps `risk_budget` for `risk_fraction · equity` (an added equity input), same
//! node shape. R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates
//! signal quality — it only scales the (deploy-edge) currency exposure.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None`
/// until both inputs are present (warm-up filter, C8).
pub struct Sizer {
risk_budget: f64,
out: [Cell; 1],
}
impl Sizer {
/// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R).
pub fn new(risk_budget: f64) -> Self {
assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0");
Self { risk_budget, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Sizer",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
],
output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Sizer::new(p[0].f64())),
)
}
}
impl Node for Sizer {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let bias = ctx.f64_in(0);
let dist = ctx.f64_in(1);
if bias.is_empty() || dist.is_empty() {
return None; // a size needs a (present) bias and a stop distance
}
let d = dist[0];
// a zero/degenerate stop distance yields zero size (no division blow-up); a real
// stop rule emits a strictly positive distance, so this guards only warm-up edges.
let size = if d > 0.0 { self.risk_budget / d } else { 0.0 };
self.out[0] = Cell::from_f64(size);
Some(&self.out)
}
fn label(&self) -> String {
format!("Sizer({})", self.risk_budget)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option<f64> {
let mut c = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
c[0].push(Scalar::f64(bias)).unwrap();
c[1].push(Scalar::f64(dist)).unwrap();
s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64())
}
// (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance.
#[test]
fn sizer_is_flat_1r_invariant() {
for budget in [1.0_f64, 2.5] {
let mut s = Sizer::new(budget);
for dist in [0.5_f64, 2.0, 10.0, 37.5] {
let size = eval_once(&mut s, 1.0, dist).expect("both inputs present");
assert!(
(size * dist - budget).abs() < 1e-9,
"size*dist must equal risk_budget; budget={budget} dist={dist} size={size}"
);
}
}
}
#[test]
#[should_panic(expected = "risk_budget must be > 0")]
fn sizer_panics_on_zero_risk_budget() {
let _ = Sizer::new(0.0);
}
#[test]
#[should_panic(expected = "risk_budget must be > 0")]
fn sizer_panics_on_negative_risk_budget() {
let _ = Sizer::new(-1.0);
}
#[test]
fn sizer_is_none_until_both_inputs_present() {
let mut s = Sizer::new(1.0);
let mut c = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only bias present -> None
c[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None);
// both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1
c[1].push(Scalar::f64(10.0)).unwrap();
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice()));
}
#[test]
fn input_slots_are_named_bias_and_stop_distance() {
let b = Sizer::builder();
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["bias", "stop_distance"]);
}
}
+6 -2
View File
@@ -202,7 +202,9 @@ mod tests {
#[test]
fn labels_carry_identifying_params() {
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, LinComb, Recorder, Sub};
use aura_strategy::Bias;
use aura_backtest::SimBroker;
use aura_core::{Firing, ScalarKind};
// the load-bearing payoff: two SMAs disambiguate by window
@@ -221,7 +223,9 @@ mod tests {
#[test]
fn nodes_declare_expected_params() {
use crate::{Add, Bias, LinComb, Recorder, SimBroker, Sub};
use crate::{Add, LinComb, Recorder, Sub};
use aura_strategy::Bias;
use aura_backtest::SimBroker;
use aura_core::{Firing, ParamSpec, ScalarKind};
// single scalar knobs (declared on the param-generic builder, pre-build)
assert_eq!(
-76
View File
@@ -1,76 +0,0 @@
//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0,
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
//! position-management latches the entry-cycle distance as the frozen R-denominator.
//!
//! `FixedStop` and `VolTfStop` (`vol_tf_stop.rs`) are the fused stop-rule
//! PRIMITIVES: `FixedStop` is a constant distance gated on its price input (a
//! source-less `Const` has no firing trigger in the push model, so the
//! triggered-constant shape is the honest primitive); `VolTfStop` folds
//! `k · √EMA(Δ², length)` over completed timescale-matched buckets. The
//! per-cycle volatility stop remains NOT a primitive — it is a COMPOSITION of
//! primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))` with
//! `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built
//! with `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`.
//! True-range ATR (a richer stop) is deferred — it needs OHLC.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// Constant stop distance, gated on a price input. `distance` must be > 0.
pub struct FixedStop {
distance: f64,
out: [Cell; 1],
}
impl FixedStop {
pub fn new(distance: f64) -> Self {
assert!(distance > 0.0, "FixedStop distance must be > 0");
Self { distance, out: [Cell::from_f64(distance)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"FixedStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "distance".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(FixedStop::new(p[0].f64())),
)
}
}
impl Node for FixedStop {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
if ctx.f64_in(0).is_empty() {
return None; // fire with price (warm-up filter)
}
self.out[0] = Cell::from_f64(self.distance);
Some(&self.out)
}
fn label(&self) -> String {
format!("FixedStop({})", self.distance)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec<Option<f64>> {
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut out = vec![];
for &p in prices {
col[0].push(Scalar::f64(p)).unwrap();
out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64()));
}
out
}
#[test]
fn fixed_stop_is_constant_after_first_price() {
let mut s = FixedStop::new(2.5);
assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]);
}
}
-140
View File
@@ -1,140 +0,0 @@
//! The closed, compiled-in dispatch from a serialized node **type identity** to
//! its `aura-std` builder factory — the load-side counterpart to the type label
//! a blueprint serializes (`PrimitiveBuilder::label`). This is **not** a dynamic
//! registry (domain invariant 9): it is a `match` the crate that *owns* the
//! nodes writes, over its own closed, compiled-in vocabulary (C24 — nodes are
//! referenced "by compiled-in type identity", never a by-name marketplace). The
//! engine cannot hold this table (it does not depend on `aura-std`); a loader
//! takes it as an injected resolver, and a project `cdylib` later supplies its
//! own (C16) through the same seam.
//!
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
//! resolvable. Builders that take structural construction arguments
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
//! rather than guessing. Serialising structural-axis construction args is a
//! later, additive extension (#156/C20).
use crate::{
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, CumSum, Delay, Div, Ema, EqConst,
FixedStop, Gt, Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax,
RollingMin, Scale, Select, SessionFrankfurt, Sign, Sizer, Sma, Sqrt, Sub, VolSlippageCost, When,
};
use aura_core::PrimitiveBuilder;
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
/// node, expanded into BOTH the `std_vocabulary` match and the
/// `std_vocabulary_types` list — the two surfaces cannot drift apart, and
/// adding a zero-arg node to the vocabulary is exactly one line here (plus the
/// conscious count-pin bumps: the in-crate shape test and aura-cli's
/// cross-boundary `--vocabulary` count e2e). What this cannot guard: a new
/// zero-arg node never rostered at all — that fails safe (clean
/// `LoadError::UnknownNodeType` on load, merely absent from `--vocabulary`;
/// #160).
macro_rules! std_vocabulary_roster {
($($type_id:literal => $ty:ty),+ $(,)?) => {
/// Resolve a serialized node type identity (the `PrimitiveBuilder::label`,
/// e.g. `"SMA"`) to a fresh, unbound builder from the node's own factory.
/// Returns `None` for any identity outside the zero-arg `aura-std`
/// vocabulary.
pub fn std_vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
Some(match type_id {
$($type_id => <$ty>::builder(),)+
_ => return None,
})
}
/// The enumerable companion to [`std_vocabulary`]: the closed set of
/// zero-arg type identities the resolver answers. A `fn(&str)->Option<…>`
/// resolver cannot be listed, so build-free vocabulary introspection
/// (#157) reads this. Generated from the same roster as the match arms
/// (#160) — the two surfaces agree by construction.
pub fn std_vocabulary_types() -> &'static [&'static str] {
&[$($type_id),+]
}
};
}
std_vocabulary_roster! {
"Abs" => Abs,
"Add" => Add,
"And" => And,
"Bias" => Bias,
"CarryCost" => CarryCost,
"Const" => Const,
"ConstantCost" => ConstantCost,
"CumSum" => CumSum,
"Delay" => Delay,
"Div" => Div,
"EMA" => Ema,
"EqConst" => EqConst,
"FixedStop" => FixedStop,
"Gt" => Gt,
"Latch" => Latch,
"LongOnly" => LongOnly,
"Max" => Max,
"Min" => Min,
"Mul" => Mul,
"PositionManagement" => PositionManagement,
"Resample" => Resample,
"RollingMax" => RollingMax,
"RollingMin" => RollingMin,
"Scale" => Scale,
"Select" => Select,
"SessionFrankfurt" => SessionFrankfurt,
"Sign" => Sign,
"Sizer" => Sizer,
"SMA" => Sma,
"Sqrt" => Sqrt,
"Sub" => Sub,
"VolSlippageCost" => VolSlippageCost,
"When" => When,
}
#[cfg(test)]
mod tests {
use super::{std_vocabulary, std_vocabulary_types};
/// Every rostered key resolves to a builder that carries that exact type
/// label back, and only the rostered keys do. The builder's own `label()`
/// is the independent oracle: a typo'd roster key fails the lookup-label
/// agreement even though the match arms and the list share the same
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
/// loader fails cleanly rather than guessing.
#[test]
fn std_vocabulary_resolves_known_and_rejects_unknown() {
for &type_id in std_vocabulary_types() {
let builder =
std_vocabulary(type_id).unwrap_or_else(|| panic!("{type_id} is in the vocabulary"));
assert_eq!(
builder.label(),
type_id,
"resolver key must round-trip to the same type label"
);
}
// an unknown id, a construction-arg node, and a sink are all absent
assert!(std_vocabulary("nope").is_none());
assert!(std_vocabulary("LinComb").is_none());
assert!(std_vocabulary("Recorder").is_none());
}
/// List <-> resolver agreement holds BY CONSTRUCTION since the #160 roster
/// macro (one source expands into both surfaces); what stays pinned here is
/// the roster's SHAPE. The count pin is deliberate friction: any roster
/// line added or dropped trips it, making a vocabulary change a conscious
/// act. The residual #160 drift — a new zero-arg node never rostered at
/// all — is not catchable here (no enumeration of zero-arg builders
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
/// from `--vocabulary`).
#[test]
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
// known non-members stay out of the list
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 33 entries
assert_eq!(std_vocabulary_types().len(), 33);
}
}
-196
View File
@@ -1,196 +0,0 @@
//! `VolSlippageCost` — a slippage cost that scales with a measured volatility
//! input, charged once per closed trade, in R. The first *state-dependent*
//! [`CostNode`] factor: its price-unit numerator is `slip_vol_mult · volatility`
//! instead of a flat constant, so the cost-in-R varies trade-to-trade. The vol is
//! supplied as an extra input (an upstream realized-range estimator), kept
//! independent of the stop's own vol — scaling by the stop's vol would collapse
//! cost-in-R to a constant. R-pure: `slip_vol_mult · vol / |entry - stop|` (C10).
//!
//! Co-temporality is the shared [`CostRunner`]'s contract: it gates only on the PM
//! geometry, so a not-yet-warm `volatility` input makes this factor's numerator 0
//! that cycle (handled below) rather than withholding and desyncing the stream.
use aura_core::{Ctx, Firing, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind};
use crate::cost::{cost_node_builder, CostNode, CostRunner, GEOMETRY_WIDTH};
/// This node's one extra input beyond the geometry: a `volatility` stream (price
/// units), read at slot `GEOMETRY_WIDTH`. One source of truth, so the schema-input
/// list (`builder()`, → graph wiring) and the runner's lookbacks count
/// (`extra_inputs()`) cannot desync — mirroring `cost::geometry_input_ports`.
fn volatility_input_ports() -> Vec<PortSpec> {
vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }]
}
/// A volatility-scaled per-trade slippage, emitted in R via the shared
/// [`CostRunner`]. One extra input beyond the geometry: a `volatility` stream
/// (price units), read at slot `GEOMETRY_WIDTH`.
pub struct VolSlippageCost {
slip_vol_mult: f64,
}
impl VolSlippageCost {
/// A volatility-scaled slippage cost node (the factor wrapped in the runner).
pub fn new(slip_vol_mult: f64) -> CostRunner<VolSlippageCost> {
assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0");
CostRunner::new(VolSlippageCost { slip_vol_mult })
}
/// The param-generic recipe: one `slip_vol_mult` F64 knob; one extra
/// `volatility` input appended after the geometry.
pub fn builder() -> PrimitiveBuilder {
cost_node_builder(
"VolSlippageCost",
volatility_input_ports(),
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|p| Box::new(VolSlippageCost::new(p[0].f64())),
)
}
}
impl CostNode for VolSlippageCost {
fn label(&self) -> String {
format!("VolSlippageCost({})", self.slip_vol_mult)
}
fn extra_inputs(&self) -> Vec<PortSpec> {
volatility_input_ports()
}
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64 {
let vol_w = ctx.f64_in(GEOMETRY_WIDTH);
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
self.slip_vol_mult * vol
}
}
#[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
AnyColumn::with_capacity(ScalarKind::F64, 1), // volatility
]
}
#[test]
fn no_geometry_yet_withholds() {
let mut c = VolSlippageCost::new(0.5);
let inputs = cols(); // all columns empty
assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn vol_not_yet_warm_emits_zero_cost_co_temporally() {
// The realized-range proxy warms after the PM geometry; during warm-up the
// node must still EMIT (a 0-cost row) so the cost stream stays 1:1 with the
// PM record — withholding here would desync the positional join.
let mut c = VolSlippageCost::new(0.5);
let mut inputs = cols();
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
inputs[1].push(Scalar::bool(false)).unwrap();
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
// volatility column empty (proxy not warm) -> vol = 0 -> 0 cost, but a row IS emitted
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 closed_charges_mult_times_vol_over_latched() {
let mut c = VolSlippageCost::new(0.5);
let mut inputs = cols();
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
inputs[1].push(Scalar::bool(false)).unwrap();
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
// per = 0.5 * 3.0 / 4.0 = 0.375; cum = 0.375; open = 0.0
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn open_emits_would_be_cost_not_charged_to_cum() {
let mut c = VolSlippageCost::new(0.5);
let mut inputs = cols();
inputs[0].push(Scalar::bool(false)).unwrap();
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
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
// cost_in_r = 0; cum 0; open_cost_in_r = 0.375
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.375)].as_slice())
);
}
#[test]
fn zero_latched_contributes_no_cost() {
let mut c = VolSlippageCost::new(0.5);
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
inputs[4].push(Scalar::f64(3.0)).unwrap();
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 cum_accumulates_across_closes() {
let mut c = VolSlippageCost::new(0.5);
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(); // latched 4
a[4].push(Scalar::f64(3.0)).unwrap(); // 0.5*3/4 = 0.375
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
b[4].push(Scalar::f64(4.0)).unwrap(); // 0.5*4/2 = 1.0; cum 1.375
assert_eq!(
c.eval(Ctx::new(&b, Timestamp(1))),
Some([Cell::from_f64(1.0), Cell::from_f64(1.375), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
fn label_carries_the_mult() {
assert_eq!(VolSlippageCost::new(0.5).label(), "VolSlippageCost(0.5)");
assert_eq!(VolSlippageCost::new(2.0).label(), "VolSlippageCost(2)");
}
#[test]
#[should_panic(expected = "slip_vol_mult must be >= 0")]
fn new_panics_on_negative_mult() {
let _ = VolSlippageCost::new(-1.0);
}
#[test]
fn builder_schema_extras_match_extra_inputs() {
// The two consumers of the extra-input list must agree: the schema beyond
// the geometry prefix (builder() -> graph wiring) and the factor's
// extra_inputs() (CostRunner::lookbacks()'s count). One helper feeds both, so
// they cannot desync; this pins that invariant against a future re-inlining.
let schema_extras = VolSlippageCost::builder().schema().inputs[GEOMETRY_WIDTH..].to_vec();
let factor_extras = VolSlippageCost { slip_vol_mult: 0.5 }.extra_inputs();
assert_eq!(schema_extras, factor_extras);
}
}
-251
View File
@@ -1,251 +0,0 @@
//! `VolTfStop` — timescale-matched volatility stop (#262):
//! `k · √EMA(Δ², length)` over completed `period_minutes` buckets — the vol
//! regime on a coarser clock. A fused primitive in the `Resample` mold: the
//! in-progress bucket lives in node state; on rollover the finished bucket's
//! close is differenced against the previous bucket's close, Δ² folds into an
//! `Ema`-convention EMA (SMA seed over the first `length` deltas,
//! `alpha = 2/(length+1)`), and the stop distance is emitted ONCE (C2: a
//! partial bucket never exists downstream). Mid-bucket the node emits
//! nothing, so the `Firing::Any` consumers (`Sizer`, `PositionManagement`)
//! hold the last completed bucket's value; the R-latch samples at entry.
/// Nanoseconds per minute — the bucket-id divisor's one scale factor.
const NS_PER_MINUTE: i64 = 60 * 1_000_000_000;
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
pub struct VolTfStop {
period_ns: i64,
k: f64,
// EMA(Δ²) over completed buckets — `Ema`'s exact convention.
alpha: f64,
length: usize,
seeded: bool,
warmup_sum: f64,
count: usize,
ema: f64,
// Bucket accumulator (`Resample`'s mold): current id, its running close,
// and the previous COMPLETED bucket's close.
bucket: Option<i64>,
close_in_bucket: f64,
prev_close: Option<f64>,
out: [Cell; 1],
}
impl VolTfStop {
pub fn new(period_minutes: i64, length: i64, k: f64) -> Self {
assert!(period_minutes >= 1, "VolTfStop period_minutes must be >= 1");
assert!(length >= 1, "VolTfStop length must be >= 1");
assert!(k > 0.0, "VolTfStop k must be > 0");
Self {
period_ns: period_minutes * NS_PER_MINUTE,
k,
alpha: 2.0 / (length as f64 + 1.0),
length: length as usize,
seeded: false,
warmup_sum: 0.0,
count: 0,
ema: 0.0,
bucket: None,
close_in_bucket: 0.0,
prev_close: None,
out: [Cell::from_f64(0.0)],
}
}
/// The param-generic recipe (#262): a graph builder adds this fused
/// primitive the same way it adds any other node — all three knobs are
/// structural (baked into the accumulator/EMA state at construction), so
/// `risk_executor`'s `VolTf` arm binds all three via `.bind(..)` (the
/// `FixedStop::builder().bind("distance", ..)` idiom, chained thrice).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"VolTfStop",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
params: vec![
ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 },
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
],
},
|p| Box::new(VolTfStop::new(p[0].i64(), p[1].i64(), p[2].f64())),
)
}
}
impl Node for VolTfStop {
// The in-progress bucket lives in node state, not the input columns.
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // fire with price (warm-up filter)
}
let price = w[0];
let bucket = ctx.now().snap_to_nearest_minute().0 / self.period_ns;
match self.bucket {
// First ever sample: open the accumulator, nothing complete yet.
None => {
self.bucket = Some(bucket);
self.close_in_bucket = price;
None
}
// Same bucket: the running close advances (last close wins).
Some(b) if bucket == b => {
self.close_in_bucket = price;
None
}
// Rollover: the finished bucket's close is final — difference it
// against the previous completed close, fold, emit once.
Some(_) => {
let finished_close = self.close_in_bucket;
self.bucket = Some(bucket);
self.close_in_bucket = price;
let Some(prev) = self.prev_close.replace(finished_close) else {
return None; // first completed bucket: no delta yet
};
let d = finished_close - prev;
let d2 = d * d;
if !self.seeded {
// SMA-seed over the first `length` deltas (Ema's convention).
self.warmup_sum += d2;
self.count += 1;
if self.count < self.length {
return None;
}
self.ema = self.warmup_sum / self.length as f64;
self.seeded = true;
} else {
// O(1) recurrence, exactly Ema's.
self.ema += self.alpha * (d2 - self.ema);
}
self.out[0] = Cell::from_f64(self.k * self.ema.sqrt());
Some(&self.out)
}
}
}
fn label(&self) -> String {
format!(
"VolTfStop({}m,{},{})",
self.period_ns / NS_PER_MINUTE,
self.length,
self.k
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, ScalarKind, Timestamp};
// Drive the node like the engine does: one eval per (minute, price) sample,
// mirroring the `feed`/`step` eval-harness idiom of `stop_rule.rs` /
// `resample.rs` — a capacity-1 ring column whose push overwrites the single
// slot, re-presenting the newest sample each cycle.
fn drive(node: &mut VolTfStop, samples: &[(i64, f64)]) -> Vec<Option<f64>> {
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
samples
.iter()
.map(|&(ts_min, price)| {
col[0].push(Scalar::f64(price)).unwrap();
node.eval(Ctx::new(&col, Timestamp(ts_min * NS_PER_MINUTE)))
.map(|c| c[0].f64())
})
.collect()
}
/// Property (#262): mid-bucket cycles emit NOTHING — the stop exists only
/// at bucket rollover (C2: a partial bucket never reaches downstream).
#[test]
fn emits_only_on_bucket_rollover() {
let mut n = VolTfStop::new(60, 1, 2.0);
// Minutes 0..59 are one bucket; minute 60 rolls it over; 61..119 the
// next; 120 rolls again (first delta -> first emission).
let out = drive(
&mut n,
&[(0, 100.0), (30, 101.0), (59, 102.0), (60, 103.0), (90, 104.0), (120, 105.0)],
);
assert_eq!(out[0], None, "first sample opens the accumulator");
assert_eq!(out[1], None, "mid-bucket");
assert_eq!(out[2], None, "mid-bucket");
assert_eq!(out[3], None, "first rollover: a close exists, no delta yet");
assert_eq!(out[4], None, "mid-bucket");
assert!(out[5].is_some(), "second rollover: first delta -> first stop");
}
/// Property (#262): the emitted value is k · √EMA(Δ², length) over the
/// BUCKET closes. length=1 (alpha=1, the Ema identity) makes each stop
/// exactly k·|Δ| of the last completed bucket pair.
#[test]
fn stop_is_k_times_root_ema_of_bucket_close_deltas() {
let mut n = VolTfStop::new(60, 1, 2.0);
// Bucket closes: 102 (bucket 0), 104 (bucket 1), 109 (bucket 2).
let out = drive(
&mut n,
&[(0, 100.0), (59, 102.0), (61, 103.0), (119, 104.0), (121, 108.0), (179, 109.0), (181, 110.0)],
);
// Rollover at 61 (close 102, no delta), 121 (Δ=2 -> 2·|2|=4), 181 (Δ=5 -> 2·|5|=10).
assert_eq!(out[2], None, "first completed bucket seeds prev_close only");
assert_eq!(out[4], Some(4.0), "k·|Δ| with length=1: 2·(104102)");
assert_eq!(out[6], Some(10.0), "2·(109104)");
}
/// Property (#280): bucket membership follows a price sample's NOMINAL minute,
/// not the raw provider stamp. Provider M1 stamps carry sub-second jitter
/// around minute boundaries; a sample whose nominal minute is a bucket boundary
/// but stamped a fraction of a second early must open the new bucket —
/// completing the previous one — rather than folding back into it. Mis-bucketing
/// the boundary sample suppresses the rollover, so the stop emission over the
/// true bucket closes never fires.
#[test]
fn buckets_a_boundary_sample_by_its_nominal_minute_despite_subsecond_jitter() {
// 5m buckets: boundaries at 0, 5min, 10min (multiples of period_ns).
let period_ns = 5 * NS_PER_MINUTE; // 300_000_000_000
let mut n = VolTfStop::new(5, 1, 2.0);
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut feed = |ns: i64, price: f64| {
col[0].push(Scalar::f64(price)).unwrap();
n.eval(Ctx::new(&col, Timestamp(ns))).map(|c| c[0].f64())
};
// Bucket 0 opens with a clean stamp; its running close is 100.0.
assert_eq!(feed(0, 100.0), None);
// The next sample's NOMINAL minute is 05:00 (the bucket-0/bucket-1
// boundary) but the provider stamped it 600ms early at 04:59:59.400. By its
// nominal minute it opens bucket 1 (close 105.0), rolling over bucket 0 and
// seeding prev_close = 100.0 (first completed bucket: no delta yet).
assert_eq!(feed(period_ns - 600_000_000, 105.0), None);
// Nominal 10:00 rolls over bucket 1: Δ = 105 100, stop = k·|Δ| = 2·5 = 10.
assert_eq!(
feed(2 * period_ns, 108.0),
Some(10.0),
"the jitter-early boundary sample must roll over bucket 0 so this \
rollover emits k·|Δ| over the TRUE bucket closes (100→105)"
);
}
/// Correspondence pin (#262 acceptance): on strictly 1-minute-spaced
/// input with CONSTANT |Δ| per minute, vol_tf{1, length, k} converges to
/// exactly what the composite vol stop computes — k·|Δ| — the constant
/// input makes the one-cycle emission lag immaterial.
#[test]
fn one_minute_periods_match_the_vol_regime_on_constant_deltas() {
let mut n = VolTfStop::new(1, 3, 2.0);
// Alternating ±1 prices: every minute-delta has |Δ| = 1, Δ² = 1.
let samples: Vec<(i64, f64)> =
(0..12).map(|m| (m, if m % 2 == 0 { 100.0 } else { 101.0 })).collect();
let out = drive(&mut n, &samples);
let last = out.last().copied().flatten().expect("steady state reached");
assert!((last - 2.0).abs() < 1e-12, "k·√EMA(1) = 2.0, got {last}");
}
}