use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use derive_more::Constructor;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct SplitRatio(Decimal);
impl SplitRatio {
#[must_use]
pub fn new(ratio: Decimal) -> Option<Self> {
(ratio > Decimal::ZERO).then_some(Self(ratio))
}
#[must_use]
pub const fn get(self) -> Decimal {
self.0
}
}
impl From<SplitRatio> for Decimal {
fn from(ratio: SplitRatio) -> Self {
ratio.0
}
}
impl std::fmt::Display for SplitRatio {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidSplitRatio(Decimal);
impl InvalidSplitRatio {
#[must_use]
pub const fn rejected(self) -> Decimal {
self.0
}
}
impl std::fmt::Display for InvalidSplitRatio {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "split ratio must be strictly positive, got {}", self.0)
}
}
impl std::error::Error for InvalidSplitRatio {}
impl TryFrom<Decimal> for SplitRatio {
type Error = InvalidSplitRatio;
fn try_from(ratio: Decimal) -> Result<Self, Self::Error> {
SplitRatio::new(ratio).ok_or(InvalidSplitRatio(ratio))
}
}
impl<'de> Deserialize<'de> for SplitRatio {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let ratio = <Decimal as Deserialize>::deserialize(deserializer)?;
SplitRatio::try_from(ratio).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[non_exhaustive]
pub enum CorporateActionKind {
StockSplit {
ratio: SplitRatio,
},
}
impl CorporateActionKind {
#[must_use]
pub fn stock_split(split_to: Decimal, split_from: Decimal) -> Option<Self> {
let positive = |d: Decimal| d > Decimal::ZERO;
if !positive(split_to) || !positive(split_from) {
return None;
}
split_to
.checked_div(split_from)
.and_then(SplitRatio::new)
.map(|ratio| Self::StockSplit { ratio })
}
#[must_use]
pub fn split_kind(&self) -> Option<SplitAdjustmentKind> {
match self {
Self::StockSplit { ratio } => {
let ratio = ratio.get();
Some(if ratio > Decimal::ONE && ratio.fract().is_zero() {
SplitAdjustmentKind::Standard
} else {
SplitAdjustmentKind::NonStandard
})
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[non_exhaustive]
pub enum SplitAdjustmentKind {
Standard,
NonStandard,
}
pub fn split_effective_instant(date: NaiveDate) -> DateTime<Utc> {
date.and_time(NaiveTime::MIN).and_utc()
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Constructor, Deserialize, Serialize,
)]
#[non_exhaustive]
pub struct CorporateAction<K> {
pub instrument: K,
pub kind: CorporateActionKind,
pub effective_date: Option<NaiveDate>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use chrono::Datelike;
#[test]
fn split_effective_instant_is_midnight_utc_on_the_date() {
let date = NaiveDate::from_ymd_opt(2026, 6, 22).unwrap();
let instant = split_effective_instant(date);
assert_eq!(instant.date_naive(), date);
assert_eq!(instant.time(), NaiveTime::MIN);
assert_eq!(instant.naive_utc().year(), 2026);
assert_eq!(instant.to_rfc3339(), "2026-06-22T00:00:00+00:00");
}
fn split_kind_with(ratio: Decimal) -> CorporateActionKind {
CorporateActionKind::StockSplit {
ratio: SplitRatio::new(ratio).unwrap(),
}
}
#[test]
fn stock_split_computes_ratio_from_components() {
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(2), Decimal::from(1)),
Some(split_kind_with(Decimal::from(2)))
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(1), Decimal::from(10)),
Some(split_kind_with(Decimal::new(1, 1)))
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(3), Decimal::from(2)),
Some(split_kind_with(Decimal::new(15, 1)))
);
}
#[test]
fn stock_split_rejects_degenerate_components() {
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(1), Decimal::ZERO),
None
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::ZERO, Decimal::from(5)),
None
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(-2), Decimal::from(1)),
None
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(2), Decimal::from(-1)),
None
);
assert_eq!(
CorporateActionKind::stock_split(Decimal::from(-2), Decimal::from(-1)),
None
);
}
#[test]
fn split_ratio_rejects_non_positive() {
assert!(SplitRatio::new(Decimal::from(2)).is_some());
assert!(SplitRatio::new(Decimal::new(1, 1)).is_some()); assert!(SplitRatio::new(Decimal::ZERO).is_none());
assert!(SplitRatio::new(Decimal::from(-1)).is_none());
assert_eq!(
SplitRatio::new(Decimal::from(2)).unwrap().get(),
Decimal::from(2)
);
assert_eq!(
Decimal::from(SplitRatio::new(Decimal::from(2)).unwrap()),
Decimal::from(2)
);
}
#[test]
fn split_ratio_try_from_matches_new_and_carries_rejected_value() {
assert_eq!(
SplitRatio::try_from(Decimal::from(2)).unwrap().get(),
Decimal::from(2)
);
assert_eq!(
SplitRatio::try_from(Decimal::from(-3)),
Err(InvalidSplitRatio(Decimal::from(-3)))
);
assert_eq!(
SplitRatio::try_from(Decimal::ZERO),
Err(InvalidSplitRatio(Decimal::ZERO))
);
}
#[test]
fn split_ratio_serde_roundtrips_and_validates() {
let ratio = SplitRatio::new(Decimal::from(2)).unwrap();
let json = serde_json::to_string(&ratio).unwrap();
assert_eq!(json, "\"2\"");
assert_eq!(serde_json::from_str::<SplitRatio>(&json).unwrap(), ratio);
let frac = SplitRatio::new(Decimal::new(5, 1)).unwrap(); let frac_json = serde_json::to_string(&frac).unwrap();
assert_eq!(
serde_json::from_str::<SplitRatio>(&frac_json).unwrap(),
frac
);
assert!(serde_json::from_str::<SplitRatio>("\"0\"").is_err());
assert!(serde_json::from_str::<SplitRatio>("\"-1\"").is_err());
}
#[test]
fn split_kind_classifies_per_occ_rules() {
use SplitAdjustmentKind::{NonStandard, Standard};
let split = split_kind_with;
assert_eq!(split(Decimal::from(2)).split_kind(), Some(Standard)); assert_eq!(split(Decimal::from(3)).split_kind(), Some(Standard)); assert_eq!(split(Decimal::from(10)).split_kind(), Some(Standard));
assert_eq!(split(Decimal::new(20, 1)).split_kind(), Some(Standard)); assert_eq!(split(Decimal::new(300, 2)).split_kind(), Some(Standard));
assert_eq!(split(Decimal::new(15, 1)).split_kind(), Some(NonStandard)); assert_eq!(split(Decimal::new(43, 10)).split_kind(), Some(NonStandard));
assert_eq!(split(Decimal::new(5, 1)).split_kind(), Some(NonStandard)); assert_eq!(split(Decimal::new(1, 1)).split_kind(), Some(NonStandard));
assert_eq!(split(Decimal::ONE).split_kind(), Some(NonStandard));
}
#[test]
fn corporate_action_descriptor_is_generic_over_the_key() {
let kind = split_kind_with(Decimal::from(4));
let date = NaiveDate::from_ymd_opt(2020, 8, 31);
let sourced = CorporateAction::new("AAPL", kind.clone(), date);
assert_eq!(sourced.instrument, "AAPL");
assert_eq!(sourced.effective_date, date);
let resolved = CorporateAction::new(0_usize, sourced.kind.clone(), sourced.effective_date);
assert_eq!(resolved.instrument, 0_usize);
assert_eq!(resolved.kind, kind);
}
}