use rust_decimal::prelude::ToPrimitive;
use rust_decimal::{Decimal, RoundingStrategy};
use crate::config::{LiquidityProfile, TouchSize};
use crate::domain::{ChainSnapshot, ContractKey, PriceCents, Quantity, QuoteView};
use crate::error::BacktestError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SeedOrder {
pub contract: ContractKey,
pub is_ask: bool,
pub price: PriceCents,
pub size: Quantity,
}
#[must_use]
fn touch_size_contracts(profile: &LiquidityProfile, quote: &QuoteView, is_ask: bool) -> u32 {
match profile.touch_size {
TouchSize::QuotedSize => {
if is_ask {
quote.ask_size.value()
} else {
quote.bid_size.value()
}
}
TouchSize::Flat { contracts } => contracts,
}
}
#[must_use = "the decayed size determines whether the ladder continues"]
fn decayed_size(touch: u32, factor: Decimal) -> Result<u32, BacktestError> {
let raw = Decimal::from(touch)
.checked_mul(factor)
.ok_or(BacktestError::ArithmeticOverflow)?;
let rounded = raw.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven);
let size = rounded.to_u32().ok_or(BacktestError::ArithmeticOverflow)?;
Ok(size)
}
pub(crate) fn plan_seed_into(
snap: &ChainSnapshot,
profile: &LiquidityProfile,
plan: &mut Vec<SeedOrder>,
) -> Result<(), BacktestError> {
plan.clear();
let tick_cents = snap.spec.tick_size_cents.value();
if tick_cents == 0 {
return Err(BacktestError::Execution(
"instrument tick_size_cents is zero at the book seeder".to_string(),
));
}
for (contract, quote) in &snap.quotes {
push_side_ladder(
plan,
contract,
false,
quote.bid.value(),
tick_cents,
profile,
quote,
)?;
push_side_ladder(
plan,
contract,
true,
quote.ask.value(),
tick_cents,
profile,
quote,
)?;
}
Ok(())
}
#[cfg(test)]
#[must_use = "the seed plan must be submitted to have any effect"]
pub(crate) fn plan_seed(
snap: &ChainSnapshot,
profile: &LiquidityProfile,
) -> Result<Vec<SeedOrder>, BacktestError> {
let mut plan: Vec<SeedOrder> = Vec::new();
plan_seed_into(snap, profile, &mut plan)?;
Ok(plan)
}
fn push_side_ladder(
plan: &mut Vec<SeedOrder>,
contract: &ContractKey,
is_ask: bool,
touch_cents: u64,
tick_cents: u64,
profile: &LiquidityProfile,
quote: &QuoteView,
) -> Result<(), BacktestError> {
let touch = touch_size_contracts(profile, quote, is_ask);
let mut factor = Decimal::ONE; for level in 0..=profile.depth_levels {
let size = decayed_size(touch, factor)?;
if size == 0 {
break;
}
let offset = tick_cents
.checked_mul(u64::from(level))
.ok_or(BacktestError::ArithmeticOverflow)?;
let price = if is_ask {
touch_cents
.checked_add(offset)
.ok_or(BacktestError::ArithmeticOverflow)?
} else {
match touch_cents.checked_sub(offset) {
Some(price) => price,
None => break,
}
};
let qty = Quantity::new(size)?;
plan.push(SeedOrder {
contract: contract.clone(),
is_ask,
price: PriceCents::new(price),
size: qty,
});
factor = factor
.checked_mul(profile.decay)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
Ok(())
}
#[cfg(all(test, feature = "orderbook"))]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use super::{SeedOrder, decayed_size, plan_seed};
use crate::config::{LiquidityProfile, TouchSize};
use crate::domain::{
ChainSnapshot, ContractKey, InstrumentSpec, PriceCents, Quantity, QuoteView, SimTime,
StepIndex, Underlying,
};
const TS0: i64 = 1_750_291_200_000_000_000;
const TICK: u64 = 5;
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is a valid quantity");
};
q
}
fn contract(strike: u64) -> ContractKey {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
ContractKey {
underlying,
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(TS0)),
strike: PriceCents::new(strike),
style: OptionStyle::Call,
}
}
fn quote(strike: u64, bid: u64, ask: u64, bid_size: u32, ask_size: u32) -> QuoteView {
QuoteView {
contract: contract(strike),
bid: PriceCents::new(bid),
ask: PriceCents::new(ask),
mid: PriceCents::new((bid + ask) / 2),
bid_size: qty(bid_size),
ask_size: qty(ask_size),
implied_volatility: dec!(0.2),
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
}
}
fn snapshot(quotes_in: &[QuoteView]) -> ChainSnapshot {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(TICK), 100) else {
panic!("valid spec");
};
let mut quotes = BTreeMap::new();
for q in quotes_in {
quotes.insert(q.contract.clone(), q.clone());
}
ChainSnapshot {
ts: SimTime::new(TS0),
step: StepIndex::new(0),
underlying,
underlying_price: PriceCents::new(510_000),
spec,
quotes,
}
}
fn profile(touch_size: TouchSize, depth_levels: u32, decay: Decimal) -> LiquidityProfile {
LiquidityProfile {
touch_size,
depth_levels,
decay,
}
}
fn triples(plan: &[SeedOrder]) -> Vec<(bool, u64, u32)> {
plan.iter()
.map(|o| (o.is_ask, o.price.value(), o.size.value()))
.collect()
}
#[test]
fn test_decayed_size_matches_round_touch_times_r_pow_i() {
let mut factor = Decimal::ONE;
let expected = [8u32, 4, 2, 1, 0];
for want in expected {
let got = decayed_size(8, factor);
assert!(
matches!(got, Ok(n) if n == want),
"level factor {factor} → {want}"
);
let Some(next) = factor.checked_mul(dec!(0.5)) else {
panic!("decay multiply must not overflow");
};
factor = next;
}
}
#[test]
fn test_decayed_size_half_to_even_rounds_two_and_a_half_to_two() {
assert!(matches!(decayed_size(5, dec!(0.5)), Ok(2)));
}
#[test]
fn test_plan_seed_builds_touch_plus_l_levels_stepping_by_tick() {
let snap = snapshot(&[quote(510_000, 490, 500, 8, 8)]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 3, dec!(0.5)));
let Ok(plan) = plan else {
panic!("plan must build");
};
assert_eq!(
triples(&plan),
vec![
(false, 490, 8),
(false, 485, 4),
(false, 480, 2),
(false, 475, 1),
(true, 500, 8),
(true, 505, 4),
(true, 510, 2),
(true, 515, 1),
]
);
}
#[test]
fn test_plan_seed_geometric_decay_terminates_before_l_at_zero_rounding() {
let snap = snapshot(&[quote(510_000, 490, 500, 1, 1)]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 5, dec!(0.5)));
let Ok(plan) = plan else {
panic!("plan must build");
};
assert_eq!(triples(&plan), vec![(false, 490, 1), (true, 500, 1)]);
}
#[test]
fn test_plan_seed_deeper_levels_are_tick_aligned() {
let snap = snapshot(&[quote(510_000, 490, 500, 32, 32)]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 5, dec!(0.5)));
let Ok(plan) = plan else {
panic!("plan must build");
};
for order in &plan {
assert_eq!(
order.price.value() % TICK,
0,
"seeded price {} must be tick-aligned",
order.price.value()
);
}
}
#[test]
fn test_plan_seed_bid_ladder_stops_on_price_underflow() {
let snap = snapshot(&[quote(510_000, 10, 40, 64, 1)]);
let plan = plan_seed(
&snap,
&profile(TouchSize::Flat { contracts: 64 }, 5, dec!(1)),
);
let Ok(plan) = plan else {
panic!("plan must build");
};
let bids: Vec<u64> = plan
.iter()
.filter(|o| !o.is_ask)
.map(|o| o.price.value())
.collect();
assert_eq!(bids, vec![10, 5, 0]);
}
#[test]
fn test_plan_seed_uniform_decay_of_one_fills_all_levels() {
let snap = snapshot(&[quote(510_000, 490, 500, 7, 7)]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 3, dec!(1)));
let Ok(plan) = plan else {
panic!("plan must build");
};
assert_eq!(plan.len(), 8, "4 bid + 4 ask levels, all size 7");
assert!(plan.iter().all(|o| o.size.value() == 7));
}
#[test]
fn test_plan_seed_quoted_touch_uses_per_side_size() {
let snap = snapshot(&[quote(510_000, 490, 500, 6, 9)]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 0, dec!(0.5)));
let Ok(plan) = plan else {
panic!("plan must build");
};
assert_eq!(triples(&plan), vec![(false, 490, 6), (true, 500, 9)]);
}
#[test]
fn test_plan_seed_flat_touch_uses_configured_depth_both_sides() {
let snap = snapshot(&[quote(510_000, 490, 500, 6, 9)]);
let plan = plan_seed(
&snap,
&profile(TouchSize::Flat { contracts: 20 }, 0, dec!(0.5)),
);
let Ok(plan) = plan else {
panic!("plan must build");
};
assert_eq!(triples(&plan), vec![(false, 490, 20), (true, 500, 20)]);
}
#[test]
fn test_plan_seed_orders_by_contract_then_bid_before_ask() {
let snap = snapshot(&[
quote(520_000, 90, 100, 4, 4),
quote(500_000, 190, 200, 4, 4),
]);
let plan = plan_seed(&snap, &profile(TouchSize::QuotedSize, 0, dec!(0.5)));
let Ok(plan) = plan else {
panic!("plan must build");
};
let seen: Vec<(u64, bool)> = plan
.iter()
.map(|o| (o.contract.strike.value(), o.is_ask))
.collect();
assert_eq!(
seen,
vec![
(500_000, false),
(500_000, true),
(520_000, false),
(520_000, true),
]
);
}
#[test]
fn test_plan_seed_is_byte_identical_across_two_builds() {
let snap = snapshot(&[
quote(500_000, 190, 200, 8, 8),
quote(520_000, 90, 100, 8, 8),
]);
let prof = profile(TouchSize::QuotedSize, 4, dec!(0.5));
let (Ok(first), Ok(second)) = (plan_seed(&snap, &prof), plan_seed(&snap, &prof)) else {
panic!("both plans must build");
};
assert_eq!(first, second);
}
}