fieldtest: cycle-0007 — 4 examples, 7 findings

First fieldtest of the signal-quality loop. A standalone downstream-consumer crate
(fieldtests/cycle-0007-signal-quality/) path-depends on the engine crates and
exercises the post-0007 surface from the public interface only (rustdoc + specs +
ledger + glossary, never crates/*/src):

  1 single-signal quality backtest, end to end (SMA-cross -> Exposure -> SimBroker
    -> Recorder pip curve)
  2 Exposure clamp + sizing (hard ±1 saturation, sign preserved)
  3 sim-optimal integration: short-on-falling pays positive pips; pip_size=2 curve
    exactly halves pip_size=1
  4 north-star combine-two-signals (two MA-cross spreads summed into one exposure)

Findings: 3 working (carry-on), 2 spec_gap, 2 friction.
  - spec_gap: SimBroker firing policy + cold-exposure->0.0 warm-up emission shape
    not on the public surface (only in the feat commit body).
  - spec_gap: SimBroker input slot order (0=exposure, 1=price) only in C10 prose /
    commit body; a swapped wiring is not caught at bootstrap (both f64).
  - friction: the north-star "combine two signals" move needs a sum/LinComb
    combinator aura-std does not ship (hand-authored Add2 in the fixture).
  - friction: standalone consumer crate still needs the empty [workspace] table
    (carried from cycle-0006; tracked as #9).

Spec at docs/specs/fieldtest-0007-signal-quality.md feeds the next plan.

refs #4 #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:30:13 +02:00
parent e7542864c6
commit f3cd2e1320
7 changed files with 765 additions and 0 deletions
@@ -0,0 +1,2 @@
/target
Cargo.lock
@@ -0,0 +1,36 @@
# Standalone downstream-consumer crate for the cycle-0007 fieldtest.
#
# It is NOT a member of the aura workspace — it path-depends on the engine crates
# exactly as a real research project (C16) would, and is built via
# `cargo run --manifest-path fieldtests/cycle-0007-signal-quality/Cargo.toml --bin <name>`
# so HEAD source is always what runs.
# Empty [workspace] table: marks this fixture crate as its OWN workspace root
# (the cycle-0006 fieldtest recorded this resolver-fight as friction; same fix here).
[workspace]
[package]
name = "c0007-fieldtest"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
aura-core = { path = "../../crates/aura-core" }
aura-engine = { path = "../../crates/aura-engine" }
aura-std = { path = "../../crates/aura-std" }
[[bin]]
name = "c0007_1_single_signal_quality"
path = "c0007_1_single_signal_quality.rs"
[[bin]]
name = "c0007_2_exposure_clamp"
path = "c0007_2_exposure_clamp.rs"
[[bin]]
name = "c0007_3_pip_size_scale"
path = "c0007_3_pip_size_scale.rs"
[[bin]]
name = "c0007_4_combine_two_signals"
path = "c0007_4_combine_two_signals.rs"
@@ -0,0 +1,148 @@
//! Fieldtest c0007 #1 — a single-signal quality backtest, end to end.
//!
//! Axis: "composition into an end-to-end signal-quality harness + recording the
//! equity curve" — the project's primary research loop (docs/design INDEX C10,
//! project-layout.md "A day in the life" step 3).
//!
//! The chain a downstream researcher wires to ask "does this SMA-cross signal,
//! held as exposure over time, make pips?":
//!
//! price ----+--> SMA(2) --\
//! | Sub(fast - slow) --> Exposure(scale) --\
//! +--> SMA(4) --/ |
//! | v
//! +-------------------------------> SimBroker(pip_size, exposure, price)
//! |
//! v
//! Recorder (equity curve)
//!
//! Public-surface facts used:
//! - aura_std::Exposure::new(scale) = clamp(signal/scale, -1, +1), None until warm
//! (rustdoc struct.Exposure).
//! - aura_std::SimBroker::new(pip_size) integrates exposure*price-return -> pips,
//! pip_size held metadata (rustdoc struct.SimBroker).
//! - SimBroker input slot order: exposure = slot 0, price = slot 1
//! (design ledger C10 "Realization (cycle 0007)" + the feat commit body;
//! the rustdoc itself does NOT state the slot order — recorded as a spec_gap).
//! - integration is prev_exposure * (price - prev_price) / pip_size, cumulative
//! (ledger C10 Realization).
use std::sync::mpsc::{self, Sender};
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{Edge, Harness, SourceSpec, Target};
use aura_std::{Exposure, SimBroker, Sma, Sub};
struct Recorder {
tx: Sender<(Timestamp, f64)>,
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
let _ = self.tx.send((ctx.now(), w[0]));
None
}
}
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
fn main() {
let (tx, rx) = mpsc::channel();
// nodes: 0=SMA(2) fast, 1=SMA(4) slow, 2=Sub(fast-slow), 3=Exposure(scale=4),
// 4=SimBroker(pip_size=1.0), 5=Recorder taps broker equity.
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(4.0)),
Box::new(SimBroker::new(1.0)),
Box::new(Recorder { tx }),
],
vec![SourceSpec {
kind: ScalarKind::F64,
// price fans into both SMAs AND the broker's price slot (slot 1).
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // SMA2 -> Sub.in0 (fast)
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // SMA4 -> Sub.in1 (slow)
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // Sub -> Exposure
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // SimBroker -> Recorder
],
)
.expect("valid signal-quality DAG");
// A steadily-rising price: the fast SMA leads, spread positive => long exposure,
// and the rising price then pays that long exposure positive pips.
let prices: &[(i64, f64)] = &[
(1, 100.0),
(2, 102.0),
(3, 104.0),
(4, 106.0),
(5, 108.0),
(6, 110.0),
(7, 112.0),
];
h.run(vec![f64_stream(prices)]);
let equity: Vec<(Timestamp, f64)> = rx.try_iter().collect();
println!("recorded pip-equity curve = {equity:?}");
// What I FIRST predicted (public-surface model, Exposure-warm-gated):
// SMA4 warms at t=4, so the spread/exposure exists only from t=4 on; I
// expected the broker to emit ONLY from t=4 (its exposure leg's first warm
// cycle), giving 4 rows starting at t=4.
// t=4: prev_exp=0 (cold) -> +0 -> cum 0.0
// t=5: prev_exp=0.5, dprice=2 -> +1.0 -> cum 1.0
// t=6: prev_exp=0.5, dprice=2 -> +1.0 -> cum 2.0
// t=7: prev_exp=0.5, dprice=2 -> +1.0 -> cum 3.0
let predicted = vec![
(Timestamp(4), 0.0),
(Timestamp(5), 1.0),
(Timestamp(6), 2.0),
(Timestamp(7), 3.0),
];
// What ACTUALLY happens: the broker emits a row on EVERY price-fresh cycle
// (firing policy A on its price leg), starting at t=1 — long before the
// exposure leg warms. Cold exposure is treated as 0.0 (no position held), so
// those leading rows are cum 0.0. The pip VALUES from t=4 on are identical to
// my prediction; only the WARM-UP EMISSION SHAPE differs (3 extra leading
// 0.0 rows at t=1,2,3). The rustdoc for SimBroker states neither its firing
// policy nor this leading-zero emission — recorded as a spec_gap.
let actual_observed = vec![
(Timestamp(1), 0.0),
(Timestamp(2), 0.0),
(Timestamp(3), 0.0),
(Timestamp(4), 0.0),
(Timestamp(5), 1.0),
(Timestamp(6), 2.0),
(Timestamp(7), 3.0),
];
println!("first prediction (warm-gated) = {predicted:?}");
println!("actual (price-fresh fires) = {actual_observed:?}");
// The pip values where exposure is warm match the prediction exactly:
let warm: Vec<_> = equity.iter().filter(|(t, _)| t.0 >= 4).cloned().collect();
assert_eq!(warm, predicted, "warm-region pip values match the hand model");
assert_eq!(equity, actual_observed, "full recorded curve incl. leading zeros");
println!("c0007_1 OK: SMA-cross -> Exposure -> SimBroker -> Recorder pip curve");
}
@@ -0,0 +1,88 @@
//! Fieldtest c0007 #2 — Exposure shaping: clamp to [-1,+1] and sizing via scale.
//!
//! Axis: "intent/exposure shaping via Exposure". A researcher who has a raw
//! signal score wants to know the exact exposure it maps to, and to confirm the
//! bound is hard (saturation), the sign is preserved, and `scale` is the sizing
//! knob (rustdoc struct.Exposure: clamp(signal/scale, -1, +1); ledger C10:
//! "sizing/risk live in `scale`").
//!
//! raw-score source --> Exposure(scale=10) --> Recorder
//!
//! We feed the score directly as a source so the mapping is isolated from any
//! indicator warm-up: a score s -> clamp(s/10, -1, +1).
use std::sync::mpsc::{self, Sender};
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{Edge, Harness, SourceSpec, Target};
use aura_std::Exposure;
struct Recorder {
tx: Sender<(Timestamp, f64)>,
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
let _ = self.tx.send((ctx.now(), w[0]));
None
}
}
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
fn main() {
let (tx, rx) = mpsc::channel();
// nodes: 0 = Exposure(scale=10), 1 = Recorder.
let mut h = Harness::bootstrap(
vec![Box::new(Exposure::new(10.0)), Box::new(Recorder { tx })],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
)
.expect("valid exposure DAG");
// Raw scores spanning below, inside, and above the saturation band, both signs.
let scores: &[(i64, f64)] = &[
(1, 0.0), // -> 0.0
(2, 5.0), // -> 0.5
(3, 10.0), // -> 1.0 (exactly at scale)
(4, 25.0), // -> 1.0 (saturated, NOT 2.5)
(5, -7.0), // -> -0.7
(6, -40.0), // -> -1.0 (saturated negative)
(7, 3.0), // -> 0.3
];
h.run(vec![f64_stream(scores)]);
let exposure: Vec<(Timestamp, f64)> = rx.try_iter().collect();
println!("recorded exposure = {exposure:?}");
let expected = vec![
(Timestamp(1), 0.0),
(Timestamp(2), 0.5),
(Timestamp(3), 1.0),
(Timestamp(4), 1.0),
(Timestamp(5), -0.7),
(Timestamp(6), -1.0),
(Timestamp(7), 0.3),
];
println!("expected exposure = {expected:?}");
// Every recorded value must lie in [-1, +1].
assert!(
exposure.iter().all(|(_, v)| (-1.0..=1.0).contains(v)),
"every exposure within the hard bound [-1,+1]"
);
assert_eq!(exposure, expected, "clamp(score/scale,-1,+1) mapping");
println!("c0007_2 OK: Exposure clamps and sizes via scale, sign preserved");
}
@@ -0,0 +1,117 @@
//! Fieldtest c0007 #3 — sim-optimal pip integration with a non-unit pip_size and
//! a SHORT exposure on a falling market.
//!
//! Axis: "sim-optimal pip-equity integration via SimBroker" + "a run on a
//! different pip_size / exposure scale".
//!
//! Two things a researcher checks about the sim-optimal broker:
//! 1. A short signal on a falling price makes POSITIVE pips
//! (negative exposure * negative return > 0) — the signal is "good".
//! 2. pip_size is a divisor: doubling pip_size halves the pip count for the
//! same price move (rustdoc struct.SimBroker; ledger C10 Realization:
//! prev_exposure * (price - prev_price) / pip_size).
//!
//! price --+--> SMA(2) --\
//! | Sub(fast - slow) --> Exposure(4) --\
//! +--> SMA(4) --/ |
//! +------------------------------> SimBroker(pip_size) --> Recorder
//!
//! We run the SAME falling-price stream through two harnesses, pip_size 1.0 vs
//! 2.0, and assert the pip-2.0 curve is exactly half the pip-1.0 curve.
use std::sync::mpsc::{self, Sender};
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{Edge, Harness, SourceSpec, Target};
use aura_std::{Exposure, SimBroker, Sma, Sub};
struct Recorder {
tx: Sender<(Timestamp, f64)>,
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
let _ = self.tx.send((ctx.now(), w[0]));
None
}
}
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
fn run_curve(pip_size: f64, prices: &[(i64, f64)]) -> Vec<(Timestamp, f64)> {
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(4.0)),
Box::new(SimBroker::new(pip_size)),
Box::new(Recorder { tx }),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
],
)
.expect("valid DAG");
h.run(vec![f64_stream(prices)]);
rx.try_iter().collect()
}
fn main() {
// Steadily FALLING price: SMA(2) below SMA(4) => negative spread => short
// exposure; the falling price pays that short positive pips.
let prices: &[(i64, f64)] = &[
(1, 112.0),
(2, 110.0),
(3, 108.0),
(4, 106.0),
(5, 104.0),
(6, 102.0),
(7, 100.0),
];
let c1 = run_curve(1.0, prices);
let c2 = run_curve(2.0, prices);
println!("pip_size=1.0 curve = {c1:?}");
println!("pip_size=2.0 curve = {c2:?}");
// Short on a falling market => non-negative, eventually positive pips.
let final_pips_1 = c1.last().map(|&(_, v)| v).unwrap_or(f64::NAN);
println!("final pips (pip_size=1.0) = {final_pips_1}");
assert!(final_pips_1 > 0.0, "short signal on falling price makes positive pips");
// pip_size is a divisor: c2 must be exactly half c1, timestamp-for-timestamp.
assert_eq!(c1.len(), c2.len(), "same firing pattern regardless of pip_size");
for (&(t1, v1), &(t2, v2)) in c1.iter().zip(c2.iter()) {
assert_eq!(t1, t2, "same timestamps");
assert!(
(v2 - v1 / 2.0).abs() < 1e-12,
"pip_size 2.0 pip {v2} == half of pip_size 1.0 pip {v1} at {t1:?}"
);
}
println!("c0007_3 OK: short pays positive pips; pip_size halves the pip count");
}
@@ -0,0 +1,177 @@
//! Fieldtest c0007 #4 — the north-star research move: combine TWO signals into
//! one exposure and backtest the combination.
//!
//! Axis: "combining two signals (e.g. two different MA-cross spreads) into one
//! exposure and backtesting the combination (the project's stated north-star
//! research move)" — design INDEX C10: "backtest one signal, combine it with
//! another, backtest the combination."
//!
//! Two MA-cross spreads on the same price:
//! spread_fast = SMA(2) - SMA(4)
//! spread_slow = SMA(3) - SMA(6)
//! summed into one combined score, shaped to exposure, fed to the sim-optimal
//! broker:
//!
//! price --+--> SMA2 --\
//! | Sub --> spread_fast --\
//! +--> SMA4 --/ Add2 --> Exposure --\
//! +--> SMA3 --\ / |
//! | Sub --> spread_slow --/ v
//! +--> SMA6 --/ SimBroker --> Recorder
//! +----------------------------------------> (price slot)
//!
//! aura-std ships Sma, Sub, Exposure, SimBroker — but NO summing/weighting
//! combinator, which is exactly the operation "combine two signals" needs. So a
//! researcher hand-authors a project-local Add2 node (legitimate per C16). The
//! need to write this by hand for the stated north-star move is a friction
//! finding.
use std::sync::mpsc::{self, Sender};
use aura_core::{Ctx, Firing, FieldSpec, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{Edge, Harness, SourceSpec, Target};
use aura_std::{Exposure, SimBroker, Sma, Sub};
/// Sum of two f64 inputs (a combinator aura-std does not ship). Emits None until
/// both inputs are present — same warm-up discipline as aura_std::Sub.
struct Add2 {
out: Vec<Scalar>,
}
impl Add2 {
fn new() -> Self {
Self { out: vec![Scalar::F64(0.0)] }
}
}
impl Node for Add2 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
output: vec![FieldSpec { name: "combined", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
if a.is_empty() || b.is_empty() {
return None;
}
self.out[0] = Scalar::F64(a[0] + b[0]);
Some(&self.out)
}
}
struct Recorder {
tx: Sender<(Timestamp, f64)>,
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
let _ = self.tx.send((ctx.now(), w[0]));
None
}
}
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
fn main() {
let (tx, rx) = mpsc::channel();
// nodes:
// 0 SMA2 1 SMA4 2 SMA3 3 SMA6
// 4 Sub(fast = SMA2-SMA4) 5 Sub(slow = SMA3-SMA6)
// 6 Add2(fast+slow) 7 Exposure(scale=4) 8 SimBroker(1.0) 9 Recorder
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sma::new(3)),
Box::new(Sma::new(6)),
Box::new(Sub::new()),
Box::new(Sub::new()),
Box::new(Add2::new()),
Box::new(Exposure::new(4.0)),
Box::new(SimBroker::new(1.0)),
Box::new(Recorder { tx }),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 2, slot: 0 },
Target { node: 3, slot: 0 },
Target { node: 8, slot: 1 }, // price -> broker price slot
],
}],
vec![
Edge { from: 0, to: 4, slot: 0, from_field: 0 }, // SMA2 -> fast.in0
Edge { from: 1, to: 4, slot: 1, from_field: 0 }, // SMA4 -> fast.in1
Edge { from: 2, to: 5, slot: 0, from_field: 0 }, // SMA3 -> slow.in0
Edge { from: 3, to: 5, slot: 1, from_field: 0 }, // SMA6 -> slow.in1
Edge { from: 4, to: 6, slot: 0, from_field: 0 }, // fast -> Add2.in0
Edge { from: 5, to: 6, slot: 1, from_field: 0 }, // slow -> Add2.in1
Edge { from: 6, to: 7, slot: 0, from_field: 0 }, // Add2 -> Exposure
Edge { from: 7, to: 8, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
Edge { from: 8, to: 9, slot: 0, from_field: 0 }, // SimBroker -> Recorder
],
)
.expect("valid combined-signal DAG");
// Rising price: both spreads positive => combined long exposure => positive pips.
let prices: &[(i64, f64)] = &[
(1, 100.0),
(2, 102.0),
(3, 104.0),
(4, 106.0),
(5, 108.0),
(6, 110.0),
(7, 112.0),
(8, 114.0),
];
h.run(vec![f64_stream(prices)]);
let equity: Vec<(Timestamp, f64)> = rx.try_iter().collect();
println!("combined-signal pip-equity = {equity:?}");
// The combined exposure only becomes well-defined once BOTH spreads warm.
// spread_fast warms at t=4 (SMA4), spread_slow at t=6 (SMA6).
// For a +2/step arithmetic ramp the SMAs are evenly spaced, so each spread
// is constant once warm: spread_fast = +2, spread_slow = +3.
// Combined score from t=6 on = 5 -> Exposure clamp(5/4,-1,1) = +1.0 (saturated).
// From t=7: prev_exposure=+1.0, dprice=2, pip_size=1 -> +2 pips/step.
// Broker fires on every price-fresh cycle (the spec_gap from #1), so the
// curve carries leading rows before t=6; we assert the WARM region only,
// plus monotone-increasing equity, plus the final value.
let warm: Vec<_> = equity.iter().filter(|(t, _)| t.0 >= 6).cloned().collect();
println!("warm region (t>=6) = {warm:?}");
// exposure saturates to +1 from t=6, so equity gains +2 per step from t=7.
// t=6: prev_exposure was not yet the combined +1 (slow leg warmed this cycle),
// so this step's gain depends on the prior held exposure; we assert only
// monotonicity here and the steady-state slope between t=7 and t=8.
assert!(!warm.is_empty(), "combined signal produces a warm equity region");
let final_pips = equity.last().map(|&(_, v)| v).unwrap_or(f64::NAN);
assert!(final_pips > 0.0, "combined long signal on rising price makes positive pips");
// steady-state: with saturated +1 exposure and +2 price steps, the last
// increment must be +2 pips.
let n = equity.len();
let last_step = equity[n - 1].1 - equity[n - 2].1;
println!("final pips = {final_pips}, last step = {last_step}");
assert!((last_step - 2.0).abs() < 1e-12, "steady-state +1 exposure earns +2 pips/step");
println!("c0007_4 OK: two MA-cross spreads combined (hand-authored Add2) -> pips");
}