feat: introduce causal capacity primitives and exact participation quotas
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user