use chrono::{DateTime, Utc};
use derive_more::From;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use std::fmt::{Display, Formatter};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct CashFlow<AssetKey, InstrumentKey> {
pub kind: CashFlowKind,
pub asset: AssetKey,
pub amount: Decimal,
pub instrument: Option<InstrumentKey>,
pub time_exchange: DateTime<Utc>,
pub id: Option<CashFlowId>,
#[serde(default)]
pub period: Option<CashFlowPeriod>,
}
impl<AssetKey, InstrumentKey> CashFlow<AssetKey, InstrumentKey> {
pub fn new(
kind: CashFlowKind,
asset: AssetKey,
amount: Decimal,
instrument: Option<InstrumentKey>,
time_exchange: DateTime<Utc>,
id: Option<CashFlowId>,
) -> Self {
Self {
kind,
asset,
amount,
instrument,
time_exchange,
id,
period: None,
}
}
pub fn with_period(mut self, period: Option<CashFlowPeriod>) -> Self {
self.period = period;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "CashFlowPeriodFields")]
pub struct CashFlowPeriod {
start: DateTime<Utc>,
end: DateTime<Utc>,
}
impl CashFlowPeriod {
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Option<Self> {
(start < end).then_some(Self { start, end })
}
pub fn start(&self) -> DateTime<Utc> {
self.start
}
pub fn end(&self) -> DateTime<Utc> {
self.end
}
pub fn contains(&self, time: DateTime<Utc>) -> bool {
(self.start..self.end).contains(&time)
}
}
#[derive(Deserialize)]
struct CashFlowPeriodFields {
start: DateTime<Utc>,
end: DateTime<Utc>,
}
impl TryFrom<CashFlowPeriodFields> for CashFlowPeriod {
type Error = String;
fn try_from(fields: CashFlowPeriodFields) -> Result<Self, Self::Error> {
Self::new(fields.start, fields.end).ok_or_else(|| {
format!(
"CashFlowPeriod end {} is not after its start {}",
fields.end, fields.start
)
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum CashFlowKind {
Funding {
rate: Option<Decimal>,
position_quantity: Option<Decimal>,
},
BorrowFee {
rate: Option<Decimal>,
quantity: Option<Decimal>,
},
MarginInterest {
rate: Option<Decimal>,
principal: Option<Decimal>,
timing: Option<InterestTiming>,
},
Financing {
rate: Option<Decimal>,
notional: Option<Decimal>,
},
Rebate,
LiquidationFee {
rate: Option<Decimal>,
repaid: Option<Decimal>,
},
Other(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum InterestTiming {
Periodic,
OnBorrow,
Other(SmolStr),
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, From)]
pub struct CashFlowId(pub SmolStr);
impl Display for CashFlowId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f)
}
}
impl CashFlowId {
pub fn new<S: AsRef<str>>(id: S) -> Self {
Self(SmolStr::new(id))
}
}
#[non_exhaustive]
#[must_use = "`resume` and `held_since` say whether the span was read in full"]
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
pub struct CashFlowsRead<AssetKey, InstrumentKey> {
pub flows: Vec<CashFlow<AssetKey, InstrumentKey>>,
pub resume: Option<DateTime<Utc>>,
pub held_since: Option<DateTime<Utc>>,
}
impl<AssetKey, InstrumentKey> CashFlowsRead<AssetKey, InstrumentKey> {
pub fn new(
flows: Vec<CashFlow<AssetKey, InstrumentKey>>,
resume: Option<DateTime<Utc>>,
) -> Self {
Self {
flows,
resume,
held_since: None,
}
}
pub fn complete(flows: Vec<CashFlow<AssetKey, InstrumentKey>>) -> Self {
Self::new(flows, None)
}
pub fn with_held_since(mut self, held_since: Option<DateTime<Utc>>) -> Self {
self.held_since = held_since;
self
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
use chrono::TimeZone;
fn at(hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 11, 2, hour, 0, 0).unwrap()
}
#[test]
fn a_period_must_end_after_it_starts() {
assert!(CashFlowPeriod::new(at(10), at(10)).is_none());
assert!(CashFlowPeriod::new(at(11), at(10)).is_none());
let period = CashFlowPeriod::new(at(10), at(11)).unwrap();
assert_eq!((period.start(), period.end()), (at(10), at(11)));
}
#[test]
fn a_period_holds_its_start_and_not_its_end() {
let period = CashFlowPeriod::new(at(10), at(11)).unwrap();
assert!(period.contains(at(10)));
assert!(period.contains(at(10) + chrono::Duration::minutes(59)));
assert!(!period.contains(at(11)));
assert!(!period.contains(at(9)));
}
#[test]
fn a_period_round_trips_and_a_reversed_one_does_not_deserialise() {
let period = CashFlowPeriod::new(at(10), at(11)).unwrap();
let json = serde_json::to_string(&period).unwrap();
assert_eq!(
serde_json::from_str::<CashFlowPeriod>(&json).unwrap(),
period
);
let reversed = json.replace("10:00:00Z", "12:00:00Z");
assert!(serde_json::from_str::<CashFlowPeriod>(&reversed).is_err());
}
#[test]
fn a_flow_serialised_without_a_period_loads_with_none() {
let flow: CashFlow<String, String> = CashFlow::new(
CashFlowKind::Rebate,
"USD".to_owned(),
Decimal::ONE,
None,
at(10),
None,
);
let mut value = serde_json::to_value(&flow).unwrap();
value.as_object_mut().unwrap().remove("period");
let loaded: CashFlow<String, String> = serde_json::from_value(value).unwrap();
assert_eq!(loaded, flow);
}
}