4de6d5cbad
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped the two-stage research model: a 1 structurally implies a 2 that no longer exists. The family is renamed by its live discriminator - the R yardstick - with members named by their signal, uniform with their signal-named siblings (sma, macd, momentum): - selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout, stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no silent alias) - identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma, r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_* - persisted identity: the sma_signal composite (param prefix sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids shift - no test pins a literal hash, the registry parses no record names) - e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e - dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical entries stay; fieldtests corpus untouched) - CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe faithfully (the token-swap alone would have laundered the retired gated-currency/realistic-broker design into unmarked live prose); the unbacked account-mode clause dropped Verification: cargo build/test --workspace green (51 targets), clippy -D warnings clean, doc build clean, acceptance grep gate leaves exactly the one resampling-stage false positive (harness.rs), smoke: --harness r-sma runs, --harness stage1-r exits 2 with the new usage line. Decision log: forks and rationale recorded on the issue (reconciliation + implementation-phase comments). closes #174
207 lines
11 KiB
Rust
207 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_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<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::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
|
||
/// `<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 `.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<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::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<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(&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].<field>` 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")
|
||
}
|