feat: add serialized causal portfolio loss controller for runtime integration
This commit is contained in:
@@ -15,6 +15,7 @@ pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod portfolio_loss;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! 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 = state.clone();
|
||||
late.last_session.as_mut().unwrap().available_at = time(2, 1);
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user