e84ad6d0d2
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.
214 lines
11 KiB
Rust
214 lines
11 KiB
Rust
//! `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")
|
||
}
|