{"id":48595277,"url":"https://github.com/Mattbusel/fin-stream","last_synced_at":"2026-04-24T14:01:08.104Z","repository":{"id":342874982,"uuid":"1175487115","full_name":"Mattbusel/fin-stream","owner":"Mattbusel","description":"Real-time market data streaming primitives — 100K+ ticks/second ingestion pipeline","archived":false,"fork":false,"pushed_at":"2026-03-23T11:03:49.000Z","size":252288,"stargazers_count":4,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-20T07:37:44.902Z","etag":null,"topics":["finance","rust","streaming","trading"],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Mattbusel.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-07T19:24:40.000Z","updated_at":"2026-04-16T23:11:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Mattbusel/fin-stream","commit_stats":null,"previous_names":["mattbusel/fin-stream"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/Mattbusel/fin-stream","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Mattbusel%2Ffin-stream","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Mattbusel%2Ffin-stream/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Mattbusel%2Ffin-stream/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Mattbusel%2Ffin-stream/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Mattbusel","download_url":"https://codeload.github.com/Mattbusel/fin-stream/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Mattbusel%2Ffin-stream/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32226408,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-24T13:21:15.438Z","status":"ssl_error","status_checked_at":"2026-04-24T13:21:15.005Z","response_time":64,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["finance","rust","streaming","trading"],"created_at":"2026-04-08T21:00:31.108Z","updated_at":"2026-04-24T14:01:08.092Z","avatar_url":"https://github.com/Mattbusel.png","language":"Rust","funding_links":[],"categories":["Market Data \u0026 Data Sources"],"sub_categories":[],"readme":"## Real-Time Risk Metrics\n\nThe `risk` module computes rolling risk metrics from a price tick window.\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `RollingRisk` | Per-symbol rolling window of prices (`VecDeque\u003cf64\u003e`); all metrics computed on demand |\n| `RiskSnapshot` | Point-in-time snapshot: `volatility`, `var_95`, `max_drawdown`, `sharpe`, `last_price` |\n| `RiskMonitor` | `DashMap`-backed concurrent monitor; `update(symbol, price)` + `risk_snapshot(symbol)` |\n\n### Metrics\n\n| Method | Formula |\n|--------|---------|\n| `volatility_annualized()` | `std_dev(log_returns) × √(252 × ticks_per_day)` |\n| `var_historical(confidence)` | Historical simulation: `(1−confidence)` percentile of sorted log returns |\n| `max_drawdown()` | `max((peak − trough) / peak)` over rolling window |\n| `sharpe(rf_daily)` | `(mean_return − rf) / std_dev × √252` (annualized) |\n| `portfolio_var(weights)` | Weighted sum: `Σ weight_i × VaR_i` |\n\n---\n\n## Trade Classifier\n\nThe `classifier` module implements the Lee-Ready (1991) algorithm for buyer/seller-initiated\ntrade classification from tick data.\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `Quote` | `bid`, `ask`, `mid = (bid+ask)/2` — prevailing quote at trade time |\n| `TradeClass` | `BuyInitiated`, `SellInitiated`, `Unknown` |\n| `ClassifiedTick` | Enriches `NormalizedTick` with `class` and `quote_at_trade` |\n| `LeeReadyClassifier` | Stateful classifier; maintains previous trade price for tick-test fallback |\n| `TradeFlowAccumulator` | Rolling window of `ClassifiedTick`s; emits `TradeFlowMetrics` |\n| `TradeFlowMetrics` | `buy_volume`, `sell_volume`, `buy_count`, `sell_count`, `order_imbalance` |\n\n### Lee-Ready Algorithm\n\n```\nprice \u003e quote_mid  →  BuyInitiated\nprice \u003c quote_mid  →  SellInitiated\nprice == quote_mid →  tick test:\n    price \u003e prev_price  →  BuyInitiated\n    price \u003c prev_price  →  SellInitiated\n    otherwise           →  Unknown\n```\n\n### Order Imbalance\n\n```\nOI = (buy_volume − sell_volume) / (buy_volume + sell_volume)   ∈ [−1, +1]\n```\n\n---\n\n[![CI](https://github.com/Mattbusel/fin-stream/actions/workflows/ci.yml/badge.svg)](https://github.com/Mattbusel/fin-stream/actions/workflows/ci.yml)\n[![Crates.io](https://img.shields.io/crates/v/fin-stream.svg)](https://crates.io/crates/fin-stream)\n[![docs.rs](https://img.shields.io/docsrs/fin-stream)](https://docs.rs/fin-stream)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![codecov](https://codecov.io/gh/Mattbusel/fin-stream/branch/main/graph/badge.svg)](https://codecov.io/gh/Mattbusel/fin-stream)\n[![Rust MSRV](https://img.shields.io/badge/MSRV-1.75-orange.svg)](https://blog.rust-lang.org/2023/12/28/Rust-1.75.0.html)\n\n# fin-stream\n\nLock-free streaming primitives for real-time financial market data. Provides a\ncomposable ingestion pipeline from raw exchange ticks to normalized, transformed\nfeatures ready for downstream models or trade execution. Built on Tokio. Targets\n100 K+ ticks/second throughput with zero heap allocation on the fast path.\n\n**v2.4.0** — 40 K+ lines of production Rust. Includes an extensive analytics\nsuite: 200+ static analytics on `NormalizedTick`, 200+ on `OhlcvBar`, and 80+\nrolling-window analytics on `MinMaxNormalizer` and `ZScoreNormalizer` (rounds 1–88).\n\n## Statistical Arbitrage\n\nThe `statarb` module detects cointegrated price pairs and emits spread-based trading signals.\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `PairCointegration` | Stateless cointegration tester |\n| `CointegrationResult` | `spread_mean`, `spread_std`, `half_life`, `is_cointegrated`, `adf_statistic` |\n| `StatArbDetector` | Tracks multiple pairs in real-time with rolling price history |\n| `SpreadMonitor` | `symbol_a`, `symbol_b`, `hedge_ratio`, `z_score`, `signal: StatArbSignal` |\n| `StatArbSignal` | `Long` (z \u003c -2), `Short` (z \u003e 2), `Exit` (|z| \u003c 0.5), `Neutral` |\n\n### Algorithm\n\n1. Hedge ratio estimated via OLS: `a = alpha + beta * b`\n2. Spread: `s = a - hedge_ratio * b`\n3. Simplified ADF: regress `Δs` on `s_{t-1}`; cointegrated if t-stat \u003c -2.86\n4. Half-life: `hl = -ln(2) / beta` from mean-reversion OLS\n5. Z-score: `(current_spread - spread_mean) / spread_std`\n\n### Usage\n\n```rust\nlet mut det = StatArbDetector::new(50, 200);\ndet.add_pair(\"AAPL\", \"MSFT\");\ndet.update(\"AAPL\", 150.0);\ndet.update(\"MSFT\", 300.0);\nlet signals = det.signals(); // Vec\u003cSpreadMonitor\u003e\n```\n\n---\n\n## Tick Pipeline\n\nThe `pipeline` module provides a composable normalization pipeline for `NormalizedTick` streams.\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `TickPipeline` | Chains filters and transforms; `process(tick) -\u003e Option\u003cNormalizedTick\u003e` |\n| `TickFilter` | Trait: `filter(\u0026tick) -\u003e bool` |\n| `TickTransform` | Trait: `transform(tick) -\u003e NormalizedTick` |\n\n### Built-in Filters\n\n| Filter | Description |\n|--------|-------------|\n| `PriceRangeFilter { min, max }` | Drop ticks with price outside `[min, max]` |\n| `VolumeFilter { min }` | Drop ticks with quantity \u003c min |\n| `SymbolFilter { symbols }` | Drop ticks not in the allowed symbol set |\n| `StaleFilter { max_age_ms }` | Drop ticks older than `max_age_ms` milliseconds |\n\n### Built-in Transforms\n\n| Transform | Description |\n|-----------|-------------|\n| `PriceRounder { decimals }` | Round price to N decimal places |\n| `VolumeNormalizer { scale_factor }` | Multiply quantity by scale factor |\n| `TimestampAligner { granularity_ms }` | Round timestamps to nearest granularity boundary |\n\n### Usage\n\n```rust\nlet mut pipeline = TickPipeline::new();\npipeline.add_filter(PriceRangeFilter { min: dec!(100), max: dec!(100_000) });\npipeline.add_filter(VolumeFilter { min: dec!(0.01) });\npipeline.add_transform(PriceRounder { decimals: 2 });\npipeline.add_transform(TimestampAligner { granularity_ms: 1000 });\n\nif let Some(tick) = pipeline.process(raw_tick) {\n    // tick is filtered and transformed\n}\n```\n\n---\n\n## Order Book Reconstruction\n\nThe `orderbook` module provides a high-performance L2 order book backed by `BTreeMap\u003cOrdF64, f64\u003e`.\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `OrdF64` | `f64` wrapper with `Ord` via `f64::total_cmp` — safe BTreeMap key |\n| `PriceLevel { price: f64, quantity: f64 }` | A single resting price level |\n| `OrderBook { symbol, bids, asks, sequence, last_updated_ms }` | Live L2 book for one symbol |\n| `BookUpdate { symbol, sequence, bids, asks }` | Incremental delta; `qty=0` removes the level |\n| `BookError::SequenceGap / StaleUpdate / CrossedBook` | Typed error variants |\n| `OrderBookManager` | `DashMap`-backed concurrent multi-symbol book manager |\n\n### Key Methods\n\n| Method | Description |\n|--------|-------------|\n| `best_bid() -\u003e Option\u003cPriceLevel\u003e` | Highest bid |\n| `best_ask() -\u003e Option\u003cPriceLevel\u003e` | Lowest ask |\n| `spread() -\u003e Option\u003cf64\u003e` | `best_ask - best_bid` |\n| `mid_price() -\u003e Option\u003cf64\u003e` | `(best_bid + best_ask) / 2` |\n| `depth(n) -\u003e (Vec\u003cPriceLevel\u003e, Vec\u003cPriceLevel\u003e)` | Top N bid/ask levels |\n| `imbalance() -\u003e f64` | `(bid_qty_top5 - ask_qty_top5) / (bid_qty_top5 + ask_qty_top5)` |\n| `apply_update(\u0026BookUpdate) -\u003e Result\u003c(), BookError\u003e` | Validates sequence, applies delta, checks crossed book |\n\n### Quick Example\n\n```rust\nuse fin_stream::orderbook::{OrderBook, BookUpdate};\n\nlet mut book = OrderBook::new(\"BTCUSDT\");\nbook.apply_update(\u0026BookUpdate {\n    symbol: \"BTCUSDT\".into(),\n    sequence: 1,\n    bids: vec![(29_999.0, 5.0), (29_998.0, 10.0)],\n    asks: vec![(30_001.0, 3.0), (30_002.0, 8.0)],\n}).unwrap();\n\nprintln!(\"Spread: {:.2}\", book.spread().unwrap());\nprintln!(\"Imbalance: {:.3}\", book.imbalance());\n```\n\n---\n\n## Tick-to-Bar Aggregation\n\nThe `aggregator::bars` module aggregates `NormalizedTick` streams into OHLCV bars.\n\n### Bar Types (`BarSpec`)\n\n| Variant | Close condition |\n|---------|----------------|\n| `Time(Duration)` | Elapsed time since first tick ≥ duration |\n| `Tick(usize)` | N ticks accumulated |\n| `Volume(f64)` | Cumulative quantity ≥ threshold |\n| `Dollar(f64)` | Cumulative price × qty ≥ threshold |\n\n### VWAP Update (Online)\n\n```\nvwap = (vwap * cum_vol + price * qty) / (cum_vol + qty)\n```\n\n### Key Types\n\n| Type | Description |\n|------|-------------|\n| `Bar { symbol, open, high, low, close, volume, vwap, tick_count, start_ms, end_ms }` | Completed bar |\n| `BarBuilder` | Single-symbol accumulator; `push(tick) -\u003e Option\u003cBar\u003e` |\n| `BarStream` | Multi-symbol router; routes ticks to per-symbol builders |\n| `BarStreamConfig { specs: Vec\u003c(String, BarSpec)\u003e }` | Symbol-to-spec mapping |\n\n### Quick Example\n\n```rust\nuse std::time::Duration;\nuse fin_stream::aggregator::bars::{BarSpec, BarStreamConfig, BarStream};\n\nlet config = BarStreamConfig::new()\n    .add(\"BTCUSDT\", BarSpec::Tick(100))\n    .add(\"ETHUSDT\", BarSpec::Volume(10.0));\n\nlet mut stream = BarStream::new(\u0026config);\n// stream.push_tick(\u0026tick) → Some(Bar) when boundary crossed\n```\n\n---\n\n## What Is Included\n\n| Module | Purpose | Key types |\n|---|---|---|\n| `ws` | WebSocket connection lifecycle with exponential-backoff reconnect and backpressure | `WsManager`, `ConnectionConfig`, `ReconnectPolicy` |\n| `tick` | Convert raw exchange payloads (Binance/Coinbase/Alpaca/Polygon) into a single canonical form; 200+ batch analytics on tick slices | `RawTick`, `NormalizedTick`, `Exchange`, `TradeSide`, `TickNormalizer` |\n| `ring` | Lock-free SPSC ring buffer: zero-allocation hot path between normalizer and consumers | `SpscRing\u003cT, N\u003e`, `SpscProducer`, `SpscConsumer` |\n| `book` | Incremental order book delta streaming with snapshot reset and crossed-book detection | `OrderBook`, `BookDelta`, `BookSide`, `PriceLevel` |\n| `ohlcv` | Bar construction at any `Seconds / Minutes / Hours` timeframe with optional gap-fill bars; 200+ batch analytics on bar slices | `OhlcvAggregator`, `OhlcvBar`, `Timeframe` |\n| `health` | Per-feed staleness detection with configurable thresholds and a circuit-breaker | `HealthMonitor`, `FeedHealth`, `HealthStatus` |\n| `session` | Trading-status classification (Open / Extended / Closed) for US Equity, Crypto, Forex | `SessionAwareness`, `MarketSession`, `TradingStatus` |\n| `norm` | Rolling min-max and z-score normalizers for streaming observations; 80+ analytics each (moments, percentiles, entropy, trend, etc.) | `MinMaxNormalizer`, `ZScoreNormalizer` |\n| `lorentz` | Lorentz spacetime transforms for feature engineering on price-time coordinates | `LorentzTransform`, `SpacetimePoint` |\n| `correlation` | Streaming NxN Pearson correlation matrix; O(N) update via Welford's algorithm; DashMap-backed for concurrent feed updates | `StreamingCorrelationMatrix`, `CorrelationPair` |\n| `fix` | FIX 4.2 session adapter: parse/serialize frames, validate checksum (tag 10), Logon, MarketDataRequest, Snapshot/Refresh → NormalizedTick | `FixSession`, `FixParser`, `FixMessage`, `FixError` |\n| `portfolio_feed` | Multi-asset parallel WebSocket feed; JoinSet-managed per-asset WsManager tasks; exponential-backoff restart; merged tick channel | `PortfolioFeed`, `AssetFeedConfig`, `AssetFeedStats` |\n| `mev` | MEV detection scaffold: sandwich, frontrun, and backrun heuristics on tick slices; no Flashbots API required | `MevDetector`, `MevCandidate`, `MevPattern` |\n| `toxicity` | Order flow toxicity: PIN, VPIN, Kyle λ, Amihud illiquidity — four-metric smart-money detection | `OrderFlowToxicityAnalyzer`, `ToxicityMetrics`, `VpinCalculator` |\n| `ofi` | Order flow imbalance: per-tick OFI from top-of-book delta, rolling accumulator, z-score standardization, VPIN | `OrderFlowImbalance`, `OfiAccumulator`, `OfiMetricsComputer`, `ToxicityEstimator`, `VpinResult` |\n| `microstructure` | Market microstructure analytics: Amihud illiquidity, Kyle's lambda, Roll spread, bid-ask bounce, streaming monitor | `MicrostructureMonitor`, `AmihudIlliquidity`, `KyleImpact`, `RollSpread`, `BidAskBounce`, `MicrostructureReport` |\n| `regime` | Real-time market regime classification: Trending / MeanReverting / HighVol / LowVol via Hurst + ADX + realised vol | `RegimeDetector`, `MarketRegime` |\n| `synthetic` | Stochastic market data generator: GBM, jump-diffusion, OU, Heston — deterministic seeded output | `SyntheticMarketGenerator`, `GeometricBrownianMotion`, `HestonModel` |\n| `multi_exchange` | NBBO-style multi-exchange aggregation; per-exchange latency divergence tracking; arbitrage opportunity detection | `MultiExchangeAggregator`, `Nbbo`, `ArbitrageOpportunity`, `AggregatorConfig` |\n| `circuit_breaker` | WebSocket circuit breaker: exponential-backoff reconnect + degraded-mode synthetic tick emission after 5 failures | `WsCircuitBreaker`, `CircuitBreakerConfig`, `CircuitState` |\n| `anomaly` | Streaming tick anomaly detection: price spikes (z-score), volume spikes, sequence gaps, timestamp inversions | `TickAnomalyDetector`, `AnomalyEvent`, `AnomalyKind`, `AnomalyDetectorConfig` |\n| `snapshot` | Binary tick recorder and N-speed replayer for backtesting with real captured tick data | `TickRecorder`, `TickReplayer` |\n| `grpc` | gRPC streaming endpoint (`grpc` feature): expose tick stream over gRPC via tonic with per-symbol/exchange filtering | `TickStreamServer` (feature-gated) |\n| `quality` | Feed quality scoring: rolling latency percentiles, gap detection, duplicate detection, 0–100 composite score | `QualityScorer`, `FeedQualityMetrics`, `FeedGapDetector`, `TickDeduplicator`, `QualityReport` |\n| `circuit` | Per-symbol circuit breakers: halt on price spikes or volume surges; Normal/Halted/Recovering FSM; hub manages one breaker per symbol | `SymbolCircuitBreaker`, `CircuitBreakerHub`, `HaltConfig`, `HaltReason`, `CircuitDecision`, `CircuitStats` |\n| `error` | Unified typed error hierarchy covering every pipeline failure mode | `StreamError` |\n\n## Feed Quality Scoring\n\n`QualityScorer` tracks per-symbol latency, gap rate, and duplicate rate over a configurable\nrolling window, then computes a composite score in `[0, 100]`.\n\n**Score formula:**\n```\nscore = 100 × (1 − gap_rate) × (1 − duplicate_rate) × exp(−latency_p99_ms / 1000)\n```\n\n```rust\nuse fin_stream::quality::{QualityScorer, FeedGapDetector, TickDeduplicator, DeduplicatorConfig, HashFields};\n\n// 1. Quality scorer with 500-tick rolling window\nlet scorer = QualityScorer::new(500);\nlet mut gap_det = FeedGapDetector::new(2000);     // flag gaps \u003e 2 s\nlet mut dedup   = TickDeduplicator::new(DeduplicatorConfig::new(5000, HashFields::PriceQtyTimestamp), 256);\n\n// On each received tick:\nlet is_gap = gap_det.process(\"BTC-USD\", tick.received_at_ms);\nlet is_dup = dedup.check(\u0026tick) == fin_stream::quality::DedupDecision::Duplicate;\nscorer.record_tick(\"BTC-USD\", tick.received_at_ms, prev_ts, is_gap, is_dup);\n\n// Get per-symbol metrics\nif let Some(m) = scorer.metrics(\"BTC-USD\") {\n    println!(\"Score: {:.1}  p50: {:.1}ms  p99: {:.1}ms  gaps: {:.2}%\",\n        m.score, m.latency_p50_ms, m.latency_p99_ms, m.gap_rate * 100.0);\n}\n\n// Aggregated report across all symbols\nlet report = scorer.report();\nprintln!(\"System health: {:.1}\", report.system_health_score);\nprintln!(\"Worst feed: {:?}\", report.worst_feed);\n```\n\n## Symbol Circuit Breakers\n\n`SymbolCircuitBreaker` monitors each symbol independently and halts processing when\nprice moves or volume surges exceed configured thresholds.\n\n**State machine:** `Normal → Halted { until, reason } → Recovering → Normal`\n\n```rust\nuse fin_stream::circuit::{CircuitBreakerHub, HaltConfig, CircuitDecision};\nuse std::time::Duration;\n\nlet config = HaltConfig::new(\n    0.05,                       // 5 % price move threshold\n    5.0,                        // 5× volume surge threshold\n    20,                         // 20-tick rolling window\n    Duration::from_secs(60),   // 60 s halt duration\n);\nlet hub = CircuitBreakerHub::new(config);\n\n// On each tick — per-symbol breaker created automatically:\nmatch hub.process_tick(\u0026tick) {\n    CircuitDecision::Allow              =\u003e { /* forward tick downstream */ }\n    CircuitDecision::Halt(reason)       =\u003e { tracing::warn!(?reason, \"circuit halted\"); }\n    CircuitDecision::Recover            =\u003e { tracing::info!(\"circuit recovered\"); }\n}\n\n// Stats across all symbols\nlet stats = hub.stats();\nprintln!(\"Total halts: {}  Currently halted: {:?}\", stats.total_halts, stats.current_halted_symbols);\n```\n\n## Streaming Correlation Matrix\n\n`StreamingCorrelationMatrix` maintains rolling Pearson r for every pair of registered\nassets. It uses Welford's online algorithm for numerically stable mean and variance, and\nstores pairwise cross-covariance in a `DashMap` so multiple WebSocket feed tasks can call\n`update` concurrently without a global lock.\n\n```rust\nuse fin_stream::correlation::StreamingCorrelationMatrix;\n\nlet matrix = StreamingCorrelationMatrix::new(100); // 100-tick rolling window\n\n// Feed ticks from any number of concurrent WebSocket tasks:\nmatrix.update(\"BTC-USD\", 30_000.0, timestamp_ms);\nmatrix.update(\"ETH-USD\",  2_000.0, timestamp_ms);\n\n// O(1) lookup:\nif let Some(r) = matrix.correlation(\"BTC-USD\", \"ETH-USD\") {\n    println!(\"Pearson r = {r:.4}\");\n}\n\n// Full NxN snapshot:\nlet full = matrix.full_matrix(); // HashMap\u003c(String, String), f64\u003e\n\n// Diversification helpers:\nlet hedges    = matrix.top_decorrelated(\"BTC-USD\", 5); // most negative r\nlet followers = matrix.top_correlated(\"BTC-USD\", 5);   // most positive r\n```\n\n## FIX 4.2 Adapter\n\n`FixParser` is a stateless, `Send + Sync` FIX 4.2 frame codec. `FixSession` wraps an\nasync TCP connection and handles Logon, Heartbeat, and MarketData message flows.\n\n```rust\nuse fin_stream::fix::{FixParser, FixSession};\n\n// Stateless parse/serialize — share across tasks via Arc:\nlet parser = FixParser::new();\nlet msg = parser.parse(\u0026raw_bytes)?;\nlet wire = parser.serialize(\u0026msg);\n\n// Full session:\nlet mut session = FixSession::connect(\"127.0.0.1:9878\").await?;\nsession.logon(\"MY_FIRM\", \"BROKER\").await?;\nsession.subscribe_market_data(\u0026[\"AAPL\", \"MSFT\"]).await?;\n\nwhile let Some(fix_msg) = session.read_message().await? {\n    if let Some(tick) = session.on_message(fix_msg) {\n        // tick is a NormalizedTick ready for the pipeline\n    }\n}\n```\n\nSupported message types: `A` (Logon), `0` (Heartbeat), `V` (MarketDataRequest),\n`W` (MarketDataSnapshot), `X` (MarketDataIncrementalRefresh).\n\n## Multi-Asset Portfolio Feed\n\n`PortfolioFeed` manages one `WsManager` per registered asset in a `JoinSet`, merges\nall tick streams into a single `mpsc` channel, and provides a lock-free latest-tick\nsnapshot via `DashMap`.\n\n```rust\nuse fin_stream::portfolio_feed::PortfolioFeed;\nuse fin_stream::tick::Exchange;\n\nlet mut feed = PortfolioFeed::new(256);\nfeed.add_asset(\"BTC-USD\", Exchange::Coinbase).await;\nfeed.add_asset(\"ETH-USD\", Exchange::Coinbase).await;\nfeed.add_asset(\"AAPL\",    Exchange::Alpaca).await;\n\nlet mut rx = feed.tick_stream(); // mpsc::Receiver\u003c(String, NormalizedTick)\u003e\nwhile let Some((symbol, tick)) = rx.recv().await {\n    println!(\"{symbol}: {}\", tick.price);\n}\n\n// At any time, get the latest price for every asset:\nlet snapshot = feed.portfolio_snapshot(); // HashMap\u003cString, NormalizedTick\u003e\n```\n\nAuto-reconnect with exponential backoff is applied at the portfolio level: if any\nasset feed task exits due to a network error, it is restarted after a delay starting\nat 500 ms and doubling up to 30 s.\n\n## MEV Detection\n\n`MevDetector` applies three heuristic passes to a slice of `NormalizedTick`s and\nreturns a `Vec\u003cMevCandidate\u003e` describing any patterns found.\n\n```rust\nuse fin_stream::mev::{MevDetector, MevPattern};\n\n// 0.5% price impact threshold, 20-tick lookahead window:\nlet detector = MevDetector::with_window(0.005, 20);\nlet candidates = detector.analyze_block(\u0026block_ticks);\n\nfor c in \u0026candidates {\n    println!(\n        \"[{:?}] tx={} profit≈${:.2} confidence={:.0}%\",\n        c.detected_pattern,\n        c.tx_hash,\n        c.estimated_profit_usd,\n        c.confidence * 100.0,\n    );\n}\n```\n\n| Pattern | Heuristic |\n|---|---|\n| `Sandwich` | Large buy → 1+ victim ticks at elevated price → large sell, both legs exceed `price_impact_threshold` |\n| `Frontrun` | Small tick immediately precedes a 3× larger tick at same price (within 0.1%) |\n| `Backrun` | Sell within 2 ticks of a large upward move, at an elevated but slightly lower price |\n\n`estimated_profit_usd` is a coarse order-of-magnitude estimate (price impact × quantity);\n`confidence` is in [0, 1] and saturates at 1.0 when the impact is 10× the threshold.\n\n## Order Flow Toxicity\n\n`OrderFlowToxicityAnalyzer` computes four complementary toxicity metrics in a single\nrolling-window pass over `NormalizedTick`s, identifying when smart-money (informed)\ntraders are active.\n\n| Metric | Formula | Interpretation |\n|---|---|---|\n| **PIN** | `α·μ / (α·μ + 2·ε)` | Fraction of order flow from informed traders (0–1) |\n| **VPIN** | `mean(|V_B − V_S| / V_bucket)` over last N buckets | High-frequency toxicity proxy (0–1) |\n| **Kyle λ** | `cov(ΔP, x) / var(x)` via OLS | Price impact per unit of signed flow |\n| **Amihud** | `mean(|r_t| / vol_t)` | Illiquidity: price sensitivity to volume |\n\n```rust\nuse fin_stream::toxicity::OrderFlowToxicityAnalyzer;\n\nlet mut analyzer = OrderFlowToxicityAnalyzer::new(\n    200,  // tick window for PIN / Kyle λ / Amihud\n    50,   // VPIN bucket size (volume units)\n    50,   // VPIN rolling window (number of buckets)\n);\n\n// … feed NormalizedTicks …\nlet m = analyzer.metrics();\nprintln!(\"PIN={:.3}  VPIN={:.3}  Kyle λ={:.6}  Amihud={:.8}\",\n         m.pin, m.vpin, m.kyle_lambda, m.amihud_illiquidity);\nprintln!(\"All metrics valid: {}\", m.is_valid());\n```\n\nThe legacy `VpinCalculator` (single VPIN metric, tick-test classification) is\nretained for backward compatibility; new code should use `OrderFlowToxicityAnalyzer`.\n\n## Order Flow Imbalance (OFI)\n\n`OrderFlowImbalance` computes a signed measure of buying vs. selling pressure from\ntop-of-book quotes. Unlike tick-test heuristics, OFI uses the full bid and ask queue\nto determine who is the aggressor at each update.\n\n### Formula\n\n```text\nOFI_t = ΔBid_Volume − ΔAsk_Volume\n\nΔBid_Volume:\n  if bid_price improved or unchanged → Δ = bid_qty_t − bid_qty_{t-1}\n  if bid_price worsened              → Δ = −bid_qty_{t-1}  (full queue disappeared)\n\nΔAsk_Volume:\n  if ask_price improved or unchanged → Δ = ask_qty_t − ask_qty_{t-1}\n  if ask_price worsened              → Δ = +ask_qty_{t-1}  (full queue disappeared)\n\nOFI \u003e 0  → net buying pressure (bid queue growing faster than ask queue)\nOFI \u003c 0  → net selling pressure\n```\n\n### Components\n\n| Type | Description |\n|---|---|\n| `OrderFlowImbalance` | Stateful per-tick OFI calculator; tracks previous top-of-book snapshot |\n| `OfiAccumulator` | Rolling window of raw OFI values; normalises via `tanh` and returns `OfiSignal` |\n| `OfiMetricsComputer` | Welford online mean/variance + percentile rank for z-score standardisation |\n| `ToxicityEstimator` | Volume-bucket VPIN: `|V_buy − V_sell| / V_total` per bucket |\n| `TopOfBook` | Snapshot of best bid/ask (`price`, `qty`, `timestamp`); `mid_price()`, `spread()` |\n| `OfiSignal` | Processed signal: `value`, `direction` (Buy/Sell/Neutral), `strength` in [0,1] |\n| `VpinResult` | Bucket result: `toxicity`, `buy_vol`, `sell_vol`, `imbalance`, `is_toxic(thr)` |\n\n```rust\nuse fin_stream::{OfiAccumulator, OfiMetricsComputer, OrderFlowImbalance, TopOfBook};\nuse rust_decimal_macros::dec;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut raw   = OrderFlowImbalance::new();\n    let mut accum = OfiAccumulator::new(50)?;   // 50-tick rolling window\n    let mut stats = OfiMetricsComputer::new(200); // 200-tick stats window\n\n    // Simulate top-of-book updates arriving from a WebSocket feed:\n    let snap1 = TopOfBook { bid_price: dec!(50000), bid_qty: dec!(1.5),\n                            ask_price: dec!(50001), ask_qty: dec!(2.0),\n                            timestamp: 0 };\n    let snap2 = TopOfBook { bid_price: dec!(50000), bid_qty: dec!(2.0), // bid grew\n                            ask_price: dec!(50001), ask_qty: dec!(1.8),\n                            timestamp: 1 };\n\n    let ofi_raw  = raw.update(snap1);\n    let ofi_raw2 = raw.update(snap2);           // = +0.5 − (−0.2) = +0.7 (net buy)\n\n    let signal   = accum.update(ofi_raw2);      // OfiSignal { direction: Buy, strength: … }\n    let metrics  = stats.update(ofi_raw2);      // OfiMetrics { zscore, percentile_rank, … }\n\n    println!(\"Direction: {:?}\", signal.direction);\n    println!(\"Strength:  {:.3}\", signal.strength);\n    println!(\"Z-score:   {:.3}\", metrics.zscore);\n    println!(\"Significant (|z| \u003e 2): {}\", metrics.is_significant(2.0));\n    Ok(())\n}\n```\n\n### VPIN (Volume-Synchronized Probability of Informed Trading)\n\n`ToxicityEstimator` accumulates volume into fixed-size buckets and computes VPIN\nper bucket. A bucket is closed when total volume reaches `bucket_size`; VPIN is the\nrolling mean of `|V_buy − V_sell| / V_total` over the last `n_buckets`.\n\n```text\nVPIN = (1/N) · Σ_b [ |V_buy_b − V_sell_b| / (V_buy_b + V_sell_b) ]\n\nV_buy  = volume attributed to buyer-initiated trades in bucket b\nV_sell = volume attributed to seller-initiated trades\nN      = number of complete buckets in the rolling window\n\nVPIN ∈ [0, 1];  VPIN \u003e 0.5 → elevated toxicity (informed trading)\n```\n\n```rust\nuse fin_stream::ToxicityEstimator;\n\nlet mut vpin = ToxicityEstimator::new(1_000.0, 50); // 1 000 vol/bucket, 50-bucket window\n\n// Feed (ofi_value, volume) pairs from your tick pipeline:\n// if let Some(result) = vpin.update(0.7, 200.0) {\n//     if result.is_toxic(0.5) {\n//         println!(\"Elevated toxicity: VPIN={:.3}\", result.toxicity);\n//     }\n// }\n```\n\n### API reference — `ofi` module\n\n```rust\nOrderFlowImbalance::new() -\u003e OrderFlowImbalance\nOrderFlowImbalance::update(\u0026mut self, snap: TopOfBook) -\u003e f64\nOrderFlowImbalance::reset(\u0026mut self)\nOrderFlowImbalance::tick_count(\u0026self) -\u003e u64\n\nOfiAccumulator::new(window_size: usize) -\u003e Result\u003cSelf, StreamError\u003e\nOfiAccumulator::update(\u0026mut self, raw_ofi: f64) -\u003e OfiSignal\n\nOfiMetricsComputer::new(lookback: usize) -\u003e OfiMetricsComputer\nOfiMetricsComputer::update(\u0026mut self, raw_ofi: f64) -\u003e OfiMetrics\nOfiMetrics::is_significant(\u0026self, z_threshold: f64) -\u003e bool\n\nToxicityEstimator::new(bucket_size: f64, n_buckets: usize) -\u003e ToxicityEstimator\nToxicityEstimator::update(\u0026mut self, ofi: f64, volume: f64) -\u003e Option\u003cVpinResult\u003e\nVpinResult::is_toxic(\u0026self, threshold: f64) -\u003e bool\n\nTopOfBook::mid_price(\u0026self) -\u003e Decimal\nTopOfBook::spread(\u0026self) -\u003e Decimal\n```\n\n---\n\n## Market Microstructure Analytics\n\n`MicrostructureMonitor` runs four complementary illiquidity and spread estimators\non a single stream of `MicroTick`s — no separate data feeds required.\n\n### Estimators\n\n| Estimator | Formula | Interpretation |\n|---|---|---|\n| **Amihud (2002)** | `mean(|ln(P_t/P_{t-1})| / V_t)` | Price sensitivity per unit of volume; higher = more illiquid |\n| **Kyle's lambda** | OLS slope of `ΔP ~ Q` (signed volume) | Price impact coefficient; higher = thinner book |\n| **Roll (1984) spread** | `2·√(−Cov(r_t, r_{t-1}))` | Bid-ask spread proxy from serial covariance of returns |\n| **Bid-Ask Bounce** | `−Cov(r_t, r_{t-1}) / Var(r_t)` clamped to [0,1] | Fraction of return variance explained by microstructure noise |\n\n```text\nAmihud illiquidity:\n  ILL_t = |r_t| / V_t          where r_t = ln(P_t / P_{t-1})\n  ILL   = (1/T) Σ ILL_t        rolling mean over window\n\nKyle's lambda (OLS):\n  ΔP_i = λ · Q_i + ε_i\n  λ     = Cov(ΔP, Q) / Var(Q)  computed over a rolling window of ticks\n\nRoll spread:\n  γ = Cov(r_t, r_{t-1})\n  s = 2·√(−γ)    if γ \u003c 0,  else 0\n\nBid-Ask Bounce:\n  B = −Cov(r_t, r_{t-1}) / Var(r_t),  clamped to [0, 1]\n```\n\n```rust\nuse fin_stream::{MicrostructureMonitor, MicroTick};\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut monitor = MicrostructureMonitor::new(100)?; // 100-tick window\n\n    // Construct ticks from your normalised feed:\n    let tick = MicroTick::new(\n        50_000.0,   // price\n        1.5,        // volume\n        1.5,        // signed volume (+buy / -sell)\n        1_700_000_000_000_000_000_i64, // nanosecond timestamp\n    )?;\n\n    let report = monitor.update(\u0026tick)?;\n\n    if report.is_complete() {\n        println!(\"Amihud:      {:.8}\", report.amihud.unwrap());\n        println!(\"Kyle lambda: {:.8}\", report.kyle_lambda.unwrap());\n        println!(\"Roll spread: {:.6}\", report.roll_spread.unwrap());\n        println!(\"Bounce frac: {:.4}\", report.bounce.unwrap_or(0.0));\n    } else {\n        println!(\"{} / 4 estimators ready\", report.available_count());\n    }\n    Ok(())\n}\n```\n\n### API reference — `microstructure` module\n\n```rust\nMicroTick::new(price: f64, volume: f64, signed_volume: f64, timestamp_ns: i64)\n    -\u003e Result\u003cMicroTick, StreamError\u003e   // validates price \u003e 0 and volume \u003e 0\n\nMicrostructureMonitor::new(window_size: usize) -\u003e Result\u003cSelf, StreamError\u003e\nMicrostructureMonitor::update(\u0026mut self, tick: \u0026MicroTick) -\u003e Result\u003cMicrostructureReport, StreamError\u003e\nMicrostructureMonitor::reset(\u0026mut self)\nMicrostructureMonitor::amihud(\u0026self)   -\u003e \u0026AmihudIlliquidity\nMicrostructureMonitor::kyle(\u0026self)     -\u003e \u0026KyleImpact\nMicrostructureMonitor::roll(\u0026self)     -\u003e \u0026RollSpread\nMicrostructureMonitor::bounce(\u0026self)   -\u003e \u0026BidAskBounce\n\nAmihudIlliquidity::new(window_size: usize) -\u003e AmihudIlliquidity\nAmihudIlliquidity::update(\u0026mut self, tick: \u0026MicroTick) -\u003e Option\u003cf64\u003e\n\nKyleImpact::new(window_size: usize) -\u003e KyleImpact\nKyleImpact::update(\u0026mut self, tick: \u0026MicroTick) -\u003e Option\u003cf64\u003e\n\nRollSpread::new(window_size: usize) -\u003e RollSpread\nRollSpread::update(\u0026mut self, tick: \u0026MicroTick) -\u003e Option\u003cf64\u003e\n\nBidAskBounce::new(window_size: usize) -\u003e BidAskBounce\nBidAskBounce::update(\u0026mut self, tick: \u0026MicroTick) -\u003e Option\u003cf64\u003e\n\nMicrostructureReport::is_complete(\u0026self) -\u003e bool      // all four estimators have values\nMicrostructureReport::available_count(\u0026self) -\u003e usize // 0–4\n```\n\n---\n\n## Real-Time Regime Detection\n\n`RegimeDetector` classifies an incoming stream of OHLCV bars into one of five\nmarket regimes using a rolling 100-bar window and three fused indicators.\n\n| Regime | Condition |\n|---|---|\n| `Trending { direction: 1 }` | ADX \u003e 25 and/or Hurst \u003e 0.58, +DI \u003e -DI |\n| `Trending { direction: -1 }` | ADX \u003e 25 and/or Hurst \u003e 0.58, -DI \u003e +DI |\n| `MeanReverting` | ADX \u003c 20 and/or Hurst \u003c 0.42 |\n| `HighVolatility` | Realised vol \u003e `HIGH_VOL_THRESHOLD` |\n| `LowVolatility` | Realised vol \u003c `LOW_VOL_THRESHOLD` |\n| `Microstructure` | Fewer than 30 warm-up bars seen |\n\n```rust\nuse fin_stream::regime::RegimeDetector;\n\nlet mut detector = RegimeDetector::new(100); // 100-bar rolling window\n\n// … feed OhlcvBars …\nprintln!(\"Regime:     {}\", detector.current_regime());\nprintln!(\"Confidence: {:.1}%\", detector.regime_confidence() * 100.0);\n\n// Static helpers (operate on slices):\nlet hurst = RegimeDetector::hurst_exponent(\u0026closes);  // R/S analysis\nlet adx   = RegimeDetector::adx(\u0026bars);               // Wilder ADX\n```\n\n### Hurst Exponent (R/S Analysis)\n\n```text\nH = log(R/S) / log(n)\n\nR  = max(Y) − min(Y)           (range of cumulative deviations)\nS  = std(log-returns)           (standard deviation)\nY_t = Σ(r_i − mean_r)         (cumulative deviation series)\n\nH \u003e 0.5 → trending / persistent (long memory)\nH ≈ 0.5 → random walk\nH \u003c 0.5 → mean-reverting / anti-persistent\n```\n\n## Synthetic Market Data Generator\n\n`SyntheticMarketGenerator` drives four stochastic price models to produce\n`NormalizedTick` and `OhlcvBar` sequences for testing and simulation.\n\n| Model | Dynamics | Use case |\n|---|---|---|\n| `GeometricBrownianMotion` | `dS = μS dt + σS dW` | Baseline equity / crypto prices |\n| `JumpDiffusion` | GBM + Poisson(λ) jumps (Merton 1976) | Flash crashes, earnings surprises |\n| `OrnsteinUhlenbeck` | `dX = θ(μ−X)dt + σ dW` | Mean-reverting spread / basis |\n| `HestonModel` | GBM + CIR variance process, corr ρ | Stochastic-vol smile dynamics |\n\n```rust\nuse fin_stream::synthetic::{\n    GeometricBrownianMotion, HestonModel, JumpDiffusion,\n    OrnsteinUhlenbeck, SyntheticMarketGenerator,\n};\nuse fin_stream::ohlcv::Timeframe;\n\n// GBM: 5% drift, 20% vol, starting at 100\nlet mut gbm = GeometricBrownianMotion::new(0.05, 0.20, 100.0);\nlet mut gen = SyntheticMarketGenerator::new(42); // seed for reproducibility\n\nlet ticks = gen.generate_ticks(1_000, \u0026mut gbm);\nlet bars  = gen.generate_ohlcv(50, \u0026mut gbm, Timeframe::Minutes(1));\n\n// Heston: stochastic vol with mean-reverting variance and correlation\nlet mut heston = HestonModel::new(\n    0.05,   // drift\n    100.0,  // initial price\n    2.0,    // kappa (mean-reversion speed of variance)\n    0.04,   // theta (long-run variance = 20% vol)\n    0.3,    // xi (vol of vol)\n    -0.7,   // rho (price-vol correlation, typically negative)\n    0.04,   // initial variance\n);\nlet heston_ticks = gen.generate_ticks(500, \u0026mut heston);\n```\n\nBoth `generate_ticks` and `generate_ohlcv` are deterministic given the same seed.\nThe generator uses a pure-Rust xorshift64 PRNG — no `unsafe` code, no external\nrandom-number dependencies.\n\n## Multi-Feed Aggregator\n\n`FeedAggregator` subscribes to N independent tick feeds simultaneously, applies\nconfigurable latency compensation per feed, and merges them into a single\nchronologically-ordered output stream using one of four merge strategies.\n\n### Merge strategies\n\n| Strategy | Description |\n|---|---|\n| `BestBid` | Emit the tick with the highest price across all feeds (best resting bid) |\n| `BestAsk` | Emit the tick with the lowest price across all feeds (best resting ask) |\n| `VwapWeighted` | Emit a synthetic tick whose price is the VWAP of all buffered ticks; quantity = total volume |\n| `PrimaryWithFallback` | Use a designated primary feed; fall back to `BestBid` when the primary is stale |\n\n### Latency compensation\n\nEvery feed has an optional `latency_offset_ms` that is subtracted from\n`received_at_ms` before merge-ordering. Faster feeds naturally arrive first;\nslower venues are time-shifted backward so the merged stream reflects the\norder events actually occurred at the exchange.\n\n```rust\nuse fin_stream::agg::{FeedAggregator, FeedHandle, AggregatorConfig, MergeStrategy};\n\nlet mut agg = FeedAggregator::new(AggregatorConfig {\n    strategy: MergeStrategy::VwapWeighted,\n    feed_buffer_capacity: 2_048,\n    merge_window: 32,\n});\n\n// Binance arrives ~5 ms earlier than Coinbase on this network.\nlet tx_binance = agg.add_feed(\n    FeedHandle::new(\"binance-btc-usdt\").with_latency_offset(5)\n).expect(\"add feed\");\n\nlet tx_coinbase = agg.add_feed(\n    FeedHandle::new(\"coinbase-btc-usd\").with_latency_offset(18)\n).expect(\"add feed\");\n\n// Push ticks from each exchange into their senders; the aggregator\n// merges them in compensated-timestamp order.\n// let merged_tick = agg.next_tick(); // → Option\u003cNormalizedTick\u003e\n```\n\n### Arbitrage detection\n\n`ArbDetector` maintains the latest tick per feed and checks every feed pair\nfor price discrepancies. When the gross spread exceeds the threshold (in basis\npoints) an `ArbOpportunity` is emitted.\n\n```rust\nuse fin_stream::agg::{ArbDetector, ArbOpportunity};\n\nlet mut detector = ArbDetector::new(10.0); // flag spreads \u003e 10 bps\n\n// Ingest ticks as they arrive from the aggregator.\ndetector.ingest(\"binance\", \u0026binance_tick);\ndetector.ingest(\"coinbase\", \u0026coinbase_tick);\n\nlet opportunities: Vec\u003cArbOpportunity\u003e = detector.check();\nfor opp in \u0026opportunities {\n    println!(\n        \"Buy on {} @ {}, sell on {} @ {} — {:.1} bps gross spread\",\n        opp.buy_feed, opp.buy_price,\n        opp.sell_feed, opp.sell_price,\n        opp.spread_bps,\n    );\n}\n```\n\n`ArbOpportunity` carries: `symbol`, `buy_feed`, `sell_feed`, `buy_price`,\n`sell_price`, `spread_bps`, and `detected_at_ms`.\n\n---\n\n## Replay Engine\n\n`TickReplayer` reads NDJSON tick files and replays them at configurable speed\nthrough the `TickSource` trait — the same interface used by live WebSocket feeds.\nStrategy and analytics code has no awareness of whether it is running against live\nor historical data.\n\n### Speed control\n\n| `speed_multiplier` | Effect |\n|---|---|\n| `1.0` | Real-time: honours original inter-tick gaps |\n| `10.0` | 10× faster than real-time |\n| `100.0` | 100× faster than real-time |\n| `0.0` | Emit all ticks as fast as possible (no delay) |\n\n### File format\n\nInput files are newline-delimited JSON (NDJSON). Each line deserialises into a\n`NormalizedTick`. Lines beginning with `#` are treated as comments and skipped.\n\n```json\n{\"exchange\":\"Binance\",\"symbol\":\"BTC-USDT\",\"price\":\"50000\",\"quantity\":\"0.01\",\"side\":null,\"trade_id\":null,\"exchange_ts_ms\":1700000000000,\"received_at_ms\":1700000000001}\n{\"exchange\":\"Coinbase\",\"symbol\":\"BTC-USD\",\"price\":\"50005\",\"quantity\":\"0.005\",\"side\":\"buy\",\"trade_id\":\"T2\",\"exchange_ts_ms\":1700000000100,\"received_at_ms\":1700000000102}\n```\n\n### Example\n\n```rust\nuse fin_stream::replay::{TickReplayer, ReplaySession, TickSource};\n\n# async fn run() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\nlet session = ReplaySession::new(\"data/btc_ticks.ndjson\")\n    .with_speed(10.0)          // replay at 10× real time\n    .with_start_offset(5_000)  // skip first 5 seconds of the recording\n    .with_max_ticks(100_000);  // stop after 100 K ticks\n\nlet mut replayer = TickReplayer::with_session(session);\n\nlet (tx, mut rx) = tokio::sync::mpsc::channel(1_024);\n\ntokio::spawn(async move {\n    replayer.run(tx).await.ok();\n});\n\nwhile let Some(tick) = rx.recv().await {\n    // identical code path as live data\n    println!(\"{} @ {}\", tick.symbol, tick.price);\n}\n# Ok(())\n# }\n```\n\n### Looping replay\n\nSet `.looping()` on the session to restart from the beginning automatically —\nuseful for continuous strategy back-tests without manually reloading the file.\n\n### ReplayStats\n\n`TickReplayer::stats()` returns a `ReplayStats` snapshot:\n\n| Field | Description |\n|---|---|\n| `ticks_replayed` | Total ticks emitted |\n| `duration_ms` | Wall-clock elapsed time |\n| `lag_ms` | Mean delay between scheduled and actual emit time |\n| `parse_errors` | Lines that could not be deserialised (skipped) |\n| `skipped_ticks` | Ticks skipped by `start_offset_ms` |\n\n`ReplayStats::ticks_per_second()` computes throughput from the above fields.\n\n---\n\n## Multi-Exchange NBBO Aggregation\n\n`MultiExchangeAggregator` merges N per-exchange `NormalizedTick` streams into a\nsingle consolidated best bid/ask (NBBO-style view), tracks per-exchange latency\ndivergence, and emits `ArbitrageOpportunity` alerts when the price spread between\nany two exchanges exceeds a configurable threshold.\n\n```rust,no_run\nuse fin_stream::multi_exchange::{AggregatorConfig, MultiExchangeAggregator};\nuse rust_decimal_macros::dec;\n\nasync fn example() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    // Create aggregator for BTC-USD, flag arb when spread \u003e $10\n    let cfg = AggregatorConfig::new(\"BTC-USD\", dec!(10))?;\n    let (agg, mut nbbo_rx, mut arb_rx) = MultiExchangeAggregator::new(cfg, 64);\n\n    // In your tick pipeline:\n    // agg.ingest(tick_from_binance).await?;\n    // agg.ingest(tick_from_coinbase).await?;\n\n    // Receive consolidated NBBO:\n    if let Some(nbbo) = nbbo_rx.recv().await {\n        println!(\"Best bid: {:?}\", nbbo.best_bid);\n        println!(\"Best ask: {:?}\", nbbo.best_ask);\n        println!(\"Spread:   {:?}\", nbbo.spread());\n        println!(\"Mid:      {:?}\", nbbo.mid_price());\n    }\n\n    // Receive arbitrage alerts:\n    if let Some(opp) = arb_rx.recv().await {\n        println!(\"Arb: buy @ {} on {}, sell @ {} on {}  (+{:.4}%)\",\n            opp.buy_price, opp.buy_exchange,\n            opp.sell_price, opp.sell_exchange,\n            opp.profit_pct());\n    }\n\n    // Latency divergence between fastest and slowest exchange (ms):\n    let now_ms = 0u64; // replace with real clock\n    println!(\"Latency divergence: {:?} ms\", agg.max_latency_divergence_ms(now_ms));\n    Ok(())\n}\n```\n\n| Method | Description |\n|---|---|\n| `ingest(tick)` | Process one tick; updates NBBO and checks arbitrage |\n| `current_nbbo()` | Snapshot the current NBBO from all exchange states |\n| `latency_stats(now_ms)` | Per-exchange latency breakdown |\n| `max_latency_divergence_ms(now_ms)` | Gap between fastest and slowest exchange (ms) |\n| `exchange_count()` | Number of exchanges with at least one tick |\n\n---\n\n## WebSocket Circuit Breaker\n\n`WsCircuitBreaker` wraps the raw message channel from a `WsManager` and counts\nconsecutive parse failures. After `failure_threshold` consecutive failures (default: 5)\nthe circuit **opens**: the breaker enters degraded mode and emits synthetic ticks\nderived from the last-known price with an inflated spread. Downstream consumers\nalways have a price reference, even when the connection is broken. The circuit\n**closes** automatically when a real tick arrives.\n\n```rust,no_run\nuse fin_stream::circuit_breaker::{CircuitBreakerConfig, CircuitState, WsCircuitBreaker};\nuse fin_stream::tick::Exchange;\n\nasync fn example() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let cfg = CircuitBreakerConfig::new(Exchange::Binance, \"BTC-USD\", 5)?;\n    let (breaker, mut tick_rx) = WsCircuitBreaker::new(cfg, 64);\n\n    // Spawn alongside your WsManager raw message channel:\n    // tokio::spawn(async move { breaker.run(raw_msg_rx).await });\n\n    // tick_rx yields both real and synthetic NormalizedTick values.\n    // Synthetic ticks have trade_id = Some(\"synthetic\") and quantity = 0.\n    while let Some(tick) = tick_rx.recv().await {\n        let is_synthetic = tick.trade_id.as_deref() == Some(\"synthetic\");\n        println!(\"Price: {} (synthetic: {})\", tick.price, is_synthetic);\n    }\n\n    println!(\"State:               {:?}\", breaker.state());\n    println!(\"Consecutive failures: {}\", breaker.consecutive_failures());\n    Ok(())\n}\n```\n\n| Config field | Default | Description |\n|---|---|---|\n| `failure_threshold` | 5 | Failures before circuit opens |\n| `synthetic_tick_interval` | 500 ms | Interval between synthetic ticks in degraded mode |\n| `degraded_spread_pct` | 0.5% | Spread applied to last-known price for synthetic ticks |\n| `initial_backoff` | 500 ms | Starting reconnect backoff |\n| `max_backoff` | 60 s | Reconnect backoff cap |\n\n---\n\n## Tick Anomaly Detection\n\n`TickAnomalyDetector` flags four anomaly types in a streaming tick pipeline.\nNormal ticks are always forwarded unchanged; anomaly events are emitted on a\n**separate channel** so they can be handled independently without blocking the\ntick pipeline.\n\n```rust,no_run\nuse fin_stream::anomaly::{AnomalyDetectorConfig, AnomalyKind, TickAnomalyDetector};\n\nasync fn example() {\n    let cfg = AnomalyDetectorConfig::default_config()\n        .with_window_size(100)              // rolling window for mean/std\n        .with_price_spike_z(4.0)            // flag if |price - mean| \u003e 4σ\n        .with_volume_spike_multiplier(10.0); // flag if qty \u003e 10× mean qty\n\n    let (mut detector, mut anomaly_rx) = TickAnomalyDetector::new(cfg, 256);\n\n    // In your tick pipeline (tick passes through unchanged):\n    // let tick = detector.process(tick).await;\n\n    // Handle anomaly events in a separate task:\n    while let Some(event) = anomaly_rx.recv().await {\n        match \u0026event.kind {\n            AnomalyKind::PriceSpike { z_score } =\u003e\n                println!(\"Price spike! z={z_score} on {}\", event.tick.symbol),\n            AnomalyKind::VolumeSpike { ratio } =\u003e\n                println!(\"Volume spike! ratio={ratio}x\"),\n            AnomalyKind::SequenceGap { last_seq, current_seq } =\u003e\n                println!(\"Gap: missed {} ticks\", current_seq - last_seq - 1),\n            AnomalyKind::TimestampInversion { previous_ms, current_ms } =\u003e\n                println!(\"Out-of-order: {} -\u003e {} ms\", previous_ms, current_ms),\n        }\n    }\n}\n```\n\n| Anomaly | Trigger |\n|---|---|\n| `PriceSpike` | `|price - rolling_mean| / rolling_std \u003e price_spike_z` |\n| `VolumeSpike` | `quantity \u003e rolling_mean_qty x volume_spike_multiplier` |\n| `SequenceGap` | `trade_id` (integer) skips one or more sequence numbers |\n| `TimestampInversion` | `tick.received_at_ms \u003c previous tick's received_at_ms` |\n\n---\n\n## Snapshot-and-Replay\n\n`TickRecorder` writes ticks to a compact binary file (length-prefixed JSON\nrecords). `TickReplayer` reads the file back and re-emits ticks at original\ntiming or at N-speed — enabling backtesting with real captured market data.\n\n### Recording\n\n```rust,no_run\nuse fin_stream::snapshot::TickRecorder;\n\nfn example() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut recorder = TickRecorder::open(\"/data/btc_20260322.bin\")?;\n\n    // In your tick pipeline:\n    // recorder.record(\u0026tick)?;\n\n    recorder.flush()?; // always flush before dropping\n    println!(\"Wrote {} ticks ({} bytes)\",\n        recorder.ticks_written(), recorder.bytes_written());\n    Ok(())\n}\n```\n\n### Replay\n\n```rust,no_run\nuse fin_stream::snapshot::TickReplayer;\n\nasync fn example() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    // Load and replay at 10x speed:\n    let replayer = TickReplayer::open(\"/data/btc_20260322.bin\", 10.0)?;\n    println!(\"Loaded {} ticks\", replayer.tick_count());\n\n    let (_handle, mut tick_rx) = replayer.start(256);\n    while let Some(tick) = tick_rx.recv().await {\n        // Process exactly as in live mode\n        let _ = tick;\n    }\n    Ok(())\n}\n```\n\n| Speed multiplier | Effect |\n|---|---|\n| `0.0` | No delay — as fast as possible |\n| `1.0` | Real-time replay |\n| `10.0` | 10x faster than real-time |\n| `0.5` | Half speed (slower than real-time) |\n\nWire format: `[4 bytes LE u32 payload_length][JSON bytes]`. Human-inspectable and\nappend-safe via `TickRecorder::open_append`.\n\n---\n\n## gRPC Streaming Endpoint\n\nEnable the `grpc` Cargo feature to expose the tick stream over gRPC using\n[tonic](https://github.com/hyperium/tonic). The proto is defined in\n`proto/tick_stream.proto` and compiled automatically at build time.\n\n```toml\n# Cargo.toml\nfin-stream = { version = \"*\", features = [\"grpc\"] }\n```\n\n### Server\n\n```rust,no_run\n# #[cfg(feature = \"grpc\")]\nasync fn example() -\u003e Result\u003c(), Box\u003cdyn std::error::Error\u003e\u003e {\n    use fin_stream::grpc::TickStreamServer;\n    use tonic::transport::Server;\n\n    // Create the server with a 1024-tick broadcast buffer per subscriber.\n    let server = TickStreamServer::new(1024);\n    let svc = server.clone_service();\n\n    // Serve in a background task:\n    tokio::spawn(async move {\n        Server::builder()\n            .add_service(svc)\n            .serve(\"0.0.0.0:50051\".parse().unwrap())\n            .await\n            .unwrap();\n    });\n\n    // Publish ticks from your pipeline:\n    // server.publish(normalized_tick);\n    println!(\"Active subscribers: {}\", server.subscriber_count());\n    Ok(())\n}\n```\n\n### Proto definition (proto/tick_stream.proto)\n\n```protobuf\nservice TickStreamService {\n  rpc SubscribeTicks(SubscribeTicksRequest) returns (stream Tick);\n}\n\nmessage TickFilter {\n  string symbol   = 1;  // \"\" = all symbols\n  string exchange = 2;  // \"\" = all exchanges\n}\n```\n\nClients filter by symbol and/or exchange name. Slow subscribers that fall behind\nthe broadcast buffer receive a lagged notification and skip buffered ticks (no\nblocking of the fast path).\n\n---\n\n## Zero-Allocation Hot Path\n\nThe hot path through `TickNormalizer → SpscRing → FeedAggregator` makes zero\nheap allocations per tick:\n\n- **`NormalizedTick`** is a plain Rust struct — stack-allocated, `Copy`-able,\n  and `Send`. No `Box`, no `Arc`, no `String` clone on the hot path.\n- **`SpscRing\u003cT, N\u003e`** uses a const-generic array (`[MaybeUninit\u003cT\u003e; N]`).\n  `push` and `pop` are single atomic operations (`Release`/`Acquire` ordering).\n  No `malloc` call is made after initial construction.\n- **`FeedAggregator::poll_feeds`** drains the bounded `mpsc` channels (which\n  are pre-allocated at `add_feed` time) into a `BinaryHeap` that is also\n  pre-allocated. Tick merge never allocates on the happy path.\n- **`ArbDetector::check`** iterates over a `HashMap` of `NormalizedTick`\n  references and performs arithmetic comparisons. The only allocation is the\n  `Vec\u003cArbOpportunity\u003e` returned on a hit — which is empty in the common case.\n\nBenchmarks on a 3.6 GHz Zen 3 core show the SPSC ring sustaining **150 million\npush/pop pairs per second** for `u64` items, exceeding the 100 K ticks/second\ndesign target by three orders of magnitude.\n\n---\n\n## Design Principles\n\n1. **Never panic on valid production inputs.** Every fallible operation returns\n   `Result\u003c_, StreamError\u003e`. The only intentional panic is `MinMaxNormalizer::new(0)`,\n   which is an API misuse guard documented in the function-level doc comment.\n2. **Zero heap allocation on the hot path.** `SpscRing\u003cT, N\u003e` is a const-generic\n   array; `push`/`pop` never call `malloc`. `NormalizedTick` is stack-allocated.\n3. **Exact decimal arithmetic for prices.** All price and quantity fields use\n   `rust_decimal::Decimal`, never `f64`. `f64` is used only for the dimensionless\n   `beta`/`gamma` Lorentz parameters and the `f64` normalizer observations.\n4. **Thread-safety where needed.** `HealthMonitor` uses `DashMap` for concurrent\n   feed updates. `OrderBook` is `Send + Sync`. `SpscRing` splits into producer/consumer\n   halves that are individually `Send`.\n5. **No unsafe code.** `#![forbid(unsafe_code)]` is active in `lib.rs`. The SPSC\n   ring buffer uses `UnsafeCell` with a documented safety invariant, gated behind a\n   safe public API.\n\n## Architecture\n\n```\n  Live Feeds                        Historical Data\n  ──────────────────────            ──────────────────────\n  WsManager (Binance)  ──┐          TickReplayer (NDJSON)\n  WsManager (Coinbase) ──┤              │  speed_multiplier\n  WsManager (Alpaca)   ──┤              │  loop_replay\n                         │              │  start_offset_ms\n                         ▼              ▼\n                  [ FeedAggregator ]           ─── TickSource trait (live ≡ replay)\n                    latency compensation\n                    BestBid / BestAsk\n                    VwapWeighted\n                    PrimaryWithFallback\n                         │\n                         +──► [ ArbDetector ]  ── spread \u003e N bps → ArbOpportunity\n                         │\n                         ▼\n               [ TickNormalizer ]     raw JSON payload → NormalizedTick (all exchanges)\n                         │\n                         ▼\n             [ SPSC Ring Buffer ]     lock-free O(1) push/pop, zero-allocation hot path\n                         │\n                         ▼\n             [ OHLCV Aggregator ]     streaming bar construction at any timeframe\n                         │\n                         ▼\n      [ MinMax / ZScore Normalizer ]  rolling-window coordinate normalization\n                         │\n                         +──► [ Lorentz Transform ]  relativistic spacetime boost\n                         │\n                         ▼\n    Downstream (ML model | trade signal engine | order management)\n\n  Parallel paths:\n  [ OrderBook ]       -- delta streaming, snapshot reset, crossed-book guard\n  [ HealthMonitor ]   -- per-feed staleness detection, circuit-breaker\n  [ SessionAwareness ]-- Open / Extended / Closed classification\n  [ MevDetector ]     -- sandwich, frontrun, backrun heuristics\n  [ AnomalyDetector ] -- price spikes, volume spikes, sequence gaps\n```\n\n## Analytics Suite\n\nOver 88 rounds of development, fin-stream has accumulated a comprehensive\nanalytics suite covering every layer of the pipeline.\n\n### `NormalizedTick` Batch Analytics (200+ functions)\n\nStatic methods operating on `\u0026[NormalizedTick]` slices for microstructure analysis:\n\n| Category | Example functions |\n|---|---|\n| VWAP / price | `vwap`, `vwap_deviation_std`, `volume_weighted_mid_price`, `mid_price_drift` |\n| Volume / notional | `total_volume`, `buy_volume`, `sell_volume`, `buy_notional`, `sell_notional_fraction`, `max_notional`, `min_notional`, `trade_notional_std` |\n| Side / flow | `buy_count`, `sell_count`, `buy_sell_count_ratio`, `buy_sell_size_ratio`, `order_flow_imbalance`, `buy_sell_avg_qty_ratio` |\n| Price movement | `price_range`, `price_mean`, `price_mad`, `price_dispersion`, `max_price_gap`, `price_range_velocity`, `max_price_drop`, `max_price_rise` |\n| Tick direction | `uptick_count`, `downtick_count`, `uptick_fraction`, `tick_direction_bias`, `price_mean_crossover_count` |\n| Timing / arrival | `tick_count_per_ms`, `volume_per_ms`, `inter_arrival_variance`, `inter_arrival_cv`, `notional_per_second` |\n| Concentration | `quantity_concentration`, `price_level_volume`, `quantity_std`, `notional_skewness` |\n| Running extremes | `running_high_count`, `running_low_count`, `max_consecutive_side_run` |\n| Spread / efficiency | `spread_efficiency`, `realized_spread`, `adverse_selection_score`, `price_impact_per_unit` |\n\n### `OhlcvBar` Batch Analytics (200+ functions)\n\nStatic methods operating on `\u0026[OhlcvBar]` slices:\n\n| Category | Example functions |\n|---|---|\n| Candle structure | `body_fraction`, `bullish_ratio`, `avg_bar_efficiency`, `avg_wick_symmetry`, `body_to_range_std` |\n| Highs / lows | `peak_close`, `trough_close`, `max_high`, `min_low`, `higher_highs_count`, `lower_lows_count`, `new_high_count`, `new_low_count` |\n| Volume | `mean_volume`, `up_volume_fraction`, `down_close_volume`, `up_close_volume`, `max_bar_volume`, `min_bar_volume`, `high_volume_fraction` |\n| Close statistics | `mean_close`, `close_std`, `close_skewness`, `close_at_high_fraction`, `close_at_low_fraction`, `close_cluster_count` |\n| Range / movement | `total_range`, `range_std_dev`, `avg_range_pct_of_open`, `volume_per_range`, `total_body_movement`, `avg_open_to_close` |\n| Patterns | `continuation_bar_count`, `zero_volume_fraction`, `complete_fraction` |\n| Shadow analysis | `avg_lower_shadow_ratio`, `tail_upper_fraction`, `tail_lower_fraction`, `avg_lower_wick_to_range` |\n| VWAP / price | `mean_vwap`, `normalized_close`, `price_channel_position`, `candle_score` |\n\n### Normalizer Analytics (80+ functions each)\n\nBoth `MinMaxNormalizer` and `ZScoreNormalizer` expose identical analytics suites:\n\n| Category | Example functions |\n|---|---|\n| Central tendency | `mean`, `median`, `geometric_mean`, `harmonic_mean`, `exponential_weighted_mean` |\n| Dispersion | `variance_f64`, `std_dev`, `interquartile_range`, `range_over_mean`, `coeff_of_variation`, `rms` |\n| Shape | `skewness`, `kurtosis`, `second_moment`, `tail_variance` |\n| Rank / quantile | `percentile_rank`, `quantile_range`, `value_rank`, `distance_from_median` |\n| Threshold | `count_above`, `above_median_fraction`, `below_mean_fraction`, `outlier_fraction`, `zero_fraction` |\n| Trend | `momentum`, `rolling_mean_change`, `is_mean_stable`, `sign_flip_count`, `new_max_count`, `new_min_count` |\n| Extremes | `max_fraction`, `min_fraction`, `peak_to_trough_ratio`, `range_normalized_value` |\n| Misc | `ema_of_z_scores`, `rms`, `distinct_count`, `interquartile_mean`, `latest_minus_mean`, `latest_to_mean_ratio` |\n\n## Mathematical Definitions\n\n### Min-Max Normalization\n\nGiven a rolling window of `W` observations `x_1, ..., x_W` with minimum `m` and\nmaximum `M`, the normalized value of a new sample `x` is:\n\n```\nx_norm = (x - m) / (M - m)    when M != m\nx_norm = 0.0                  when M == m  (degenerate; all window values identical)\n```\n\nThe result is clamped to `[0.0, 1.0]`. This ensures that observations falling\noutside the current window range are mapped to the boundary rather than outside it.\n\n### Z-Score Normalization\n\nGiven a rolling window of `W` observations with mean `μ` and standard deviation `σ`:\n\n```\nz = (x - μ) / σ    when σ != 0\nz = 0.0            when σ == 0  (degenerate; all window values identical)\n```\n\n`ZScoreNormalizer` also provides IQR, percentile rank, variance, EMA of z-scores,\nand rolling mean change across the window.\n\n### Lorentz Transform\n\nThe `LorentzTransform` applies the special-relativistic boost with velocity\nparameter `beta = v/c` (speed of light normalized to `c = 1`):\n\n```\nt' = gamma * (t - beta * x)\nx' = gamma * (x - beta * t)\n\nwhere  beta  = v/c            (0 \u003c= beta \u003c 1, dimensionless drift velocity)\n       gamma = 1 / sqrt(1 - beta^2)   (Lorentz factor, always \u003e= 1)\n```\n\nThe inverse transform is:\n\n```\nt  = gamma * (t' + beta * x')\nx  = gamma * (x' + beta * t')\n```\n\nThe spacetime interval `s^2 = t^2 - x^2` is invariant under the transform.\n`beta = 0` gives the identity (`gamma = 1`). `beta \u003e= 1` is invalid (gamma is\nundefined) and is rejected at construction time with `StreamError::LorentzConfigError`.\n\n**Financial interpretation.** `t` is elapsed time normalized to a convenient\nscale. `x` is a normalized log-price or price coordinate. The boost maps the\nprice-time plane along Lorentz hyperbolas. Certain microstructure signals that\nappear curved in the untransformed frame can appear as straight lines in a\nsuitably boosted frame, simplifying downstream linear models.\n\n### OHLCV Invariants\n\nEvery completed `OhlcvBar` satisfies:\n\n| Invariant | Expression |\n|---|---|\n| High is largest | `high \u003e= max(open, close)` |\n| Low is smallest | `low \u003c= min(open, close)` |\n| Valid ordering | `high \u003e= low` |\n| Volume non-negative | `volume \u003e= 0` |\n\n### Order Book Guarantees\n\n| Property | Guarantee |\n|---|---|\n| No crossed book | Any delta that would produce `best_bid \u003e= best_ask` is rejected with `StreamError::BookCrossed`; the book is not mutated |\n| Sequence gap detection | If a delta carries a sequence number that is not exactly `last_sequence + 1`, the apply returns `StreamError::BookReconstructionFailed` |\n| Zero quantity removes level | A delta with `quantity = 0` removes the price level entirely |\n\n### Reconnect Backoff\n\n`ReconnectPolicy::backoff_for_attempt(n)` returns:\n\n```\nbackoff(n) = min(initial_backoff * multiplier^n, max_backoff)\n```\n\n`multiplier` must be `\u003e= 1.0` and `max_attempts` must be `\u003e 0`; both are validated\nat construction time.\n\n## Performance Characteristics\n\n| Metric | Value |\n|---|---|\n| SPSC push/pop latency | O(1), single cache-line access |\n| SPSC throughput | \u003e100 K ticks/second (zero allocation) |\n| OHLCV feed per tick | O(1) |\n| Normalization update | O(1) amortized; O(W) after window eviction |\n| Lorentz transform | O(1), two multiplications per coordinate |\n| Ring buffer memory | N * sizeof(T) bytes (N is const generic) |\n| OFI raw update | O(1) per top-of-book snapshot |\n| OFI accumulator | O(1) amortized; rolling VecDeque eviction |\n| Microstructure update | O(1) amortized per `MicroTick`; O(W) on window eviction |\n| VPIN bucket | O(1) per tick; O(n_buckets) on bucket close |\n\n## Quickstart\n\n### Normalize a Binance tick and aggregate OHLCV\n\n```rust\nuse fin_stream::tick::{Exchange, RawTick, TickNormalizer};\nuse fin_stream::ohlcv::{OhlcvAggregator, Timeframe};\nuse serde_json::json;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let normalizer = TickNormalizer::new();\n    let mut agg = OhlcvAggregator::new(\"BTCUSDT\", Timeframe::Minutes(1));\n\n    let raw = RawTick::new(\n        Exchange::Binance,\n        \"BTCUSDT\",\n        json!({ \"p\": \"65000.50\", \"q\": \"0.002\", \"m\": false, \"t\": 1u64, \"T\": 1_700_000_000_000u64 }),\n    );\n    let tick = normalizer.normalize(raw)?;\n    let completed_bars = agg.feed(\u0026tick)?;\n\n    for bar in completed_bars {\n        println!(\"{}: close={}\", bar.bar_start_ms, bar.close);\n    }\n    Ok(())\n}\n```\n\n### SPSC ring buffer pipeline\n\n```rust\nuse fin_stream::ring::SpscRing;\nuse fin_stream::tick::{Exchange, RawTick, TickNormalizer, NormalizedTick};\nuse serde_json::json;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let ring: SpscRing\u003cNormalizedTick, 1024\u003e = SpscRing::new();\n    let (prod, cons) = ring.split();\n\n    // Producer thread\n    let normalizer = TickNormalizer::new();\n    let raw = RawTick::new(\n        Exchange::Coinbase,\n        \"BTC-USD\",\n        json!({ \"price\": \"65001.00\", \"size\": \"0.01\", \"side\": \"buy\", \"trade_id\": \"abc\" }),\n    );\n    let tick = normalizer.normalize(raw)?;\n    prod.push(tick)?;\n\n    // Consumer thread\n    while let Ok(t) = cons.pop() {\n        println!(\"received tick: {} @ {}\", t.symbol, t.price);\n    }\n    Ok(())\n}\n```\n\n### Min-max normalization of closing prices\n\n```rust\nuse fin_stream::norm::MinMaxNormalizer;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut norm = MinMaxNormalizer::new(20);\n\n    let closes = vec![100.0, 102.0, 98.0, 105.0, 103.0];\n    for \u0026c in \u0026closes {\n        norm.update(c);\n    }\n\n    let v = norm.normalize(103.0)?;\n    println!(\"normalized: {v:.4}\");  // a value in [0.0, 1.0]\n    Ok(())\n}\n```\n\n### Z-score normalization with analytics\n\n```rust\nuse fin_stream::norm::ZScoreNormalizer;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut z = ZScoreNormalizer::new(30);\n\n    for v in [100.0, 102.5, 99.0, 103.0, 101.5] {\n        z.update(v);\n    }\n\n    let score = z.normalize(104.0)?;\n    println!(\"z-score: {score:.4}\");\n    println!(\"positive z count: {}\", z.count_positive_z_scores());\n    println!(\"mean stable: {}\", z.is_mean_stable(0.5));\n    Ok(())\n}\n```\n\n### Lorentz feature engineering\n\n```rust\nuse fin_stream::lorentz::{LorentzTransform, SpacetimePoint};\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let lt = LorentzTransform::new(0.3)?; // beta = 0.3\n    let p = SpacetimePoint::new(1.0, 0.5);\n    let boosted = lt.transform(p);\n    println!(\"t'={:.4} x'={:.4}\", boosted.t, boosted.x);\n\n    // Round-trip\n    let recovered = lt.inverse_transform(boosted);\n    assert!((recovered.t - p.t).abs() \u003c 1e-10);\n    Ok(())\n}\n```\n\n### Order book delta streaming\n\n```rust\nuse fin_stream::book::{BookDelta, BookSide, OrderBook};\nuse rust_decimal_macros::dec;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let mut book = OrderBook::new(\"BTC-USD\");\n    book.apply(BookDelta::new(\"BTC-USD\", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(1))?;\n    book.apply(BookDelta::new(\"BTC-USD\", BookSide::Ask, dec!(50001), dec!(2)).with_sequence(2))?;\n\n    println!(\"mid: {}\", book.mid_price().unwrap());\n    println!(\"spread: {}\", book.spread().unwrap());\n    println!(\"notional: {}\", book.total_notional_both_sides());\n    Ok(())\n}\n```\n\n### Feed health monitoring with circuit breaker\n\n```rust\nuse fin_stream::health::HealthMonitor;\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let monitor = HealthMonitor::new(5_000)          // 5 s stale threshold\n        .with_circuit_breaker_threshold(3);           // open after 3 consecutive stale checks\n\n    monitor.register(\"BTC-USD\", None);\n    monitor.heartbeat(\"BTC-USD\", 1_000_000)?;\n\n    let stale_errors = monitor.check_all(1_010_000); // 10 s later — stale\n    for e in stale_errors {\n        eprintln!(\"stale: {e}\");\n    }\n\n    println!(\"circuit open: {}\", monitor.is_circuit_open(\"BTC-USD\"));\n    println!(\"ratio healthy: {:.2}\", monitor.ratio_healthy());\n    Ok(())\n}\n```\n\n### Session classification\n\n```rust\nuse fin_stream::session::{MarketSession, SessionAwareness};\n\nfn main() -\u003e Result\u003c(), fin_stream::StreamError\u003e {\n    let sa = SessionAwareness::new(MarketSession::UsEquity);\n    let status = sa.status(1_700_000_000_000)?; // some UTC ms timestamp\n    println!(\"US equity status: {status:?}\");\n    println!(\"session name: {}\", sa.session_name());\n    Ok(())\n}\n```\n\n## API Reference\n\n### `tick` module\n\n```rust\n// Parse an exchange identifier string.\nExchange::from_str(\"binance\") -\u003e Result\u003cExchange, StreamError\u003e\nExchange::Display              // \"Binance\" / \"Coinbase\" / \"Alpaca\" / \"Polygon\"\n\n// Construct a raw tick (system clock stamp applied automatically).\nRawTick::new(exchange: Exchange, symbol: impl Into\u003cString\u003e, payload: serde_json::Value) -\u003e RawTick\n\n// Normalize a raw tick into a canonical representation.\nTickNormalizer::new() -\u003e TickNormalizer\nTickNormalizer::normalize(\u0026self, raw: RawTick) -\u003e Result\u003cNormalizedTick, StreamError\u003e\n\n// NormalizedTick query methods\nNormalizedTick::is_above_price(\u0026self, reference: Decimal) -\u003e bool\nNormalizedTick::is_below_price(\u0026self, reference: Decimal) -\u003e bool\nNormalizedTick::is_at_price(\u0026self, target: Decimal) -\u003e bool\nNormalizedTick::price_change_from(\u0026self, reference: Decimal) -\u003e Decimal\nNormalizedTick::quantity_above(\u0026self, threshold: Decimal) -\u003e bool\nNormalizedTick::is_round_number(\u0026self, step: Decimal) -\u003e bool\nNormalizedTick::is_market_open_tick(\u0026self, session_start_ms: u64, session_end_ms: u64) -\u003e bool\nNormalizedTick::signed_quantity(\u0026self) -\u003e Decimal   // +qty Buy, -qty Sell, 0 Unknown\nNormalizedTick::as_price_level(\u0026self) -\u003e (Decimal, Decimal)  // (price, quantity)\nNormalizedTick::is_buy(\u0026self) -\u003e bool\nNormalizedTick::is_sell(\u0026self) -\u003e bool\nNormalizedTick::age_ms(\u0026self, now_ms: u64) -\u003e u64\nNormalizedTick::has_exchange_ts(\u0026self) -\u003e bool\nNormalizedTick::exchange_latency_ms(\u0026self, now_ms: u64) -\u003e Option\u003cu64\u003e\nNormalizedTick::price_change_pct(\u0026self, reference: Decimal) -\u003e Option\u003cf64\u003e\nNormalizedTick::is_same_symbol_as(\u0026self, other: \u0026NormalizedTick) -\u003e bool\nNormalizedTick::side_str(\u0026self) -\u003e \u0026'static str\nNormalizedTick::is_large_tick(\u0026self, threshold: Decimal) -\u003e bool\nNormalizedTick::is_zero_quantity(\u0026self) -\u003e bool\nNormalizedTick::dollar_value(\u0026self) -\u003e Decimal      // price * quantity\nNormalizedTick::vwap(\u0026self, total_volume: Decimal, cumulative_pv: Decimal) -\u003e Option\u003cDecimal\u003e\n```\n\n### `ring` module\n\n```rust\n// Create a const-generic SPSC ring buffer.\nSpscRing::\u003cT, N\u003e::new() -\u003e SpscRing\u003cT, N\u003e          // N slots, zero allocation\n\n// Split into thread-safe producer/consumer halves.\nSpscRing::split(self) -\u003e (SpscProducer\u003cT, N\u003e, SpscConsumer\u003cT, N\u003e)\n\nSpscProducer::push(\u0026self, value: T) -\u003e Result\u003c(), StreamError\u003e  // StreamError::RingBufferFull on overflow\nSpscConsumer::pop(\u0026self) -\u003e Result\u003cT, StreamError\u003e              // StreamError::RingBufferEmpty on underflow\nSpscRing::len(\u0026self) -\u003e usize                                   // items currently queued\nSpscRing::is_empty(\u0026self) -\u003e bool\nSpscRing::capacity(\u0026self) -\u003e usize                              // always N\n\n// Analytics on a populated ring (clone-based reads on initialized slots)\nSpscRing::sum_cloned(\u0026self) -\u003e T                where T: Clone + Sum + Default\nSpscRing::average_cloned(\u0026self) -\u003e Option\u003cf64\u003e  where T: Clone + Into\u003cf64\u003e\nSpscRing::peek_nth(\u0026self, n: usize) -\u003e Option\u003cT\u003e where T: Clone   // 0 = oldest\nSpscRing::contains_cloned(\u0026self, value: \u0026T) -\u003e bool where T: Clone + PartialEq\nSpscRing::max_cloned_by\u003cF, K\u003e(\u0026self, key: F) -\u003e Option\u003cT\u003e  where F: Fn(\u0026T) -\u003e K, K: Ord\nSpscRing::min_cloned_by\u003cF, K\u003e(\u0026self, key: F) -\u003e Option\u003cT\u003e  where F: Fn(\u0026T) -\u003e K, K: Ord\nSpscRing::to_vec_sorted(\u0026self) -\u003e Vec\u003cT\u003e        where T: Clone + Ord\nSpscRing::to_vec_cloned(\u0026self) -\u003e Vec\u003cT\u003e        where T: Clone\nSpscRing::first(\u0026self) -\u003e Option\u003cT\u003e             where T: Clone   // oldest item\nSpscRing::drain_into(\u0026self, dest: \u0026mut Vec\u003cT\u003e)  where T: Clone\n```\n\n### `book` module\n\n```rust\n// Construct a delta (sequence number optional).\nBookDelta::new(symbol, side: BookSide, price: Decimal, quantity: Decimal) -\u003e BookDelta\nBookDelta::with_sequence(self, seq: u64) -\u003e BookDelta\n\n// Apply deltas and query the book.\nOrderBook::new(symbol: impl Into\u003cString\u003e) -\u003e OrderBook\nOrderBook::apply(\u0026mut self, delta: BookDelta) -\u003e Result\u003c(), StreamError\u003e\nOrderBook::reset(\u0026mut self, bids: Vec\u003cPriceLevel\u003e, asks: Vec\u003cPriceLevel\u003e)\nOrderBook::best_bid(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::best_ask(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::mid_price(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::spread(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::spread_bps(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::top_bids(\u0026self, n: usize) -\u003e Vec\u003cPriceLevel\u003e\nOrderBook::top_asks(\u0026self, n: usize) -\u003e Vec\u003cPriceLevel\u003e\nOrderBook::total_bid_volume(\u0026self) -\u003e Decimal\nOrderBook::total_ask_volume(\u0026self) -\u003e Decimal\nOrderBook::bid_ask_volume_ratio(\u0026self) -\u003e Option\u003cf64\u003e\nOrderBook::depth_imbalance(\u0026self) -\u003e Option\u003cf64\u003e\nOrderBook::weighted_mid_price(\u0026self) -\u003e Option\u003cDecimal\u003e\nOrderBook::bid_levels_above(\u0026self, price: Decimal) -\u003e usize\nOrderBook::ask_levels_below(\u0026self, price: Decimal) -\u003e usize\nOrderBook::bid_volume_at_price(\u0026self, price: Decimal) -\u003e Option\u003cDecimal\u003e\nOrderBook::ask_volume_at_price(\u0026self, price: Decimal) -\u003e Option\u003cDecimal\u003e\nOrderBook::cumulative_bid_volume(\u0026self, n: usize) -\u003e Decimal\nOrderBook::cumulative_ask_volume(\u0026self, n: usize) -\u003e Decimal\nOrderBook::is_within_spread(\u0026self, price: Decimal) -\u003e bool\nOrderBook::bid_wall(\u0026self, threshold: Decimal) -\u003e Option\u003cDecimal\u003e\nOrderBook::ask_wall(\u0026self, threshold: Decimal) -\u003e Option\u003cDecimal\u003e\n\n// Extended book analytics (added rounds 36–40)\nOrderBook::total_value_at_level(\u0026self, side: BookSide, price: Decimal) -\u003e Option\u003cDecimal\u003e\nOrderBook::ask_volume_above(\u0026self, price: Decimal) -\u003e Decimal\nOrderBook::bid_volume_below(\u0026self, price: Decimal) -\u003e Decimal\nOrderBook::total_notional_both_sides(\u0026self) -\u003e Decimal\nOrderBook::price_level_exists(\u0026self, side: BookSide, price: Decimal) -\u003e bool\nOrderBook::level_count_both_sides(\u0026self) -\u003e usize\nOrderBook::ask_price_at_rank(\u0026self, n: usize) -\u003e Option\u003cDecimal\u003e  // 0 = best ask\nOrderBook::bid_price_at_rank(\u0026self, n: usize) -\u003e Option\u003cDecimal\u003e  // 0 = best bid\n```\n\n### `ohlcv` module\n\n```rust\n// Construct an aggregator.\nOhlcvAggregator::new(symbol: impl Into\u003cString\u003e, timeframe: Timeframe) -\u003e OhlcvAggregator\nOhlcvAggregator::with_emit_empty_bars(self, emit: bool) -\u003e OhlcvAggregator\n\n// Feed ticks; returns completed bars (may be empty or multiple on gaps).\nOhlcvAggregator::feed(\u0026mut self, tick: \u0026NormalizedTick) -\u003e Result\u003cVec\u003cOhlcvBar\u003e, StreamError\u003e\n\n// Bar boundary alignment.\nTimeframe::duration_ms(self) -\u003e u64\nTimeframe::bar_start_ms(self, ts_ms: u64) -\u003e u64\n\n// OhlcvBar computed properties\nOhlcvBar::true_range(\u0026self, prev_close: Decimal) -\u003e Decimal\nOhlcvBar::body_ratio(\u0026self) -\u003e Option\u003cf64\u003e          // body / range\nOhlcvBar::upper_shadow(\u0026self) -\u003e Decimal\nOhlcvBar::lower_shadow(\u0026self) -\u003e Decimal\nOhlcvBar::hlc3(\u0026self) -\u003e Decimal                    // (high + low + close) / 3\nOhlcvBar::ohlc4(\u0026self) -\u003e Decimal                   // (open + high + low + close) / 4\nOhlcvBar::typical_price(\u0026self) -\u003e Decimal           // hlc3 alias\nOhlcvBar::weighted_close(\u0026self) -\u003e Decimal          // (high + low + 2*close) / 4\nOhlcvBar::close_location_value(\u0026self) -\u003e Option\u003cf64\u003e  // (close - low) / (high - low)\nOhlcvBar::is_bullish(\u0026self) -\u003e bool\nOhlcvBar::is_bearish(\u0026self) -\u003e bool\nOhlcvBar::is_doji(\u0026self, threshold: Decimal) -\u003e bool\nOhlcvBar::is_marubozu(\u0026self, wick_threshold: Decimal) -\u003e bool\nOhlcvBar::is_spinning_top(\u0026self, body_threshold: Decimal) -\u003e bool\nOhlcvBar::is_shooting_star(\u0026self) -\u003e bool\nOhlcvBar::is_inside_bar(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::is_outside_bar(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::is_harami(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::is_engulfing(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::has_upper_wick(\u0026self) -\u003e bool\nOhlcvBar::has_lower_wick(\u0026self) -\u003e bool\nOhlcvBar::volume_notional(\u0026self) -\u003e Decimal         // volume * close\nOhlcvBar::range_pct(\u0026self) -\u003e Option\u003cf64\u003e           // (high - low) / open\nOhlcvBar::price_change_pct(\u0026self, prev: \u0026OhlcvBar) -\u003e Option\u003cf64\u003e\nOhlcvBar::body_size(\u0026self) -\u003e Decimal               // |close - open|\n\n// Analytics added in rounds 35–41\nOhlcvBar::mean_volume(bars: \u0026[OhlcvBar]) -\u003e Option\u003cDecimal\u003e   // static\nOhlcvBar::vwap_deviation(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::relative_volume(\u0026self, avg_volume: Decimal) -\u003e Option\u003cf64\u003e\nOhlcvBar::intraday_reversal(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::high_close_ratio(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::lower_shadow_pct(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::open_close_ratio(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::is_wide_range_bar(\u0026self, threshold: Decimal) -\u003e bool\nOhlcvBar::close_to_low_ratio(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::volume_per_trade(\u0026self) -\u003e Option\u003cDecimal\u003e\nOhlcvBar::price_range_overlap(\u0026self, other: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::bar_height_pct(\u0026self) -\u003e Option\u003cf64\u003e\nOhlcvBar::is_bullish_engulfing(\u0026self, prev: \u0026OhlcvBar) -\u003e bool\nOhlcvBar::close_gap(\u0026self, prev: \u0026OhlcvBar) -\u003e Decimal\nOhlcvBar::close_above_midpoint(\u0026self) -\u003e bool\nOhlcvBar::close_momentum(\u0026self, prev: \u0026OhlcvBar) -\u003e Decimal\nOhlcvBar::bar_range(\u0026self) -\u003e Decimal\n\n// Batch analytics added in rounds 42–88 (static, operate on \u0026[OhlcvBar])\n// See Analytics Suite section for the full categorized list (200+ functions).\n// Representative selection:\nOhlcvBar::bullish_ratio(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::peak_close(bars: \u0026[OhlcvBar]) -\u003e Option\u003cDecimal\u003e\nOhlcvBar::trough_close(bars: \u0026[OhlcvBar]) -\u003e Option\u003cDecimal\u003e\nOhlcvBar::body_fraction(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::up_volume_fraction(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::range_std_dev(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::higher_highs_count(bars: \u0026[OhlcvBar]) -\u003e usize\nOhlcvBar::lower_lows_count(bars: \u0026[OhlcvBar]) -\u003e usize\nOhlcvBar::mean_close(bars: \u0026[OhlcvBar]) -\u003e Option\u003cDecimal\u003e\nOhlcvBar::close_std(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::mean_vwap(bars: \u0026[OhlcvBar]) -\u003e Option\u003cDecimal\u003e\nOhlcvBar::total_body_movement(bars: \u0026[OhlcvBar]) -\u003e Decimal\nOhlcvBar::avg_wick_symmetry(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\nOhlcvBar::complete_fraction(bars: \u0026[OhlcvBar]) -\u003e Option\u003cf64\u003e\n```\n\n### `norm` module\n\nBoth normalizers expose 80+ analytics. Only core methods are shown here; see\nthe **Analytics Suite** section above for the full categorized function list.\n\n```rust\n// Min-max rolling normalizer\nMinMaxNormalizer::new(window_size: usize) -\u003e MinMaxNormalizer  // panics if window_size == 0\nMinMaxNormalizer::update(\u0026mut self, value: f64)                // O(1) amortized\nMinMaxNormalizer::normalize(\u0026mut self, value: f64) -\u003e Result\u003cf64, StreamError\u003e  // [0.0, 1.0]\nMinMaxNormalizer::min_max(\u0026mut self) -\u003e Option\u003c(f64, f64)\u003e\nMinMaxNormalizer::reset(\u0026mut self)\nMinMaxNormalizer::len(\u0026self) -\u003e usize\nMinMaxNormalizer::is_empty(\u0026self) -\u003e bool\nMinMaxNormalizer::window_size(\u0026self) -\u003e usize\nMinMaxNormalizer::count_above(\u0026self, threshold: f64) -\u003e usize\nMinMaxNormalizer::normalized_range(\u0026mut self) -\u003e Option\u003cf64\u003e\nMinMaxNormalizer::fraction_above_mid(\u0026mut self) -\u003e Option\u003cf64\u003e\n// ... 70+ additional analytics (moments, percentiles, trend, shape — see Analytics Suite)\n\n// Z-score rolling normalizer\nZScoreNormalizer::new(window_size: usize) -\u003e ZScoreNormalizer\nZScoreNormalizer::update(\u0026mut self, value: f64)\nZScoreNormalizer::normalize(\u0026mut self, value: f64) -\u003e Result\u003cf64, StreamError\u003e\nZScoreNormalizer::mean(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::std_dev(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::variance_f64(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::len(\u0026self) -\u003e usize\nZScoreNormalizer::is_empty(\u0026self) -\u003e bool\nZScoreNormalizer::window_size(\u0026self) -\u003e usize\nZScoreNormalizer::interquartile_range(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::percentile_rank(\u0026self, value: f64) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::ema_of_z_scores(\u0026self, alpha: f64) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::trim_outliers(\u0026self, z_threshold: f64) -\u003e Vec\u003cf64\u003e\nZScoreNormalizer::is_outlier(\u0026self, value: f64, z_threshold: f64) -\u003e bool\nZScoreNormalizer::clamp_to_window(\u0026self, value: f64) -\u003e f64\nZScoreNormalizer::rolling_mean_change(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::count_positive_z_scores(\u0026self) -\u003e usize\nZScoreNormalizer::above_threshold_count(\u0026self, z_threshold: f64) -\u003e usize\nZScoreNormalizer::window_span_f64(\u0026self) -\u003e Option\u003cf64\u003e\nZScoreNormalizer::is_mean_stable(\u0026self, threshold: f64) -\u003e bool\n// ... 60+ additional analytics (see Analytics Suite)\n```\n\n### `lorentz` module\n\n```rust\nLorentzTransform::new(beta: f64) -\u003e Result\u003cLorentzTransform, StreamError\u003e  // beta in [0, 1)\nLorentzTransform::beta(\u0026self) -\u003e f64\nLorentzTransform::gamma(\u0026self) -\u003e f64\nLorentzTransform::transform(\u0026self, p: SpacetimePoint) -\u003e SpacetimePoint\nLorentzTransform::inverse_transform(\u0026self, p: SpacetimePoint) -\u003e SpacetimePoint\nLorentzTransform::transform_batch(\u0026self, points: \u0026[SpacetimePoint]) -\u003e Vec\u003cSpacetimePoint\u003e\nLorentzTransform::dilate_time(\u0026self, t: f64) -\u003e f64        // t' = gamma * t (x = 0)\nLorentzTransform::contract_length(\u0026self, x: f64) -\u003e f64   // x' = x / gamma (t = 0)\nLorentzTransform::spacetime_interval(\u0026self, p: SpacetimePoint) -\u003e f64  // t^2 - x^2\nLorentzTransform::rapidity(\u0026self) -\u003e f64                   // atanh(beta)\nLorentzTransform::relativistic_momentum(\u0026self, mass: f64) -\u003e f64  // gamma * mass * beta\nLorentzTransform::four_momentum(\u0026self, mass: f64) -\u003e (f64, f64)   // (E, p)\nLorentzTransform::velocity_addition(\u0026self, other_beta: f64) -\u003e Result\u003cf64, StreamError\u003e\nLorentzTransform::proper_acceleration(\u0026self, coordinate_accel: f64) -\u003e f64\nLorentzTransform::proper_length(\u0026self, coordinate_length: f64) -\u003e f64\nLorentzTransform::time_dilation_ms(\u0026self, coordinate_time_ms: f64) -\u003e f64\nLorentzTransform::boost_composition(\u0026self, other: \u0026LorentzTransform) -\u003e Result\u003cLorentzTransform, StreamError\u003e\nLorentzTransform::beta_times_gamma(\u0026self) -\u003e f64           // β·γ\nLorentzTransform::energy_momentum_invariant(\u0026self, mass: f64) -\u003e f64  // E² - p² = m²\n\nSpacetimePoint::new(t: f64, x: f64) -\u003e SpacetimePoint\nSpacetimePoint { t: f64, x: f64 }  // public fields\n```\n\n### `health` module\n\n```rust\nHealthMonitor::new(default_stale_threshold_ms: u64) -\u003e HealthMonitor\nHealthMonitor::with_circuit_breaker_threshold(self, threshold: u32) -\u003e HealthMonitor\nHealthMonitor::register(\u0026self, feed_id: impl Into\u003cString\u003e, stale_threshold_ms: Option\u003cu64\u003e)\nHealthMonitor::register_many(\u0026self, feed_ids: \u0026[impl AsRef\u003cstr\u003e])\nHealthMonitor::register_batch(\u0026self, feeds: \u0026[(impl AsRef\u003cstr\u003e, u64)])  // per-feed custom thresholds\nHealthMonitor::heartbeat(\u0026self, feed_id: \u0026str, ts_ms: u64) -\u003e Result\u003c(), StreamError\u003e\nHealthMonitor::check_all(\u0026self, now_ms: u64) -\u003e Vec\u003cStreamError\u003e\nHealthMonitor::is_circuit_open(\u0026self, feed_id: \u0026str) -\u003e bool\nHealthMonitor::get(\u0026self, feed_id: \u0026str) -\u003e Option\u003cFeedHealth\u003e\nHealthMonitor::all_feeds(\u0026self) -\u003e Vec\u003cFeedHealth\u003e\nHealthMonitor::feed_count(\u0026self) -\u003e usize\nHealthMonitor::healthy_count(\u0026self) -\u003e usize\nHealthMonitor::stale_count(\u0026self) -\u003e usize\nHealthMonitor::degraded_count(\u0026self) -\u003e usize\nHealthMonitor::healthy_feed_ids(\u0026self) -\u003e Vec\u003cString\u003e\nHealthMonitor::unknown_feed_ids(\u0026self) -\u003e Vec\u003cString\u003e      // feeds with no heartbeat yet\nHealthMonitor::feeds_needing_check(\u0026self) -\u003e Vec\u003cString\u003e   // sorted non-Healthy feed IDs\nHealthMonitor::ratio_healthy(\u0026self) -\u003e f64                 // healthy / total\nHealthMonitor::total_tick_count(\u0026self) -\u003e u64\nHealthMonitor::last_updated_feed_id(\u0026self) -\u003e Option\u003cString\u003e\nHealthMonitor::is_any_stale(\u0026self) -\u003e bool\nHealthMonitor::min_healthy_age_ms(\u0026self, now_ms: u64) -\u003e Option\u003cu64\u003e\n\nFeedHealth::elapsed_ms(\u0026self, now_ms: u64) -\u003e Option\u003cu64\u003e\n```\n\n### `session` module\n\n```rust\nSessionAwareness::new(session: MarketSession) -\u003e SessionAwareness\nSessionAwareness::status(\u0026self, utc_ms: u64) -\u003e Result\u003cTradingStatus, StreamError\u003e\nSessionAwareness::is_active(\u0026self, utc_ms: u64) -\u003e bool          // Open or Extended\nSessionAwareness::remaining_ms(\u0026self, utc_ms: u64) -\u003e Option\u003cu64\u003e\nSessionAwareness::time_until_close(\u0026self, utc_ms: u64) -\u003e Option\u003cu64\u003e\nSessionAwareness::minutes_until_close(\u0026self, utc_ms: u64) -\u003e Option\u003cf64\u003e\nSessionAwareness::session_duration(\u0026self) -\u003e u64\nSessionAwareness::is_pre_market(\u0026self, utc_ms: u64) -\u003e bool\nSessionAwareness::is_after_hours(\u0026self, utc_ms: u64) -\u003e bool\nSessionAwareness::session_label(\u0026self) -\u003e \u0026'static str\nSessionAwareness::session_name(\u0026self) -\u003e \u0026'static str\nSessionAwareness::seconds_until_open(\u0026self, utc_ms: u64) -\u003e f64\nSessionAwareness::is_closing_bell_minute(\u0026self, utc_ms: u64) -\u003e bool\nSessionAwareness::is_expiry_week(\u0026self, date: chrono::NaiveDate) -\u003e bool\nSessionAwareness::is_fomc_blackout_window(\u0026self, date: chrono::NaiveDate) -\u003e bool\nSessionAwareness::is_market_holiday_adjacent(\u0026self, date: chrono::NaiveDate) -\u003e bool\nSessionAwareness::day_of_week_name(\u0026self, date: chrono::NaiveDate) -\u003e \u0026'static str\n\nis_tradeable(session: MarketSession, utc_ms: u64) -\u003e Result\u003cbool, StreamError\u003e\n```\n\n### `ws` module\n\n```rust\nReconnectPolicy::new(\n    max_attempts: u32,\n    initial_backoff: Duration,\n    max_backoff: Duration,\n    multiplier: f64,\n) -\u003e Result\u003cReconnectPolicy, StreamError\u003e\nReconnectPolicy::default() -\u003e ReconnectPolicy   // 10 attempts, 500ms initial, 30s cap, 2x multiplier\nReconnectPolicy::backoff_for_attempt(\u0026self, attempt: u32) -\u003e Duration\n\nConnectionConfig::new(url: impl Into\u003cString\u003e, channel_capacity: usize) -\u003e Result\u003cConnectionConfig, StreamError\u003e\nConnectionConfig::with_reconnect_policy(self, policy: ReconnectPolicy) -\u003e ConnectionConfig\nConnectionConfig::with_ping_interval(self, interval: Duration) -\u003e ConnectionConfig\n\nWsManager::new(config: ConnectionConfig) -\u003e WsManager\nWsManager::connect(\u0026mut self) -\u003e Result\u003c(), StreamError\u003e\nWsManager::disconnect(\u0026mut self)\nWsManager::is_connected(\u0026self) -\u003e bool\nWsManager::next_reconnect_backoff(\u0026mut self) -\u003e Result\u003cDuration, StreamError\u003e\n```\n\n## Supported Exchanges\n\n| Exchange | Adapter | Status | Wire-format fields used |\n|---|---|---|---|\n| Binance | `Exchange::Binance` | Stable | `p` (price), `q` (qty), `m` (maker/taker), `t` (trade id), `T` (exchange ts) |\n| Coinbase | `Exchange::Coinbase` | Stable | `price`, `size`, `side`, `trade_id` |\n| Alpaca | `Exchange::Alpaca` | Stable | `p` (price), `s` (size), `i` (trade id) |\n| Polygon | `Exchange::Polygon` | Stable | `p` (price), `s` (size), `i` (trade id), `t` (exchange ts) |\n\nAll four adapters are covered by unit and integration tests. To add a new exchange,\nsee the **Contributing** section below.\n\n## Precision and Accuracy Notes\n\n- **Price and quantity fields** use `rust_decimal::Decimal` — a 96-bit integer\n  mantissa with a power-of-10 exponent. This guarantees exact representation of\n  any finite decimal number with up to 28 significant digits. There is no\n  floating-point rounding error on price arithmetic.\n- **Normalization (`f64`)** uses IEEE 754 double precision. The error bound on\n  `normalize(x)` is roughly `2 * machine_epsilon * |x|` in the worst case.\n  For typical price ranges this is well below any practical threshold.\n- **Lorentz parameters (`f64`)** use `f64` throughout. The round-trip error of\n  `inverse_transform(transform(p))` is bounded by `4 * gamma^2 * machine_epsilon`.\n  For `beta \u003c= 0.9`, `gamma \u003c= ~2.3` and the round-trip error is `\u003c 1e-13`.\n- **Bar aggregation** accumulates volume with `Decimal` addition. OHLC fields\n  carry the exact decimal values from normalized ticks with no intermediate rounding.\n\n## Error Handling\n\nAll fallible operations return `Result\u003c_, StreamError\u003e`. `StreamError` variants:\n\n| Variant | Subsystem | When emitted |\n|---|---|---|\n| `ConnectionFailed` | ws | WebSocket connection attempt rejected |\n| `Disconnected` | ws | Live connection dropped unexpectedly |\n| `ReconnectExhausted` | ws | All reconnect attempts consumed |\n| `Backpressure` | ws / ring | Downstream channel or ring buffer is full |\n| `ParseError` | tick | Tick deserialization failed (missing field, invalid decimal) |\n| `UnknownExchange` | tick | Exchange identifier string not recognized |\n| `InvalidTick` | tick | Tick failed validation (negative price, zero quantity) |\n| `BookReconstructionFailed` | book | Delta applied to wrong symbol, or sequence gap |\n| `BookCrossed` | book | Order book bid \u003e= ask after applying a delta |\n| `StaleFeed` | health | Feed has not produced data within staleness threshold |\n| `AggregationError` | ohlcv | Wrong symbol or zero-duration timeframe |\n| `NormalizationError` | norm | `normalize()` called before any observations fed |\n| `RingBufferFull` | ring | SPSC ring buffer has no free slots |\n| `RingBufferEmpty` | ring | SPSC ring buffer has no pending items |\n| `LorentzConfigError` | lorentz | `beta \u003e= 1` or `beta \u003c 0` or `beta = NaN` |\n| `ConfigError { reason }` | ofi / microstructure | Invalid constructor argument (e.g. zero window size) |\n| `Io` | all | Underlying I/O error |\n| `WebSocket` | ws | WebSocket protocol-level error |\n\n## Custom Pipeline Extensions\n\n### Implementing a custom tick normalizer\n\n```rust\nuse fin_stream::tick::{NormalizedTick, RawTick, TradeSide};\nuse fin_stream::error::StreamError;\n\nstruct MyNormalizer;\n\nimpl MyNormalizer {\n    fn normalize(\u0026self, raw: RawTick) -\u003e Result\u003cNormalizedTick, StreamError\u003e {\n        let price = raw.payload[\"price\"]\n            .as_str()\n            .ok_or_else(|| StreamError::ParseError { reason: \"missing price\".into() })?\n            .parse()\n            .map_err(|e: rust_decimal::Error| StreamError::ParseError { reason: e.to_string() })?;\n        Ok(NormalizedTick {\n            exchange: raw.exchange,\n            symbol: raw.symbol.clone(),\n            price,\n            quantity: rust_decimal::Decimal::ONE,\n            side: TradeSide::Buy,\n            trade_id: None,\n            exchange_ts_ms: None,\n            received_at_ms: raw.received_at_ms,\n        })\n    }\n}\n```\n\n### Implementing a custom downstream consumer\n\n```rust\nuse fin_stream::ohlcv::OhlcvBar;\n\nfn process_bar(bar: \u0026OhlcvBar) {\n    // Access typed fields: bar.open, bar.high, bar.low, bar.close, bar.volume\n    let range = bar.bar_range();\n    let body = bar.body_size();\n    println!(\"range={range} body={body} trades={}\", bar.trade_count);\n    println!(\"close_location={:.4}\", bar.close_location_value().unwrap_or(0.0));\n}\n```\n\n## Running Tests and Benchmarks\n\n```bash\ncargo test                          # unit and integration tests\ncargo test --release                # release-mode correctness check\nPROPTEST_CASES=1000 cargo test      # extended property-based test coverage\ncargo clippy --all-features -- -D warnings\ncargo fmt --all -- --check\ncargo doc --no-deps --all-features --open\ncargo bench                         # Criterion microbenchmarks\ncargo audit                         # security vulnerability scan\n```\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md) for a full version-by-version history.\n\n## Contributing\n\n### General workflow\n\n1. Fork the repository and create a feature branch.\n2. Add or update tests for any changed behaviour. The CI gate requires all tests\n   to pass and Clippy to report no warnings.\n3. Run `cargo fmt` before opening a pull request.\n4. Keep public APIs documented with `///` doc comments; `#![deny(missing_docs)]`\n   is active in `lib.rs` — undocumented public items cause a build failure.\n5. Open a pull request against `main`. The CI pipeline (fmt, clippy, test\n   on three platforms, bench, doc, deny, coverage) must be green before merge.\n\n### Adding a new exchange adapter\n\n1. Add the variant to `Exchange` in `src/tick/mod.rs` with a `///` doc comment.\n2. Implement `Display` and `FromStr` for the new variant in the same file.\n3. Add a `normalize_\u003cexchange\u003e` method following the pattern of `normalize_binance`.\n4. Wire the method into `TickNormalizer::normalize` via the match arm.\n5. Add unit tests covering: happy-path, each required missing field returning\n   `StreamError::ParseError`, and an invalid decimal string.\n6. Update the README \"Supported Exchanges\" table and `CHANGELOG.md` `[Unreleased]`.\n\n## License\n\nMIT. See [LICENSE](LICENSE) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FMattbusel%2Ffin-stream","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FMattbusel%2Ffin-stream","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FMattbusel%2Ffin-stream/lists"}