use fin_stream::health::{HealthMonitor, HealthStatus};
use fin_stream::ohlcv::{OhlcvAggregator, Timeframe};
use fin_stream::ring::SpscRing;
use fin_stream::session::{MarketSession, SessionAwareness, TradingStatus};
use fin_stream::tick::{Exchange, NormalizedTick, TradeSide};
use fin_stream::ws::{ConnectionConfig, ReconnectPolicy, WsManager};
use fin_stream::StreamError;
use rust_decimal_macros::dec;
use std::time::Duration;
fn make_tick(symbol: &str, price_val: rust_decimal::Decimal, qty: rust_decimal::Decimal, ts_ms: u64) -> NormalizedTick {
NormalizedTick {
exchange: Exchange::Binance,
symbol: symbol.to_string(),
price: price_val,
quantity: qty,
side: Some(TradeSide::Buy),
trade_id: None,
exchange_ts_ms: None,
received_at_ms: ts_ms,
}
}
fn default_ws_config() -> ConnectionConfig {
ConnectionConfig::new("wss://example.com/ws", 1024).unwrap()
}
#[test]
fn spsc_ring_wraparound_at_exact_capacity_boundary() {
let ring: SpscRing<u32, 4> = SpscRing::new();
assert_eq!(ring.capacity(), 3);
ring.push(10).unwrap();
ring.push(20).unwrap();
ring.push(30).unwrap();
assert!(ring.is_full());
assert_eq!(ring.pop().unwrap(), 10);
assert_eq!(ring.pop().unwrap(), 20);
assert_eq!(ring.pop().unwrap(), 30);
assert!(ring.is_empty());
ring.push(100).unwrap();
ring.push(200).unwrap();
ring.push(300).unwrap();
assert!(ring.is_full());
assert_eq!(ring.pop().unwrap(), 100);
assert_eq!(ring.pop().unwrap(), 200);
assert_eq!(ring.pop().unwrap(), 300);
assert!(ring.is_empty());
}
#[test]
fn spsc_ring_capacity_boundary_push_pop_push() {
let ring: SpscRing<u64, 8> = SpscRing::new(); for i in 0..7u64 {
ring.push(i).unwrap();
}
let err = ring.push(99).unwrap_err();
assert!(matches!(err, StreamError::RingBufferFull { capacity: 7 }));
assert_eq!(ring.pop().unwrap(), 0u64);
ring.push(999).unwrap();
assert_eq!(ring.len(), 7);
}
#[test]
fn spsc_ring_empty_pop_returns_empty_error() {
let ring: SpscRing<u32, 8> = SpscRing::new();
let err = ring.pop().unwrap_err();
assert!(matches!(err, StreamError::RingBufferEmpty));
}
#[test]
fn spsc_ring_multiple_wraparound_cycles_are_fifo() {
let ring: SpscRing<u32, 4> = SpscRing::new(); let mut next_push = 0u32;
let mut next_pop = 0u32;
for _cycle in 0..10 {
for _ in 0..3 {
ring.push(next_push).unwrap();
next_push += 1;
}
for _ in 0..3 {
let v = ring.pop().unwrap();
assert_eq!(v, next_pop, "FIFO order violated at value {next_pop}");
next_pop += 1;
}
assert!(ring.is_empty());
}
}
#[test]
fn ohlcv_bar_boundary_last_tick_of_bar_and_first_of_next() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Minutes(1)).unwrap();
let last_tick_of_bar_0 = make_tick("BTC-USD", dec!(50_000), dec!(1), 0);
let second_tick_of_bar_0 = make_tick("BTC-USD", dec!(50_500), dec!(2), 59_999);
let first_tick_of_bar_1 = make_tick("BTC-USD", dec!(51_000), dec!(1), 60_000);
let r1 = agg.feed(&last_tick_of_bar_0).unwrap();
assert!(r1.is_empty(), "first tick must not complete a bar");
let r2 = agg.feed(&second_tick_of_bar_0).unwrap();
assert!(r2.is_empty(), "last tick of current bar must not complete it yet");
let mut r3 = agg.feed(&first_tick_of_bar_1).unwrap();
assert_eq!(r3.len(), 1, "bar boundary tick must emit exactly one completed bar");
let completed_bar = r3.remove(0);
assert!(completed_bar.is_complete, "emitted bar must be marked complete");
assert_eq!(completed_bar.bar_start_ms, 0, "completed bar must start at 0ms");
assert_eq!(completed_bar.open, dec!(50_000), "open must be first tick price");
assert_eq!(completed_bar.close, dec!(50_500), "close must be last tick of that bar");
assert_eq!(completed_bar.high, dec!(50_500));
assert_eq!(completed_bar.low, dec!(50_000));
assert_eq!(completed_bar.volume, dec!(3));
assert_eq!(completed_bar.trade_count, 2);
let current = agg.current_bar().unwrap();
assert_eq!(current.bar_start_ms, 60_000, "new bar must start at 60_000ms");
assert_eq!(current.open, dec!(51_000), "new bar open must be boundary tick price");
assert_eq!(current.trade_count, 1);
}
#[test]
fn ohlcv_single_tick_at_bar_boundary_starts_new_bar() {
let mut agg = OhlcvAggregator::new("ETH-USD", Timeframe::Seconds(30)).unwrap();
let tick_at_boundary = make_tick("ETH-USD", dec!(3_000), dec!(5), 30_000);
let result = agg.feed(&tick_at_boundary).unwrap();
assert!(result.is_empty(), "first tick ever cannot complete a bar");
assert_eq!(agg.current_bar().unwrap().bar_start_ms, 30_000);
}
#[test]
fn ohlcv_two_ticks_same_bar_window_accumulate() {
let mut agg = OhlcvAggregator::new("BTC-USD", Timeframe::Seconds(30)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(65_000), dec!(1), 30_000)).unwrap();
agg.feed(&make_tick("BTC-USD", dec!(66_000), dec!(2), 59_999)).unwrap();
let bar = agg.current_bar().unwrap();
assert_eq!(bar.trade_count, 2);
assert_eq!(bar.volume, dec!(3));
assert_eq!(bar.high, dec!(66_000));
}
#[test]
fn feed_health_transitions_unknown_healthy_stale_healthy() {
let monitor = HealthMonitor::new(5_000); monitor.register("BTC-USD", None);
let h = monitor.get("BTC-USD").unwrap();
assert_eq!(h.status, HealthStatus::Unknown, "newly registered feed must be Unknown");
assert!(h.last_tick_ms.is_none());
monitor.heartbeat("BTC-USD", 1_000_000).unwrap();
let h = monitor.get("BTC-USD").unwrap();
assert_eq!(h.status, HealthStatus::Healthy, "heartbeat must transition to Healthy");
let errors = monitor.check_all(1_006_000);
assert!(!errors.is_empty(), "feed should be stale after threshold exceeded");
let h = monitor.get("BTC-USD").unwrap();
assert_eq!(h.status, HealthStatus::Stale, "feed must be Stale after threshold exceeded");
assert_eq!(h.consecutive_stale, 1);
monitor.heartbeat("BTC-USD", 1_007_000).unwrap();
let h = monitor.get("BTC-USD").unwrap();
assert_eq!(h.status, HealthStatus::Healthy, "recovery heartbeat must restore Healthy status");
assert_eq!(h.consecutive_stale, 0, "consecutive_stale must reset on heartbeat");
}
#[test]
fn feed_health_unknown_feed_never_transitions_to_stale_without_heartbeat() {
let monitor = HealthMonitor::new(1_000);
monitor.register("ETH-USD", None);
for i in 0..5 {
let errors = monitor.check_all(1_000_000 + i * 10_000);
assert!(errors.is_empty(), "feed with no heartbeat must not be flagged stale");
}
let h = monitor.get("ETH-USD").unwrap();
assert_eq!(h.status, HealthStatus::Unknown);
}
#[test]
fn feed_health_circuit_breaker_opens_and_closes() {
let monitor = HealthMonitor::new(1_000).with_circuit_breaker_threshold(2);
monitor.register("FEED-X", None);
monitor.heartbeat("FEED-X", 1_000_000).unwrap();
monitor.check_all(1_002_000);
assert!(!monitor.is_circuit_open("FEED-X"), "circuit must not open after 1 stale check");
monitor.check_all(1_004_000);
assert!(monitor.is_circuit_open("FEED-X"), "circuit must open at threshold = 2");
monitor.heartbeat("FEED-X", 1_005_000).unwrap();
assert!(!monitor.is_circuit_open("FEED-X"), "heartbeat must close the circuit");
}
#[test]
fn feed_health_stale_detection_is_per_feed() {
let monitor = HealthMonitor::new(5_000);
monitor.register("BTC-USD", None);
monitor.register("ETH-USD", Some(1_000));
monitor.heartbeat("BTC-USD", 1_000_000).unwrap();
monitor.heartbeat("ETH-USD", 1_000_000).unwrap();
let errors = monitor.check_all(1_002_000);
assert_eq!(errors.len(), 1, "only ETH-USD should be stale");
assert!(errors[0].1.to_string().contains("ETH-USD"));
assert_eq!(monitor.healthy_count(), 1);
assert_eq!(monitor.stale_count(), 1);
}
#[test]
fn session_us_equity_full_day_lifecycle() {
let sa = SessionAwareness::new(MarketSession::UsEquity);
let overnight: u64 = 1704672000000;
let status = sa.status(overnight).unwrap();
assert!(
status == TradingStatus::Extended || status == TradingStatus::Closed,
"Mon 00:00 UTC (Sun 19:00 ET) must be Extended or Closed, got {:?}", status
);
let pre_market: u64 = 1704704400000;
let status = sa.status(pre_market).unwrap();
assert!(
status == TradingStatus::Extended || status == TradingStatus::Open,
"04:00 ET must be Extended or Open, got {:?}", status
);
let market_open: u64 = 1704724200000;
assert_eq!(sa.status(market_open).unwrap(), TradingStatus::Open);
let market_close: u64 = 1704747600000;
let status = sa.status(market_close).unwrap();
assert!(
status == TradingStatus::Extended || status == TradingStatus::Closed,
"16:00 ET should be Extended or Closed, got {:?}", status
);
}
#[test]
fn session_crypto_always_open_full_week() {
let sa = SessionAwareness::new(MarketSession::Crypto);
let timestamps: Vec<u64> = vec![
1704672000000, 1704715200000, 1704758400000, 1705017600000, 1705104000000, 1705147200000, 1705190400000, ];
for ts in timestamps {
assert_eq!(
sa.status(ts).unwrap(),
TradingStatus::Open,
"crypto must be Open at all times (ts={ts})"
);
}
}
#[test]
fn session_forex_closed_on_weekend_open_on_weekday() {
let sa = SessionAwareness::new(MarketSession::Forex);
let sat_morning: u64 = 1705104000000; let sat_evening: u64 = sat_morning + 20 * 3600 * 1000;
assert_eq!(sa.status(sat_morning).unwrap(), TradingStatus::Closed);
assert_eq!(sa.status(sat_evening).unwrap(), TradingStatus::Closed);
let sun_before: u64 = 1704621600000; assert_eq!(sa.status(sun_before).unwrap(), TradingStatus::Closed);
let sun_after: u64 = 1704664800000; assert_eq!(sa.status(sun_after).unwrap(), TradingStatus::Open);
let mon_midday: u64 = 1704715200000; assert_eq!(sa.status(mon_midday).unwrap(), TradingStatus::Open);
}
#[test]
fn ws_reconnect_after_disconnect_produces_exponential_backoff() {
let policy = ReconnectPolicy::new(
5,
Duration::from_millis(100),
Duration::from_secs(30),
2.0,
).unwrap();
let config = ConnectionConfig::new("wss://feed.example.com/ws", 512)
.unwrap()
.with_reconnect(policy);
let mut mgr = WsManager::new(config);
mgr.connect_simulated();
assert!(mgr.is_connected());
assert_eq!(mgr.connect_attempts(), 1);
mgr.disconnect_simulated();
assert!(!mgr.is_connected());
let mut backoffs = Vec::new();
while mgr.can_reconnect() {
backoffs.push(mgr.next_reconnect_backoff().unwrap());
}
assert!(!backoffs.is_empty(), "should have consumed at least one reconnect slot");
for w in backoffs.windows(2) {
assert!(
w[1] >= w[0],
"backoff must be non-decreasing; got {:?} then {:?}", w[0], w[1]
);
}
let err = mgr.next_reconnect_backoff().unwrap_err();
assert!(matches!(err, StreamError::ReconnectExhausted { .. }));
}
#[test]
fn ws_reconnect_exhausted_can_reconnect_is_false() {
let policy = ReconnectPolicy::new(2, Duration::from_millis(50), Duration::from_secs(5), 2.0).unwrap();
let config = ConnectionConfig::new("wss://feed.io/ws", 256)
.unwrap()
.with_reconnect(policy);
let mut mgr = WsManager::new(config);
mgr.next_reconnect_backoff().unwrap();
mgr.next_reconnect_backoff().unwrap();
assert!(!mgr.can_reconnect());
}
#[test]
fn ws_manager_initial_state_not_connected() {
let mgr = WsManager::new(default_ws_config());
assert!(!mgr.is_connected());
assert_eq!(mgr.connect_attempts(), 0);
assert!(mgr.can_reconnect(), "fresh manager must allow reconnects");
}
#[test]
fn ws_manager_connect_disconnect_reconnect_counts_attempts() {
let mut mgr = WsManager::new(default_ws_config());
mgr.connect_simulated(); mgr.disconnect_simulated();
mgr.connect_simulated(); assert_eq!(mgr.connect_attempts(), 2);
assert!(mgr.is_connected());
}
#[test]
fn ws_reconnect_first_attempt_backoff_equals_initial() {
let initial = Duration::from_millis(250);
let policy = ReconnectPolicy::new(5, initial, Duration::from_secs(30), 2.0).unwrap();
let config = ConnectionConfig::new("wss://x.io/ws", 64)
.unwrap()
.with_reconnect(policy);
let mut mgr = WsManager::new(config);
let backoff = mgr.next_reconnect_backoff().unwrap();
assert_eq!(backoff, initial, "first reconnect backoff must equal initial_backoff");
}
#[test]
fn ws_reconnect_backoff_capped_at_max() {
let max = Duration::from_secs(3);
let policy = ReconnectPolicy::new(20, Duration::from_millis(500), max, 2.0).unwrap();
let config = ConnectionConfig::new("wss://x.io/ws", 64)
.unwrap()
.with_reconnect(policy);
let mut mgr = WsManager::new(config);
while mgr.can_reconnect() {
let b = mgr.next_reconnect_backoff().unwrap();
assert!(b <= max, "backoff {b:?} must not exceed max {max:?}");
}
}