From 8c190597aec1d064374f9e2f674a05242af1103d Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 05:05:04 +0800 Subject: [PATCH] feat: add serialized causal portfolio loss controller for runtime integration --- crates/fidc-core/src/lib.rs | 1 + crates/fidc-core/src/portfolio_loss.rs | 484 +++++++++++++++++++++ docs/production-portfolio-risk-contract.md | 25 ++ 3 files changed, 510 insertions(+) create mode 100644 crates/fidc-core/src/portfolio_loss.rs create mode 100644 docs/production-portfolio-risk-contract.md diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 7213797..6293c7c 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -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; diff --git a/crates/fidc-core/src/portfolio_loss.rs b/crates/fidc-core/src/portfolio_loss.rs new file mode 100644 index 0000000..9d9a9ff --- /dev/null +++ b/crates/fidc-core/src/portfolio_loss.rs @@ -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, + 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 = 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)); + } +} diff --git a/docs/production-portfolio-risk-contract.md b/docs/production-portfolio-risk-contract.md new file mode 100644 index 0000000..5da2ffb --- /dev/null +++ b/docs/production-portfolio-risk-contract.md @@ -0,0 +1,25 @@ +# Production Portfolio Risk Contract + +Status: implementation in progress. This document does not admit a strategy to production. + +Research breadth/loss rules are not yet production controls: Alpha currently rejects dynamic breadth without full-market PIT input, and the existing Strategy Runtime creates a fresh strategy per request. A single successful request cannot prove stateful drawdown or cooldown behavior. + +## Ownership + +- Source Lake owns market-only aggregates, with a full-market universe distinct from the trading selection, completed-date visibility, formula/adjustment semantics and source identity. A selected Top40 subset is not a market-breadth input. +- Engine owns simulated portfolio accounting. Risk observations must be finalized after execution, settlement and fees, not inferred from benchmark returns or recorded before management fees. +- Trading Platform owns strategy-instance/generation-scoped observations and state in PostgreSQL. Loading and committing state require the execution lease and optimistic version checks. Account/generation/config identity must be checked before runtime planning; a content hash alone is not authorization. +- Strategy Runtime is a pure calculation boundary: restore verified state, consume closed-session facts, calculate intents and return proposed next state. Never silently initialize an established strategy's state on every HTTP request. +- AiQuant must calculate its own portfolio observations from its own fills/accounting under the same declared formulas. Historical target weights or researcher-generated risk-off booleans are not production logic. + +## Loss Rule + +The v1 research rule compounds completed net daily unit returns divided by the maximum of beginning/end gross exposure. A zero-exposure session advances continuity but adds no return observation. Window sizes are valid invested observations, not calendar days. Only sessions before the execution day and available by the decision may be consumed. + +The loss trigger, floor and cooldown are explicit. Repeated evaluation within one execution day must not decrement cooldown twice. A reduced current exposure budget still caps the returned target. Corrections, dropped sessions, nonfinite values and wrong configuration are reconciliation errors, not zero-filled history. State is serialized and validated on restore, bounded to 120 observations, and is never shared across accounts or strategies. + +With a zero floor, the original invested-observation rule can remain in cash while its loss window stays unchanged. That behavior must not be described as automatic market re-entry; an alternate rearm policy requires a separately frozen semantic version and research validation. Current v13 research floors are positive. + +## Remaining Integration + +Wire finalized engine events, JSON configuration/capability contracts and runner diagnostics. Add authoritative trading-state storage/restore and fail closed when that state is absent. Add full-market breadth input construction and both-framework consumers. Verify independent daily inputs, state after restart, exact risk decisions, orders/holdings/NAV and real same-bundle backtests before removing production gates. No live orders or production approval are authorized by component tests.