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/.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
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();
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}"
+ );
+ }
}