Files
Aura/crates/aura-composites/src/lib.rs
T
claude e84ad6d0d2 feat(aura-market, aura-std, aura-strategy): arg-bearing builders go zero-arg
Session, LinComb, and CostSum — the three builders the roster scope doc
excludes — re-shape onto the args seam (refs #271): builder() is now
zero-arg and returns a pending recipe (Session: tz + open; LinComb:
arity; CostSum: n_costs), the full signature forms in make, and
configured(...) is the Rust-path convenience producing the identical
recipe (twin-pinned). Session's period_minutes becomes a real ParamSpec
bound by configured instead of a baked struct field — same behaviour,
now sweepable when left open. Session::new and SessionFrankfurt are
untouched.

All builder(n)-form call sites move mechanically to configured(n):
aura-runner member.rs (wrap_r), aura-composites (vol_stop/cost_graph),
aura-engine blueprint.rs + e2e tests, aura-ingest breakout example,
and the crates' own tests. aura-std drops its unused chrono/chrono-tz
deps (stale since the b39fd63 session move).

The roster is deliberately untouched here: rostering before the op
seam lands would expose unconfigured pending builders to add_node.
2026-07-24 21:42:50 +02:00

214 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `aura-composites` — reusable composite-builders for the feed-forward R loop: the volatility stop and
//! the per-symbol RiskExecutor. Both are `GraphBuilder` compositions (from
//! `aura-engine`) of `aura-std` primitives, so this crate sits *above* both and is
//! the single place where the engine's builder and the standard nodes are wired
//! together.
//!
//! It lives in its own crate on purpose. The engine routes type-erased `Scalar`
//! records and never names a concrete node — `aura_engine::summarize_r` reads the
//! `PositionManagement` record positionally, by column index, without linking
//! `aura-std`. Keeping these node-naming builders out of `aura-engine` preserves
//! that: the engine stays `-> aura-core` only (domain-free), `aura-std` stays the
//! lean node library (`-> aura-core`), and the `aura-engine -> aura-std` edge these
//! builders would otherwise force is dissolved. `aura-composites -> {aura-engine,
//! aura-std}` is the one layer that couples both, and the graph stays acyclic.
//!
//! The Veto is a documented seam, not a node (a pass-through identity is exactly
//! what C19/C23 DCE deletes), so it appears nowhere here.
use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_backtest::{PositionManagement, PM_FIELD_NAMES};
use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub};
use aura_strategy::{
cost_port, intern_port, CostSum, FixedStop, Sizer, VolTfStop, COST_FIELD_NAMES,
GEOMETRY_WIDTH,
};
/// The volatility stop as a composition of primitives:
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price Delay(price, 1)`
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
pub fn vol_stop(length: i64, k: f64) -> Composite {
vol_stop_inner(Some((length, k)))
}
/// The single `vol_stop` topology, with its two knobs either BOUND to constants
/// (`Some((length, k))` — the [`vol_stop`] form, byte-identical to the original
/// build-time bind) or left OPEN (`None` — the gridding form: the EWMA and scale nodes
/// are `named` `stop_length` / `stop_k` so their slots surface in `param_space` under
/// the `.stop_length.length` / `.stop_k.weights[0]` suffixes). One body, so the
/// topology — every node, edge, and exposed role — cannot drift between the two arms;
/// only the two knobs differ (the `Option<i64>`-knob idiom `r_sma_graph` uses).
fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
let mut g = GraphBuilder::new("vol_stop");
let price = g.input_role("price");
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
let sub = g.add(Sub::builder()); // Δ = price prev
let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
// EWMA variance: length bound (Some) or open and named `stop_length` (None).
let ema = g.add(match knobs {
Some((length, _)) => Ema::builder().bind("length", Scalar::i64(length)),
None => Ema::builder().named("stop_length"),
});
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
let scale = g.add(match knobs {
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
None => LinComb::configured(1).named("stop_k"),
});
g.feed(price, [delay.input("series"), sub.input("lhs")]);
g.connect(delay.output("value"), sub.input("rhs"));
g.connect(sub.output("value"), sq.input("lhs"));
g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
g.connect(sq.output("value"), ema.input("series"));
g.connect(ema.output("value"), sqrt.input("value"));
g.connect(sqrt.output("value"), scale.input("term[0]"));
g.expose(scale.output("value"), "stop_distance");
g.build().expect("vol_stop composite wires")
}
/// The protective stop-rule axis (C11 structural): a fixed distance, or the
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
/// RiskExecutor embeds either by identical downstream wiring.
///
/// Mirrors the sibling axis enum [`RollMode`](aura_engine::RollMode); `Eq` is omitted
/// because the `f64` payloads forbid it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum StopRule {
Fixed(f64),
Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
}
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
// Add the chosen stop into the shared body's builder: a FixedStop node directly,
// or the BOUND `vol_stop` composite — byte-identical to the pre-dedup arms.
risk_executor_inner(
Box::new(move |g| match stop {
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
StopRule::VolTf { period_minutes, length, k } => g.add(
VolTfStop::builder()
.bind("period_minutes", Scalar::i64(period_minutes))
.bind("length", Scalar::i64(length))
.bind("k", Scalar::f64(k)),
),
}),
risk_budget,
)
}
/// The shared `risk_executor` body, given a closure that adds the chosen stop node
/// (returning its handle). Open input roles `bias` + `price`, internal
/// `<stop> → Sizer → PositionManagement`, exposing every field of PM's dense R-record;
/// price fans to BOTH the stop and PM, bias to the Sizer and PM, the Sizer's `size`
/// into PM. Every stop exposes `price → stop_distance`, so a fixed stop, a bound
/// vol-stop, or an OPEN vol-stop all embed by this one identical downstream wiring —
/// the single place the fan / Sizer / PM / exposed record live, so [`risk_executor`]
/// and [`risk_executor_vol_open`] cannot drift apart (one body, two stop arms).
fn risk_executor_inner(
add_stop: Box<dyn FnOnce(&mut GraphBuilder) -> NodeHandle + '_>,
risk_budget: f64,
) -> Composite {
let mut g = GraphBuilder::new("risk_executor");
let bias = g.input_role("bias");
let price = g.input_role("price");
let stop = add_stop(&mut g);
let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
let pm = g.add(PositionManagement::builder());
g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
for field in PM_FIELD_NAMES {
g.expose(pm.output(field), field);
}
g.build().expect("risk_executor wires")
}
/// A `vol_stop`-armed RiskExecutor whose two stop knobs are left **open** (unbound)
/// so they enter `param_space` as sweepable axes — the gridding sibling of
/// [`risk_executor`]`(StopRule::Vol { .. }, ..)`, which binds them to constants. The
/// interior `vol_stop`'s EWMA length and `k`-multiplier nodes are named `stop_length`
/// / `stop_k` so their path-qualified slots are addressable as
/// `<path>.vol_stop.stop_length.length` (I64) and `<path>.vol_stop.stop_k.weights[0]`
/// (F64). Everything else (the price/bias fan, Sizer, PositionManagement, the exposed
/// dense R-record) is identical to [`risk_executor`], so a member built from any
/// `(length, k)` point matches the bound form at the same values byte-for-byte.
pub fn risk_executor_vol_open(risk_budget: f64) -> Composite {
// The same shared body as [`risk_executor`], with the OPEN vol-stop
// (`vol_stop_inner(None)`) as the stop arm — so the price/bias fan, Sizer, PM,
// and exposed dense R-record are guaranteed identical to the bound form, and a
// member built from any `(length, k)` point matches the bound form byte-for-byte.
risk_executor_inner(Box::new(|g| g.add(vol_stop_inner(None))), risk_budget)
}
/// A cost-model graph as a composition: `n` cost nodes fanned the 4 PM-geometry
/// inputs, each node's extra inputs surfaced as `cost[k].<port>` roles, all summed
/// by [`CostSum`] into the single 3-field cost-in-R stream the net-R seam consumes
/// (`summarize_r` + the `net_r_equity` tap stay unchanged). Inlines at bootstrap
/// (C11) to the same flat fan-in the hand-wired CLI block produced, so a cost run's
/// `net_expectancy_r` is byte-identical. Requires `n >= 1` (mirrors `CostSum::new`).
///
/// Each cost node is a `PrimitiveBuilder` (built via `cost_node_builder`), whose
/// schema is geometry-prefix-first then the factor's extras (slot `GEOMETRY_WIDTH`
/// onward); `cost_graph` reads `schema().inputs[GEOMETRY_WIDTH..]` to discover the
/// extras and names them `cost[k].<port>` — mirroring `CostSum`'s own `cost[k].*`
/// input vocabulary. Runtime-computed port names are interned (`cost_port` /
/// `intern_port`, the `COL_PORTS` production pattern — #152): one allocation
/// per DISTINCT name for the process lifetime, so rebuilding the graph per
/// member across a sweep reuses the same strings instead of leaking fresh
/// ones per build.
pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
assert!(!cost_nodes.is_empty(), "cost_graph needs at least one cost node");
let n = cost_nodes.len();
let mut g = GraphBuilder::new("cost_graph");
// The 4 PM-geometry input roles, fanned to every cost node's geometry inputs.
let closed = g.input_role("closed");
let open = g.input_role("open");
let entry = g.input_role("entry_price");
let stop = g.input_role("stop_price");
let agg = g.add(CostSum::configured(n));
// Per-role geometry fan targets, collected across all nodes, fed once per role.
let mut closed_t = Vec::with_capacity(n);
let mut open_t = Vec::with_capacity(n);
let mut entry_t = Vec::with_capacity(n);
let mut stop_t = Vec::with_capacity(n);
for (k, node) in cost_nodes.into_iter().enumerate() {
// The factor's extra ports = everything past the 4-wide geometry prefix.
let extra_names: Vec<String> =
node.schema().inputs[GEOMETRY_WIDTH..].iter().map(|p| p.name.clone()).collect();
let h = g.add(node);
closed_t.push(h.input("closed"));
open_t.push(h.input("open"));
entry_t.push(h.input("entry_price"));
stop_t.push(h.input("stop_price"));
// Each extra input becomes a `cost[k].<port>` composite role.
for name in &extra_names {
let role = g.input_role(cost_port(k, name));
g.feed(role, [h.input(intern_port(name))]);
}
// The node's 3 cost fields -> CostSum's `cost[k].<field>` inputs.
for field in COST_FIELD_NAMES {
g.connect(h.output(field), agg.input(cost_port(k, field)));
}
}
g.feed(closed, closed_t);
g.feed(open, open_t);
g.feed(entry, entry_t);
g.feed(stop, stop_t);
// Expose CostSum's aggregate as the composite's 3-field cost output.
for field in COST_FIELD_NAMES {
g.expose(agg.output(field), field);
}
g.build().expect("cost_graph wires")
}