use fin_primitives::error::FinError;
use fin_primitives::orderbook::{BookDelta, DeltaAction, OrderBook};
use fin_primitives::position::{Fill, Position, PositionLedger};
use fin_primitives::signals::indicators::Rsi;
use fin_primitives::signals::{Signal, SignalValue};
use fin_primitives::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::HashMap;
fn sym(s: &str) -> Symbol {
Symbol::new(s).unwrap()
}
fn p(v: Decimal) -> Price {
Price::new(v).unwrap()
}
fn q(v: Decimal) -> Quantity {
Quantity::new(v).unwrap()
}
fn fill(symbol: &str, side: Side, qty: Decimal, price: Decimal, comm: Decimal) -> Fill {
Fill {
symbol: sym(symbol),
side,
quantity: q(qty),
price: p(price),
timestamp: NanoTimestamp::new(0),
commission: comm,
}
}
fn set_delta(side: Side, price: &str, qty: &str, seq: u64) -> BookDelta {
BookDelta {
side,
price: Price::new(price.parse().unwrap()).unwrap(),
quantity: Quantity::new(qty.parse().unwrap()).unwrap(),
action: DeltaAction::Set,
sequence: seq,
}
}
#[allow(dead_code)]
fn remove_delta(side: Side, price: &str, seq: u64) -> BookDelta {
BookDelta {
side,
price: Price::new(price.parse().unwrap()).unwrap(),
quantity: Quantity::zero(),
action: DeltaAction::Remove,
sequence: seq,
}
}
fn bar(close: Decimal) -> fin_primitives::ohlcv::OhlcvBar {
let pr = p(close);
fin_primitives::ohlcv::OhlcvBar {
symbol: sym("X"),
open: pr,
high: pr,
low: pr,
close: pr,
volume: q(dec!(0)),
ts_open: NanoTimestamp::new(0),
ts_close: NanoTimestamp::new(1),
tick_count: 1,
}
}
#[test]
fn zero_quantity_buy_fill_leaves_position_flat() {
let mut pos = Position::new(sym("AAPL"));
let pnl = pos
.apply_fill(&fill("AAPL", Side::Bid, dec!(0), dec!(100), dec!(0)))
.unwrap();
assert_eq!(pnl, dec!(0));
assert!(pos.is_flat());
assert_eq!(pos.quantity, dec!(0));
}
#[test]
fn zero_quantity_sell_fill_does_not_change_long_position() {
let mut pos = Position::new(sym("AAPL"));
pos.apply_fill(&fill("AAPL", Side::Bid, dec!(10), dec!(100), dec!(0)))
.unwrap();
let qty_before = pos.quantity;
pos.apply_fill(&fill("AAPL", Side::Ask, dec!(0), dec!(110), dec!(0)))
.unwrap();
assert_eq!(
pos.quantity, qty_before,
"zero-quantity sell must not reduce position"
);
}
#[test]
fn zero_quantity_fill_ledger_cash_unchanged() {
let mut ledger = PositionLedger::new(dec!(10_000));
let cash_before = ledger.cash();
ledger
.apply_fill(Fill {
symbol: sym("BTC"),
side: Side::Bid,
quantity: q(dec!(0)),
price: p(dec!(50_000)),
timestamp: NanoTimestamp::new(0),
commission: dec!(0),
})
.unwrap();
assert_eq!(
ledger.cash(),
cash_before,
"zero-quantity buy must not debit cash"
);
}
#[test]
fn crossed_book_bid_above_ask_rejected() {
let mut book = OrderBook::new(sym("BTC"));
book.apply_delta(set_delta(Side::Ask, "100", "5", 1))
.unwrap();
let result = book.apply_delta(set_delta(Side::Bid, "100", "5", 2));
assert!(
matches!(result, Err(FinError::InvertedSpread { .. })),
"bid at same price as ask must produce InvertedSpread, got {:?}",
result
);
}
#[test]
fn crossed_book_ask_below_bid_rejected() {
let mut book = OrderBook::new(sym("ETH"));
book.apply_delta(set_delta(Side::Bid, "200", "3", 1))
.unwrap();
let result = book.apply_delta(set_delta(Side::Ask, "200", "3", 2));
assert!(
matches!(result, Err(FinError::InvertedSpread { .. })),
"ask at same price as bid must produce InvertedSpread, got {:?}",
result
);
}
#[test]
fn crossed_book_sequence_does_not_advance_on_rejection() {
let mut book = OrderBook::new(sym("X"));
book.apply_delta(set_delta(Side::Ask, "100", "5", 1))
.unwrap();
let _ = book.apply_delta(set_delta(Side::Bid, "101", "5", 2));
assert_eq!(
book.sequence(),
1,
"sequence must stay at 1 after rejected delta"
);
}
#[test]
fn crossed_book_rejected_bid_not_in_book() {
let mut book = OrderBook::new(sym("X"));
book.apply_delta(set_delta(Side::Ask, "100", "5", 1))
.unwrap();
let _ = book.apply_delta(set_delta(Side::Bid, "101", "5", 2));
assert!(
book.best_bid().is_none(),
"rejected bid must not appear in book after rollback"
);
}
#[test]
fn duplicate_price_level_update_replaces_quantity() {
let mut book = OrderBook::new(sym("AAPL"));
book.apply_delta(set_delta(Side::Bid, "150", "10", 1))
.unwrap();
book.apply_delta(set_delta(Side::Bid, "150", "25", 2))
.unwrap();
let best = book.best_bid().unwrap();
assert_eq!(best.price.value(), dec!(150));
assert_eq!(
best.quantity.value(),
dec!(25),
"second set at same price must overwrite quantity"
);
assert_eq!(
book.bid_count(),
1,
"should still have exactly one bid level"
);
}
#[test]
fn duplicate_ask_price_level_update_replaces_quantity() {
let mut book = OrderBook::new(sym("MSFT"));
book.apply_delta(set_delta(Side::Ask, "300", "5", 1))
.unwrap();
book.apply_delta(set_delta(Side::Ask, "300", "20", 2))
.unwrap();
let best = book.best_ask().unwrap();
assert_eq!(best.price.value(), dec!(300));
assert_eq!(best.quantity.value(), dec!(20));
assert_eq!(book.ask_count(), 1);
}
#[test]
fn price_zero_returns_invalid_price_error() {
let result = Price::new(dec!(0));
assert!(matches!(result, Err(FinError::InvalidPrice(_))));
}
#[test]
fn price_negative_returns_invalid_price_error() {
let result = Price::new(dec!(-0.01));
assert!(matches!(result, Err(FinError::InvalidPrice(_))));
}
#[test]
fn price_very_small_positive_is_valid() {
assert!(Price::new(dec!(0.000001)).is_ok());
}
#[test]
fn quantity_negative_returns_invalid_quantity_error() {
let result = Quantity::new(dec!(-1));
assert!(matches!(result, Err(FinError::InvalidQuantity(_))));
}
#[test]
fn symbol_empty_string_returns_invalid_symbol_error() {
let result = Symbol::new("");
assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
}
#[test]
fn symbol_tab_character_returns_invalid_symbol_error() {
let result = Symbol::new("AA\tPL");
assert!(matches!(result, Err(FinError::InvalidSymbol(_))));
}
#[test]
fn rsi_period_14_unavailable_for_first_14_bars() {
let mut rsi = Rsi::new("rsi14", 14).unwrap();
let prices = [
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
dec!(44),
];
for price in &prices {
let val = rsi.update_bar(&bar(*price)).unwrap();
assert!(
matches!(val, SignalValue::Unavailable),
"expected Unavailable for < period bars, got {:?}",
val
);
}
assert!(
!rsi.is_ready(),
"RSI must not be ready after exactly 14 bars (need period+1 changes)"
);
}
#[test]
fn rsi_period_14_produces_scalar_after_period_plus_one_bars() {
let mut rsi = Rsi::new("rsi14", 14).unwrap();
let prices: Vec<Decimal> = (0..15).map(|i| dec!(100) + Decimal::from(i)).collect();
let mut last = SignalValue::Unavailable;
for p in &prices {
last = rsi.update_bar(&bar(*p)).unwrap();
}
assert!(
matches!(last, SignalValue::Scalar(_)),
"RSI must produce Scalar after period+1 bars"
);
assert!(rsi.is_ready());
}
#[test]
fn rsi_period_3_unavailable_for_first_3_bars() {
let mut rsi = Rsi::new("rsi3", 3).unwrap();
let _ = rsi.update_bar(&bar(dec!(100))).unwrap();
let v1 = rsi.update_bar(&bar(dec!(101))).unwrap();
let v2 = rsi.update_bar(&bar(dec!(102))).unwrap();
assert!(matches!(v1, SignalValue::Unavailable));
assert!(matches!(v2, SignalValue::Unavailable));
assert!(!rsi.is_ready());
let v3 = rsi.update_bar(&bar(dec!(103))).unwrap();
assert!(matches!(v3, SignalValue::Scalar(_)));
assert!(rsi.is_ready());
}
#[test]
fn rsi_all_losses_approaches_zero() {
let mut rsi = Rsi::new("rsi3", 3).unwrap();
let prices = [dec!(100), dec!(90), dec!(80), dec!(70), dec!(60)];
let mut last_val: Option<Decimal> = None;
for price in &prices {
if let SignalValue::Scalar(v) = rsi.update_bar(&bar(*price)).unwrap() {
last_val = Some(v);
}
}
let val = last_val.expect("RSI must be Scalar after period+1 bars");
assert_eq!(val, dec!(0), "all losses should produce RSI = 0");
}
#[test]
fn pnl_accounting_identity_buy_then_sell_net_cash_change() {
let initial_cash = dec!(10_000);
let mut ledger = PositionLedger::new(initial_cash);
ledger
.apply_fill(Fill {
symbol: sym("AAPL"),
side: Side::Bid,
quantity: q(dec!(10)),
price: p(dec!(100)),
timestamp: NanoTimestamp::new(0),
commission: dec!(1),
})
.unwrap();
ledger
.apply_fill(Fill {
symbol: sym("AAPL"),
side: Side::Ask,
quantity: q(dec!(10)),
price: p(dec!(110)),
timestamp: NanoTimestamp::new(0),
commission: dec!(1),
})
.unwrap();
let expected_cash =
initial_cash - (dec!(10) * dec!(100) + dec!(1)) + (dec!(10) * dec!(110) - dec!(1));
assert_eq!(ledger.cash(), expected_cash);
let pos = ledger.position(&sym("AAPL")).unwrap();
assert!(pos.is_flat());
let prices: HashMap<String, Price> = HashMap::new();
let equity = ledger.equity(&prices).unwrap();
assert_eq!(equity, expected_cash);
assert_eq!(ledger.realized_pnl_total(), dec!(98));
}
#[test]
fn pnl_accounting_identity_multiple_buys_avg_cost_invariant() {
let mut pos = Position::new(sym("X"));
let fills: Vec<(Decimal, Decimal)> = vec![
(dec!(10), dec!(100)),
(dec!(5), dec!(120)),
(dec!(15), dec!(110)),
];
let mut total_notional = dec!(0);
let mut total_qty = dec!(0);
for (qty_val, price_val) in &fills {
total_notional += qty_val * price_val;
total_qty += qty_val;
pos.apply_fill(&Fill {
symbol: sym("X"),
side: Side::Bid,
quantity: q(*qty_val),
price: p(*price_val),
timestamp: NanoTimestamp::new(0),
commission: dec!(0),
})
.unwrap();
}
let expected_avg_cost = total_notional / total_qty;
assert_eq!(
pos.avg_cost, expected_avg_cost,
"average cost must equal total notional / total quantity"
);
assert_eq!(pos.quantity, total_qty);
let mv = pos.market_value(p(expected_avg_cost));
let mv_diff = (mv - total_notional).abs();
assert!(
mv_diff < dec!(0.01),
"market_value must be within 0.01 of total_notional, diff={mv_diff}"
);
let upnl = pos.unrealized_pnl(p(expected_avg_cost));
assert!(
upnl.abs() < dec!(0.01),
"unrealized_pnl at avg_cost must be near 0, got {upnl}"
);
}
#[test]
fn pnl_accounting_identity_realized_pnl_sums_correctly() {
let mut pos = Position::new(sym("X"));
pos.apply_fill(&Fill {
symbol: sym("X"),
side: Side::Bid,
quantity: q(dec!(20)),
price: p(dec!(50)),
timestamp: NanoTimestamp::new(0),
commission: dec!(0),
})
.unwrap();
let sell_fills: Vec<(Decimal, Decimal)> = vec![
(dec!(5), dec!(60)),
(dec!(10), dec!(55)),
(dec!(5), dec!(65)),
];
let mut expected_realized = dec!(0);
for (qty_val, price_val) in &sell_fills {
let pnl = pos
.apply_fill(&Fill {
symbol: sym("X"),
side: Side::Ask,
quantity: q(*qty_val),
price: p(*price_val),
timestamp: NanoTimestamp::new(0),
commission: dec!(0),
})
.unwrap();
expected_realized += pnl;
}
assert_eq!(
pos.realized_pnl, expected_realized,
"sum of per-fill realized PnL must equal cumulative realized_pnl field"
);
let manual: Decimal = sell_fills
.iter()
.map(|(qty_val, price_val)| qty_val * (price_val - dec!(50)))
.sum();
assert_eq!(pos.realized_pnl, manual);
}