1use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::clock::{Clock, SystemClock};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionPnlConfig {
21 pub loss_limit: f64,
27}
28
29impl Default for SessionPnlConfig {
30 fn default() -> Self {
31 Self { loss_limit: -50.0 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct SessionPnlSnapshot {
47 pub realised: f64,
49 pub fees: f64,
51 pub trades: u32,
53 pub wins: u32,
55 pub losses: u32,
57 pub breakevens: u32,
59 pub halted: bool,
61 pub last_reset_day: u64,
63}
64
65#[derive(Debug, Clone)]
84pub struct SessionPnl {
85 pub symbol: String,
87 pub realised: f64,
89 pub fees: f64,
91 pub trades: u32,
93 pub wins: u32,
95 pub losses: u32,
97 pub breakevens: u32,
99 config: SessionPnlConfig,
100 halted: bool,
101 last_reset_day: u64,
104 clock: Arc<dyn Clock>,
105}
106
107impl SessionPnl {
108 pub fn new(symbol: impl Into<String>, config: SessionPnlConfig) -> Self {
110 Self::with_clock(symbol, config, Arc::new(SystemClock))
111 }
112
113 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 pub fn net_pnl(&self) -> f64 {
138 self.realised - self.fees
139 }
140
141 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 pub fn is_session_halted(&self) -> bool {
153 self.halted
154 }
155
156 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 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 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 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 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); 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); assert!(!p.is_session_halted());
288 p.record_close(-5.0, 1.0); 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); p.record_close(-5.0, 1.0); p.record_close(1.0, 1.0); assert!((p.win_rate() - 0.5).abs() < 1e-9);
310 }
311
312 #[test]
313 fn utc_rollover_resets_session_via_tick() {
314 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 clock.advance_secs(3_600); p.record_close(-20.0, 0.0);
323 assert!(p.is_session_halted());
324 assert_eq!(p.trades, 1);
325
326 clock.advance_secs(3_600); p.tick();
329 assert!(
330 p.is_session_halted(),
331 "should still be halted before midnight"
332 );
333
334 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); let before = p.net_pnl();
349
350 clock.advance_secs(60 * 60 * 12); 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); p.record_close(-30.0, 2.0); let snap = p.snapshot();
365
366 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 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(); 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 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 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}