//! Causal portfolio-loss state, independent of market-data and order adapters. use std::collections::VecDeque; use chrono::{DateTime, FixedOffset, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use thiserror::Error; const STATE_SCHEMA: &str = "fidc.portfolio-loss-state/v1"; const MAX_OBSERVATIONS: usize = 120; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PortfolioLossConfig { pub lookback: usize, pub loss_trigger: f64, pub floor_exposure: f64, pub cooldown_trading_days: usize, } impl PortfolioLossConfig { pub fn validate(&self) -> Result<(), PortfolioLossError> { if !matches!(self.lookback, 10 | 20 | 40 | 60) || !self.loss_trigger.is_finite() || !(0.02..=0.30).contains(&self.loss_trigger) || !self.floor_exposure.is_finite() || !(0.0..=1.0).contains(&self.floor_exposure) || !(1..=120).contains(&self.cooldown_trading_days) { return Err(PortfolioLossError::InvalidConfig); } Ok(()) } } /// Finalized portfolio accounting, not a market close used as a proxy for NAV. /// Unit NAV must already exclude external deposits and withdrawals. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ClosedPortfolioSession { pub date: NaiveDate, pub previous_session_date: Option, pub available_at: DateTime, pub start_unit_nav: f64, pub end_unit_nav: f64, pub start_gross_exposure: f64, pub end_gross_exposure: f64, pub source_sha256: String, } impl ClosedPortfolioSession { fn validate(&self) -> Result<(), PortfolioLossError> { let earliest = self.date.and_hms_opt(7, 30, 0).unwrap().and_utc(); if [self.start_unit_nav, self.end_unit_nav] .iter() .any(|value| !value.is_finite() || *value <= 0.0) || [self.start_gross_exposure, self.end_gross_exposure] .iter() .any(|value| !value.is_finite() || *value < 0.0) || self .previous_session_date .is_some_and(|date| date >= self.date) || self.available_at < earliest || self.source_sha256.len() != 64 || !self .source_sha256 .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return Err(PortfolioLossError::InvalidObservation); } self.unit_return()?; Ok(()) } fn unit_return(&self) -> Result, PortfolioLossError> { let gross = self.start_gross_exposure.max(self.end_gross_exposure); if gross <= 1e-12 { return Ok(None); } let value = (self.end_unit_nav / self.start_unit_nav - 1.0) / gross; if !value.is_finite() { return Err(PortfolioLossError::InvalidObservation); } Ok(Some(value)) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PortfolioLossDecision { pub execution_date: NaiveDate, pub observed_through: Option, pub observation_count: usize, pub trailing_unit_return: Option, pub threshold_breached: bool, pub newly_triggered: bool, pub risk_off: bool, pub cooldown_before: usize, pub cooldown_after: usize, pub target_exposure: f64, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PortfolioLossState { schema_version: String, config: PortfolioLossConfig, started_on: NaiveDate, observations: VecDeque, last_session: Option, cooldown_remaining: usize, trigger_count: usize, last_decision: Option, } #[derive(Debug, Error, PartialEq, Eq)] pub enum PortfolioLossError { #[error("invalid portfolio loss configuration")] InvalidConfig, #[error("invalid finalized portfolio session observation")] InvalidObservation, #[error("portfolio loss state does not match its frozen configuration")] StateMismatch, #[error("portfolio session history is missing, reordered or corrected")] SessionDiscontinuity, #[error("portfolio loss observation is not visible at the decision")] ObservationNotVisible, #[error("portfolio loss decisions must follow trading-session order")] DecisionOrder, } impl PortfolioLossState { pub fn new( config: PortfolioLossConfig, started_on: NaiveDate, ) -> Result { config.validate()?; Ok(Self { schema_version: STATE_SCHEMA.to_owned(), config, started_on, observations: VecDeque::new(), last_session: None, cooldown_remaining: 0, trigger_count: 0, last_decision: None, }) } /// Validation is required after deserialization; a JSON hash alone is not /// account/generation authorization, which belongs to the state owner. pub fn validate(&self, expected: &PortfolioLossConfig) -> Result<(), PortfolioLossError> { expected.validate()?; if self.schema_version != STATE_SCHEMA || &self.config != expected || self.observations.len() > MAX_OBSERVATIONS || self.cooldown_remaining >= expected.cooldown_trading_days { return Err(PortfolioLossError::StateMismatch); } let mut previous = None; for item in &self.observations { item.validate()?; if item.date < self.started_on || previous.is_some_and(|date| item.date <= date) || item.unit_return()?.is_none() { return Err(PortfolioLossError::StateMismatch); } previous = Some(item.date); } if let Some(last) = &self.last_session { last.validate()?; if last.date < self.started_on || previous.is_some_and(|date| date > last.date) || (last.unit_return()?.is_some() && self.observations.back() != Some(last)) { return Err(PortfolioLossError::StateMismatch); } } else if !self.observations.is_empty() { return Err(PortfolioLossError::StateMismatch); } if let Some(decision) = &self.last_decision { let breached = decision .trailing_unit_return .is_some_and(|value| value <= -expected.loss_trigger); let triggered = decision.cooldown_before == 0 && breached; let after = if decision.cooldown_before > 0 { decision.cooldown_before - 1 } else if triggered { expected.cooldown_trading_days - 1 } else { 0 }; if decision.execution_date < self.started_on || decision .observed_through .is_some_and(|date| date >= decision.execution_date) || !decision.target_exposure.is_finite() || !(0.0..=1.0).contains(&decision.target_exposure) || decision .trailing_unit_return .is_some_and(|value| !value.is_finite()) || decision.cooldown_after != self.cooldown_remaining || decision.observation_count > MAX_OBSERVATIONS || decision.cooldown_before >= expected.cooldown_trading_days || decision.threshold_breached != breached || decision.newly_triggered != triggered || decision.risk_off != (decision.cooldown_before > 0 || triggered) || decision.cooldown_after != after || decision.trailing_unit_return.is_some() != (decision.observation_count >= expected.lookback) || self.trigger_count > (decision.execution_date - self.started_on).num_days() as usize + 1 { return Err(PortfolioLossError::StateMismatch); } } else if self.cooldown_remaining != 0 || self.trigger_count != 0 { return Err(PortfolioLossError::StateMismatch); } Ok(()) } /// Exact duplicate delivery is idempotent. Historical corrections require /// explicit reconciliation instead of changing an already-used window. pub fn observe(&mut self, session: ClosedPortfolioSession) -> Result { self.validate(&self.config)?; session.validate()?; if self.last_session.as_ref() == Some(&session) { return Ok(false); } let previous_date = self.last_session.as_ref().map(|value| value.date); if session.date < self.started_on || session.previous_session_date != previous_date || previous_date.is_some_and(|date| session.date <= date) || (previous_date.is_none() && session.date != self.started_on) || self .last_session .as_ref() .is_some_and(|last| session.start_unit_nav != last.end_unit_nav) { return Err(PortfolioLossError::SessionDiscontinuity); } if session.unit_return()?.is_some() { self.observations.push_back(session.clone()); if self.observations.len() > MAX_OBSERVATIONS { self.observations.pop_front(); } } self.last_session = Some(session); Ok(true) } pub fn decide( &mut self, execution_date: NaiveDate, previous_completed_session: Option, decision_at: DateTime, risk_on_exposure: f64, ) -> Result { self.validate(&self.config)?; if !risk_on_exposure.is_finite() || !(0.0..=1.0).contains(&risk_on_exposure) { return Err(PortfolioLossError::InvalidConfig); } if execution_date < self.started_on || previous_completed_session.is_some_and(|date| date >= execution_date) || decision_at .with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap()) .date_naive() != execution_date || self .last_decision .as_ref() .is_some_and(|last| execution_date < last.execution_date) { return Err(PortfolioLossError::DecisionOrder); } if let Some(last) = &self.last_session { if last.date >= execution_date || last.available_at > decision_at { return Err(PortfolioLossError::ObservationNotVisible); } if Some(last.date) != previous_completed_session { return Err(PortfolioLossError::SessionDiscontinuity); } } else if execution_date != self.started_on { return Err(PortfolioLossError::SessionDiscontinuity); } if let Some(cached) = self .last_decision .as_mut() .filter(|last| last.execution_date == execution_date) { cached.target_exposure = if cached.risk_off { self.config.floor_exposure.min(risk_on_exposure) } else { risk_on_exposure }; return Ok(cached.clone()); } let trailing = if self.observations.len() >= self.config.lookback { let start = self.observations.len() - self.config.lookback; let mut growth = 1.0; for item in self.observations.iter().skip(start) { growth *= (1.0 + item.unit_return()?.expect("nonzero exposure observation")).max(0.0); } let result = growth - 1.0; if !result.is_finite() { return Err(PortfolioLossError::InvalidObservation); } Some(result) } else { None }; let breached = trailing.is_some_and(|value| value <= -self.config.loss_trigger); let before = self.cooldown_remaining; let triggered = before == 0 && breached; let risk_off = before > 0 || triggered; let after = if before > 0 { before - 1 } else if triggered { self.config.cooldown_trading_days - 1 } else { 0 }; let decision = PortfolioLossDecision { execution_date, observed_through: self.last_session.as_ref().map(|value| value.date), observation_count: self.observations.len(), trailing_unit_return: trailing, threshold_breached: breached, newly_triggered: triggered, risk_off, cooldown_before: before, cooldown_after: after, target_exposure: if risk_off { self.config.floor_exposure.min(risk_on_exposure) } else { risk_on_exposure }, }; self.cooldown_remaining = after; self.trigger_count += usize::from(triggered); self.last_decision = Some(decision.clone()); Ok(decision) } pub fn last_session(&self) -> Option<&ClosedPortfolioSession> { self.last_session.as_ref() } pub fn last_decision(&self) -> Option<&PortfolioLossDecision> { self.last_decision.as_ref() } } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, TimeZone}; fn date(day: i64) -> NaiveDate { NaiveDate::from_ymd_opt(2023, 1, 3).unwrap() + Duration::days(day) } fn time(day: i64, hour: u32) -> DateTime { Utc.from_utc_datetime(&date(day).and_hms_opt(hour, 0, 0).unwrap()) } fn config() -> PortfolioLossConfig { PortfolioLossConfig { lookback: 10, loss_trigger: 0.05, floor_exposure: 0.2, cooldown_trading_days: 3, } } fn session(day: i64, start: f64, end: f64, gross: f64) -> ClosedPortfolioSession { ClosedPortfolioSession { date: date(day), previous_session_date: (day > 0).then(|| date(day - 1)), available_at: time(day, 8), start_unit_nav: start, end_unit_nav: end, start_gross_exposure: gross, end_gross_exposure: gross, source_sha256: "a".repeat(64), } } #[test] fn restart_is_exact_and_duplicate_decisions_do_not_consume_cooldown() { let mut state = PortfolioLossState::new(config(), date(0)).unwrap(); let mut nav = 1.0; for day in 0..10 { let end = nav * 0.994; state.observe(session(day, nav, end, 1.0)).unwrap(); nav = end; } let first = state .decide(date(10), Some(date(9)), time(10, 1), 0.9) .unwrap(); assert!(first.newly_triggered); assert_eq!(first.cooldown_after, 2); let serialized = serde_json::to_string(&state).unwrap(); let mut restored: PortfolioLossState = serde_json::from_str(&serialized).unwrap(); restored.validate(&config()).unwrap(); assert_eq!( first, restored .decide(date(10), Some(date(9)), time(10, 1), 0.9) .unwrap() ); let lowered = restored .decide(date(10), Some(date(9)), time(10, 2), 0.1) .unwrap(); assert_eq!(lowered.target_exposure, 0.1); assert_eq!(lowered.cooldown_after, 2); for day in 10..15 { let end = nav * 1.01; let row = session(day, nav, end, 0.2); state.observe(row.clone()).unwrap(); restored.observe(row).unwrap(); nav = end; assert_eq!( state .decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9) .unwrap(), restored .decide(date(day + 1), Some(date(day)), time(day + 1, 1), 0.9) .unwrap() ); } } #[test] fn refuses_future_missing_corrected_and_incomplete_accounting() { let mut state = PortfolioLossState::new(config(), date(0)).unwrap(); let first = session(0, 1.0, 0.99, 1.0); assert!(state.observe(first.clone()).unwrap()); assert!(!state.observe(first.clone()).unwrap()); let original = state.clone(); let mut changed = first; changed.end_unit_nav = 0.98; assert_eq!( state.observe(changed), Err(PortfolioLossError::SessionDiscontinuity) ); assert_eq!(state, original); assert_eq!( state.decide(date(0), None, time(0, 1), 0.9), Err(PortfolioLossError::ObservationNotVisible) ); assert_eq!( state.decide(date(2), Some(date(1)), time(2, 1), 0.9), Err(PortfolioLossError::SessionDiscontinuity) ); let mut late = PortfolioLossState::new(config(), date(0)).unwrap(); let mut delayed = session(0, 1.0, 0.99, 1.0); delayed.available_at = time(2, 1); late.observe(delayed).unwrap(); assert_eq!( late.decide(date(1), Some(date(0)), time(1, 1), 0.9), Err(PortfolioLossError::ObservationNotVisible) ); let mut invalid = session(1, 0.99, 1.0, 1.0); invalid.end_unit_nav = f64::NAN; assert_eq!( state.observe(invalid), Err(PortfolioLossError::InvalidObservation) ); } #[test] fn cash_sessions_preserve_continuity_without_inventing_returns() { let mut state = PortfolioLossState::new(config(), date(0)).unwrap(); for day in 0..20 { state.observe(session(day, 1.0, 1.0, 0.0)).unwrap(); } let decision = state .decide(date(20), Some(date(19)), time(20, 1), 0.9) .unwrap(); assert_eq!(decision.observation_count, 0); assert_eq!(decision.trailing_unit_return, None); assert_eq!(decision.target_exposure, 0.9); assert_eq!(state.last_session().unwrap().date, date(19)); } #[test] fn restored_state_rejects_changed_policy_and_forged_cooldown() { let state = PortfolioLossState::new(config(), date(0)).unwrap(); let mut changed = config(); changed.floor_exposure = 0.5; assert_eq!( state.validate(&changed), Err(PortfolioLossError::StateMismatch) ); let mut forged = state.clone(); forged.cooldown_remaining = 1; assert_eq!( forged.validate(&config()), Err(PortfolioLossError::StateMismatch) ); } #[test] fn nav_serialization_preserves_float_bits() { let mut seed = 0xabcddcba12345678_u64; for _ in 0..2000 { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; let value = 0.01 + (seed as f64 / u64::MAX as f64) * 9.99; let serialized = serde_json::to_string(&value).unwrap(); let restored: f64 = serde_json::from_str(&serialized).unwrap(); assert_eq!(value.to_bits(), restored.to_bits()); } } }