opendeviationbar-streaming 13.66.2

Real-time streaming engine for open deviation bar processing
Documentation
//! Type definitions for the live bar engine.

use opendeviationbar_core::OpenDeviationBar;
use opendeviationbar_providers::binance::{AdaptiveRateLimiter, ReconnectionPolicy};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::sync::watch;

use opendeviationbar_core::checkpoint::Checkpoint;

/// Microseconds per UTC day -- used for midnight boundary detection.
pub(crate) const DAY_US: i64 = 86_400_000_000;

/// Ouroboros reset mode for bar construction.
///
/// Controls whether processors reset at UTC midnight boundaries:
/// - `Day` (default): Reset at midnight, producing orphan bars (existing production behavior)
/// - `Aion`: Continuous mode, no midnight reset -- bars span across day boundaries (24/7 crypto)
/// - `Week`: Gap-based reset for forex weekend boundaries. When the gap between consecutive
///   trades exceeds `max_gap_us` (default 4 hours = 14_400_000_000 microseconds),
///   `reset_at_ouroboros()` fires, emitting the forming bar as orphan. No midnight resets.
///   Uses timestamp arithmetic -- no timezone dependency in the reset mechanism itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OuroborosMode {
    /// Day mode: reset at UTC midnight (existing behavior)
    #[default]
    Day,
    /// Aion mode: continuous, no midnight reset (24/7 crypto)
    Aion,
    /// Week mode: gap-based reset for forex weekend boundaries.
    /// `max_gap_us` is the inter-trade gap threshold in microseconds.
    /// Default: 14_400_000_000 (4 hours).
    Week { max_gap_us: i64 },
}

/// A completed bar with metadata identifying its source.
/// Issue #96: `symbol` is `Arc<str>` -- created once per symbol task, cheap clone per bar.
#[derive(Debug, Clone)]
pub struct CompletedBar {
    pub symbol: Arc<str>,
    pub threshold_decimal_bps: u32,
    pub bar: OpenDeviationBar,
}

/// Issue #214: A forming (incomplete) bar snapshot for SSE push to consumers.
///
/// Published at 1Hz via `tokio::sync::watch` channels. Each (symbol, threshold)
/// pair has its own watch channel. The daemon thread reads snapshots without
/// blocking the trade-processing hot path.
#[derive(Debug, Clone)]
pub struct FormingBar {
    pub symbol: Arc<str>,
    pub threshold_decimal_bps: u32,
    pub bar: OpenDeviationBar,
    /// Microsecond timestamp of the last trade that updated this forming bar.
    /// Used by the daemon thread to skip broadcasting stale forming bars
    /// (e.g., when markets are closed and no trades arrive).
    pub last_trade_timestamp_us: i64,
}

/// Key type for forming bar watch channels: (symbol, threshold).
pub(crate) type FormingBarKey = (Arc<str>, u32);

/// Map of watch receivers for forming bar snapshots (Issue #214).
/// Extracted from the engine before `start()` (like `take_checkpoint_receiver()`).
pub type FormingBarWatches = HashMap<FormingBarKey, watch::Receiver<Option<FormingBar>>>;

/// Metrics for the live bar engine.
/// Issue #96 Task #6: Added backpressure metrics for monitoring queue behavior
/// Issue #96 Task #12: Expose ring buffer metrics (dropped bars, queue depth)
#[derive(Debug)]
pub struct LiveEngineMetrics {
    pub trades_received: AtomicU64,
    pub bars_emitted: AtomicU64,
    pub reconnections: AtomicU64,
    pub backpressure_events: AtomicU64, // Times ring buffer was full and bar dropped
    pub dropped_bars: AtomicU64,        // Total bars dropped due to full ring buffer
    pub max_queue_depth: AtomicU64,     // Maximum observed queue depth
    pub total_block_time_ms: AtomicU64, // Accumulated time producer waited for queue space
    pub gap_fills: AtomicU64,           // Number of reconnection gap fills performed
    pub gap_trades_recovered: AtomicU64, // Total trades recovered via REST gap fill
}

impl Default for LiveEngineMetrics {
    fn default() -> Self {
        Self {
            trades_received: AtomicU64::new(0),
            bars_emitted: AtomicU64::new(0),
            reconnections: AtomicU64::new(0),
            backpressure_events: AtomicU64::new(0),
            dropped_bars: AtomicU64::new(0),
            max_queue_depth: AtomicU64::new(0),
            total_block_time_ms: AtomicU64::new(0),
            gap_fills: AtomicU64::new(0),
            gap_trades_recovered: AtomicU64::new(0),
        }
    }
}

/// WebSocket connection mode for the live bar engine.
///
/// Controls how the sidecar connects to Binance WebSocket streams:
/// - `Combined` (default): 2 combined connections, symbols split by volume for fault isolation
/// - `PerSymbol`: N individual connections, one per symbol (rollback path per D-01)
///
/// Set via `OPENDEVIATIONBAR_STREAMING_WS_MODE` env var ("combined" or "per_symbol").
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum WsMode {
    #[default]
    Combined,
    PerSymbol,
}

/// Split symbols into 2 groups by known Binance spot volume ranking.
///
/// High-volume symbols are assigned to group A, lower-volume to group B,
/// alternating to balance group sizes. Symbols not in the ranking go to
/// the smaller group.
///
/// Returns (group_a, group_b) where group_a contains the first half of
/// ranked symbols and group_b contains the second half.
pub fn split_symbols_by_volume(symbols: &[String]) -> (Vec<String>, Vec<String>) {
    // Hardcoded volume ranking order (top 13 by Binance spot 24h volume, stable)
    const VOLUME_RANKING: &[&str] = &[
        "BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT", "DOGEUSDT", "ADAUSDT", "AVAXUSDT",
        "LINKUSDT", "TRXUSDT", "SUIUSDT", "LTCUSDT", "NEARUSDT",
    ];

    if symbols.len() <= 1 {
        return (symbols.to_vec(), Vec::new());
    }

    // Score each symbol: ranked symbols get their rank index, unranked get a high value
    let mut scored: Vec<(usize, &String)> = symbols
        .iter()
        .map(|s| {
            let rank = VOLUME_RANKING
                .iter()
                .position(|&r| r == s.as_str())
                .unwrap_or(VOLUME_RANKING.len() + 1000);
            (rank, s)
        })
        .collect();

    // Sort by rank (high-volume first)
    scored.sort_by_key(|(rank, _)| *rank);

    let half = scored.len().div_ceil(2); // 5 -> 3, 26 -> 13
    let mut group_a = Vec::with_capacity(half);
    let mut group_b = Vec::with_capacity(scored.len() - half);

    for (i, (_, sym)) in scored.into_iter().enumerate() {
        if i < half {
            group_a.push(sym.clone());
        } else {
            group_b.push(sym.clone());
        }
    }

    (group_a, group_b)
}

/// Configuration for the live bar engine.
#[derive(Debug, Clone)]
pub struct LiveEngineConfig {
    /// Symbols to stream (e.g., ["BTCUSDT", "ETHUSDT"])
    pub symbols: Vec<String>,
    /// Thresholds in decimal basis points (e.g., [250, 500, 750, 1000])
    pub thresholds: Vec<u32>,
    /// Whether to compute inter-bar + intra-bar microstructure features
    pub include_microstructure: bool,
    /// Channel capacity for completed bars (backpressure)
    pub bar_channel_capacity: usize,
    /// Reconnection policy for WebSocket connections
    pub reconnection_policy: ReconnectionPolicy,
    /// Initial checkpoints keyed by (symbol, threshold) for resuming incomplete bars
    pub initial_checkpoints: HashMap<(String, u32), Checkpoint>,
    /// Optional rate limiter for REST API gap-fill pacing (Issue #162).
    /// When set, replaces fixed 100ms sleep with budget-aware acquire().
    pub rate_limiter: Option<Arc<AdaptiveRateLimiter>>,
    /// Issue #128: Per-feature computation toggles
    pub compute_tier2: bool,
    pub compute_tier3: bool,
    pub compute_hurst: Option<bool>,
    pub compute_permutation_entropy: Option<bool>,
    /// Per-symbol gap detector seeds from ClickHouse (max trade ID across thresholds).
    /// Propagated from StreamManager's `trade_id_state` before `start()`.
    /// Used to seed `TradeIdGapDetector` so the first WS trade triggers gap
    /// detection even when no checkpoint files exist.
    pub gap_detector_seeds: HashMap<String, i64>,
    /// WebSocket connection mode: Combined (2 connections) or PerSymbol (N connections).
    /// Default: Combined. Set via OPENDEVIATIONBAR_STREAMING_WS_MODE env var.
    pub ws_mode: WsMode,
    /// Per-symbol ouroboros mode. Symbols not in this map default to Day.
    /// Used by Phase 16 (Sidecar Aion Startup) to thread per-symbol mode
    /// through start(), fill_from_rest(), symbol_task(), and puck_fill().
    pub symbol_modes: HashMap<String, OuroborosMode>,
    /// WebSocket base URL (e.g., "wss://stream.binance.com:9443").
    /// Injected from registry MarketConfig.ws_base. Defaults to DEFAULT_WS_BASE_URL.
    pub ws_base_url: String,
    /// Extra query params appended to WS URL (e.g., "timeUnit=MICROSECOND").
    /// Injected from registry MarketConfig.ws_params. Defaults to empty string.
    pub ws_params: String,
    /// REST API base URL for fill_from_rest (e.g., "https://api.binance.com/api/v3/aggTrades").
    /// Injected from registry MarketConfig.rest_url. Defaults to Binance Spot.
    pub rest_base_url: String,
    /// Extra HTTP headers for REST requests (e.g., [("X-MBX-TIME-UNIT", "MICROSECOND")]).
    /// Injected from registry MarketConfig.rest_headers. Defaults to empty vec.
    pub rest_headers: Vec<(String, String)>,
}

impl LiveEngineConfig {
    /// Get bar channel capacity from environment or use default (10K)
    /// Issue #96 Task #6: OPENDEVIATIONBAR_MAX_PENDING_BARS env var support
    fn get_bar_channel_capacity() -> usize {
        std::env::var("OPENDEVIATIONBAR_MAX_PENDING_BARS")
            .ok()
            .and_then(|v| v.parse::<usize>().ok())
            .unwrap_or(10_000)
    }

    /// Create config with sensible defaults.
    pub fn new(symbols: Vec<String>, thresholds: Vec<u32>) -> Self {
        Self {
            symbols,
            thresholds,
            include_microstructure: true,
            bar_channel_capacity: Self::get_bar_channel_capacity(),
            reconnection_policy: ReconnectionPolicy::default(),
            initial_checkpoints: HashMap::new(),
            compute_tier2: true,
            compute_tier3: false,
            compute_hurst: None,
            compute_permutation_entropy: None,
            rate_limiter: None,
            gap_detector_seeds: HashMap::new(),
            ws_mode: WsMode::default(),
            symbol_modes: HashMap::new(),
            ws_base_url: opendeviationbar_providers::binance::DEFAULT_WS_BASE_URL.to_string(),
            ws_params: String::new(),
            rest_base_url: "https://api.binance.com/api/v3/aggTrades".to_string(),
            rest_headers: Vec::new(),
        }
    }

    /// Set rate limiter for REST API gap-fill pacing (Issue #162).
    pub fn with_rate_limiter(mut self, limiter: Arc<AdaptiveRateLimiter>) -> Self {
        self.rate_limiter = Some(limiter);
        self
    }

    /// Inject a checkpoint for a specific (symbol, threshold) pair.
    /// Must be called before `LiveBarEngine::start()`.
    pub fn with_checkpoint(
        mut self,
        symbol: String,
        threshold: u32,
        checkpoint: Checkpoint,
    ) -> Self {
        self.initial_checkpoints
            .insert((symbol, threshold), checkpoint);
        self
    }

    /// Set bar channel capacity explicitly (overrides env var)
    pub fn with_bar_channel_capacity(mut self, capacity: usize) -> Self {
        self.bar_channel_capacity = capacity;
        self
    }
}

impl LiveEngineMetrics {
    /// Snapshot of current metrics.
    pub fn snapshot(&self) -> LiveEngineMetricsSnapshot {
        LiveEngineMetricsSnapshot {
            trades_received: self.trades_received.load(Ordering::Relaxed),
            bars_emitted: self.bars_emitted.load(Ordering::Relaxed),
            reconnections: self.reconnections.load(Ordering::Relaxed),
            dropped_bars: self.dropped_bars.load(Ordering::Relaxed),
            max_queue_depth: self.max_queue_depth.load(Ordering::Relaxed),
            backpressure_events: self.backpressure_events.load(Ordering::Relaxed),
            gap_fills: self.gap_fills.load(Ordering::Relaxed),
            gap_trades_recovered: self.gap_trades_recovered.load(Ordering::Relaxed),
        }
    }

    /// Get ring buffer queue depth estimate (from snapshot timing).
    /// Note: This is approximate due to concurrent updates.
    pub fn estimate_queue_depth(&self) -> u64 {
        let emitted = self.bars_emitted.load(Ordering::Relaxed);
        let dropped = self.dropped_bars.load(Ordering::Relaxed);
        let received = self.trades_received.load(Ordering::Relaxed);
        // Rough estimate: bars emitted + dropped vs trades received
        // This is not exact but gives a sense of queue pressure
        if emitted + dropped > received {
            0
        } else {
            received - emitted - dropped
        }
    }
}

/// Immutable metrics snapshot for reporting.
#[derive(Debug, Clone)]
pub struct LiveEngineMetricsSnapshot {
    pub trades_received: u64,
    pub bars_emitted: u64,
    pub reconnections: u64,
    pub dropped_bars: u64,
    pub max_queue_depth: u64,
    pub backpressure_events: u64,
    pub gap_fills: u64,
    pub gap_trades_recovered: u64,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ws_mode_combined_is_default() {
        let config = LiveEngineConfig::new(vec!["BTCUSDT".into(), "ETHUSDT".into()], vec![250]);
        assert_eq!(config.ws_mode, WsMode::Combined);
    }

    #[test]
    fn test_ws_mode_per_symbol_can_be_set() {
        let mut config = LiveEngineConfig::new(vec!["BTCUSDT".into()], vec![250]);
        config.ws_mode = WsMode::PerSymbol;
        assert_eq!(config.ws_mode, WsMode::PerSymbol);
    }

    #[test]
    fn test_split_symbols_by_volume_26_symbols() {
        // 26 symbols should split into 2 groups of 13
        let symbols: Vec<String> = vec![
            "BTCUSDT",
            "ETHUSDT",
            "SOLUSDT",
            "XRPUSDT",
            "BNBUSDT",
            "DOGEUSDT",
            "ADAUSDT",
            "AVAXUSDT",
            "LINKUSDT",
            "TRXUSDT",
            "SUIUSDT",
            "LTCUSDT",
            "NEARUSDT",
            "DOTUSDT",
            "MATICUSDT",
            "SHIBUSDT",
            "BCHUSDT",
            "UNIUSDT",
            "APTUSDT",
            "ICPUSDT",
            "ETCUSDT",
            "FILUSDT",
            "XLMUSDT",
            "ATOMUSDT",
            "VETUSDT",
            "AAVEUSDT",
        ]
        .into_iter()
        .map(String::from)
        .collect();

        let (a, b) = split_symbols_by_volume(&symbols);
        assert_eq!(a.len(), 13);
        assert_eq!(b.len(), 13);
        // High-volume symbols should be in group A
        assert!(a.contains(&"BTCUSDT".to_string()));
        assert!(a.contains(&"ETHUSDT".to_string()));
        assert!(a.contains(&"SOLUSDT".to_string()));
    }

    #[test]
    fn test_split_symbols_by_volume_odd_count() {
        let symbols: Vec<String> = vec!["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "BNBUSDT"]
            .into_iter()
            .map(String::from)
            .collect();

        let (a, b) = split_symbols_by_volume(&symbols);
        assert_eq!(a.len() + b.len(), 5);
        // One group should have 3, the other 2
        assert!(a.len() == 3 || a.len() == 2);
        assert!(b.len() == 3 || b.len() == 2);
    }

    #[test]
    fn test_split_symbols_by_volume_single_symbol() {
        let symbols: Vec<String> = vec!["BTCUSDT".to_string()];
        let (a, b) = split_symbols_by_volume(&symbols);
        assert_eq!(a.len(), 1);
        assert_eq!(b.len(), 0);
    }
}