Skip to main content

rustrade_risk/
session_pnl.rs

1//! Per-session realised PnL tracker with optional drawdown cap and
2//! daily 00:00 UTC rollover.
3//!
4//! Generalized from the `kucoin/bot/pnl.rs` shipped with the Apr 2026 bot.
5//! Strategy code records every trade close via [`SessionPnl::record_close`];
6//! the framework checks [`SessionPnl::is_session_halted`] before allowing
7//! new entries.
8//!
9//! Time is read through the [`Clock`] trait so tests can advance the
10//! clock and verify rollover without sleeping for a day.
11
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::clock::{Clock, SystemClock};
17
18/// Configuration for [`SessionPnl`].
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionPnlConfig {
21    /// Per-session net loss ceiling in quote currency (negative number,
22    /// e.g. -50.0 USDT). When net PnL drops to or below this value, the
23    /// session is halted until the next 00:00 UTC rollover.
24    ///
25    /// Set to `f64::NEG_INFINITY` to disable the halt entirely.
26    pub loss_limit: f64,
27}
28
29impl Default for SessionPnlConfig {
30    fn default() -> Self {
31        Self { loss_limit: -50.0 }
32    }
33}
34
35/// Restart-durable snapshot of a [`SessionPnl`]'s mutable state.
36///
37/// Captures the running totals and halt flag — **not** the configured
38/// `loss_limit`, the symbol, or the clock. Those come from the live
39/// instance on restore, so an operator can adjust the loss limit between
40/// runs and the new value takes effect immediately.
41///
42/// Restore via [`SessionPnl::restore`]. Because `last_reset_day` is kept,
43/// calling [`SessionPnl::tick`] right after a restore correctly rolls the
44/// session over if the snapshot was taken on an earlier UTC day.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct SessionPnlSnapshot {
47    /// Cumulative gross realised PnL, in quote currency.
48    pub realised: f64,
49    /// Cumulative fees paid this session, in quote currency.
50    pub fees: f64,
51    /// Total trades recorded this session.
52    pub trades: u32,
53    /// Wins (net PnL > 0).
54    pub wins: u32,
55    /// Losses (net PnL < 0).
56    pub losses: u32,
57    /// Break-evens (net PnL == 0).
58    pub breakevens: u32,
59    /// Whether the session was halted by the loss cap when snapshotted.
60    pub halted: bool,
61    /// UTC day number of the last reset, for rollover detection on restore.
62    pub last_reset_day: u64,
63}
64
65/// Running PnL totals for one trading session, with an optional drawdown
66/// cap that auto-resets at the daily 00:00 UTC boundary.
67///
68/// # Example
69///
70/// ```
71/// use rustrade_risk::{SessionPnl, SessionPnlConfig};
72///
73/// let mut pnl = SessionPnl::new("XBTUSDTM", SessionPnlConfig {
74///     loss_limit: -100.0,
75/// });
76///
77/// pnl.record_close(20.0, 1.5);  // gross +20, fee 1.5 → net +18.5
78/// pnl.record_close(-50.0, 1.5); // gross -50, fee 1.5 → net -51.5
79/// pnl.record_close(-80.0, 1.5); // running net = -113.0 → halts (≤ -100)
80///
81/// assert!(pnl.is_session_halted());
82/// ```
83#[derive(Debug, Clone)]
84pub struct SessionPnl {
85    /// Symbol this PnL tracker is for (used in log records).
86    pub symbol: String,
87    /// Cumulative gross realised PnL, in quote currency.
88    pub realised: f64,
89    /// Cumulative fees paid this session, in quote currency.
90    pub fees: f64,
91    /// Total trades recorded this session.
92    pub trades: u32,
93    /// Wins (net PnL > 0).
94    pub wins: u32,
95    /// Losses (net PnL < 0).
96    pub losses: u32,
97    /// Break-evens (net PnL == 0).
98    pub breakevens: u32,
99    config: SessionPnlConfig,
100    halted: bool,
101    /// UTC day number (days since epoch) of the last reset. Used to detect
102    /// rollover; the `tick` method is the only place this is updated.
103    last_reset_day: u64,
104    clock: Arc<dyn Clock>,
105}
106
107impl SessionPnl {
108    /// Create with the default system clock.
109    pub fn new(symbol: impl Into<String>, config: SessionPnlConfig) -> Self {
110        Self::with_clock(symbol, config, Arc::new(SystemClock))
111    }
112
113    /// Create with an injected clock — typically `Arc<ManualClock>` from
114    /// [`crate::clock`] in tests.
115    pub fn with_clock(
116        symbol: impl Into<String>,
117        config: SessionPnlConfig,
118        clock: Arc<dyn Clock>,
119    ) -> Self {
120        let last_reset_day = clock.utc_day_number();
121        Self {
122            symbol: symbol.into(),
123            realised: 0.0,
124            fees: 0.0,
125            trades: 0,
126            wins: 0,
127            losses: 0,
128            breakevens: 0,
129            config,
130            halted: false,
131            last_reset_day,
132            clock,
133        }
134    }
135
136    /// Net PnL after fees.
137    pub fn net_pnl(&self) -> f64 {
138        self.realised - self.fees
139    }
140
141    /// Win rate over decided trades (excludes break-evens).
142    pub fn win_rate(&self) -> f64 {
143        let decided = self.wins + self.losses;
144        if decided == 0 {
145            0.0
146        } else {
147            f64::from(self.wins) / f64::from(decided)
148        }
149    }
150
151    /// Is the session currently halted by the loss cap?
152    pub fn is_session_halted(&self) -> bool {
153        self.halted
154    }
155
156    /// Record a closed trade.
157    ///
158    /// `gross_pnl` is the realised PnL before fees (negative for losses).
159    /// `fee` is the round-trip fee charged for opening + closing the
160    /// position. The trade is classified as W/L/B on **net** PnL so that
161    /// fee-flipped trades (small gross win, large fee = real loss) count
162    /// correctly.
163    pub fn record_close(&mut self, gross_pnl: f64, fee: f64) {
164        self.realised += gross_pnl;
165        self.fees += fee;
166        self.trades += 1;
167
168        let net = gross_pnl - fee;
169        if net > 0.0 {
170            self.wins += 1;
171        } else if net < 0.0 {
172            self.losses += 1;
173        } else {
174            self.breakevens += 1;
175        }
176
177        tracing::info!(
178            target: "pnl",
179            symbol      = %self.symbol,
180            trade       = self.trades,
181            gross_usdt  = format!("{:.4}", gross_pnl),
182            fee_usdt    = format!("{:.4}", fee),
183            net_usdt    = format!("{:.4}", net),
184            outcome     = if net > 0.0 { "WIN" } else if net < 0.0 { "LOSS" } else { "BREAKEVEN" },
185            running_net = format!("{:.4}", self.net_pnl()),
186            "trade closed",
187        );
188
189        if !self.halted && self.net_pnl() <= self.config.loss_limit {
190            self.halted = true;
191            tracing::warn!(
192                target: "pnl",
193                symbol  = %self.symbol,
194                net_pnl = format!("{:.4}", self.net_pnl()),
195                limit   = format!("{:.4}", self.config.loss_limit),
196                "session loss limit breached — trading halted",
197            );
198        }
199    }
200
201    /// Call periodically (e.g. once per candle poll) to detect 00:00 UTC
202    /// rollover and reset the session totals + halt flag.
203    pub fn tick(&mut self) {
204        let today = self.clock.utc_day_number();
205        if today > self.last_reset_day {
206            self.reset_session();
207            self.last_reset_day = today;
208        }
209    }
210
211    /// Capture the mutable session state for persistence.
212    ///
213    /// Pairs with [`Self::restore`]. The configured `loss_limit`, symbol,
214    /// and clock are intentionally excluded — they belong to the live
215    /// instance, not the snapshot.
216    pub fn snapshot(&self) -> SessionPnlSnapshot {
217        SessionPnlSnapshot {
218            realised: self.realised,
219            fees: self.fees,
220            trades: self.trades,
221            wins: self.wins,
222            losses: self.losses,
223            breakevens: self.breakevens,
224            halted: self.halted,
225            last_reset_day: self.last_reset_day,
226        }
227    }
228
229    /// Restore session state from a [`SessionPnlSnapshot`].
230    ///
231    /// Overwrites the running totals, halt flag, and last-reset day; keeps
232    /// the symbol, configured loss limit, and clock from the live instance.
233    /// Call [`Self::tick`] afterwards so a snapshot taken on an earlier UTC
234    /// day rolls over to a fresh session instead of resuming a stale halt.
235    pub fn restore(&mut self, snap: SessionPnlSnapshot) {
236        self.realised = snap.realised;
237        self.fees = snap.fees;
238        self.trades = snap.trades;
239        self.wins = snap.wins;
240        self.losses = snap.losses;
241        self.breakevens = snap.breakevens;
242        self.halted = snap.halted;
243        self.last_reset_day = snap.last_reset_day;
244    }
245
246    /// Force a session reset. Normally called automatically by `tick` at
247    /// the daily UTC rollover.
248    pub fn reset_session(&mut self) {
249        tracing::info!(
250            target: "pnl",
251            symbol = %self.symbol,
252            trades = self.trades,
253            net_usdt = format!("{:.4}", self.net_pnl()),
254            "session reset — rolling over",
255        );
256        self.realised = 0.0;
257        self.fees = 0.0;
258        self.trades = 0;
259        self.wins = 0;
260        self.losses = 0;
261        self.breakevens = 0;
262        self.halted = false;
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::clock::ManualClock;
270
271    fn cfg(limit: f64) -> SessionPnlConfig {
272        SessionPnlConfig { loss_limit: limit }
273    }
274
275    #[test]
276    fn classifies_on_net_not_gross() {
277        let mut p = SessionPnl::new("TEST", cfg(-1000.0));
278        p.record_close(1.0, 3.0); // net = -2 → loss
279        assert_eq!(p.losses, 1);
280        assert_eq!(p.wins, 0);
281    }
282
283    #[test]
284    fn halts_when_limit_breached() {
285        let mut p = SessionPnl::new("TEST", cfg(-10.0));
286        p.record_close(-5.0, 1.0); // net = -6, above limit
287        assert!(!p.is_session_halted());
288        p.record_close(-5.0, 1.0); // net = -12, below limit
289        assert!(p.is_session_halted());
290    }
291
292    #[test]
293    fn reset_clears_halt_and_totals() {
294        let mut p = SessionPnl::new("TEST", cfg(-10.0));
295        p.record_close(-20.0, 0.0);
296        assert!(p.is_session_halted());
297        p.reset_session();
298        assert!(!p.is_session_halted());
299        assert_eq!(p.trades, 0);
300        assert!((p.net_pnl()).abs() < 1e-9);
301    }
302
303    #[test]
304    fn win_rate_excludes_breakevens() {
305        let mut p = SessionPnl::new("TEST", cfg(-1000.0));
306        p.record_close(10.0, 1.0); // win
307        p.record_close(-5.0, 1.0); // loss
308        p.record_close(1.0, 1.0); // breakeven
309        assert!((p.win_rate() - 0.5).abs() < 1e-9);
310    }
311
312    #[test]
313    fn utc_rollover_resets_session_via_tick() {
314        // Start at midnight UTC on day 100. Halt the session within the
315        // day, advance past midnight, then verify tick() rolls over.
316        let day = 100u64;
317        let clock = Arc::new(ManualClock::new(day * 86_400));
318        let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
319
320        // Take a halting loss intra-day.
321        clock.advance_secs(3_600); // +1 h, still day 100
322        p.record_close(-20.0, 0.0);
323        assert!(p.is_session_halted());
324        assert_eq!(p.trades, 1);
325
326        // Tick before midnight — no rollover yet.
327        clock.advance_secs(3_600); // +1 h more, still day 100
328        p.tick();
329        assert!(
330            p.is_session_halted(),
331            "should still be halted before midnight"
332        );
333
334        // Cross midnight into day 101 and tick — should reset cleanly.
335        clock.set((day + 1) * 86_400 + 5);
336        p.tick();
337        assert!(!p.is_session_halted(), "rollover must clear the halt");
338        assert_eq!(p.trades, 0);
339        assert!((p.net_pnl()).abs() < 1e-9);
340    }
341
342    #[test]
343    fn tick_within_same_day_is_a_noop() {
344        let clock = Arc::new(ManualClock::new(100 * 86_400 + 10));
345        let mut p = SessionPnl::with_clock("TEST", cfg(-1000.0), clock.clone());
346
347        p.record_close(5.0, 1.0); // net +4
348        let before = p.net_pnl();
349
350        clock.advance_secs(60 * 60 * 12); // +12 h, still day 100
351        p.tick();
352        assert!(
353            (p.net_pnl() - before).abs() < 1e-9,
354            "intra-day tick must not reset session totals"
355        );
356        assert_eq!(p.trades, 1);
357    }
358
359    #[test]
360    fn snapshot_restore_roundtrips_state() {
361        let mut p = SessionPnl::new("TEST", cfg(-100.0));
362        p.record_close(10.0, 1.0); // win
363        p.record_close(-30.0, 2.0); // loss
364        let snap = p.snapshot();
365
366        // Restore into a fresh instance (same config) and compare totals.
367        let mut q = SessionPnl::new("TEST", cfg(-100.0));
368        q.restore(snap.clone());
369        assert_eq!(q.snapshot(), snap);
370        assert!((q.net_pnl() - p.net_pnl()).abs() < 1e-9);
371        assert_eq!(q.trades, 2);
372        assert_eq!(q.wins, 1);
373        assert_eq!(q.losses, 1);
374    }
375
376    #[test]
377    fn restore_preserves_halt_within_same_day() {
378        // Snapshot a halted session, restore on the *same* UTC day, tick:
379        // the halt must survive (a restart must not reopen a halted day).
380        let clock = Arc::new(ManualClock::new(200 * 86_400 + 100));
381        let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
382        p.record_close(-20.0, 0.0);
383        assert!(p.is_session_halted());
384        let snap = p.snapshot();
385
386        let mut q = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
387        q.restore(snap);
388        q.tick(); // same day → no rollover
389        assert!(
390            q.is_session_halted(),
391            "halt must survive a same-day restore"
392        );
393    }
394
395    #[test]
396    fn restore_then_tick_rolls_over_stale_day() {
397        // Snapshot a halted session on day 300; restore on day 301. A
398        // post-restore tick must roll the session over (fresh, un-halted)
399        // so a day-old breaker doesn't wrongly block today's trading.
400        let day = 300u64;
401        let clock = Arc::new(ManualClock::new(day * 86_400 + 100));
402        let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
403        p.record_close(-50.0, 0.0);
404        assert!(p.is_session_halted());
405        let snap = p.snapshot();
406
407        // Reboot "the next day".
408        let next = Arc::new(ManualClock::new((day + 1) * 86_400 + 5));
409        let mut q = SessionPnl::with_clock("TEST", cfg(-10.0), next);
410        q.restore(snap);
411        q.tick();
412        assert!(!q.is_session_halted(), "stale day must roll over to fresh");
413        assert_eq!(q.trades, 0);
414        assert!(q.net_pnl().abs() < 1e-9);
415    }
416}