rustrade_risk/portfolio.rs
1//! Account-level (portfolio) risk.
2//!
3//! The per-symbol [`SessionPnl`](crate::SessionPnl) and
4//! [`CircuitBreaker`](crate::CircuitBreaker) bound each symbol in isolation.
5//! [`PortfolioRisk`] bounds the **whole account**, which is what multi-asset
6//! trading needs: a single bad day across many symbols, or too much aggregate
7//! exposure, should stop new risk even when no individual symbol has tripped.
8//!
9//! It provides two things:
10//!
11//! 1. An **account-wide daily-loss halt** — when net realised PnL summed
12//! across all symbols breaches the limit, every new entry is halted until
13//! the next 00:00 UTC rollover. The halt **latches** ([`PortfolioRisk::observe`]):
14//! once breached it stays halted for the day even if a later realised win
15//! nudges the sum back above the limit.
16//! 2. A **pre-trade entry gate** ([`PortfolioRisk::check_entry`]) over aggregate
17//! state: the daily-loss halt, max concurrent open positions, and a
18//! gross-exposure cap.
19//!
20//! The account net PnL is **derived** from the per-symbol session PnLs by the
21//! framework rather than bookkept separately here — that keeps a single source
22//! of truth and avoids drift. The framework computes the sum (in its periodic
23//! risk sweep and in the pre-trade gate) and hands it in via [`PortfolioRisk::observe`]
24//! and [`PortfolioState::account_net_pnl`].
25//!
26//! Every limit defaults to **off** ([`PortfolioRiskConfig::default`]), so a bot
27//! that doesn't configure portfolio risk behaves exactly as before — it's
28//! purely additive and opt-in.
29//!
30//! Time is read through the [`Clock`] trait so tests advance the clock instead
31//! of sleeping.
32
33use std::fmt;
34use std::sync::Arc;
35
36use serde::{Deserialize, Serialize};
37
38use crate::clock::{Clock, SystemClock};
39
40/// Configuration for [`PortfolioRisk`]. Every limit is independently
41/// disable-able, and the [`Default`] disables them all (opt-in).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PortfolioRiskConfig {
44 /// Account-wide net loss ceiling for the day, in quote currency (a
45 /// negative number, e.g. `-500.0`). When net realised PnL across all
46 /// symbols drops to or below this, **every** new entry is halted until
47 /// the next 00:00 UTC rollover. `f64::NEG_INFINITY` disables the halt.
48 pub max_daily_loss: f64,
49 /// Maximum number of symbols holding a position at once. A new entry on a
50 /// symbol that is currently flat is blocked when this many symbols are
51 /// already open. `0` means unlimited.
52 pub max_concurrent_positions: u32,
53 /// Cap on aggregate **gross** exposure (the sum of `|notional|` across all
54 /// open positions) in quote currency. A new entry is blocked when it would
55 /// push gross exposure past this. `f64::INFINITY` disables the cap.
56 pub max_gross_exposure: f64,
57}
58
59impl Default for PortfolioRiskConfig {
60 fn default() -> Self {
61 // All-off: a bot that doesn't opt in is unaffected.
62 Self {
63 max_daily_loss: f64::NEG_INFINITY,
64 max_concurrent_positions: 0,
65 max_gross_exposure: f64::INFINITY,
66 }
67 }
68}
69
70/// Why [`PortfolioRisk::check_entry`] blocked a new entry.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum PortfolioBlock {
73 /// The account-wide daily loss limit is breached; halted until UTC rollover.
74 DailyLossHalt {
75 /// Net realised PnL across the account.
76 net_pnl: f64,
77 /// The configured ceiling.
78 limit: f64,
79 },
80 /// Already at the maximum number of concurrent open positions.
81 MaxConcurrentPositions {
82 /// How many symbols are currently open.
83 open: u32,
84 /// The configured ceiling.
85 limit: u32,
86 },
87 /// Adding this position would exceed the gross-exposure cap.
88 GrossExposureCap {
89 /// Gross exposure already on the book.
90 current: f64,
91 /// Notional this entry would add.
92 additional: f64,
93 /// The configured ceiling.
94 limit: f64,
95 },
96}
97
98impl fmt::Display for PortfolioBlock {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::DailyLossHalt { net_pnl, limit } => write!(
102 f,
103 "account daily-loss halt (net {net_pnl:.2} ≤ limit {limit:.2})"
104 ),
105 Self::MaxConcurrentPositions { open, limit } => write!(
106 f,
107 "max concurrent positions reached ({open} open ≥ limit {limit})"
108 ),
109 Self::GrossExposureCap {
110 current,
111 additional,
112 limit,
113 } => write!(
114 f,
115 "gross-exposure cap (current {current:.2} + {additional:.2} > limit {limit:.2})"
116 ),
117 }
118 }
119}
120
121/// Aggregate account state at the moment of a pre-trade check, assembled by
122/// the framework from its per-symbol risk + position state.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct PortfolioState {
125 /// Number of symbols currently holding a non-flat position.
126 pub open_positions: u32,
127 /// Aggregate gross exposure already on the book (quote currency).
128 pub gross_exposure: f64,
129 /// Notional the proposed new entry would add (quote currency).
130 pub new_notional: f64,
131 /// Whether the entry's symbol already holds a position — if so the entry
132 /// adds to an existing slot rather than consuming a new concurrency slot.
133 pub symbol_already_open: bool,
134 /// Account-wide **net realised** PnL (sum of every symbol's session net
135 /// PnL). Checked live so a fresh breach blocks immediately, independent of
136 /// the latched halt.
137 pub account_net_pnl: f64,
138}
139
140/// Restart-durable snapshot of [`PortfolioRisk`]'s latch state.
141///
142/// The account PnL itself is derived from the per-symbol session snapshots, so
143/// only the halt latch + rollover day are persisted here.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct PortfolioRiskSnapshot {
146 /// Whether the account was halted by the daily-loss limit when snapshotted.
147 pub halted: bool,
148 /// UTC day number of the last reset, for rollover detection on restore.
149 pub last_reset_day: u64,
150}
151
152/// Account-wide risk: a latching daily-loss halt plus a pre-trade entry gate
153/// over aggregate exposure and concurrency. See the [module docs](self).
154///
155/// # Example
156///
157/// ```
158/// use rustrade_risk::{PortfolioRisk, PortfolioRiskConfig, PortfolioState, PortfolioBlock};
159///
160/// let mut pf = PortfolioRisk::new(PortfolioRiskConfig {
161/// max_daily_loss: -100.0,
162/// max_concurrent_positions: 2,
163/// max_gross_exposure: 10_000.0,
164/// });
165///
166/// // A fresh entry that fits every limit is allowed.
167/// assert!(pf.check_entry(PortfolioState {
168/// open_positions: 1,
169/// gross_exposure: 3_000.0,
170/// new_notional: 2_000.0,
171/// symbol_already_open: false,
172/// account_net_pnl: -10.0,
173/// }).is_ok());
174///
175/// // Once the account net PnL breaches the limit the whole account halts.
176/// pf.observe(-120.0);
177/// assert!(pf.is_halted());
178/// assert!(matches!(
179/// pf.check_entry(PortfolioState {
180/// open_positions: 0, gross_exposure: 0.0, new_notional: 1.0,
181/// symbol_already_open: false, account_net_pnl: -120.0,
182/// }),
183/// Err(PortfolioBlock::DailyLossHalt { .. })
184/// ));
185/// ```
186#[derive(Debug, Clone)]
187pub struct PortfolioRisk {
188 config: PortfolioRiskConfig,
189 halted: bool,
190 last_reset_day: u64,
191 clock: Arc<dyn Clock>,
192}
193
194impl PortfolioRisk {
195 /// Create with the default system clock.
196 pub fn new(config: PortfolioRiskConfig) -> Self {
197 Self::with_clock(config, Arc::new(SystemClock))
198 }
199
200 /// Create with an injected clock — typically `Arc<ManualClock>` in tests.
201 pub fn with_clock(config: PortfolioRiskConfig, clock: Arc<dyn Clock>) -> Self {
202 let last_reset_day = clock.utc_day_number();
203 Self {
204 config,
205 halted: false,
206 last_reset_day,
207 clock,
208 }
209 }
210
211 /// Is the account currently halted by the daily-loss limit?
212 pub fn is_halted(&self) -> bool {
213 self.halted
214 }
215
216 /// Borrow the configuration.
217 pub fn config(&self) -> &PortfolioRiskConfig {
218 &self.config
219 }
220
221 /// Observe the account-wide net realised PnL (the sum of every symbol's
222 /// session net PnL). Latches the daily-loss halt once it breaches the
223 /// limit; the latch is sticky until the next UTC rollover ([`Self::tick`]).
224 /// The framework calls this from its periodic risk sweep.
225 pub fn observe(&mut self, account_net_pnl: f64) {
226 if !self.halted && account_net_pnl <= self.config.max_daily_loss {
227 self.halted = true;
228 tracing::warn!(
229 target: "portfolio",
230 net_pnl = format!("{:.4}", account_net_pnl),
231 limit = format!("{:.4}", self.config.max_daily_loss),
232 "account daily-loss limit breached — all new entries halted",
233 );
234 }
235 }
236
237 /// Pre-trade gate for a **new entry**. Returns `Err` with the binding
238 /// reason if any account-level limit blocks it. Exits / reduce-only orders
239 /// should not be gated by this — only entries that add risk.
240 ///
241 /// The daily-loss check fires on either the latched halt or a live breach
242 /// in `state.account_net_pnl`, so a fresh breach blocks immediately even
243 /// between sweeps.
244 pub fn check_entry(&self, state: PortfolioState) -> Result<(), PortfolioBlock> {
245 if self.halted || state.account_net_pnl <= self.config.max_daily_loss {
246 return Err(PortfolioBlock::DailyLossHalt {
247 net_pnl: state.account_net_pnl,
248 limit: self.config.max_daily_loss,
249 });
250 }
251
252 if self.config.max_concurrent_positions > 0
253 && !state.symbol_already_open
254 && state.open_positions >= self.config.max_concurrent_positions
255 {
256 return Err(PortfolioBlock::MaxConcurrentPositions {
257 open: state.open_positions,
258 limit: self.config.max_concurrent_positions,
259 });
260 }
261
262 if state.gross_exposure + state.new_notional > self.config.max_gross_exposure {
263 return Err(PortfolioBlock::GrossExposureCap {
264 current: state.gross_exposure,
265 additional: state.new_notional,
266 limit: self.config.max_gross_exposure,
267 });
268 }
269
270 Ok(())
271 }
272
273 /// Call periodically to detect the 00:00 UTC rollover and clear the halt
274 /// latch. Mirrors [`SessionPnl::tick`](crate::SessionPnl::tick).
275 pub fn tick(&mut self) {
276 let today = self.clock.utc_day_number();
277 if today > self.last_reset_day {
278 self.reset_session();
279 self.last_reset_day = today;
280 }
281 }
282
283 /// Force a session reset (normally automatic at the UTC rollover).
284 pub fn reset_session(&mut self) {
285 if self.halted {
286 tracing::info!(target: "portfolio", "account session reset — daily-loss halt cleared");
287 }
288 self.halted = false;
289 }
290
291 /// Capture the latch state for persistence.
292 pub fn snapshot(&self) -> PortfolioRiskSnapshot {
293 PortfolioRiskSnapshot {
294 halted: self.halted,
295 last_reset_day: self.last_reset_day,
296 }
297 }
298
299 /// Restore latch state from a snapshot, keeping the live config + clock.
300 /// Call [`Self::tick`] afterwards so a snapshot from an earlier UTC day
301 /// rolls over instead of resuming a stale halt.
302 pub fn restore(&mut self, snap: PortfolioRiskSnapshot) {
303 self.halted = snap.halted;
304 self.last_reset_day = snap.last_reset_day;
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::clock::ManualClock;
312
313 fn cfg(loss: f64, max_pos: u32, max_gross: f64) -> PortfolioRiskConfig {
314 PortfolioRiskConfig {
315 max_daily_loss: loss,
316 max_concurrent_positions: max_pos,
317 max_gross_exposure: max_gross,
318 }
319 }
320
321 fn state(open: u32, gross: f64, new: f64, already: bool, net: f64) -> PortfolioState {
322 PortfolioState {
323 open_positions: open,
324 gross_exposure: gross,
325 new_notional: new,
326 symbol_already_open: already,
327 account_net_pnl: net,
328 }
329 }
330
331 #[test]
332 fn default_config_blocks_nothing() {
333 let pf = PortfolioRisk::new(PortfolioRiskConfig::default());
334 // Huge exposure, many positions, big loss — all allowed when off.
335 assert!(
336 pf.check_entry(state(1_000, 1e12, 1e12, false, -1e12))
337 .is_ok()
338 );
339 }
340
341 #[test]
342 fn live_daily_loss_blocks_even_before_latch() {
343 let pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
344 // Not latched yet, but the live net is already past the limit.
345 assert!(matches!(
346 pf.check_entry(state(0, 0.0, 1.0, false, -150.0)),
347 Err(PortfolioBlock::DailyLossHalt { .. })
348 ));
349 // Comfortably above the limit → allowed.
350 assert!(pf.check_entry(state(0, 0.0, 1.0, false, -50.0)).is_ok());
351 }
352
353 #[test]
354 fn observe_latches_sticky_halt() {
355 let mut pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
356 pf.observe(-60.0);
357 assert!(!pf.is_halted());
358 pf.observe(-120.0); // breach → latch
359 assert!(pf.is_halted());
360 // Even if PnL recovers above the limit, the latch holds for the day.
361 assert!(matches!(
362 pf.check_entry(state(0, 0.0, 1.0, false, 50.0)),
363 Err(PortfolioBlock::DailyLossHalt { .. })
364 ));
365 }
366
367 #[test]
368 fn max_concurrent_blocks_new_symbol_only() {
369 let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 2, f64::INFINITY));
370 assert!(matches!(
371 pf.check_entry(state(2, 0.0, 1.0, false, 0.0)),
372 Err(PortfolioBlock::MaxConcurrentPositions { open: 2, limit: 2 })
373 ));
374 // Adding to an already-open symbol consumes no new slot.
375 assert!(pf.check_entry(state(2, 0.0, 1.0, true, 0.0)).is_ok());
376 // Below the cap a new symbol is fine.
377 assert!(pf.check_entry(state(1, 0.0, 1.0, false, 0.0)).is_ok());
378 }
379
380 #[test]
381 fn gross_exposure_cap_blocks_when_exceeded() {
382 let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 0, 10_000.0));
383 assert!(
384 pf.check_entry(state(1, 8_000.0, 1_500.0, false, 0.0))
385 .is_ok()
386 );
387 assert!(matches!(
388 pf.check_entry(state(1, 8_000.0, 2_500.0, false, 0.0)),
389 Err(PortfolioBlock::GrossExposureCap { .. })
390 ));
391 }
392
393 #[test]
394 fn halt_takes_precedence_over_other_gates() {
395 let pf = PortfolioRisk::new(cfg(-10.0, 1, 100.0));
396 // Live breach blocks even an entry that fits concurrency + exposure.
397 assert!(matches!(
398 pf.check_entry(state(0, 0.0, 1.0, false, -20.0)),
399 Err(PortfolioBlock::DailyLossHalt { .. })
400 ));
401 }
402
403 #[test]
404 fn utc_rollover_clears_latch_via_tick() {
405 let day = 100u64;
406 let clock = Arc::new(ManualClock::new(day * 86_400));
407 let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
408 pf.observe(-20.0);
409 assert!(pf.is_halted());
410
411 clock.advance_secs(3_600);
412 pf.tick(); // same day → still halted
413 assert!(pf.is_halted());
414
415 clock.set((day + 1) * 86_400 + 5);
416 pf.tick(); // next UTC day → cleared
417 assert!(!pf.is_halted());
418 }
419
420 #[test]
421 fn snapshot_restore_preserves_latch_same_day() {
422 let clock = Arc::new(ManualClock::new(200 * 86_400 + 100));
423 let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
424 pf.observe(-20.0);
425 let snap = pf.snapshot();
426
427 let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
428 q.restore(snap.clone());
429 assert_eq!(q.snapshot(), snap);
430 q.tick(); // same day → latch survives
431 assert!(q.is_halted());
432 }
433
434 #[test]
435 fn restore_then_tick_rolls_over_stale_day() {
436 let day = 300u64;
437 let clock = Arc::new(ManualClock::new(day * 86_400 + 100));
438 let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock);
439 pf.observe(-50.0);
440 let snap = pf.snapshot();
441
442 let next = Arc::new(ManualClock::new((day + 1) * 86_400 + 5));
443 let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), next);
444 q.restore(snap);
445 q.tick();
446 assert!(!q.is_halted(), "stale halted day must roll over fresh");
447 }
448}