//! `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_std::{ CostSum, Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES, }; /// 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`-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::builder(1).bind("weights[0]", Scalar::f64(k)), None => LinComb::builder(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 }, } /// 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)), }), 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 /// ` → 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 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 /// `.vol_stop.stop_length.length` (I64) and `.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].` 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].` — mirroring `CostSum`'s own `cost[k].*` /// input vocabulary. Runtime-computed port names are `.leak()`ed to `&'static str` /// to satisfy the `NodeHandle::input`/`output` bound — fine while cost is run-path /// only (the graph is built once, a one-shot leak), but to be interned (the /// `COL_PORTS` production pattern) before cost reaches the per-member sweep path, /// where a per-build leak would accumulate. See #152. pub fn cost_graph(cost_nodes: Vec) -> 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::builder(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 = 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].` composite role. for name in &extra_names { let role = g.input_role(&format!("cost[{k}].{name}")); let port: &'static str = name.clone().leak(); g.feed(role, [h.input(port)]); } // The node's 3 cost fields -> CostSum's `cost[k].` inputs. for field in COST_FIELD_NAMES { let agg_in: &'static str = format!("cost[{k}].{field}").leak(); g.connect(h.output(field), agg.input(agg_in)); } } 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") }