171 lines
7.3 KiB
Rust
171 lines
7.3 KiB
Rust
//! Causal volume budgets. Session totals may audit fills, never size earlier orders.
|
|
use chrono::{NaiveDate, NaiveDateTime};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum VolumeCapacityMode {
|
|
#[default]
|
|
ExecutionObservation,
|
|
CompletedBar,
|
|
SessionCapacityAudit,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
|
pub enum CapacityError {
|
|
#[error("execution capacity ratio must be finite and in (0, 1]")]
|
|
InvalidRatio,
|
|
#[error("execution capacity decimal cannot be represented exactly")]
|
|
InvalidDecimal,
|
|
#[error("execution capacity observation has invalid time bounds")]
|
|
InvalidWindow,
|
|
#[error("execution capacity is not visible: available={available_at}, execution={execution_at}")]
|
|
NotVisible { available_at: NaiveDateTime, execution_at: NaiveDateTime },
|
|
#[error("execution capacity observation belongs to another session")]
|
|
WrongSession,
|
|
#[error("execution-time capacity is missing; daily session volume cannot size an earlier fill")]
|
|
MissingObservation,
|
|
}
|
|
|
|
/// Decimal semantics of the frozen JSON rate, evaluated without a float product.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ParticipationRate {
|
|
numerator: u128,
|
|
denominator: u128,
|
|
}
|
|
|
|
impl ParticipationRate {
|
|
pub fn new(rate: f64) -> Result<Self, CapacityError> {
|
|
if !rate.is_finite() || rate <= 0.0 || rate > 1.0 {
|
|
return Err(CapacityError::InvalidRatio);
|
|
}
|
|
if rate < 1e-20 {
|
|
// Even u64::MAX shares at this rate cannot admit a single share.
|
|
return Ok(Self { numerator: 0, denominator: 1 });
|
|
}
|
|
if rate == 1.0 {
|
|
return Ok(Self { numerator: 1, denominator: 1 });
|
|
}
|
|
let text = rate.to_string();
|
|
let digits = text.strip_prefix("0.").ok_or(CapacityError::InvalidDecimal)?;
|
|
let digits = digits.trim_end_matches('0');
|
|
let numerator = digits.parse::<u128>().map_err(|_| CapacityError::InvalidDecimal)?;
|
|
let denominator = 10_u128.checked_pow(digits.len() as u32).ok_or(CapacityError::InvalidDecimal)?;
|
|
if numerator > u128::MAX / u128::from(u64::MAX) {
|
|
return Err(CapacityError::InvalidDecimal);
|
|
}
|
|
Ok(Self { numerator, denominator })
|
|
}
|
|
|
|
pub fn total_shares(self, market_shares: u64) -> u64 {
|
|
let total = u128::from(market_shares) * self.numerator / self.denominator;
|
|
u64::try_from(total).expect("participation rate cannot exceed the market shares")
|
|
}
|
|
|
|
pub fn remaining(self, market_shares: u64, consumed_shares: u64, requested: u32) -> u32 {
|
|
self.total_shares(market_shares).saturating_sub(consumed_shares).min(u64::from(requested)) as u32
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum VolumeObservationKind {
|
|
TradeIncrement,
|
|
CompletedBar,
|
|
CumulativeSession,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub struct VolumeObservation {
|
|
pub kind: VolumeObservationKind,
|
|
pub start: NaiveDateTime,
|
|
pub end: NaiveDateTime,
|
|
pub available_at: NaiveDateTime,
|
|
pub shares: u64,
|
|
}
|
|
|
|
impl VolumeObservation {
|
|
pub fn visible_shares(self, execution_at: NaiveDateTime) -> Result<u64, CapacityError> {
|
|
if self.start > self.end || self.available_at < self.end {
|
|
return Err(CapacityError::InvalidWindow);
|
|
}
|
|
if self.available_at > execution_at {
|
|
return Err(CapacityError::NotVisible { available_at: self.available_at, execution_at });
|
|
}
|
|
if self.start.date() != self.end.date() || self.end.date() != execution_at.date() {
|
|
return Err(CapacityError::WrongSession);
|
|
}
|
|
Ok(self.shares)
|
|
}
|
|
|
|
pub fn remaining(self, execution_at: NaiveDateTime, rate: ParticipationRate, consumed: u64, requested: u32) -> Result<u32, CapacityError> {
|
|
Ok(rate.remaining(self.visible_shares(execution_at)?, consumed, requested))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct SessionCapacityAudit {
|
|
pub date: NaiveDate,
|
|
pub symbol: String,
|
|
pub filled_shares: u64,
|
|
pub session_shares: u64,
|
|
pub allowed_shares: u64,
|
|
pub passed: bool,
|
|
}
|
|
|
|
impl SessionCapacityAudit {
|
|
pub fn new(date: NaiveDate, symbol: String, filled_shares: u64, session_shares: u64, rate: ParticipationRate) -> Self {
|
|
let allowed_shares = rate.total_shares(session_shares);
|
|
Self { date, symbol, filled_shares, session_shares, allowed_shares, passed: filled_shares <= allowed_shares }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decimal_participation_never_rounds_a_fractional_share_up_or_overflows() {
|
|
assert_eq!(ParticipationRate::new(0.58).unwrap().total_shares(50), 29);
|
|
assert_eq!(ParticipationRate::new(0.25).unwrap().total_shares(3), 0);
|
|
assert_eq!(ParticipationRate::new(0.5).unwrap().total_shares(3), 1);
|
|
assert_eq!(ParticipationRate::new(1.).unwrap().total_shares(u64::MAX), u64::MAX);
|
|
assert_eq!(ParticipationRate::new(0.25).unwrap().remaining(u64::MAX, 0, u32::MAX), u32::MAX);
|
|
assert_eq!(ParticipationRate::new(f64::MIN_POSITIVE).unwrap().total_shares(u64::MAX), 0);
|
|
for rate in [0., -1., f64::NAN, f64::INFINITY, 1.001] {
|
|
assert!(ParticipationRate::new(rate).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn completed_volume_cannot_be_used_for_an_earlier_open() {
|
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
|
let opening = day.and_hms_opt(9,30,0).unwrap();
|
|
let closing = day.and_hms_opt(15,0,0).unwrap();
|
|
let observation = VolumeObservation { kind:VolumeObservationKind::CompletedBar, start:opening, end:closing, available_at:closing, shares:10000 };
|
|
assert!(matches!(observation.visible_shares(opening), Err(CapacityError::NotVisible { .. })));
|
|
assert_eq!(observation.remaining(closing, ParticipationRate::new(0.25).unwrap(), 1000, 5000).unwrap(), 1500);
|
|
assert!(matches!(observation.visible_shares(closing+chrono::Duration::days(1)), Err(CapacityError::WrongSession)));
|
|
}
|
|
|
|
#[test]
|
|
fn delayed_publication_and_invalid_bounds_are_not_treated_as_zero_volume() {
|
|
let at = NaiveDate::from_ymd_opt(2025,1,2).unwrap().and_hms_opt(10,18,0).unwrap();
|
|
let observation = VolumeObservation { kind:VolumeObservationKind::TradeIncrement, start:at, end:at, available_at:at+chrono::Duration::seconds(1), shares:0 };
|
|
assert!(matches!(observation.visible_shares(at), Err(CapacityError::NotVisible { .. })));
|
|
assert_eq!(VolumeObservation { available_at:at-chrono::Duration::seconds(1), ..observation }.visible_shares(at), Err(CapacityError::InvalidWindow));
|
|
assert_eq!(VolumeObservation { available_at:at, ..observation }.visible_shares(at).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn session_audit_changes_verdict_not_executed_quantity() {
|
|
let day = NaiveDate::from_ymd_opt(2025,1,2).unwrap();
|
|
let rate = ParticipationRate::new(0.25).unwrap();
|
|
let a = SessionCapacityAudit::new(day,"TEST".into(),1000,3000,rate);
|
|
let b = SessionCapacityAudit::new(day,"TEST".into(),1000,5000,rate);
|
|
assert!(!a.passed); assert!(b.passed);
|
|
assert_eq!(a.filled_shares,b.filled_shares);
|
|
}
|
|
}
|