2c43296c2c
Iteration 1 of the Stage-1 "R-based signal quality" cycle (spec 0065): a strategy's signal quality, measured in R on its unsized bias stream, feed-forward. New discrete-trade machinery, NOT a SimBroker extension. What lands: - exposure -> bias (refs #126): the Exposure node/type/file/output-field renamed to Bias (computation unchanged: clamp(signal/scale,-1,+1); the SEMANTICS change — the output is the unsized strategy bias, sizing leaves the strategy). Scoped to the semantic core; behaviour-preserving cosmetics are deferred (see below). - stop-rule nodes (refs #119): VolStop = k*EMA(|price - prev_price|) (one fused node; the volatility that defines 1R, close-to-close — true-range ATR deferred, needs OHLC) + FixedStop (constant, the test fixture / fixed-vs-vol structural-axis sibling). - PositionManagement (refs #127): the stateful heart. Latches the entry-cycle stop distance as the FROZEN R-denominator (never re-read), marks/exits no-look-ahead like SimBroker (a position earns from the next cycle; an exit realises against the cycle's close), and emits ONE dense per-cycle R-record (C8). Stop-outs are NOT capped at -1R (a gap through the stop realises R < -1 — the honest loss tail); R is computed size-invariantly (size = (exit-entry)*dir / latched_dist cancels). The trade ledger is the rows where closed_this_cycle; the R-equity is cum_realized + unrealized; a position open at window end is the last row (open=true) — explicit, never silent MtM. - summarize_r + RMetrics (refs #129): the post-run fold (NOT an in-graph node — recorders drain post-run). E[R], win-rate, profit-factor, avg win/loss R, by-trade max R-drawdown, n_open_at_end (window-end force-close). SQN / conviction terciles / net-of-cost / RunMetrics.r are iteration 2. Design adversarially hardened before implementation (12-juror refute panel; decisions on #117). Ratified implementer deviations from the plan snippet, verified by hand: - col-4 `direction` tracks the OPEN position at cycle end (window-end synthesis; names the reopened leg on a reversal), falling back to the closed trade's dir only when flat. summarize_r does not read col 4; the R-metrics are unaffected. - .named("exposure") kept on the 4 previously-unnamed Bias nodes so the auto-derived param-path stays exposure.scale (no manifest/dir-name drift) — the unnamed nodes would otherwise flip to bias.scale. - intra-doc links [`Exposure`] -> [`Bias`] in sim_broker/latch + the sample-model test pin (cosmetic, behaviour-neutral; broken intra-doc links fail cargo doc). - the E2E drives a full bootstrapped Harness (stronger than the plan's direct-node drive — exercises the real cross-crate producer->consumer seam). Deferred (behaviour-preserving, separate cosmetic pass — NOT this iteration): RunMetrics.exposure_sign_flips / Metric::ExposureSignFlips, SimBroker/LongOnly "exposure" input ports, the "exposure" tap/trace names, and the .named("exposure") instance labels. Iteration 2: the Sizer seam, the RiskExecutor composite (+ the Veto documented-seam), summarize_r enrichment, RunMetrics.r, and the CLI/recording surface. Verification (orchestrator-run, not agent-claimed): - cargo build --workspace: clean. - cargo test --workspace: all green, 0 failed (incl. the new bias/stop_rule/ position_management unit tests, the summarize_r arithmetic tests, and the stage1_r_e2e capstone + layout guard). - cargo clippy --workspace --all-targets -- -D warnings: clean (exit 0). - Keystone RED tests pass: no_lookahead_bias_exit_realises_the_held_move, stop_out_is_not_capped_at_minus_one_r, no_gap_stop_is_exactly_minus_one_r, reversal_closes_one_leg_and_reopens, open_at_window_end_is_carried_on_the_last_row. refs #117 #119 #126 #127 #129
343 lines
16 KiB
Rust
343 lines
16 KiB
Rust
//! Self-contained HTML graph viewer assembly (C9: read-only render path).
|
|
//!
|
|
//! `render_html` walks the blueprint into the deterministic JSON model
|
|
//! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the
|
|
//! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The
|
|
//! emitted page fetches nothing at view time. No `eval`/build is on this path
|
|
//! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`).
|
|
|
|
use aura_engine::Composite;
|
|
|
|
/// The shared `<head>` (charset, viewport, `<style>`) + the opening `<body>` tag,
|
|
/// up to the per-page `<title>` slot. Derived from the prototype
|
|
/// (`docs/design/prototypes/graph-render/index.html` lines 1-38), minus the two
|
|
/// `<script src="./…">` tags (the assets are inlined). The `{title}` placeholder
|
|
/// and the per-page `<header>` let one shell serve both the graph and the chart
|
|
/// page without one borrowing the other's label.
|
|
const SHELL_HEAD: &str = r#"<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{title}</title>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
|
|
font-family: ui-monospace, monospace; }
|
|
header { padding: 8px 14px; border-bottom: 1px solid #313244; font-size: 13px;
|
|
display: flex; gap: 16px; align-items: baseline; flex-wrap: nowrap;
|
|
white-space: nowrap; overflow: hidden; }
|
|
header b { color: #f5e0dc; } .sub { color: #6c7086; }
|
|
#crumb a { color: #89b4fa; cursor: pointer; text-decoration: none; }
|
|
#crumb a:hover { text-decoration: underline; }
|
|
#crumb .sep { color: #6c7086; }
|
|
#status { color: #6c7086; margin-left: auto; }
|
|
#stage { width: 100%; height: calc(100% - 44px); }
|
|
#stage svg { width: 100%; height: 100%; cursor: default; }
|
|
#stage svg text { cursor: inherit; user-select: none; -webkit-user-select: none; }
|
|
#stage svg a { cursor: inherit; }
|
|
#err { padding: 14px; color: #f38ba8; white-space: pre-wrap; }
|
|
#tip { position: fixed; display: none; pointer-events: none; z-index: 10;
|
|
background: #1e1e2e; border: 1px solid #585b70; border-radius: 6px; padding: 6px 9px;
|
|
font-family: ui-monospace, monospace; font-size: 12px; color: #cdd6f4;
|
|
max-width: 420px; box-shadow: 0 6px 18px rgba(0,0,0,.55); line-height: 1.5; }
|
|
#tip b { color: #f5e0dc; } #tip code { color: #89b4fa; } #tip .dim { color: #7f849c; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
"#;
|
|
|
|
/// The graph page's `<title>` text and `<header>` line (hover/expand/drill hint),
|
|
/// graph-specific — split out of the shared shell.
|
|
const GRAPH_HEAD: &str = r#"<header>
|
|
<b>aura graph</b>
|
|
<span id="crumb"></span>
|
|
<span class="sub">hover · [+] expand · body drill</span>
|
|
</header>
|
|
<div id="stage"></div>
|
|
<div id="tip"></div>
|
|
<pre id="err"></pre>
|
|
"#;
|
|
|
|
/// The chart page's `<title>` text and `<header>` line — describes what the chart
|
|
/// view actually renders (timestamp-aligned trace series), not the graph viewer. The
|
|
/// `#xmode-toggle` button is wired by `chart-viewer.js` (`mount`) to flip the x-axis
|
|
/// between the ordinal (gap-collapsed) default and continuous (real-time) spacing.
|
|
const CHART_HEAD: &str = r#"<header>
|
|
<b>aura chart</b>
|
|
<span class="sub">timestamp-aligned trace series</span>
|
|
<span id="ctx" class="sub"></span>
|
|
<button id="xmode-toggle" type="button">x: collapsed (gaps off)</button>
|
|
</header>
|
|
<div id="stage"></div>
|
|
<pre id="err"></pre>
|
|
"#;
|
|
|
|
const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
|
|
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
|
|
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
|
|
|
const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js");
|
|
const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css");
|
|
const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js");
|
|
|
|
/// Chart-page-only styling (the `#xmode-toggle` button) — injected by
|
|
/// `render_chart_html` after the vendored uPlot CSS, NOT into the shared `SHELL_HEAD`
|
|
/// (the graph page has no toggle and must not inherit its rule).
|
|
const CHART_CSS: &str = r#"
|
|
#xmode-toggle { margin-left: auto; background: #1e1e2e; color: #cdd6f4;
|
|
border: 1px solid #313244; border-radius: 6px; padding: 4px 10px;
|
|
font-family: ui-monospace, monospace; font-size: 12px; cursor: pointer; }
|
|
#xmode-toggle:hover { border-color: #89b4fa; color: #f5e0dc; }
|
|
"#;
|
|
|
|
/// Assemble the self-contained interactive graph viewer page for `root`.
|
|
///
|
|
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
|
|
/// `svgPanZoom`) and the model (`window.AURA_MODEL`) must be defined before the
|
|
/// viewer script runs.
|
|
pub fn render_html(root: &Composite) -> String {
|
|
let model = aura_engine::model_to_json(root);
|
|
let mut html = String::with_capacity(
|
|
SHELL_HEAD.len() + GRAPH_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
|
);
|
|
html.push_str(&SHELL_HEAD.replace("{title}", "aura graph"));
|
|
html.push_str(GRAPH_HEAD);
|
|
html.push_str("<script>");
|
|
html.push_str(VIZ_JS);
|
|
html.push_str("</script>\n<script>");
|
|
html.push_str(PANZOOM_JS);
|
|
html.push_str("</script>\n<script>window.AURA_MODEL = ");
|
|
html.push_str(&model);
|
|
html.push_str(";</script>\n<script>");
|
|
html.push_str(VIEWER_JS);
|
|
html.push_str("</script>\n</body>\n</html>\n");
|
|
html
|
|
}
|
|
|
|
/// Which way the chart draws the (timestamp-aligned) series.
|
|
#[derive(Clone, Copy)]
|
|
pub enum ChartMode {
|
|
/// One plot, all series superimposed, each on its own y-scale.
|
|
Overlay,
|
|
/// One stacked plot per series, all sharing the timestamp x-axis.
|
|
Panels,
|
|
}
|
|
|
|
/// How `decimate` reduces a series within each bucket (#111). An *envelope* series
|
|
/// (a cumulative equity curve) keeps its min+max so drawdown spikes survive; a
|
|
/// *level* series (the bounded exposure, f64 ∈ [-1,+1]) takes the per-bucket mean,
|
|
/// so a high-flip signal shows its net/duty-cycle level instead of collapsing to a
|
|
/// -1..+1 band (every bucket of a multi-year exposure straddles many flips, so
|
|
/// min/max would be ±1 everywhere). Server-side only — never serialized into the
|
|
/// page (the viewer reads decimated points, not how they were reduced). Rendering
|
|
/// min/max as range bars / OHLC is a separate follow-up; this only fixes the
|
|
/// reduction.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub enum ReduceKind {
|
|
/// Per-bucket min and max — the envelope (equity, unbounded cumulative).
|
|
#[default]
|
|
MinMax,
|
|
/// Per-bucket mean — the net level (exposure, bounded ∈ [-1,+1]).
|
|
Mean,
|
|
}
|
|
|
|
/// One chart series: a name, its own y-scale id (so disparate magnitudes stay
|
|
/// legible), one `Option<f64>` per shared-x slot (`null` = a gap where the tap did
|
|
/// not fire at that timestamp), and how `decimate` reduces it per bucket.
|
|
#[derive(serde::Serialize)]
|
|
pub struct Series {
|
|
pub name: String,
|
|
pub y_scale_id: String,
|
|
pub points: Vec<Option<f64>>,
|
|
/// Per-bucket reduction for `decimate` (#111); server-side only, not part of the
|
|
/// served payload.
|
|
#[serde(skip)]
|
|
pub reduce: ReduceKind,
|
|
}
|
|
|
|
/// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta`
|
|
/// and rendered client-side by `chart-viewer.js`'s pure `buildHeader`. A single run
|
|
/// carries its manifest fields + bound params; a family carries its shared identity
|
|
/// (commit/broker — one frozen artifact, one broker profile) plus the SPANNING
|
|
/// window across its members (see the `window` field) + member count — per-member
|
|
/// identity is the series label (`member.key`), not repeated here.
|
|
#[derive(Default, serde::Serialize)]
|
|
pub struct ChartMeta {
|
|
/// "run" | "family".
|
|
pub kind: String,
|
|
/// The chart name (run name or family id).
|
|
pub name: String,
|
|
/// Git commit of the run's frozen artifact (C18); `buildHeader` shows the short form.
|
|
pub commit: String,
|
|
/// Inclusive data window `(from, to)` epoch-ns. For a single run, the run's
|
|
/// manifest window. For a family, the SPAN across all members —
|
|
/// `(min member.from, max member.to)` — NOT the first member's window: a
|
|
/// walk-forward family's members are disjoint OOS windows, so only the span
|
|
/// labels the family's true coverage (and for sweep/MC, whose members share a
|
|
/// window, the span collapses to it).
|
|
pub window: (i64, i64),
|
|
/// Broker profile label, e.g. "sim-optimal(pip_size=1)".
|
|
pub broker: String,
|
|
/// RNG seed (0 for a seed-free synthetic run).
|
|
pub seed: u64,
|
|
/// The charted taps (single run: all charted taps or the `--tap` pick; family:
|
|
/// the one compared tap).
|
|
pub taps: Vec<String>,
|
|
/// Family member count; `None` for a single run.
|
|
pub members: Option<usize>,
|
|
/// Bound params (single run only; empty for a family, where params are the
|
|
/// per-member series labels). Stringified `name -> display` for the header.
|
|
pub params: Vec<(String, String)>,
|
|
}
|
|
|
|
/// The serve-ready chart payload: the shared timestamp axis, the per-(tap,column)
|
|
/// series, and the run-context `meta`. These are exactly the fields `chart-viewer.js`
|
|
/// reads; injected verbatim as `window.AURA_TRACES` (plus the `mode`). `meta` carries
|
|
/// the manifest context for the header (#102); the full manifest stays on disk.
|
|
pub struct ChartData {
|
|
pub xs: Vec<i64>,
|
|
pub series: Vec<Series>,
|
|
pub meta: ChartMeta,
|
|
}
|
|
|
|
/// Assemble the self-contained uPlot chart page for one run's aligned traces.
|
|
///
|
|
/// Order is load-bearing (mirrors `render_html`): the uPlot global and CSS, then the
|
|
/// injected `window.AURA_TRACES`, must be defined before `chart-viewer.js` runs.
|
|
pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
|
|
let mode_str = match mode {
|
|
ChartMode::Overlay => "overlay",
|
|
ChartMode::Panels => "panels",
|
|
};
|
|
let traces = serde_json::json!({
|
|
"mode": mode_str,
|
|
"xs": data.xs,
|
|
"series": data.series,
|
|
"meta": data.meta,
|
|
})
|
|
.to_string();
|
|
|
|
let mut html = String::with_capacity(
|
|
SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_CSS.len() + CHART_VIEWER_JS.len() + traces.len() + 256,
|
|
);
|
|
html.push_str(&SHELL_HEAD.replace("{title}", "aura chart"));
|
|
html.push_str(CHART_HEAD);
|
|
html.push_str("<style>");
|
|
html.push_str(UPLOT_CSS);
|
|
html.push_str(CHART_CSS);
|
|
html.push_str("</style>\n<script>");
|
|
html.push_str(UPLOT_JS);
|
|
html.push_str("</script>\n<script>window.AURA_TRACES = ");
|
|
html.push_str(&traces);
|
|
html.push_str(";</script>\n<script>");
|
|
html.push_str(CHART_VIEWER_JS);
|
|
html.push_str("</script>\n</body>\n</html>\n");
|
|
html
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::sample_blueprint;
|
|
|
|
/// `aura graph`'s page is self-contained (no remote fetch), embeds the
|
|
/// deterministic model as the viewer's data source, carries the Graphviz-WASM
|
|
/// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel:
|
|
/// the DOT/SVG are Graphviz-version-dependent and are exercised by hand
|
|
/// against the prototype.
|
|
#[test]
|
|
fn render_html_is_self_contained_and_embeds_the_model() {
|
|
let html = render_html(&sample_blueprint());
|
|
// a complete HTML document
|
|
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
|
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
|
// the graph page wears its own label (not the chart's)
|
|
assert!(html.contains("<title>aura graph</title>"), "graph page title missing");
|
|
assert!(html.contains("<b>aura graph</b>"), "graph header label missing");
|
|
// self-contained: every asset is inlined, no remote <script src>
|
|
assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
|
|
// the deterministic model is injected as the viewer's data source
|
|
assert!(html.contains("window.AURA_MODEL = {\"root\":"), "model JSON not injected");
|
|
// a known node from the sample harness round-trips via the model
|
|
assert!(html.contains("\"type\":\"Bias\""), "sample model content missing");
|
|
// the vendored Graphviz-WASM is present (its bootstrap global)
|
|
assert!(html.contains("Viz.instance"), "viewer bootstrap (Viz.instance) missing");
|
|
// C4: the four-scalar palette, no fifth colour
|
|
assert!(
|
|
html.contains("i64: \"#f9e2af\"") && html.contains("timestamp: \"#cba6f7\""),
|
|
"C4 four-colour palette missing"
|
|
);
|
|
// the chart-only toggle control must not leak onto the graph page
|
|
assert!(!html.contains("xmode-toggle"), "chart toggle leaked onto the graph page");
|
|
}
|
|
|
|
fn sample_chart_data() -> ChartData {
|
|
ChartData {
|
|
xs: vec![1, 2, 3],
|
|
series: vec![
|
|
Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)], reduce: ReduceKind::MinMax },
|
|
Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)], reduce: ReduceKind::Mean },
|
|
],
|
|
meta: ChartMeta {
|
|
kind: "run".into(),
|
|
name: "sample".into(),
|
|
commit: "abc1234def".into(),
|
|
window: (1, 3),
|
|
broker: "sim-optimal(pip_size=1)".into(),
|
|
seed: 0,
|
|
taps: vec!["equity".into(), "exposure".into()],
|
|
members: None,
|
|
params: vec![],
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_chart_html_is_self_contained_and_injects_traces() {
|
|
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
|
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
|
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
|
// self-contained: no remote asset
|
|
assert!(!html.contains("<script src"), "assets must be inlined");
|
|
// data injected as the viewer's source, with the mode reflected
|
|
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
|
assert!(html.contains("\"mode\":\"overlay\""), "overlay mode not reflected");
|
|
assert!(html.contains("\"equity\""), "series name missing from injected data");
|
|
// the vendored uPlot bundle + first-party viewer are inlined
|
|
assert!(html.contains("uPlot=function"), "vendored uPlot bundle missing");
|
|
assert!(html.contains("buildCharts"), "chart-viewer.js not inlined");
|
|
}
|
|
|
|
/// The chart page labels itself as a chart, never borrowing the graph viewer's
|
|
/// `<title>`/header/breadcrumb — a served chart must not claim to be a graph.
|
|
#[test]
|
|
fn render_chart_html_wears_its_own_chart_label() {
|
|
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
|
assert!(html.contains("<title>aura chart</title>"), "chart page title not chart-specific");
|
|
assert!(html.contains("<b>aura chart</b>"), "chart header label not chart-specific");
|
|
// the x-axis toggle control is part of the chart chrome
|
|
assert!(html.contains("id=\"xmode-toggle\""), "x-axis toggle control missing from the chart page");
|
|
// none of the graph viewer's chrome leaked in
|
|
assert!(!html.contains("aura graph"), "graph label leaked onto the chart page");
|
|
assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
|
|
}
|
|
|
|
/// `window.AURA_TRACES.meta` now carries the run-context the header reads
|
|
/// (#102): the run's commit / window / broker / taps, threaded from the
|
|
/// manifest (this flipped the earlier "manifest is NOT carried" pin).
|
|
#[test]
|
|
fn render_chart_html_injects_run_context() {
|
|
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
|
assert!(html.contains("\"meta\""), "run-context meta not injected");
|
|
assert!(html.contains("abc1234def"), "manifest commit not injected");
|
|
assert!(html.contains("sim-optimal(pip_size=1)"), "manifest broker not injected");
|
|
assert!(html.contains("\"kind\":\"run\""), "meta kind not injected");
|
|
}
|
|
|
|
#[test]
|
|
fn render_chart_html_reflects_panels_mode() {
|
|
let html = render_chart_html(&sample_chart_data(), ChartMode::Panels);
|
|
assert!(html.contains("\"mode\":\"panels\""), "panels mode not reflected");
|
|
}
|
|
}
|