27f850dc52
Generalises the #105 foot-gun: the family-member trace key was derived from two hardcoded axis names (f{fast}s{slow}), collision-free only over the built-in SMA grid. It is now derived from the axes that actually VARY (SweepBinder::varying_axes) and rendered as a filesystem-conformant directory component: - charset restricted to [A-Za-z0-9._-] (valid on Linux/Windows/macOS, also URL-path- and cloud-sync-safe); any other byte sanitised; - case-less values (i64/timestamp decimal, bool true/false, f64 via Display, no scientific notation) so two members of one family never differ only by letter case -> no silent overwrite on case-insensitive filesystems (NTFS/APFS); - length-capped (MAX_KEY=200) with a version-stable FNV-1a fallback for the unbounded-grid edge. Pinned singleton axes are omitted; the SMA sweep's member dirs change from f2s4 to signals.trend.fast.length-2_signals.trend.slow.length-4 (its --trace integration test + the stale prose updated accordingly). Adds the first aura-std node with a bool *param*, LongOnly (a long-only exposure gate: enabled=true clamps short exposure to >=0, false passes through) — the block the momentum demo strategy (next commit) sweeps to prove the key generic over a bool axis. The engine change is one additive pure read accessor; no existing signature changes, trace state stays out of the engine. Self-verified: cargo build --workspace, cargo test --workspace (all green incl. the LongOnly node tests, the member_key worked-examples corpus, the varying_axes accessor test, and the updated SMA sweep-trace integration test), cargo clippy --workspace --all-targets -D warnings. refs #105
109 lines
3.7 KiB
Rust
109 lines
3.7 KiB
Rust
//! `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);
|
|
}
|
|
}
|