Files
Brummel 90c1de841d fieldtest: GER40 session-breakout research deep-dive — 7 bins, 8 findings
Public-API research deep-dive driving the just-shipped GER40 real-data
session-breakout demo (5b5f034) through the escalating sequence a serious
researcher attempts next: scale to multi-year, walk-forward, structural-param
sweep, multi-instrument compare. Standalone consumer crate (path-deps only;
public interface = ledger + glossary + example corpus + cargo doc; no
crates/*/src read). All runs against the real /mnt/tickdata/Pepperstone archive.

Findings: 3 bugs, 1 friction, 3 spec_gap, 1 working. Triaged to the tracker:
- spec_gap: the flagship breakout ships as a raw FlatGraph the World API cannot
  consume (sweep / walk_forward / compare all forced a hand re-author to a
  Composite, which reproduced the FlatGraph numbers EXACTLY) — refs #94.
- bug: process residency is O(window) (6->173 MiB across 1mo->10y) despite the
  documented O(one-chunk) claim; the aura ring stays <=1024 but data-server
  retention scales, and streaming_seam.rs only measures the ring — refs #95.
- bug: the bar-period is one knob split across Resample (tunable param) and
  Session (baked) and desyncs silently to 0.0 pips; Delay.lag leaks into the
  default param_space (the C34 deform-vs-tune discriminator) — refs #96.
- spec_gap: the walk_forward empty-space idiom for param-free strategies is
  undefined — refs #97.
- spec_gap (no per-instrument pip registry) and friction (one-directional
  ns->ms conversion) re-confirm the known #22 and #80.
- working: C1 determinism over 7y / 148k bars, the lazy streaming source, and
  the sweep/WFO cores held under real multi-year load; the Composite re-author
  was behaviour-preserving (C23).

Scratch consumer crate + run transcripts committed under fieldtests/ per the
fieldtest convention (matching cycle-0049). The crate path-deps the engine and
reads the real archive; its bins are the repros cited in the issues above.
2026-06-17 18:55:04 +02:00

230 lines
11 KiB
Rust

// Task 3 — Parameter sweep of the breakout's STRUCTURAL params.
//
// The researcher's task: "sweep the breakout's entry bar index, exit bar index,
// and bar period. NOTE the coupling: the Resample period and the Session period
// must stay EQUAL, and the entry/exit bar indices are the EqConst targets. Can
// the public named-param sweep express this without a topology change?"
//
// To sweep ANYTHING I must first RE-AUTHOR the shipped breakout (a hand-wired
// FlatGraph with no param_space()) as a Composite blueprint — only a Composite
// exposes param_space() / the named-axis sweep / bootstrap_with_cells that the
// World sweep API consumes. The shipped example does NOT ship the breakout as a
// Composite, so this re-authoring is forced on the researcher (recorded as the
// headline Task-3 friction). The 13-node wiring below is the SAME topology as the
// shipped shared/breakout_real.rs, re-expressed as a Composite.
//
// PUBLIC SURFACE ONLY: Composite/axis/param_space/bootstrap_with_cells from cargo
// doc; EqConst/Resample/Session builder param declarations from their rustdoc
// (EqConst declares `target`; Resample declares `period_minutes`; Session BAKES
// open/tz/period — "NOT scalar params, the declared param list is empty").
use std::sync::mpsc;
use std::sync::Arc;
use aura_core::{Cell, Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
sweep, summarize, BlueprintNode, Composite, Edge, GridSpace, RunManifest, RunReport,
Role, Target,
};
use aura_ingest::{M1Field, M1FieldSource};
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder, Resample, Session, SimBroker};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
use data_server::{DataServer, DEFAULT_DATA_PATH};
const SYMBOL: &str = "GER40";
const PIP_SIZE: f64 = 1.0;
const BAR_MINUTES: i64 = 15;
// The breakout re-authored as a Composite. Same 9-node strategy topology +
// SimBroker + 2 recorder sinks (equity, held). Sources bind to the 4 OHLC roles.
// Returns the blueprint plus the equity/held receivers a run will drain.
fn breakout_composite() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
// Node indices, parallel to the shipped FlatGraph (sans the two extra taps).
// 0 Resample, 1 Delay, 2 Gt, 3 Session, 4 EqConst(entry), 5 EqConst(exit),
// 6 And, 7 Latch, 8 SimBroker, 9 Rec(equity), 10 Rec(held).
let nodes: Vec<BlueprintNode> = vec![
Resample::builder().into(), // 0
Delay::builder().into(), // 1
Gt::builder().into(), // 2
Session::builder(9, 0, Berlin, BAR_MINUTES).into(), // 3 (period BAKED)
EqConst::builder().named("entry_bar").into(), // 4 (target tunable)
EqConst::builder().named("exit_bar").into(), // 5 (target tunable)
And::builder().into(), // 6
Latch::builder().into(), // 7
SimBroker::builder(PIP_SIZE).into(), // 8
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), // 9
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_held).into(), // 10
];
const F_HIGH15: usize = 1;
const F_CLOSE15: usize = 3;
let edges = vec![
Edge { from: 0, to: 2, slot: 0, from_field: F_CLOSE15 }, // close15 -> Gt.a
Edge { from: 0, to: 1, slot: 0, from_field: F_HIGH15 }, // high15 -> Delay
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // prevHigh15 -> Gt.b
Edge { from: 0, to: 3, slot: 0, from_field: F_CLOSE15 }, // close15 -> Session.trigger
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // bars -> EqConst(entry)
Edge { from: 3, to: 5, slot: 0, from_field: 0 }, // bars -> EqConst(exit)
Edge { from: 2, to: 6, slot: 0, from_field: 0 }, // breakout -> And.a
Edge { from: 4, to: 6, slot: 1, from_field: 0 }, // isEntryBar -> And.b
Edge { from: 6, to: 7, slot: 0, from_field: 0 }, // entry -> Latch.set
Edge { from: 5, to: 7, slot: 1, from_field: 0 }, // isExitBar -> Latch.reset
Edge { from: 7, to: 8, slot: 0, from_field: 0 }, // held -> SimBroker.exposure
Edge { from: 0, to: 8, slot: 1, from_field: F_CLOSE15 }, // close15 -> SimBroker.price
Edge { from: 8, to: 9, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 7, to: 10, slot: 0, from_field: 0 }, // held -> sink
];
// Four bound OHLC source roles -> the Resample's four Barrier(0) slots, in the
// fixed C4 merge order open(0) high(1) low(2) close(3).
let roles = (0..4)
.map(|slot| Role {
name: ["open", "high", "low", "close"][slot as usize].into(),
targets: vec![Target { node: 0, slot }],
source: Some(ScalarKind::F64),
})
.collect();
let bp = Composite::new("ger40_breakout", nodes, edges, roles, vec![]);
(bp, rx_eq, rx_held)
}
fn utc_month_window_ms(year: i32, month: u32) -> (i64, i64) {
let from = chrono::Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).unwrap().timestamp_millis();
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
let next = chrono::Utc.with_ymd_and_hms(ny, nm, 1, 0, 0, 0).unwrap().timestamp_millis();
(from, next - 1)
}
fn run_point(server: &Arc<DataServer>, point: &[Cell], from_ms: i64, to_ms: i64) -> RunReport {
let (bp, rx_eq, rx_held) = breakout_composite();
let mut h = bp
.bootstrap_with_cells(point)
.expect("sweep point kind-checked against param_space");
let fields = [M1Field::Open, M1Field::High, M1Field::Low, M1Field::Close];
let srcs: Vec<Box<dyn aura_engine::Source>> = fields
.iter()
.map(|&f| {
Box::new(
M1FieldSource::open(server, SYMBOL, Some(from_ms), Some(to_ms), f)
.expect("OHLC field overlaps window"),
) as Box<dyn aura_engine::Source>
})
.collect();
h.run(srcs);
drop(h);
let eq: Vec<(Timestamp, f64)> = rx_eq.try_iter().map(|(t, r)| (t, r[0].as_f64())).collect();
let ex: Vec<(Timestamp, f64)> = rx_held.try_iter().map(|(t, r)| (t, r[0].as_f64())).collect();
RunReport {
manifest: RunManifest {
commit: "breakout-sweep".into(),
params: vec![],
window: (
aura_ingest::unix_ms_to_epoch_ns(from_ms),
aura_ingest::unix_ms_to_epoch_ns(to_ms),
),
seed: 0,
broker: "sim-optimal(pip_size=1)".into(),
},
metrics: summarize(&eq, &ex),
}
}
fn main() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if !server.has_symbol(SYMBOL) {
println!("skip: no local {SYMBOL} data");
return;
}
println!("=== Task 3: sweep the breakout's structural params ===\n");
// 1. What does param_space() actually expose for the re-authored breakout?
let space = breakout_composite().0.param_space();
println!("param_space() of the breakout Composite:");
for ps in &space {
println!(" {:<28} : {:?}", ps.name, ps.kind);
}
println!();
// The CRUX (recorded): note what is and is NOT in the space.
// - entry_bar.target / exit_bar.target ARE here (EqConst declares `target`).
// - resample.period_minutes IS here (Resample declares it tunable).
// - There is NO session.* period knob — Session BAKES its period (a structural
// factory arg). So "Resample period == Session period" CANNOT be a single
// swept named param: the Session leg of the coupling is invisible to the
// sweep. Sweeping resample.period_minutes alone would silently DESYNC the
// two periods (a correctness trap), and changing the Session period at all
// requires REBUILDING the Composite (a different blueprint), exactly the
// "topology change, not a param" line C11 draws.
let has_resample_period = space.iter().any(|p| p.name.contains("period_minutes"));
let has_session_period = space.iter().any(|p| p.name.to_lowercase().contains("session"));
println!("coupling check:");
println!(" resample.period_minutes in space? {has_resample_period}");
println!(" any session.* period in space? {has_session_period}");
println!(" => the bar-period coupling is NOT expressible as one swept param.\n");
// 2. The sweep that DOES work: entry_bar x exit_bar (both EqConst targets),
// at the FIXED 15m period (no period coupling to violate). A 3x3 grid over
// one month of GER40.
let (from_ms, to_ms) = utc_month_window_ms(2024, 9);
let grid = GridSpace::new(
&space,
space
.iter()
.map(|ps| match ps.name.as_str() {
"entry_bar.target" => vec![Scalar::I64(2), Scalar::I64(3), Scalar::I64(4)],
"exit_bar.target" => vec![Scalar::I64(5), Scalar::I64(6), Scalar::I64(8)],
// resample.period_minutes pinned to 15 (== the baked Session 15m).
"resample.period_minutes" => vec![Scalar::I64(15)],
// SURPRISE (recorded): delay.lag is a STRUCTURAL deformer (the
// "previous bar's high" of the breakout) yet ships in param_space
// as a tunable knob — so the grid is FORCED to enumerate it (here
// pinned to its structural value 1) or panic. C34's bind() would
// remove it from the space, but the node exposes it by default.
"delay.lag" => vec![Scalar::I64(1)],
other => panic!("unexpected param slot {other}"),
})
.collect(),
)
.expect("grid well-formed for the space");
println!("sweeping entry_bar x exit_bar (period pinned 15m) over GER40 2024-09:");
let server_for_closure = Arc::clone(&server);
let family = sweep(&grid, |pt: &[Cell]| run_point(&server_for_closure, pt, from_ms, to_ms));
// Print each point with its named params (by index — SweepFamily::named_params(i)).
for (i, p) in family.points.iter().enumerate() {
let named = family.named_params(i);
let label: Vec<String> = named
.iter()
.filter(|(n, _)| n != "resample.period_minutes")
.map(|(n, v)| format!("{}={}", n.replace(".target", ""), scalar_str(v)))
.collect();
println!(
" {{ {} }} total_pips={:>8.1} max_dd={:>7.1}",
label.join(", "),
p.report.metrics.total_pips,
p.report.metrics.max_drawdown,
);
}
println!("\nOK: entry/exit-bar grid sweep works; bar-period coupling does not.");
}
fn scalar_str(s: &Scalar) -> String {
match s {
Scalar::I64(v) => v.to_string(),
Scalar::F64(v) => format!("{v:.2}"),
Scalar::Bool(v) => v.to_string(),
Scalar::Timestamp(t) => t.0.to_string(),
}
}