518 lines
19 KiB
Rust
518 lines
19 KiB
Rust
//! 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<NaiveDate>,
|
|
pub available_at: DateTime<Utc>,
|
|
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<Option<f64>, 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<NaiveDate>,
|
|
pub observation_count: usize,
|
|
pub trailing_unit_return: Option<f64>,
|
|
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<ClosedPortfolioSession>,
|
|
last_session: Option<ClosedPortfolioSession>,
|
|
cooldown_remaining: usize,
|
|
trigger_count: usize,
|
|
last_decision: Option<PortfolioLossDecision>,
|
|
}
|
|
|
|
#[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<Self, PortfolioLossError> {
|
|
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<bool, PortfolioLossError> {
|
|
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<NaiveDate>,
|
|
decision_at: DateTime<Utc>,
|
|
risk_on_exposure: f64,
|
|
) -> Result<PortfolioLossDecision, PortfolioLossError> {
|
|
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> {
|
|
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());
|
|
}
|
|
}
|
|
}
|