From acf42b125cf9ea3772febc1b5697ee19e4e5817a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 19:08:58 +0000 Subject: [PATCH 1/3] fix(engine): harden tick price and window parsing against bad input Only `size` was validated at parse; `price` was not, yet it feeds VWAP and the log-return `ln(price / prev)`. A zero, negative or non-finite price produced `inf`/`NaN` realized volatility that was written to SQLite and later serialized as invalid JSON. Add a `finite_positive_price` serde validator mirroring `positive_size`, closing the same class of latent hole the zero-size fix already addressed, and document the now-guaranteed invariant at the log-return use site. Also guard `parse_window` with `checked_mul`: a large `--window` overflowed i64 and silently wrapped in release builds (the demo/build path). It now returns a clear "too large" error instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ldy7vSi5nzVnDygUYUHV6P --- engine/src/main.rs | 11 +++++++++- engine/src/metrics.rs | 3 +++ engine/src/tick.rs | 48 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/engine/src/main.rs b/engine/src/main.rs index 62209e0..13d71b3 100644 --- a/engine/src/main.rs +++ b/engine/src/main.rs @@ -59,7 +59,9 @@ fn parse_window(input: &str) -> anyhow::Result { .parse() .with_context(|| format!("invalid window '{input}'"))?; anyhow::ensure!(value > 0, "window must be positive, got '{input}'"); - Ok(value * unit_ns) + value + .checked_mul(unit_ns) + .with_context(|| format!("window '{input}' is too large")) } #[cfg(test)] @@ -79,4 +81,11 @@ mod tests { assert!(parse_window(bad).is_err(), "expected error for {bad}"); } } + + #[test] + fn rejects_overflowing_window() { + // i64 nanoseconds overflow far below i64::MAX minutes; the multiply + // must be checked rather than wrap (release builds don't panic on it). + assert!(parse_window("9223372036854775807m").is_err()); + } } diff --git a/engine/src/metrics.rs b/engine/src/metrics.rs index 5ee6427..3c1f615 100644 --- a/engine/src/metrics.rs +++ b/engine/src/metrics.rs @@ -87,6 +87,9 @@ pub fn compute(ticks: &[Tick], window_ns: i64) -> Vec { acc.trade_count += 1; acc.price_size_sum += tick.price * f64::from(tick.size); if let Some(prev) = prev_price { + // Both prices are parse-validated positive and finite (see + // `tick::finite_positive_price`), so the ratio is positive and its + // log is a real, finite number — never `NaN`/`inf`. let log_return = (tick.price / prev).ln(); acc.sum_sq_returns += log_return * log_return; } diff --git a/engine/src/tick.rs b/engine/src/tick.rs index 92fdbc6..cfca04c 100644 --- a/engine/src/tick.rs +++ b/engine/src/tick.rs @@ -7,6 +7,11 @@ use serde::Deserialize; #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] pub struct Tick { pub timestamp_ns: i64, + /// Trade print price. Validated positive and finite when the tape is parsed: + /// it feeds VWAP and the log-return realized volatility, so a zero, negative + /// or non-finite price would make those metrics `inf`/`NaN` and poison the + /// `SQLite` write — the same failure mode a zero `size` causes for OFI/VWAP. + #[serde(deserialize_with = "finite_positive_price")] pub price: f64, /// Contracts traded. Validated positive when the tape is parsed so a /// window's total volume is never zero (which would make OFI and VWAP @@ -42,6 +47,26 @@ where Ok(size) } +/// Deserialize a tick `price`, rejecting non-positive and non-finite values. +/// +/// The price feeds VWAP's numerator and the log return `ln(price / prev)` in +/// [`crate::metrics::compute`]. A zero, negative or non-finite (`NaN`/`inf`) +/// price would make realized volatility `inf`/`NaN`, which then poisons the +/// `SQLite` write. Rejecting it at parse time — with row context from the CSV +/// reader — keeps those metrics well-defined, mirroring [`positive_size`]. +fn finite_positive_price<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let price = f64::deserialize(deserializer)?; + if !(price.is_finite() && price > 0.0) { + return Err(serde::de::Error::custom(format!( + "price must be a positive, finite number, got {price}" + ))); + } + Ok(price) +} + /// Read a tick tape from a CSV file with the header /// `timestamp_ns,price,size,aggressor`. pub fn read_csv(path: &Path) -> Result, csv::Error> { @@ -106,4 +131,27 @@ mod tests { let csv = "timestamp_ns,price,size,aggressor\n1000,21400.00,-1,B\n"; assert!(read_from(csv.as_bytes()).is_err()); } + + #[test] + fn rejects_zero_price() { + let csv = "timestamp_ns,price,size,aggressor\n\ + 1000,0,3,B\n"; + let err = read_from(csv.as_bytes()).unwrap_err(); + assert!( + err.to_string() + .contains("price must be a positive, finite number"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_negative_price() { + let csv = "timestamp_ns,price,size,aggressor\n1000,-21400.00,3,B\n"; + let err = read_from(csv.as_bytes()).unwrap_err(); + assert!( + err.to_string() + .contains("price must be a positive, finite number"), + "unexpected error: {err}" + ); + } } From 5b905ab8a7780d6e8021632a7091fc9a3eeda0c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 19:09:12 +0000 Subject: [PATCH 2/3] ci: guard the cross-language pipeline with an integration job The three per-stack jobs never exercised scripts/demo.sh or scripts/verify_pipeline.sh, so the flagship `make demo` path could break while CI stayed green. Add an `integration` job that runs verify_pipeline.sh (tape -> Rust engine -> metrics DB -> journal seed -> regime join); no Anthropic key required. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ldy7vSi5nzVnDygUYUHV6P --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01eb81c..3e03db5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,20 @@ jobs: - run: npm run typecheck - run: npm test - run: npm run build + + integration: + name: integration (pipeline) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: engine + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + # End-to-end smoke test of the cross-language pipeline (tape -> Rust engine + # -> metrics DB -> journal seed -> regime join). No Anthropic key needed; + # guards the `make demo`/`make verify` path that the per-stack jobs miss. + - run: bash scripts/verify_pipeline.sh From d25606ea030513709c2e87a12afa35d994d6109d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 19:09:12 +0000 Subject: [PATCH 3/3] fix(dashboard): color tokens, dead-filter and empty-state polish - Move the raw severity hex (#fab219/#ec835a) into --severity-* tokens in globals.css, honoring the "never raw hex in components" rule. - Drop the dead `vwap !== null` filter in VwapChart (VolumeRow.vwap is a non-null number from the engine's NOT NULL column). - Include volatility in the metrics empty-state check so all three series gate it, not just ofi/volume. - Document BACKEND_URL (the dashboard's API proxy target) in .env.example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ldy7vSi5nzVnDygUYUHV6P --- .env.example | 5 +++++ dashboard/src/app/globals.css | 3 +++ dashboard/src/app/page.tsx | 4 +++- dashboard/src/components/AnalysisPanel.tsx | 4 ++-- dashboard/src/components/charts/VwapChart.tsx | 1 - 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 810119d..e5a7615 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ ANTHROPIC_API_KEY= # Optional. Path to the SQLite database (metrics + journal). Defaults to metrics.db. # METRICS_DB=metrics.db + +# Optional. Where the dashboard proxies its /api/backend/* requests (read by +# dashboard/next.config.ts). Defaults to http://localhost:8000; demo.sh sets it +# automatically. Override only when the API runs on a different host/port. +# BACKEND_URL=http://localhost:8000 diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index f84690c..046bd39 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -18,6 +18,9 @@ --series-buy: #3987e5; --series-sell: #e66767; --series-1: #3987e5; + /* Severity dots for the behavioral-analysis panel (low reuses --muted). */ + --severity-medium: #fab219; + --severity-high: #ec835a; } @theme inline { diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 5f9550f..b98d064 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -88,7 +88,9 @@ export default function MetricsPage() { error={firstError} hint={firstError.status === 503 ? ENGINE_HINT : undefined} /> - ) : ofi.data?.length === 0 && volume.data?.length === 0 ? ( + ) : ofi.data?.length === 0 && + volatility.data?.length === 0 && + volume.data?.length === 0 ? ( ) : ( <> diff --git a/dashboard/src/components/AnalysisPanel.tsx b/dashboard/src/components/AnalysisPanel.tsx index 4a52f98..45d9326 100644 --- a/dashboard/src/components/AnalysisPanel.tsx +++ b/dashboard/src/components/AnalysisPanel.tsx @@ -8,8 +8,8 @@ import { analyzeJournal, ApiError, type BehavioralAnalysis, type Severity } from * the dot is the icon, so severity never rides on color alone. */ const SEVERITY_DOT: Record = { low: "var(--muted)", - medium: "#fab219", - high: "#ec835a", + medium: "var(--severity-medium)", + high: "var(--severity-high)", }; function SeverityBadge({ severity }: { severity: Severity }) { diff --git a/dashboard/src/components/charts/VwapChart.tsx b/dashboard/src/components/charts/VwapChart.tsx index c433007..ae1ea06 100644 --- a/dashboard/src/components/charts/VwapChart.tsx +++ b/dashboard/src/components/charts/VwapChart.tsx @@ -28,7 +28,6 @@ import { * measures of different scale never share one chart (no dual axes). */ export function VwapChart({ rows }: { rows: VolumeRow[] }) { const data = rows - .filter((row) => row.vwap !== null) .map((row) => ({ t: nsToMs(row.bucket_start_ns), vwap: row.vwap })) .reverse();