From f3cd2e132084eda8aa205ce3bd870087292cccb8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 4 Jun 2026 17:30:13 +0200 Subject: [PATCH] =?UTF-8?q?fieldtest:=20cycle-0007=20=E2=80=94=204=20examp?= =?UTF-8?q?les,=207=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/specs/fieldtest-0007-signal-quality.md | 197 ++++++++++++++++++ .../cycle-0007-signal-quality/.gitignore | 2 + .../cycle-0007-signal-quality/Cargo.toml | 36 ++++ .../c0007_1_single_signal_quality.rs | 148 +++++++++++++ .../c0007_2_exposure_clamp.rs | 88 ++++++++ .../c0007_3_pip_size_scale.rs | 117 +++++++++++ .../c0007_4_combine_two_signals.rs | 177 ++++++++++++++++ 7 files changed, 765 insertions(+) create mode 100644 docs/specs/fieldtest-0007-signal-quality.md create mode 100644 fieldtests/cycle-0007-signal-quality/.gitignore create mode 100644 fieldtests/cycle-0007-signal-quality/Cargo.toml create mode 100644 fieldtests/cycle-0007-signal-quality/c0007_1_single_signal_quality.rs create mode 100644 fieldtests/cycle-0007-signal-quality/c0007_2_exposure_clamp.rs create mode 100644 fieldtests/cycle-0007-signal-quality/c0007_3_pip_size_scale.rs create mode 100644 fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs diff --git a/docs/specs/fieldtest-0007-signal-quality.md b/docs/specs/fieldtest-0007-signal-quality.md new file mode 100644 index 0000000..9a47b44 --- /dev/null +++ b/docs/specs/fieldtest-0007-signal-quality.md @@ -0,0 +1,197 @@ +# Fieldtest — cycle-0007 (signal-quality loop) — 2026-06-04 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill) + +## Scope + +Cycle 0007 realizes the C10 reframe (the signal-quality half) as two new +`aura-std` nodes on the unchanged, domain-free engine: + +- `Exposure { scale }` — the decision/sizing node: `clamp(signal / scale, -1, +1)`, + one `f64` exposure per fired cycle, `None` until its input is present. +- `SimBroker { pip_size }` — the sim-optimal broker: a two-input node (exposure, + price) that accumulates `prev_exposure · (price − prev_price) / pip_size` and + emits cumulative pip equity. `pip_size` is held reference metadata, never + streamed. + +Composed with the existing `Sma` / `Sub` and the cycle-0006 recording-node +pattern (a node with `output: vec![]` pushing `(ctx.now(), row)` out of graph), +these wire an end-to-end signal-quality backtest whose pip-equity curve is +recorded via a sink. The fieldtest exercised this from a standalone downstream +consumer crate (`fieldtests/cycle-0007-signal-quality/`) that `path`-depends on +the engine crates and uses only the public surface (rustdoc + design ledger + +glossary + project-layout, never `crates/*/src`). + +**Build exercised:** all four binaries built and run from the current working +tree via `cargo run --manifest-path fieldtests/cycle-0007-signal-quality/Cargo.toml +--bin ` (debug profile, HEAD source recompiled each invocation — confirmed +by the `Compiling aura-core/std/engine` lines). + +## Examples + +### fieldtests/cycle-0007-signal-quality/c0007_1_single_signal_quality.rs — single-signal quality backtest, end to end +- Wires the full primary research loop: `price → {SMA(2), SMA(4)} → Sub → + Exposure(4.0) → SimBroker(1.0) → Recorder`, price also fanning into the broker's + price slot. Records the cumulative pip-equity curve over a rising price. +- Fits the headline axis: composition into an end-to-end signal-quality harness + + recording the equity curve (project-layout "A day in the life" step 3). +- Outcome: built, ran. The pip VALUES matched the hand model exactly in the warm + region; the recorded curve had three *extra* leading `0.0` rows (t=1,2,3) my + first hand model did not predict — see the firing-shape spec_gap. Final + assertion adjusted to the observed (principled) shape; passes. + +### fieldtests/cycle-0007-signal-quality/c0007_2_exposure_clamp.rs — Exposure shaping (clamp + sizing) +- Feeds raw scores spanning below / at / above the saturation band, both signs, + straight into `Exposure(10.0) → Recorder`. Asserts each output ∈ [-1,+1] and the + exact `clamp(score/scale, -1, +1)` mapping (incl. hard saturation at ±1 and sign + preservation). +- Fits the "intent/exposure shaping via Exposure" axis. +- Outcome: built, ran, matched expected `[0.0, 0.5, 1.0, 1.0, -0.7, -1.0, 0.3]` + on the first try. Clean. + +### fieldtests/cycle-0007-signal-quality/c0007_3_pip_size_scale.rs — sim-optimal integration, short signal, pip_size divisor +- Runs the same falling-price stream through two harnesses (`pip_size` 1.0 vs 2.0). + Asserts (1) a short signal on a falling market makes positive pips + (`neg exposure · neg return > 0`); (2) the `pip_size=2.0` curve is exactly half + the `pip_size=1.0` curve, timestamp-for-timestamp. +- Fits the "sim-optimal pip-equity integration via SimBroker" + "different pip_size" + axes. +- Outcome: built, ran. Final pips +3.0 (short pays); pip_size=2.0 curve exactly + halved. Clean. + +### fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs — combine two signals into one exposure (north-star move) +- Two MA-cross spreads (`SMA2-SMA4` and `SMA3-SMA6`) summed by a **hand-authored + `Add2` node** into one combined score, shaped to exposure, fed to the broker. + Asserts a warm equity region exists, equity is positive, and steady-state +1 + (saturated) exposure earns +2 pips/step. +- Fits the "combine two signals into one exposure and backtest the combination + (the stated north-star research move)" axis (design INDEX C10). +- Outcome: built, ran, final pips +4.0, steady-state slope +2/step. Passes — but + the combine step required hand-writing a summing node aura-std does not ship + (see friction). + +## Findings + +### [working] Exposure mapping is exactly as documented, bound is hard +- Example: c0007_2. +- What happened: `clamp(score/scale, -1, +1)` reproduced verbatim from the rustdoc + one-line description — 0→0, 5/10→0.5, 25/10→saturates to 1.0 (not 2.5), + −40/10→−1.0, sign preserved throughout. Every recorded value lay in [-1,+1]. +- Why working: the new surface was reached for, used as the rustdoc described, and + correct on the first run with no surprises; the hard saturation (a downstream + author's main safety question — "can exposure exceed ±1?") is empirically firm. +- Recommended action: carry-on. + +### [working] Sim-optimal pip integration: sign, causality, and pip_size divisor all correct +- Example: c0007_3 (and the warm region of c0007_1). +- What happened: a short exposure on a falling price integrated to **positive** + pips (+3.0), confirming the `prev_exposure · Δprice` sign convention; doubling + `pip_size` exactly halved every pip value (the divisor relationship); the t-1 + exposure earning t's return (causality, C2) showed up as the steady +1.0-pip/step + slope under constant exposure and constant +2 price steps in c0007_1. +- Why working: the broker's documented integration model held bit-for-bit against + three independent hand computations (rising-long, falling-short, pip_size scaling). +- Recommended action: carry-on. + +### [working] End-to-end signal-quality harness composes and records cleanly +- Examples: c0007_1, c0007_4. +- What happened: the full `SMA-cross → Exposure → SimBroker → Recorder` chain + bootstrapped and ran with the cycle-0006 recording pattern unchanged; one source + fanning into both the indicator legs and the broker's price slot wired without + fuss; the recorded pip-equity curve drained from the channel exactly as in 0006. +- Why working: the headline cycle deliverable — a runnable signal-quality backtest + whose equity curve you can record — works from the public surface, including the + north-star "combine two signals" composition. +- Recommended action: carry-on. + +### [spec_gap] SimBroker's firing policy / warm-up emission shape is not on the public surface +- Examples: c0007_1 (surfaced), c0007_3, c0007_4 (same shape). +- What happened: I predicted the broker would emit its first equity row only once + its exposure leg warmed (from t=4 in c0007_1). It actually emits on **every + price-fresh cycle** from t=1, treating a still-cold exposure as 0.0 and recording + leading `0.0` rows (t=1,2,3). Verbatim, c0007_1: + `recorded = [(1,0.0),(2,0.0),(3,0.0),(4,0.0),(5,1.0),(6,2.0),(7,3.0)]` vs my first + `predicted = [(4,0.0),(5,1.0),(6,2.0),(7,3.0)]`. The pip *values* matched once + warm; only the emission *shape* differed. +- Why spec_gap: the `SimBroker` rustdoc states the integration formula but **not** + (a) its per-input firing policy (does it fire on price-fresh, exposure-fresh, or + a barrier?), nor (b) that it emits `0.0`-equity rows before the exposure leg + warms. Both readings — "emit from first price tick, cold-exposure = flat" (what + ships) and "emit only once exposure is defined" (what I guessed) — are plausible + from the prose, and they change the recorded-curve length a downstream consumer + reasons about. The feat commit body documents the cold-exposure→0.0 detail + (`Window::get(0)` "or 0.0 when the exposure leg is cold"), but a commit body is + not the public reference surface; the rustdoc / ledger C10 Realization is silent + on it. I did not probe the engine internals to confirm *why* (that would end the + test); the behaviour is the finding. +- Recommended action: tighten the design ledger / rustdoc — state `SimBroker`'s + firing policy and the cold-exposure-emits-`0.0` warm-up contract on the rustdoc + (and, if it is a contract worth pinning, in C10's Realization note), so a + consumer can predict the recorded curve's length and leading values. + +### [spec_gap] SimBroker's input slot order (exposure vs price) is only in the commit body / C10 prose, not the rustdoc +- Examples: c0007_1, c0007_3, c0007_4 (all wire `Target { node: broker, slot: 1 }` + for price and `Edge { to: broker, slot: 0 }` for exposure). +- What happened: to wire the broker I had to know which input slot is exposure and + which is price. The `SimBroker` rustdoc (`struct.SimBroker.html`, including its + `Node` impl) does **not** render `schema()`'s body, so it does not state the slot + order. I recovered it from design-ledger C10's Realization note ("a two-input + node (exposure, price)") plus the feat commit body ("exposure (slot 0) + price + (slot 1)"). I picked slot 0 = exposure, slot 1 = price; the pip signs came out + correct, confirming the guess — but a wrong guess (swapping them) would also + bootstrap successfully (both slots are f64, no kind mismatch to catch it) and + silently produce a wrong-but-plausible equity curve. +- Why spec_gap: a two-input node whose slots are not symmetric and not type- + distinguishable needs its slot contract on the consumer-facing surface; the + rustdoc omits it, and a mis-wiring is *not* caught at bootstrap (both f64). The + ordering "exposure first" is only inferable from prose order in C10 / a commit + message. +- Recommended action: tighten the rustdoc — name the input slots on `SimBroker`'s + doc comment (slot 0 = exposure ∈ [-1,+1], slot 1 = price). Same applies to any + future multi-`f64`-input node where slots are role-distinct but kind-identical. + +### [friction] The north-star "combine two signals" move needs a combinator aura-std does not ship +- Example: c0007_4. +- What happened: aura-std ships `Sma`, `Sub`, `Exposure`, `SimBroker`. To combine + two signals into one exposure — the explicitly stated north-star research move + ("backtest one signal, combine it with another", C10) — I needed to *add* (or + weight-sum) two spread streams, and there is no `Add` / weighted-sum / mean + combinator in aura-std. I hand-authored a project-local `Add2` node (a `schema` + with two f64 inputs + one f64 output, an `eval` summing the newest values), + which is legitimate per C16 but is boilerplate every consumer attempting the + headline move must rewrite. `Sub` exists (difference) but not its companion sum. +- Why friction: the task completed, but the cycle's own stated primary research + loop ("combine it with another") cannot be expressed with the shipped nodes + alone; the most basic combinator for it is absent, so the consumer writes it by + hand. This is the natural next-tidy candidate (cf. the cycle-0006 "recorder + boilerplate" friction). +- Recommended action: plan — add an `Add` / weighted-sum (or a small `LinComb`) + combinator to aura-std so the stated north-star combination is expressible from + shipped blocks. Pairs naturally with the existing `Sub`. + +### [friction] Standalone consumer crate still needs the empty `[workspace]` table (carried from cycle-0006) +- Example: all (the fixture crate's `Cargo.toml`). +- What happened: the same nested-consumer-crate resolver fight the cycle-0006 + fieldtest recorded. I pre-emptively added the empty `[workspace]` table (Cargo's + own keep-it-out-of-the-workspace fix) so the crate builds; without it the build + fails with "current package believes it's in a workspace when it's not". +- Why friction: it recurs for every downstream consumer crate created beside/under + the engine repo (C16: "a project is always a Rust crate" depending on aura). Not + new this cycle, but it bit again, confirming it is a standing onboarding wall, not + a one-off. +- Recommended action: plan — already noted in the cycle-0006 fieldtest; fold into + the future `aura new` scaffolder / onboarding docs (open ledger thread). Carry-on + is acceptable if 0006's item is already queued. + +## Recommendation summary + +| Finding | Action | +|---|---| +| [working] Exposure clamp/sizing exact, hard bound | carry-on | +| [working] SimBroker integration: sign / causality / pip_size divisor | carry-on | +| [working] end-to-end harness composes + records (incl. combine) | carry-on | +| [spec_gap] SimBroker firing policy / warm-up `0.0` emission undocumented | tighten the design ledger / rustdoc | +| [spec_gap] SimBroker input slot order only in commit body / C10 prose | tighten the rustdoc | +| [friction] no sum/weighted combinator in aura-std for the north-star move | plan | +| [friction] consumer crate needs empty `[workspace]` (carried from 0006) | plan (or carry-on if 0006 item queued) | diff --git a/fieldtests/cycle-0007-signal-quality/.gitignore b/fieldtests/cycle-0007-signal-quality/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/fieldtests/cycle-0007-signal-quality/Cargo.toml b/fieldtests/cycle-0007-signal-quality/Cargo.toml new file mode 100644 index 0000000..2a118ed --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/Cargo.toml @@ -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 ` +# 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" diff --git a/fieldtests/cycle-0007-signal-quality/c0007_1_single_signal_quality.rs b/fieldtests/cycle-0007-signal-quality/c0007_1_single_signal_quality.rs new file mode 100644 index 0000000..ba8e55c --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/c0007_1_single_signal_quality.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"); +} diff --git a/fieldtests/cycle-0007-signal-quality/c0007_2_exposure_clamp.rs b/fieldtests/cycle-0007-signal-quality/c0007_2_exposure_clamp.rs new file mode 100644 index 0000000..ea3d152 --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/c0007_2_exposure_clamp.rs @@ -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"); +} diff --git a/fieldtests/cycle-0007-signal-quality/c0007_3_pip_size_scale.rs b/fieldtests/cycle-0007-signal-quality/c0007_3_pip_size_scale.rs new file mode 100644 index 0000000..3d40fce --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/c0007_3_pip_size_scale.rs @@ -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"); +} diff --git a/fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs b/fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs new file mode 100644 index 0000000..f1f9c8d --- /dev/null +++ b/fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs @@ -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, +} +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"); +}