Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions dashboard/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<EmptyState title="No metric buckets yet" body={ENGINE_HINT} />
) : (
<>
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/components/AnalysisPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Severity, string> = {
low: "var(--muted)",
medium: "#fab219",
high: "#ec835a",
medium: "var(--severity-medium)",
high: "var(--severity-high)",
};

function SeverityBadge({ severity }: { severity: Severity }) {
Expand Down
1 change: 0 additions & 1 deletion dashboard/src/components/charts/VwapChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
11 changes: 10 additions & 1 deletion engine/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ fn parse_window(input: &str) -> anyhow::Result<i64> {
.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)]
Expand All @@ -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());
}
}
3 changes: 3 additions & 0 deletions engine/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub fn compute(ticks: &[Tick], window_ns: i64) -> Vec<MetricsBucket> {
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;
}
Expand Down
48 changes: 48 additions & 0 deletions engine/src/tick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<f64, D::Error>
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<Vec<Tick>, csv::Error> {
Expand Down Expand Up @@ -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}"
);
}
}
Loading