use fin_stream::norm::MinMaxNormalizer;
use fin_stream::tick::{Exchange, NormalizedTick, RawTick, TickNormalizer, TradeSide};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde_json::json;
use std::str::FromStr;
#[test]
fn test_normalized_prices_in_unit_interval() {
let mut norm = MinMaxNormalizer::new(20).unwrap();
let prices: Vec<Decimal> = (0..20)
.map(|i| dec!(50000) + Decimal::from(i) * dec!(100))
.collect();
for &p in &prices {
norm.update(p);
}
for &p in &prices {
let v = norm.normalize(p).unwrap();
assert!(
(0.0..=1.0).contains(&v),
"normalize({p}) = {v} is out of [0, 1]"
);
}
}
#[test]
fn test_normalized_value_clamped_outside_window() {
let mut norm = MinMaxNormalizer::new(5).unwrap();
for i in 0..5 {
norm.update(Decimal::from(i) * dec!(10)); }
assert_eq!(norm.normalize(dec!(-100)).unwrap(), 0.0);
assert_eq!(norm.normalize(dec!(1000)).unwrap(), 1.0);
assert_eq!(norm.normalize(dec!(0)).unwrap(), 0.0);
assert_eq!(norm.normalize(dec!(40)).unwrap(), 1.0);
}
#[test]
fn test_rolling_window_reset_makes_window_empty() {
let mut norm = MinMaxNormalizer::new(5).unwrap();
for i in 0..5 {
norm.update(Decimal::from(i));
}
assert!(!norm.is_empty());
norm.reset();
assert!(norm.is_empty());
assert!(norm.normalize(dec!(1)).is_err());
}
#[test]
fn test_rolling_window_reset_then_renormalize_fresh_range() {
let mut norm = MinMaxNormalizer::new(3).unwrap();
norm.update(dec!(0));
norm.update(dec!(5));
norm.update(dec!(10));
assert_eq!(norm.normalize(dec!(10)).unwrap(), 1.0);
norm.reset();
norm.update(dec!(100));
norm.update(dec!(150));
norm.update(dec!(200));
assert_eq!(norm.normalize(dec!(100)).unwrap(), 0.0);
assert_eq!(norm.normalize(dec!(200)).unwrap(), 1.0);
let mid = norm.normalize(dec!(150)).unwrap();
assert!((mid - 0.5).abs() < 1e-10, "mid should be 0.5, got {mid}");
}
#[test]
fn test_rolling_window_evicts_and_range_shifts() {
let mut norm = MinMaxNormalizer::new(3).unwrap();
norm.update(dec!(0)); norm.update(dec!(50));
norm.update(dec!(100));
let (min1, max1) = norm.min_max().unwrap();
assert_eq!(min1, dec!(0));
assert_eq!(max1, dec!(100));
norm.update(dec!(200));
let (min2, max2) = norm.min_max().unwrap();
assert_eq!(min2, dec!(50));
assert_eq!(max2, dec!(200));
}
fn make_raw_tick(exchange: Exchange, payload: serde_json::Value) -> RawTick {
RawTick {
exchange,
symbol: "SYM".into(),
payload,
received_at_ms: 1_700_000_000_000,
}
}
#[test]
fn test_all_exchange_normalized_tick_has_positive_price() {
let normalizer = TickNormalizer::new();
let cases = vec![
make_raw_tick(
Exchange::Binance,
json!({ "p": "65000.50", "q": "0.01", "m": false }),
),
make_raw_tick(
Exchange::Coinbase,
json!({ "price": "65000.50", "size": "0.01", "side": "buy" }),
),
make_raw_tick(Exchange::Alpaca, json!({ "p": "180.50", "s": "10" })),
make_raw_tick(Exchange::Polygon, json!({ "p": "180.50", "s": "5" })),
];
for raw in cases {
let exchange = raw.exchange;
let tick = normalizer.normalize(raw).unwrap();
assert!(
tick.price > Decimal::ZERO,
"Exchange {exchange}: price should be > 0, got {}",
tick.price
);
assert!(
tick.quantity > Decimal::ZERO,
"Exchange {exchange}: quantity should be > 0, got {}",
tick.quantity
);
}
}
#[test]
fn test_normalized_binance_ticks_in_unit_interval_via_min_max() {
let normalizer = TickNormalizer::new();
let mut norm = MinMaxNormalizer::new(10).unwrap();
let prices = [
"50000", "50100", "49900", "50200", "49800", "50300", "49700", "50400", "49600", "50500",
];
let ticks: Vec<NormalizedTick> = prices
.iter()
.map(|&p| {
let raw = make_raw_tick(Exchange::Binance, json!({ "p": p, "q": "1", "m": false }));
normalizer.normalize(raw).unwrap()
})
.collect();
for tick in &ticks {
norm.update(tick.price);
}
for tick in &ticks {
let v = norm.normalize(tick.price).unwrap();
assert!(
(0.0..=1.0).contains(&v),
"normalized price {} = {} is out of [0, 1]",
tick.price,
v
);
}
}
#[test]
fn test_tick_normalization_binance_side_mapping() {
let normalizer = TickNormalizer::new();
let buy_raw = make_raw_tick(
Exchange::Binance,
json!({ "p": "100", "q": "1", "m": false }),
);
let sell_raw = make_raw_tick(
Exchange::Binance,
json!({ "p": "100", "q": "1", "m": true }),
);
assert_eq!(
normalizer.normalize(buy_raw).unwrap().side,
Some(TradeSide::Buy)
);
assert_eq!(
normalizer.normalize(sell_raw).unwrap().side,
Some(TradeSide::Sell)
);
}
#[test]
fn test_tick_normalization_coinbase_side_mapping() {
let normalizer = TickNormalizer::new();
let buy_raw = make_raw_tick(
Exchange::Coinbase,
json!({ "price": "100", "size": "1", "side": "buy" }),
);
let sell_raw = make_raw_tick(
Exchange::Coinbase,
json!({ "price": "100", "size": "1", "side": "sell" }),
);
assert_eq!(
normalizer.normalize(buy_raw).unwrap().side,
Some(TradeSide::Buy)
);
assert_eq!(
normalizer.normalize(sell_raw).unwrap().side,
Some(TradeSide::Sell)
);
}
#[test]
fn test_tick_normalization_alpaca_polygon_side_is_none() {
let normalizer = TickNormalizer::new();
let alpaca = make_raw_tick(Exchange::Alpaca, json!({ "p": "100", "s": "1" }));
let polygon = make_raw_tick(Exchange::Polygon, json!({ "p": "100", "s": "1" }));
assert_eq!(normalizer.normalize(alpaca).unwrap().side, None);
assert_eq!(normalizer.normalize(polygon).unwrap().side, None);
}
#[test]
fn test_exchange_from_str_case_insensitive() {
let cases = [
("BINANCE", Exchange::Binance),
("binance", Exchange::Binance),
("Binance", Exchange::Binance),
("COINBASE", Exchange::Coinbase),
("alpaca", Exchange::Alpaca),
("POLYGON", Exchange::Polygon),
];
for (s, expected) in cases {
let got = Exchange::from_str(s).unwrap();
assert_eq!(got, expected, "from_str({s}) failed");
}
}
#[test]
fn test_tick_normalization_accepts_numeric_price_fields() {
let normalizer = TickNormalizer::new();
let raw = make_raw_tick(Exchange::Binance, json!({ "p": 65000.0, "q": 0.5 }));
let tick = normalizer.normalize(raw).unwrap();
assert!(tick.price > Decimal::ZERO);
assert!(tick.quantity > Decimal::ZERO);
}