use optionstratlib::Side;
use serde::{Deserialize, Serialize};
use crate::domain::contract::ContractKey;
use crate::domain::money::{Cents, PriceCents, Quantity};
use crate::domain::time::{SimTime, StepIndex};
use crate::error::BacktestError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(u8)]
pub enum ExecutionMode {
Naive = 0,
Realistic = 1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PositionId(u64);
impl PositionId {
#[must_use]
pub const fn new(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn value(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrderId(u64);
impl OrderId {
#[must_use]
pub const fn new(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn value(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TradeId(u64);
impl TradeId {
#[must_use]
pub const fn new(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn value(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PositionAction {
Open,
Close(PositionId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[repr(u8)]
pub enum TimeInForce {
Ioc = 0,
Gtc = 1,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "OrderIntentWire")]
pub struct OrderIntent {
pub contract: ContractKey,
pub action: PositionAction,
pub side: Side,
pub quantity: Quantity,
pub limit: Option<PriceCents>,
pub tif: TimeInForce,
pub decision_mid: PriceCents,
}
impl OrderIntent {
pub fn new(
contract: ContractKey,
action: PositionAction,
side: Side,
quantity: Quantity,
limit: Option<PriceCents>,
tif: TimeInForce,
decision_mid: PriceCents,
) -> Result<Self, BacktestError> {
let intent = Self {
contract,
action,
side,
quantity,
limit,
tif,
decision_mid,
};
intent.validate()?;
Ok(intent)
}
pub fn validate(&self) -> Result<(), BacktestError> {
if self.limit.is_none() && matches!(self.tif, TimeInForce::Gtc) {
return Err(BacktestError::Execution(
"marketable order intent (limit = None) must be IOC, not GTC; \
a market order cannot rest in the book"
.to_string(),
));
}
Ok(())
}
}
#[derive(Deserialize)]
struct OrderIntentWire {
contract: ContractKey,
action: PositionAction,
side: Side,
quantity: Quantity,
limit: Option<PriceCents>,
tif: TimeInForce,
decision_mid: PriceCents,
}
impl TryFrom<OrderIntentWire> for OrderIntent {
type Error = BacktestError;
fn try_from(wire: OrderIntentWire) -> Result<Self, Self::Error> {
Self::new(
wire.contract,
wire.action,
wire.side,
wire.quantity,
wire.limit,
wire.tif,
wire.decision_mid,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OrderCommand {
Submit(OrderIntent),
Cancel(OrderId),
Replace {
order_id: OrderId,
replacement: OrderIntent,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenPosition {
pub position_id: PositionId,
pub contract: ContractKey,
pub side: Side,
pub quantity: Quantity,
pub entry_premium: PriceCents,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingOrder {
pub order_id: OrderId,
pub intent: OrderIntent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fill {
pub ts: SimTime,
pub step: StepIndex,
pub contract: ContractKey,
pub side: Side,
pub quantity: Quantity,
pub price: PriceCents,
pub fees: Cents,
pub slippage: Cents,
pub mode: ExecutionMode,
}
pub mod sign_convention {
use optionstratlib::Side;
use crate::domain::money::{Cents, PriceCents, Quantity};
use crate::error::BacktestError;
#[must_use]
pub const fn side_sign(side: Side) -> i64 {
match side {
Side::Long => 1,
Side::Short => -1,
}
}
#[must_use = "the computed slippage must be used"]
pub fn slippage_cents(
price: PriceCents,
decision_mid: PriceCents,
quantity: Quantity,
side: Side,
) -> Result<Cents, BacktestError> {
let diff = i128::from(price.value()) - i128::from(decision_mid.value());
let product = diff
.checked_mul(i128::from(quantity.value()))
.and_then(|p| p.checked_mul(i128::from(side_sign(side))))
.ok_or(BacktestError::ArithmeticOverflow)?;
let cents = i64::try_from(product).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(Cents::new(cents))
}
#[must_use = "the computed spread capture must be used"]
pub fn spread_capture_cents<I>(slippages: I) -> Result<Cents, BacktestError>
where
I: IntoIterator<Item = Cents>,
{
let mut total = Cents::new(0);
for slippage in slippages {
total = total.checked_add(slippage)?;
}
let negated = total
.value()
.checked_neg()
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(Cents::new(negated))
}
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::sign_convention::{side_sign, slippage_cents, spread_capture_cents};
use super::{OrderIntent, PositionAction, TimeInForce};
use crate::domain::contract::{ContractKey, Underlying};
use crate::domain::money::{Cents, PriceCents, Quantity};
use crate::error::BacktestError;
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is a valid quantity");
};
q
}
fn contract_key() -> ContractKey {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
ContractKey {
underlying,
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
1_750_291_200_000_000_000,
)),
strike: PriceCents::new(510_000),
style: OptionStyle::Call,
}
}
#[test]
fn test_order_intent_new_enforces_marketable_ioc_invariant() {
let resting_market = OrderIntent::new(
contract_key(),
PositionAction::Open,
Side::Short,
qty(1),
None,
TimeInForce::Gtc,
PriceCents::new(150),
);
assert!(matches!(resting_market, Err(BacktestError::Execution(_))));
assert!(
OrderIntent::new(
contract_key(),
PositionAction::Open,
Side::Short,
qty(1),
None,
TimeInForce::Ioc,
PriceCents::new(150),
)
.is_ok()
);
assert!(
OrderIntent::new(
contract_key(),
PositionAction::Open,
Side::Short,
qty(1),
Some(PriceCents::new(148)),
TimeInForce::Gtc,
PriceCents::new(150),
)
.is_ok()
);
}
#[test]
fn test_order_intent_deserialize_rejects_resting_market_order() {
let Ok(valid) = OrderIntent::new(
contract_key(),
PositionAction::Open,
Side::Short,
qty(1),
None,
TimeInForce::Ioc,
PriceCents::new(150),
) else {
panic!("a marketable IOC intent must build");
};
let json = serde_json::to_string(&valid).unwrap_or_default();
let back: Result<OrderIntent, _> = serde_json::from_str(&json);
assert!(back.is_ok(), "marketable IOC intent must round-trip");
let mut value: serde_json::Value = match serde_json::from_str(&json) {
Ok(value) => value,
Err(e) => panic!("intent json must parse: {e}"),
};
let Some(obj) = value.as_object_mut() else {
panic!("intent json must be an object");
};
obj.insert("limit".to_string(), serde_json::Value::Null);
obj.insert(
"tif".to_string(),
serde_json::Value::String("gtc".to_string()),
);
let bad: Result<OrderIntent, _> = serde_json::from_value(value);
assert!(
bad.is_err(),
"a resting (gtc) market order must fail to deserialize"
);
}
#[test]
fn test_slippage_positive_when_buy_worse_than_mid() {
let slippage = slippage_cents(
PriceCents::new(152),
PriceCents::new(150),
qty(1),
Side::Long,
);
assert!(matches!(slippage, Ok(s) if s.value() == 2));
let slippage = slippage_cents(
PriceCents::new(149),
PriceCents::new(150),
qty(1),
Side::Long,
);
assert!(matches!(slippage, Ok(s) if s.value() == -1));
}
#[test]
fn test_slippage_positive_when_sell_worse_than_mid() {
let slippage = slippage_cents(
PriceCents::new(148),
PriceCents::new(150),
qty(1),
Side::Short,
);
assert!(matches!(slippage, Ok(s) if s.value() == 2));
let slippage = slippage_cents(
PriceCents::new(153),
PriceCents::new(150),
qty(1),
Side::Short,
);
assert!(matches!(slippage, Ok(s) if s.value() == -3));
}
#[test]
fn test_slippage_scales_with_quantity() {
let slippage = slippage_cents(
PriceCents::new(152),
PriceCents::new(150),
qty(5),
Side::Long,
);
assert!(matches!(slippage, Ok(s) if s.value() == 10));
}
#[test]
fn test_execution_mode_serde_snake_case_round_trip() {
let naive = serde_json::to_string(&super::ExecutionMode::Naive);
assert!(matches!(naive.as_deref(), Ok("\"naive\"")));
let back: Result<super::ExecutionMode, _> = serde_json::from_str("\"realistic\"");
assert!(matches!(back, Ok(super::ExecutionMode::Realistic)));
}
#[test]
fn test_side_sign_truth_table() {
assert_eq!(side_sign(Side::Long), 1);
assert_eq!(side_sign(Side::Short), -1);
}
#[test]
fn test_spread_capture_is_negated_slippage_sum() {
let capture = spread_capture_cents([Cents::new(2), Cents::new(-1), Cents::new(4)]);
assert!(matches!(capture, Ok(c) if c.value() == -5));
let capture = spread_capture_cents([]);
assert!(matches!(capture, Ok(c) if c.value() == 0));
}
}