plan: 0065 stage1-r-signal-quality
This commit is contained in:
@@ -0,0 +1,867 @@
|
||||
# Stage-1 R-based signal quality — Iteration 1 — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Land the Stage-1 R-machinery — a strategy's signal quality measured in R
|
||||
on a `bias` stream, feed-forward — as runnable, tested `aura-std` nodes plus a
|
||||
post-run `summarize_r` fold.
|
||||
|
||||
**Architecture:** `bias → stop-rule (VolStop/FixedStop, emits stop_distance) →
|
||||
position-management (dense per-cycle R-record) → Recorder → summarize_r → RMetrics`.
|
||||
position-management is the stateful heart (latch the entry stop distance as the
|
||||
frozen R-denominator; mark/exit no-look-ahead like `SimBroker`; stop-outs not capped
|
||||
at −1R). The R-evaluator is the post-run fold `summarize_r`, **not** an in-graph node.
|
||||
|
||||
**Tech Stack:** Rust, `aura-core` (Node/Cell/Scalar), `aura-std` (the new nodes),
|
||||
`aura-engine/src/report.rs` (the fold). `aura-std` is a **dev-dependency** of
|
||||
`aura-engine`, so `summarize_r` (non-test) reads the dense record by **column index +
|
||||
convention** (a guard test asserts the layout matches).
|
||||
|
||||
---
|
||||
|
||||
## Scope decisions (resolving plan-recon's open questions — orchestrator's call)
|
||||
|
||||
The `exposure → bias` rename radius is large (319 `exposure` + 83 `Exposure` lines /
|
||||
37 files). commit-0 is scoped to the **semantic core of #126** and defers the rest as
|
||||
behaviour-preserving cosmetics (recorded on #117):
|
||||
|
||||
- **IN commit-0:** the `Exposure` *type* → `Bias` (struct/impl/`new`/builder-name/
|
||||
closure/`label`/file `exposure.rs`→`bias.rs`/`lib.rs` mod+use), every importer's
|
||||
`Exposure::new`/`Exposure::builder`/`use …Exposure` (compiler-enumerated), and the
|
||||
**output FieldSpec name `"exposure"` → `"bias"`** with its three content-pins
|
||||
(`graph_model.rs` expected JSON, `fixtures/sample-model.json`, the `sma.rs` label
|
||||
pin `"Exposure(0.5)"`→`"Bias(0.5)"`).
|
||||
- **DEFERRED (kept green, NOT this iteration):** `SimBroker`'s slot-0 port name
|
||||
`"exposure"` (renaming it churns all `broker.input("exposure")` wiring + its test;
|
||||
`SimBroker` is the pre-reframe node, retained unchanged); `RunMetrics.exposure_sign_flips`
|
||||
(a pip-side turnover proxy orthogonal to R — renaming touches a `registry` enum
|
||||
variant + 9 files for zero R-value); the node *instance* label `.named("exposure")`
|
||||
and everything derived from it (`exposure.scale` param-path, `"exposure_scale"`
|
||||
manifests, member-dir names, the recorded tap name `"exposure"`); `summarize`'s 2nd
|
||||
param + local `exposure` vars; `LongOnly`'s field; `Latch` prose. These are
|
||||
behaviour-preserving to leave and are a separate cosmetic-modernization follow-up.
|
||||
- **File layout:** `VolStop` + `FixedStop` share one file `stop_rule.rs`.
|
||||
- **`ExitReason`** lives in `aura-std` (`position_management.rs`); `summarize_r`
|
||||
(in `aura-engine`, which only dev-depends on `aura-std`) matches the **raw i64**
|
||||
(`0=stop, 1=bias-flip, 2=reversal-leg, 3=window-end`) by convention, guarded by a test.
|
||||
|
||||
**Timing convention (the keystone — SimBroker mirror, pins spec §4).** A trade opens
|
||||
at the observed close of the cycle its bias first goes nonzero (`entry_price =
|
||||
price_t`); it earns from the **next** cycle (its mark at the entry cycle is 0 — the
|
||||
analogue of `SimBroker` setting `prev_exposure` *after* taking PnL, so a new position
|
||||
never earns the pre-entry move). An exit (stop / bias-flip / window-end) realises
|
||||
against the **current** close `price_t` (a stop fills no better than the stop level).
|
||||
This is no-look-ahead: a position held into cycle t earns t's move and then closes, exactly
|
||||
as `SimBroker`'s held exposure earns t's move before the flip takes effect. No
|
||||
`prev_price` is needed — the position's own `entry_price` is the memory.
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify→rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs` — `Bias` node (was `Exposure`); output field `"exposure"`→`"bias"`; `label` `"Bias(..)"`.
|
||||
- Modify: `crates/aura-std/src/lib.rs:23,39` — `mod bias; pub use bias::Bias;` (+ new `mod stop_rule; mod position_management;` + their `pub use`).
|
||||
- Modify: `crates/aura-engine/src/graph_model.rs:497` — expected-JSON literal `"type":"Exposure"`→`"Bias"`, `["exposure","f64"]`→`["bias","f64"]` (only if this fixture uses the Bias node; verify by build/test).
|
||||
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — same `Exposure`/`exposure`→`Bias`/`bias` (verify usage).
|
||||
- Modify: `crates/aura-std/src/sma.rs:205,212` — cross-node label pin `"Exposure(0.5)"`→`"Bias(0.5)"`, `Exposure::new`/`builder`→`Bias`.
|
||||
- Modify (compiler-enumerated `Exposure`→`Bias` ctor/builder/use): `crates/aura-engine/src/{report.rs,blueprint.rs,builder.rs,harness.rs,graph_model.rs,test_fixtures.rs}`, `crates/aura-cli/src/main.rs`, `crates/aura-ingest/tests/{real_bars.rs,streaming_seam.rs}`, `crates/aura-engine/tests/random_sweep_e2e.rs`.
|
||||
- Create: `crates/aura-std/src/stop_rule.rs` — `VolStop` + `FixedStop` (+ inline tests).
|
||||
- Create: `crates/aura-std/src/position_management.rs` — `PositionManagement` + `ExitReason` + layout consts (+ inline tests, incl. the no-look-ahead keystone).
|
||||
- Modify: `crates/aura-engine/src/report.rs` — add `RMetrics` (near `RunMetrics` ~16) + `summarize_r` (near `summarize` ~122) + inline tests.
|
||||
- Create: `crates/aura-engine/tests/stage1_r_e2e.rs` — the synthetic bias+price→stop→position-management→Recorder→summarize_r E2E + the dense-record layout guard test (dev-context, imports `aura-std`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `Exposure` → `Bias` rename (compiler-driven, behaviour-preserving)
|
||||
|
||||
**Files:**
|
||||
- Rename: `crates/aura-std/src/exposure.rs` → `crates/aura-std/src/bias.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`, `crates/aura-std/src/sma.rs`, the importer files above, the 2 content-pin fixtures.
|
||||
|
||||
- [ ] **Step 1: Rename the file + the type/field inside it**
|
||||
|
||||
`git mv crates/aura-std/src/exposure.rs crates/aura-std/src/bias.rs`, then in `bias.rs`
|
||||
apply (computation UNCHANGED — `clamp(signal/scale,-1,+1)`; only names + the doc semantics change):
|
||||
|
||||
```rust
|
||||
//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional
|
||||
//! bias (C10). The strategy's primary output: one f64 input, one f64 output
|
||||
//! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional)
|
||||
//! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer).
|
||||
//! `scale` sets which signal magnitude maps to full conviction.
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Bounded bias from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
|
||||
/// Emits `None` until its input is present (warm-up filter, C8).
|
||||
pub struct Bias {
|
||||
scale: f64,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Bias {
|
||||
/// Build a bias node with saturation magnitude `scale` (must be > 0).
|
||||
pub fn new(scale: f64) -> Self {
|
||||
assert!(scale > 0.0, "Bias scale must be > 0");
|
||||
Self { scale, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Bias",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
|
||||
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(Bias::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Bias {
|
||||
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; }
|
||||
self.out[0] = Cell::from_f64((w[0] / self.scale).clamp(-1.0, 1.0));
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { format!("Bias({})", self.scale) }
|
||||
}
|
||||
```
|
||||
|
||||
Rename the inline tests: `exposure_clamps_to_unit_band`→`bias_clamps_to_unit_band`,
|
||||
`exposure_is_none_until_input_present`→`bias_is_none_until_input_present`,
|
||||
`input_slot_is_named_signal` keeps its name but assert stays `"signal"`. Update their
|
||||
bodies `Exposure`→`Bias` and any `"exposure"` output-name assert → `"bias"`.
|
||||
|
||||
- [ ] **Step 2: Update `lib.rs` exports**
|
||||
|
||||
In `crates/aura-std/src/lib.rs`: `mod exposure;`→`mod bias;`; `pub use exposure::Exposure;`→`pub use bias::Bias;`.
|
||||
(The new-node mod/use lines are added in Tasks 2 & 3.)
|
||||
|
||||
- [ ] **Step 3: Update the cross-node label pin in `sma.rs`**
|
||||
|
||||
`crates/aura-std/src/sma.rs:205,212` — in `labels_carry_identifying_params` and
|
||||
`nodes_declare_expected_params`: `use …Exposure`→`Bias`, `Exposure::new(0.5)`→`Bias::new(0.5)`,
|
||||
`Exposure::builder()`→`Bias::builder()`, and the label assert `"Exposure(0.5)"`→`"Bias(0.5)"`.
|
||||
|
||||
- [ ] **Step 4: Build — let the compiler enumerate the importer call sites**
|
||||
|
||||
Run: `cargo build --workspace 2>&1 | grep -E "error|cannot find" | head -40`
|
||||
Expected: a finite list of `cannot find … Exposure` errors across `aura-engine`
|
||||
(`report.rs`, `blueprint.rs`, `builder.rs`, `harness.rs`, `graph_model.rs`,
|
||||
`test_fixtures.rs`), `aura-cli` (`main.rs`), `aura-ingest` tests, `random_sweep_e2e.rs`.
|
||||
|
||||
- [ ] **Step 5: Replace each `Exposure` ctor/builder/use site with `Bias`**
|
||||
|
||||
At every site the build reported: `use aura_std::Exposure`→`use aura_std::Bias`,
|
||||
`Exposure::new(`→`Bias::new(`, `Exposure::builder()`→`Bias::builder()`. Do NOT touch
|
||||
the node *instance* label `.named("exposure")`, the `exposure.scale` param-paths, the
|
||||
`"exposure"` tap names, `RunMetrics.exposure_sign_flips`, or `SimBroker`'s port (all
|
||||
DEFERRED). Re-run `cargo build --workspace` until clean.
|
||||
|
||||
- [ ] **Step 6: Fix the two output-field content-pins**
|
||||
|
||||
Run: `cargo test --workspace 2>&1 | grep -E "FAILED|panicked|assertion" | head -30`
|
||||
For each failure asserting the rendered output field/type (expected
|
||||
`graph_model.rs:497` literal + `fixtures/sample-model.json`): update `"Exposure"`→`"Bias"`
|
||||
and the output field `["exposure","f64"]`→`["bias","f64"]` **only where it is the Bias
|
||||
node's own type/output** (a port named "exposure" on another node, e.g. SimBroker's
|
||||
input, stays). Re-run until green.
|
||||
|
||||
- [ ] **Step 7: Full suite green-unchanged (the behaviour-preserving gate)**
|
||||
|
||||
Run: `cargo test --workspace 2>&1 | tail -15`
|
||||
Expected: all green (same count as before the rename ± the renamed test names). No new
|
||||
failures. Then `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `stop_rule.rs` — `FixedStop` + `VolStop`
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/stop_rule.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs` (mod + use)
|
||||
|
||||
- [ ] **Step 1: Write the file with both nodes + their RED tests**
|
||||
|
||||
```rust
|
||||
//! Stop-rule nodes — emit a protective-stop *distance* (price units, ≥ 0,
|
||||
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped);
|
||||
//! position-management latches the entry-cycle distance as the frozen R-denominator.
|
||||
//! `FixedStop` is a constant distance (test fixture / structural-axis sibling);
|
||||
//! `VolStop` is a close-to-close volatility stop `k * EMA_length(|price - prev_price|)`
|
||||
//! (true-range ATR is deferred — it needs OHLC). One fused node each (no Abs/Mul
|
||||
//! primitive exists, so abs+delta+EMA fuse inside VolStop).
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Constant stop distance. `distance` must be > 0.
|
||||
pub struct FixedStop { distance: f64, out: [Cell; 1] }
|
||||
impl FixedStop {
|
||||
pub fn new(distance: f64) -> Self {
|
||||
assert!(distance > 0.0, "FixedStop distance must be > 0");
|
||||
Self { distance, out: [Cell::from_f64(distance)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"FixedStop",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
|
||||
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "distance".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(FixedStop::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Node for FixedStop {
|
||||
fn lookbacks(&self) -> Vec<usize> { vec![1] }
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
if ctx.f64_in(0).is_empty() { return None; } // fire with price (warm-up filter)
|
||||
self.out[0] = Cell::from_f64(self.distance);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { format!("FixedStop({})", self.distance) }
|
||||
}
|
||||
|
||||
/// Volatility stop distance: `k * EMA_length(|price - prev_price|)`. EMA is the
|
||||
/// standard `alpha = 2/(length+1)` recursion, seeded by the first abs-return; `None`
|
||||
/// until `length` abs-returns have been seen (warm-up). `prev_price` updated AFTER use
|
||||
/// (intra-node z⁻¹, C2-clean). `length >= 1`, `k > 0`.
|
||||
pub struct VolStop {
|
||||
length: usize,
|
||||
k: f64,
|
||||
prev_price: Option<f64>,
|
||||
ema: f64,
|
||||
count: usize,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
impl VolStop {
|
||||
pub fn new(length: usize, k: f64) -> Self {
|
||||
assert!(length >= 1, "VolStop length must be >= 1");
|
||||
assert!(k > 0.0, "VolStop k must be > 0");
|
||||
Self { length, k, prev_price: None, ema: 0.0, count: 0, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"VolStop",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
|
||||
output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![
|
||||
ParamSpec { name: "length".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "k".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
},
|
||||
|p| Box::new(VolStop::new(p[0].i64() as usize, p[1].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Node for VolStop {
|
||||
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; }
|
||||
let price = w[0];
|
||||
let Some(pp) = self.prev_price else {
|
||||
self.prev_price = Some(price);
|
||||
return None; // need a prior price to form the first abs-return
|
||||
};
|
||||
let d = (price - pp).abs();
|
||||
let alpha = 2.0 / (self.length as f64 + 1.0);
|
||||
if self.count == 0 { self.ema = d; } else { self.ema += alpha * (d - self.ema); }
|
||||
self.count += 1;
|
||||
self.prev_price = Some(price); // update AFTER use (C2)
|
||||
if self.count < self.length { return None; } // warm-up
|
||||
self.out[0] = Cell::from_f64(self.k * self.ema);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { format!("VolStop({},{})", self.length, self.k) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
fn feed(node: &mut dyn Node, prices: &[f64]) -> Vec<Option<f64>> {
|
||||
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
let mut out = vec![];
|
||||
for &p in prices {
|
||||
col[0].push(Scalar::f64(p)).unwrap();
|
||||
out.push(node.eval(Ctx::new(&col, Timestamp(0))).map(|c| c[0].f64()));
|
||||
}
|
||||
out
|
||||
}
|
||||
#[test]
|
||||
fn fixed_stop_is_constant_after_first_price() {
|
||||
let mut s = FixedStop::new(2.5);
|
||||
assert_eq!(feed(&mut s, &[100.0, 110.0, 90.0]), vec![Some(2.5), Some(2.5), Some(2.5)]);
|
||||
}
|
||||
#[test]
|
||||
fn vol_stop_is_none_until_warm() {
|
||||
// length 3: needs a prior price (cycle 1 -> None) then 3 abs-returns.
|
||||
let mut s = VolStop::new(3, 2.0);
|
||||
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
|
||||
assert_eq!(got[0], None); // no prior price
|
||||
assert_eq!(got[1], None); // 1 return
|
||||
assert_eq!(got[2], None); // 2 returns
|
||||
assert!(got[3].is_some()); // 3 returns -> warm
|
||||
}
|
||||
#[test]
|
||||
fn vol_stop_tracks_k_times_ema_abs_return() {
|
||||
// constant abs-return of 1.0 -> EMA = 1.0 -> distance = k*1.0 = 2.0.
|
||||
let mut s = VolStop::new(2, 2.0);
|
||||
let got = feed(&mut s, &[100.0, 101.0, 102.0, 103.0]);
|
||||
assert_eq!(got.last().unwrap().unwrap(), 2.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the module + run the tests**
|
||||
|
||||
Add to `crates/aura-std/src/lib.rs`: `mod stop_rule;` and `pub use stop_rule::{FixedStop, VolStop};`.
|
||||
Run: `cargo test -p aura-std stop` Expected: PASS (`fixed_stop_is_constant_after_first_price`,
|
||||
`vol_stop_is_none_until_warm`, `vol_stop_tracks_k_times_ema_abs_return`).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `position_management.rs` — `ExitReason`, layout, node skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/position_management.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write the enum, layout constants, struct, schema, builder**
|
||||
|
||||
```rust
|
||||
//! `PositionManagement` — the stateful heart of Stage-1 risk-based execution. Turns a
|
||||
//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes.
|
||||
//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the
|
||||
//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the
|
||||
//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end
|
||||
//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY:
|
||||
//! `realized_r = direction * (exit - entry) / latched_distance`.
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
Timestamp,
|
||||
};
|
||||
|
||||
/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r`
|
||||
/// matches the raw i64 (it lives across a crate boundary — aura-std is only a
|
||||
/// dev-dependency of aura-engine), so the encoding is the load-bearing contract.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
pub enum ExitReason { Stop = 0, BiasFlip = 1, ReversalLeg = 2, WindowEnd = 3 }
|
||||
|
||||
/// Dense record column layout — the lockstep contract shared (by convention) with the
|
||||
/// `Recorder` tap kinds and `summarize_r`'s column reads.
|
||||
pub const WIDTH: usize = 14;
|
||||
pub const FIELD_NAMES: [&str; WIDTH] = [
|
||||
"closed_this_cycle", "realized_r", "exit_reason", "was_stopped", "direction",
|
||||
"entry_ts", "entry_price", "stop_price", "exit_price", "bias_at_entry_abs",
|
||||
"size", "open", "unrealized_r", "cum_realized_r",
|
||||
];
|
||||
pub const RECORD_KINDS: [ScalarKind; WIDTH] = {
|
||||
use ScalarKind::*;
|
||||
[Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64]
|
||||
};
|
||||
|
||||
struct Open { dir: i64, entry: f64, latched_dist: f64, stop_level: f64, entry_ts: Timestamp, bias_abs: f64 }
|
||||
|
||||
pub struct PositionManagement {
|
||||
pos: Option<Open>,
|
||||
cum_realized_r: f64,
|
||||
out: [Cell; WIDTH],
|
||||
}
|
||||
impl PositionManagement {
|
||||
pub fn new() -> Self { Self { pos: None, cum_realized_r: 0.0, out: [Cell::from_f64(0.0); WIDTH] } }
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
let inputs = vec![
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
|
||||
];
|
||||
let output = FIELD_NAMES.iter().zip(RECORD_KINDS).map(|(n, k)| FieldSpec { name: (*n).into(), kind: k }).collect();
|
||||
PrimitiveBuilder::new(
|
||||
"PositionManagement",
|
||||
NodeSchema { inputs, output, params: vec![] },
|
||||
|_| Box::new(PositionManagement::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Default for PositionManagement { fn default() -> Self { Self::new() } }
|
||||
|
||||
fn sign0(v: f64) -> f64 { if v > 0.0 { 1.0 } else if v < 0.0 { -1.0 } else { 0.0 } }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the module + a warm-up RED test**
|
||||
|
||||
Add to `lib.rs`: `mod position_management;` and `pub use position_management::{PositionManagement, ExitReason};`.
|
||||
Add this inline test (skeleton `eval` returning `None` makes it RED until Task 4):
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar};
|
||||
// Drive one cycle: push bias/price/stop into the three slots, eval, return the row.
|
||||
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
|
||||
cols[0].push(Scalar::f64(bias)).unwrap();
|
||||
cols[1].push(Scalar::f64(price)).unwrap();
|
||||
cols[2].push(Scalar::f64(dist)).unwrap();
|
||||
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
|
||||
}
|
||||
fn cols() -> Vec<AnyColumn> { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
|
||||
|
||||
#[test]
|
||||
fn emits_one_dense_record_per_cycle() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
|
||||
assert_eq!(row.len(), WIDTH);
|
||||
assert!(!row[0].bool()); // closed_this_cycle = false
|
||||
assert!(!row[11].bool()); // open = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cargo test -p aura-std emits_one_dense_record` Expected: **FAIL** (skeleton `eval`
|
||||
is `None` / unimplemented) — this is the RED gate for Task 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `PositionManagement` lifecycle — the no-look-ahead keystone
|
||||
|
||||
**Files:** Modify `crates/aura-std/src/position_management.rs` (add `Node` impl + tests).
|
||||
|
||||
- [ ] **Step 1: Write the keystone RED tests**
|
||||
|
||||
Add to the `#[cfg(test)] mod tests`:
|
||||
|
||||
```rust
|
||||
// (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
|
||||
// 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
|
||||
// move to 110; the flip closes it THERE, never retroactively flattening it).
|
||||
#[test]
|
||||
fn no_lookahead_bias_exit_realises_the_held_move() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
|
||||
let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit
|
||||
assert!(row[0].bool()); // closed_this_cycle
|
||||
assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0
|
||||
assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64);
|
||||
assert!(!row[3].bool()); // not stopped
|
||||
}
|
||||
// A position opened this cycle never earns the pre-entry move: entered @110, its
|
||||
// own-cycle unrealized R is 0.
|
||||
#[test]
|
||||
fn entry_does_not_earn_pre_entry_move() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
|
||||
let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110
|
||||
assert!(row[11].bool()); // open
|
||||
assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle
|
||||
}
|
||||
// (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1.
|
||||
#[test]
|
||||
fn stop_out_is_not_capped_at_minus_one_r() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
|
||||
let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90)
|
||||
assert!(row[0].bool());
|
||||
assert!(row[3].bool()); // was_stopped
|
||||
assert_eq!(row[2].i64(), ExitReason::Stop as i64);
|
||||
assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail
|
||||
}
|
||||
// A no-gap stop fills exactly at the stop -> exactly -1R.
|
||||
#[test]
|
||||
fn no_gap_stop_is_exactly_minus_one_r() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
|
||||
let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly
|
||||
assert_eq!(row[1].f64(), -1.0);
|
||||
}
|
||||
// (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg
|
||||
// (one record, ReversalLeg) and reopens short as state (open=true, dir=-1).
|
||||
#[test]
|
||||
fn reversal_closes_one_leg_and_reopens() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
|
||||
let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110
|
||||
assert!(row[0].bool()); // one close
|
||||
assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10
|
||||
assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64);
|
||||
assert!(row[11].bool()); // reopened: open
|
||||
assert_eq!(row[4].i64(), -1); // new direction short
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm RED**
|
||||
|
||||
Run: `cargo test -p aura-std position_management`
|
||||
Expected: FAIL (the lifecycle is not implemented; `no_lookahead_…`, `stop_out_…`, etc. fail).
|
||||
|
||||
- [ ] **Step 3: Implement the `Node` impl**
|
||||
|
||||
```rust
|
||||
impl Node for PositionManagement {
|
||||
fn lookbacks(&self) -> Vec<usize> { vec![1, 1, 1] }
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let pw = ctx.f64_in(1);
|
||||
if pw.is_empty() { return None; } // no price yet — nothing to do
|
||||
let price = pw[0];
|
||||
let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
|
||||
let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
|
||||
let now = ctx.now();
|
||||
|
||||
let mut closed = false;
|
||||
let mut realized = 0.0;
|
||||
let mut reason = ExitReason::Stop as i64;
|
||||
let mut was_stopped = false;
|
||||
let mut ex_dir = 0i64;
|
||||
let mut ex_entry_ts = Timestamp(0);
|
||||
let mut ex_entry = 0.0;
|
||||
let mut ex_stop = 0.0;
|
||||
let mut ex_exit = 0.0;
|
||||
let mut ex_bias_abs = 0.0;
|
||||
|
||||
// (A) Maintain/exit the position held into this cycle, marked to `price`.
|
||||
if let Some(p) = &self.pos {
|
||||
let stop_hit = (p.dir > 0 && price <= p.stop_level) || (p.dir < 0 && price >= p.stop_level);
|
||||
let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0
|
||||
if stop_hit {
|
||||
let fill = if p.dir > 0 { price.min(p.stop_level) } else { price.max(p.stop_level) };
|
||||
realized = p.dir as f64 * (fill - p.entry) / p.latched_dist;
|
||||
was_stopped = true;
|
||||
reason = ExitReason::Stop as i64;
|
||||
ex_exit = fill;
|
||||
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
|
||||
self.cum_realized_r += realized;
|
||||
closed = true;
|
||||
self.pos = None;
|
||||
} else if bias_exit {
|
||||
realized = p.dir as f64 * (price - p.entry) / p.latched_dist;
|
||||
reason = if sign0(bias) != 0.0 { ExitReason::ReversalLeg as i64 } else { ExitReason::BiasFlip as i64 };
|
||||
ex_exit = price;
|
||||
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) = (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
|
||||
self.cum_realized_r += realized;
|
||||
closed = true;
|
||||
self.pos = None;
|
||||
}
|
||||
}
|
||||
// (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available.
|
||||
if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 {
|
||||
let dir = sign0(bias) as i64;
|
||||
let stop_level = if dir > 0 { price - dist } else { price + dist };
|
||||
self.pos = Some(Open { dir, entry: price, latched_dist: dist, stop_level, entry_ts: now, bias_abs: bias.abs() });
|
||||
}
|
||||
// (C) Open-position fields (carry for window-end) + unrealized mark.
|
||||
let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos {
|
||||
Some(p) => (true, p.dir as f64 * (price - p.entry) / p.latched_dist, p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs),
|
||||
None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0),
|
||||
};
|
||||
// Trade-detail columns describe the CLOSED trade if closed, else the OPEN one.
|
||||
let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed {
|
||||
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs)
|
||||
} else {
|
||||
(o_dir, o_ets, o_entry, o_stop, 0.0, o_babs)
|
||||
};
|
||||
self.out = [
|
||||
Cell::from_bool(closed), Cell::from_f64(realized), Cell::from_i64(reason),
|
||||
Cell::from_bool(was_stopped), Cell::from_i64(d_dir), Cell::from_ts(d_ets),
|
||||
Cell::from_f64(d_entry), Cell::from_f64(d_stop), Cell::from_f64(d_exit),
|
||||
Cell::from_f64(d_babs), Cell::from_f64(1.0), // size: flat-1R placeholder (iter-2 Sizer)
|
||||
Cell::from_bool(open), Cell::from_f64(unrealized), Cell::from_f64(self.cum_realized_r),
|
||||
];
|
||||
debug_assert!(!closed || (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs() < 1e-9);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String { "PositionManagement".to_string() }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to GREEN + add the window-end test**
|
||||
|
||||
Run: `cargo test -p aura-std position_management` Expected: PASS (all Task-3/4 tests).
|
||||
Then add and pass the window-end test (open at end → last row carries open=true with the
|
||||
unrealized R the fold force-closes):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn open_at_window_end_is_carried_on_the_last_row() {
|
||||
let mut n = PositionManagement::new();
|
||||
let mut c = cols();
|
||||
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
|
||||
let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close
|
||||
assert!(!row[0].bool()); // not closed
|
||||
assert!(row[11].bool()); // open
|
||||
assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10
|
||||
assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cargo test -p aura-std position_management` Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: R-invariance is structural — assert it as a doc test note**
|
||||
|
||||
R = `direction*(exit-entry)/latched_dist` reads no size, so size scaling cannot change
|
||||
it (the size column is a constant 1.0 placeholder this iteration). Add a comment in the
|
||||
`eval` above the `realized =` lines: `// size-invariant: R is a pure stop-distance ratio (Stage-1 feed-forward).`
|
||||
(The runtime R-invariance-under-Sizer test lands in iteration 2 when the Sizer exists.)
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `summarize_r` + `RMetrics` (post-run fold)
|
||||
|
||||
**Files:** Modify `crates/aura-engine/src/report.rs`.
|
||||
|
||||
- [ ] **Step 1: Add `RMetrics` + `summarize_r` + column-index consts**
|
||||
|
||||
Insert near `RunMetrics` (~line 30):
|
||||
|
||||
```rust
|
||||
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
|
||||
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The
|
||||
/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RMetrics {
|
||||
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
|
||||
pub n_trades: u64,
|
||||
pub win_rate: f64, // fraction with R > 0
|
||||
pub avg_win_r: f64,
|
||||
pub avg_loss_r: f64,
|
||||
pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
|
||||
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
|
||||
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
|
||||
}
|
||||
|
||||
// Dense `PositionManagement` record column indices — the lockstep contract with
|
||||
// aura-std's FIELD_NAMES/RECORD_KINDS (aura-std is only a dev-dependency here, so the
|
||||
// layout is shared by convention; `stage1_r_e2e.rs` guards that it matches).
|
||||
mod r_col {
|
||||
pub const CLOSED: usize = 0;
|
||||
pub const REALIZED_R: usize = 1;
|
||||
pub const OPEN: usize = 11;
|
||||
pub const UNREALIZED_R: usize = 12;
|
||||
}
|
||||
|
||||
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
|
||||
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
|
||||
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
|
||||
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
|
||||
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
|
||||
let mut rs: Vec<f64> = Vec::new();
|
||||
let mut n_open_at_end = 0u64;
|
||||
for (_, row) in record {
|
||||
if row.get(r_col::CLOSED).map(|s| s.as_bool()).unwrap_or(false) {
|
||||
rs.push(row[r_col::REALIZED_R].as_f64());
|
||||
}
|
||||
}
|
||||
if let Some((_, last)) = record.last() {
|
||||
if last.get(r_col::OPEN).map(|s| s.as_bool()).unwrap_or(false) {
|
||||
rs.push(last[r_col::UNREALIZED_R].as_f64());
|
||||
n_open_at_end = 1;
|
||||
}
|
||||
}
|
||||
let n = rs.len() as u64;
|
||||
if n == 0 {
|
||||
return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 };
|
||||
}
|
||||
let sum: f64 = rs.iter().sum();
|
||||
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
|
||||
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
|
||||
let sum_win: f64 = wins.iter().sum();
|
||||
let sum_loss: f64 = losses.iter().sum(); // <= 0
|
||||
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
|
||||
// by-trade cumulative-R drawdown
|
||||
let mut peak = f64::NEG_INFINITY;
|
||||
let mut cum = 0.0;
|
||||
let mut max_dd = 0.0_f64;
|
||||
for &r in &rs {
|
||||
cum += r;
|
||||
if cum > peak { peak = cum; }
|
||||
let dd = peak - cum;
|
||||
if dd > max_dd { max_dd = dd; }
|
||||
}
|
||||
RMetrics {
|
||||
expectancy_r: sum / n as f64,
|
||||
n_trades: n,
|
||||
win_rate: wins.len() as f64 / n as f64,
|
||||
avg_win_r: avg(&wins),
|
||||
avg_loss_r: avg(&losses),
|
||||
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
|
||||
max_r_drawdown: max_dd,
|
||||
n_open_at_end,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add RED arithmetic tests**
|
||||
|
||||
In `report.rs` `#[cfg(test)] mod tests` (alongside the `summarize_*` tests), add a
|
||||
hand-built dense ledger helper + tests:
|
||||
|
||||
```rust
|
||||
// Build a minimal dense record row: only the columns summarize_r reads matter.
|
||||
fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<Scalar>) {
|
||||
let mut v = vec![Scalar::f64(0.0); 14];
|
||||
v[0] = Scalar::bool(closed);
|
||||
v[1] = Scalar::f64(realized);
|
||||
v[11] = Scalar::bool(open);
|
||||
v[12] = Scalar::f64(unreal);
|
||||
(Timestamp(0), v)
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_is_zero_on_empty() {
|
||||
let m = summarize_r(&[]);
|
||||
assert_eq!(m.n_trades, 0);
|
||||
assert_eq!(m.expectancy_r, 0.0);
|
||||
assert_eq!(m.max_r_drawdown, 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_expectancy_winrate_profit_factor() {
|
||||
// three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3.
|
||||
let rec = vec![
|
||||
r_row(true, 2.0, true, 0.0),
|
||||
r_row(false, 0.0, true, 0.0),
|
||||
r_row(true, -1.0, true, 0.0),
|
||||
r_row(true, 1.0, false, 0.0), // last row not open
|
||||
];
|
||||
let m = summarize_r(&rec);
|
||||
assert_eq!(m.n_trades, 3);
|
||||
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
|
||||
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
|
||||
assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1
|
||||
assert_eq!(m.n_open_at_end, 0);
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_force_closes_open_position_at_window_end() {
|
||||
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
|
||||
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
|
||||
let m = summarize_r(&rec);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
assert_eq!(m.n_open_at_end, 1);
|
||||
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_max_drawdown_on_by_trade_curve() {
|
||||
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
|
||||
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
|
||||
assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9);
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cargo test -p aura-engine summarize_r` Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: E2E synthetic chain + the layout guard test
|
||||
|
||||
**Files:** Create `crates/aura-engine/tests/stage1_r_e2e.rs` (dev-context — imports `aura-std`).
|
||||
|
||||
- [ ] **Step 1: The layout guard (lockstep across the crate boundary)**
|
||||
|
||||
```rust
|
||||
//! Stage-1 R E2E: a synthetic bias+price chain through stop-rule + position-management,
|
||||
//! recorded and folded by summarize_r. Also guards the dense-record layout contract
|
||||
//! (aura-std's FIELD_NAMES/RECORD_KINDS vs the column indices summarize_r reads).
|
||||
use aura_std::position_management::{FIELD_NAMES, RECORD_KINDS, WIDTH};
|
||||
use aura_core::ScalarKind;
|
||||
|
||||
#[test]
|
||||
fn dense_record_layout_matches_summarize_r_contract() {
|
||||
assert_eq!(WIDTH, 14);
|
||||
assert_eq!(FIELD_NAMES.len(), WIDTH);
|
||||
assert_eq!(RECORD_KINDS.len(), WIDTH);
|
||||
// The columns summarize_r reads, by the convention in report.rs::r_col.
|
||||
assert_eq!(FIELD_NAMES[0], "closed_this_cycle"); assert_eq!(RECORD_KINDS[0], ScalarKind::Bool);
|
||||
assert_eq!(FIELD_NAMES[1], "realized_r"); assert_eq!(RECORD_KINDS[1], ScalarKind::F64);
|
||||
assert_eq!(FIELD_NAMES[11], "open"); assert_eq!(RECORD_KINDS[11], ScalarKind::Bool);
|
||||
assert_eq!(FIELD_NAMES[12], "unrealized_r"); assert_eq!(RECORD_KINDS[12], ScalarKind::F64);
|
||||
}
|
||||
```
|
||||
|
||||
(If `FIELD_NAMES`/`RECORD_KINDS`/`WIDTH` are not `pub` at the path used, expose them —
|
||||
they ARE the public layout contract. Adjust the `use` path to match `lib.rs` re-exports.)
|
||||
|
||||
- [ ] **Step 2: Drive the nodes directly end-to-end + fold**
|
||||
|
||||
Rather than bootstrap a full graph, drive the chain node-by-node (the nodes' `eval` is
|
||||
the contract; the E2E proves stop→position-management→summarize_r composes). A rising
|
||||
then falling synthetic price with a constant long bias must produce ≥1 trade and a
|
||||
finite `RMetrics`:
|
||||
|
||||
```rust
|
||||
use aura_std::{FixedStop, PositionManagement};
|
||||
use aura_core::{AnyColumn, Ctx, Node, Scalar, Timestamp};
|
||||
use aura_engine::report::summarize_r;
|
||||
|
||||
#[test]
|
||||
fn synthetic_long_then_stop_produces_a_sane_rmetric() {
|
||||
let mut stop = FixedStop::new(5.0);
|
||||
let mut pm = PositionManagement::new();
|
||||
let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price
|
||||
let mut pc: Vec<AnyColumn> = (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
|
||||
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = vec![];
|
||||
// price path: up to 110 then down through the 95 stop.
|
||||
let prices = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0];
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
sc[0].push(Scalar::f64(p)).unwrap();
|
||||
let dist = stop.eval(Ctx::new(&sc, Timestamp(i as i64))).map(|c| c[0].f64()).unwrap_or(0.0);
|
||||
pc[0].push(Scalar::f64(1.0)).unwrap(); // constant long bias
|
||||
pc[1].push(Scalar::f64(p)).unwrap();
|
||||
pc[2].push(Scalar::f64(dist)).unwrap();
|
||||
if let Some(row) = pm.eval(Ctx::new(&pc, Timestamp(i as i64))) {
|
||||
ledger.push((Timestamp(i as i64), row.iter().enumerate().map(|(j, c)| Scalar::from_cell(RECORD_KINDS[j], *c)).collect()));
|
||||
}
|
||||
}
|
||||
let m = summarize_r(&ledger);
|
||||
assert!(m.n_trades >= 1, "expected at least one trade (entry then stop), got {m:?}");
|
||||
assert!(m.expectancy_r.is_finite());
|
||||
// entered ~100, stopped at the 95 level (price gapped to 94 through it) -> a loss.
|
||||
assert!(m.expectancy_r < 0.0 || m.n_open_at_end == 1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the E2E + full suite + lint**
|
||||
|
||||
Run: `cargo test -p aura-engine --test stage1_r_e2e` Expected: PASS (both tests).
|
||||
Run: `cargo test --workspace 2>&1 | tail -15` Expected: all green.
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** #126 rename (Task 1, scoped), #119 stop-rule (Task 2), #127 core
|
||||
position-management + dense R-record (Tasks 3-4), #129 core summarize_r/RMetrics
|
||||
(Task 5), E2E + layout guard (Task 6). Iter-2 items (Sizer, RiskExecutor, SQN,
|
||||
conviction, net-of-cost, RunMetrics.r, CLI) explicitly deferred — not in this plan.
|
||||
- **Placeholder scan:** none (the `size = 1.0` is a pinned flat-1R placeholder, named).
|
||||
- **Type consistency:** `Bias`, `VolStop`, `FixedStop`, `PositionManagement`,
|
||||
`ExitReason`, `RMetrics`, `summarize_r`, `FIELD_NAMES`/`RECORD_KINDS`/`WIDTH`,
|
||||
column indices (`r_col`) match across Tasks 3-6.
|
||||
- **Step granularity:** each step 2-5 min; the rename (Task 1) is compiler-enumerated.
|
||||
- **No commit steps:** none (orchestrator commits the iter).
|
||||
- **Compile-gate ordering:** Task 1's signature/symbol rename threads all callers
|
||||
inside Task 1 (Steps 4-5 loop to a clean workspace build before the green gate).
|
||||
- **Verification filters resolve:** `cargo test -p aura-std stop` /
|
||||
`position_management` / `-p aura-engine summarize_r` / `--test stage1_r_e2e` are
|
||||
single substrings matching the new test names introduced in the same task; the
|
||||
rename gate uses the unfiltered `cargo test --workspace` with a green-unchanged
|
||||
assertion (no filter that could match nothing).
|
||||
Reference in New Issue
Block a user