use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::clock::{Clock, SystemClock};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioRiskConfig {
pub max_daily_loss: f64,
pub max_concurrent_positions: u32,
pub max_gross_exposure: f64,
}
impl Default for PortfolioRiskConfig {
fn default() -> Self {
Self {
max_daily_loss: f64::NEG_INFINITY,
max_concurrent_positions: 0,
max_gross_exposure: f64::INFINITY,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PortfolioBlock {
DailyLossHalt {
net_pnl: f64,
limit: f64,
},
MaxConcurrentPositions {
open: u32,
limit: u32,
},
GrossExposureCap {
current: f64,
additional: f64,
limit: f64,
},
}
impl fmt::Display for PortfolioBlock {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DailyLossHalt { net_pnl, limit } => write!(
f,
"account daily-loss halt (net {net_pnl:.2} ≤ limit {limit:.2})"
),
Self::MaxConcurrentPositions { open, limit } => write!(
f,
"max concurrent positions reached ({open} open ≥ limit {limit})"
),
Self::GrossExposureCap {
current,
additional,
limit,
} => write!(
f,
"gross-exposure cap (current {current:.2} + {additional:.2} > limit {limit:.2})"
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PortfolioState {
pub open_positions: u32,
pub gross_exposure: f64,
pub new_notional: f64,
pub symbol_already_open: bool,
pub account_net_pnl: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortfolioRiskSnapshot {
pub halted: bool,
pub last_reset_day: u64,
}
#[derive(Debug, Clone)]
pub struct PortfolioRisk {
config: PortfolioRiskConfig,
halted: bool,
last_reset_day: u64,
clock: Arc<dyn Clock>,
}
impl PortfolioRisk {
pub fn new(config: PortfolioRiskConfig) -> Self {
Self::with_clock(config, Arc::new(SystemClock))
}
pub fn with_clock(config: PortfolioRiskConfig, clock: Arc<dyn Clock>) -> Self {
let last_reset_day = clock.utc_day_number();
Self {
config,
halted: false,
last_reset_day,
clock,
}
}
pub fn is_halted(&self) -> bool {
self.halted
}
pub fn config(&self) -> &PortfolioRiskConfig {
&self.config
}
pub fn observe(&mut self, account_net_pnl: f64) {
if !self.halted && account_net_pnl <= self.config.max_daily_loss {
self.halted = true;
tracing::warn!(
target: "portfolio",
net_pnl = format!("{:.4}", account_net_pnl),
limit = format!("{:.4}", self.config.max_daily_loss),
"account daily-loss limit breached — all new entries halted",
);
}
}
pub fn check_entry(&self, state: PortfolioState) -> Result<(), PortfolioBlock> {
if self.halted || state.account_net_pnl <= self.config.max_daily_loss {
return Err(PortfolioBlock::DailyLossHalt {
net_pnl: state.account_net_pnl,
limit: self.config.max_daily_loss,
});
}
if self.config.max_concurrent_positions > 0
&& !state.symbol_already_open
&& state.open_positions >= self.config.max_concurrent_positions
{
return Err(PortfolioBlock::MaxConcurrentPositions {
open: state.open_positions,
limit: self.config.max_concurrent_positions,
});
}
if state.gross_exposure + state.new_notional > self.config.max_gross_exposure {
return Err(PortfolioBlock::GrossExposureCap {
current: state.gross_exposure,
additional: state.new_notional,
limit: self.config.max_gross_exposure,
});
}
Ok(())
}
pub fn tick(&mut self) {
let today = self.clock.utc_day_number();
if today > self.last_reset_day {
self.reset_session();
self.last_reset_day = today;
}
}
pub fn reset_session(&mut self) {
if self.halted {
tracing::info!(target: "portfolio", "account session reset — daily-loss halt cleared");
}
self.halted = false;
}
pub fn snapshot(&self) -> PortfolioRiskSnapshot {
PortfolioRiskSnapshot {
halted: self.halted,
last_reset_day: self.last_reset_day,
}
}
pub fn restore(&mut self, snap: PortfolioRiskSnapshot) {
self.halted = snap.halted;
self.last_reset_day = snap.last_reset_day;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::ManualClock;
fn cfg(loss: f64, max_pos: u32, max_gross: f64) -> PortfolioRiskConfig {
PortfolioRiskConfig {
max_daily_loss: loss,
max_concurrent_positions: max_pos,
max_gross_exposure: max_gross,
}
}
fn state(open: u32, gross: f64, new: f64, already: bool, net: f64) -> PortfolioState {
PortfolioState {
open_positions: open,
gross_exposure: gross,
new_notional: new,
symbol_already_open: already,
account_net_pnl: net,
}
}
#[test]
fn default_config_blocks_nothing() {
let pf = PortfolioRisk::new(PortfolioRiskConfig::default());
assert!(
pf.check_entry(state(1_000, 1e12, 1e12, false, -1e12))
.is_ok()
);
}
#[test]
fn live_daily_loss_blocks_even_before_latch() {
let pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
assert!(matches!(
pf.check_entry(state(0, 0.0, 1.0, false, -150.0)),
Err(PortfolioBlock::DailyLossHalt { .. })
));
assert!(pf.check_entry(state(0, 0.0, 1.0, false, -50.0)).is_ok());
}
#[test]
fn observe_latches_sticky_halt() {
let mut pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
pf.observe(-60.0);
assert!(!pf.is_halted());
pf.observe(-120.0); assert!(pf.is_halted());
assert!(matches!(
pf.check_entry(state(0, 0.0, 1.0, false, 50.0)),
Err(PortfolioBlock::DailyLossHalt { .. })
));
}
#[test]
fn max_concurrent_blocks_new_symbol_only() {
let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 2, f64::INFINITY));
assert!(matches!(
pf.check_entry(state(2, 0.0, 1.0, false, 0.0)),
Err(PortfolioBlock::MaxConcurrentPositions { open: 2, limit: 2 })
));
assert!(pf.check_entry(state(2, 0.0, 1.0, true, 0.0)).is_ok());
assert!(pf.check_entry(state(1, 0.0, 1.0, false, 0.0)).is_ok());
}
#[test]
fn gross_exposure_cap_blocks_when_exceeded() {
let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 0, 10_000.0));
assert!(
pf.check_entry(state(1, 8_000.0, 1_500.0, false, 0.0))
.is_ok()
);
assert!(matches!(
pf.check_entry(state(1, 8_000.0, 2_500.0, false, 0.0)),
Err(PortfolioBlock::GrossExposureCap { .. })
));
}
#[test]
fn halt_takes_precedence_over_other_gates() {
let pf = PortfolioRisk::new(cfg(-10.0, 1, 100.0));
assert!(matches!(
pf.check_entry(state(0, 0.0, 1.0, false, -20.0)),
Err(PortfolioBlock::DailyLossHalt { .. })
));
}
#[test]
fn utc_rollover_clears_latch_via_tick() {
let day = 100u64;
let clock = Arc::new(ManualClock::new(day * 86_400));
let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
pf.observe(-20.0);
assert!(pf.is_halted());
clock.advance_secs(3_600);
pf.tick(); assert!(pf.is_halted());
clock.set((day + 1) * 86_400 + 5);
pf.tick(); assert!(!pf.is_halted());
}
#[test]
fn snapshot_restore_preserves_latch_same_day() {
let clock = Arc::new(ManualClock::new(200 * 86_400 + 100));
let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
pf.observe(-20.0);
let snap = pf.snapshot();
let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
q.restore(snap.clone());
assert_eq!(q.snapshot(), snap);
q.tick(); assert!(q.is_halted());
}
#[test]
fn restore_then_tick_rolls_over_stale_day() {
let day = 300u64;
let clock = Arc::new(ManualClock::new(day * 86_400 + 100));
let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock);
pf.observe(-50.0);
let snap = pf.snapshot();
let next = Arc::new(ManualClock::new((day + 1) * 86_400 + 5));
let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), next);
q.restore(snap);
q.tick();
assert!(!q.is_halted(), "stale halted day must roll over fresh");
}
}