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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }]);
|
||||
}
|
||||
}
|
||||
@@ -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:00–09:15 bar closes at **09:15 → 1**,
|
||||
//! 09:30–09:45 closes at **09:45 → 3**, 10:00–10: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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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·(104−102)");
|
||||
assert_eq!(out[6], Some(10.0), "2·(109−104)");
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user