use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::clock::{Clock, SystemClock};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionPnlConfig {
pub loss_limit: f64,
}
impl Default for SessionPnlConfig {
fn default() -> Self {
Self { loss_limit: -50.0 }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionPnlSnapshot {
pub realised: f64,
pub fees: f64,
pub trades: u32,
pub wins: u32,
pub losses: u32,
pub breakevens: u32,
pub halted: bool,
pub last_reset_day: u64,
}
#[derive(Debug, Clone)]
pub struct SessionPnl {
pub symbol: String,
pub realised: f64,
pub fees: f64,
pub trades: u32,
pub wins: u32,
pub losses: u32,
pub breakevens: u32,
config: SessionPnlConfig,
halted: bool,
last_reset_day: u64,
clock: Arc<dyn Clock>,
}
impl SessionPnl {
pub fn new(symbol: impl Into<String>, config: SessionPnlConfig) -> Self {
Self::with_clock(symbol, config, Arc::new(SystemClock))
}
pub fn with_clock(
symbol: impl Into<String>,
config: SessionPnlConfig,
clock: Arc<dyn Clock>,
) -> Self {
let last_reset_day = clock.utc_day_number();
Self {
symbol: symbol.into(),
realised: 0.0,
fees: 0.0,
trades: 0,
wins: 0,
losses: 0,
breakevens: 0,
config,
halted: false,
last_reset_day,
clock,
}
}
pub fn net_pnl(&self) -> f64 {
self.realised - self.fees
}
pub fn win_rate(&self) -> f64 {
let decided = self.wins + self.losses;
if decided == 0 {
0.0
} else {
f64::from(self.wins) / f64::from(decided)
}
}
pub fn is_session_halted(&self) -> bool {
self.halted
}
pub fn record_close(&mut self, gross_pnl: f64, fee: f64) {
self.realised += gross_pnl;
self.fees += fee;
self.trades += 1;
let net = gross_pnl - fee;
if net > 0.0 {
self.wins += 1;
} else if net < 0.0 {
self.losses += 1;
} else {
self.breakevens += 1;
}
tracing::info!(
target: "pnl",
symbol = %self.symbol,
trade = self.trades,
gross_usdt = format!("{:.4}", gross_pnl),
fee_usdt = format!("{:.4}", fee),
net_usdt = format!("{:.4}", net),
outcome = if net > 0.0 { "WIN" } else if net < 0.0 { "LOSS" } else { "BREAKEVEN" },
running_net = format!("{:.4}", self.net_pnl()),
"trade closed",
);
if !self.halted && self.net_pnl() <= self.config.loss_limit {
self.halted = true;
tracing::warn!(
target: "pnl",
symbol = %self.symbol,
net_pnl = format!("{:.4}", self.net_pnl()),
limit = format!("{:.4}", self.config.loss_limit),
"session loss limit breached — trading halted",
);
}
}
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 snapshot(&self) -> SessionPnlSnapshot {
SessionPnlSnapshot {
realised: self.realised,
fees: self.fees,
trades: self.trades,
wins: self.wins,
losses: self.losses,
breakevens: self.breakevens,
halted: self.halted,
last_reset_day: self.last_reset_day,
}
}
pub fn restore(&mut self, snap: SessionPnlSnapshot) {
self.realised = snap.realised;
self.fees = snap.fees;
self.trades = snap.trades;
self.wins = snap.wins;
self.losses = snap.losses;
self.breakevens = snap.breakevens;
self.halted = snap.halted;
self.last_reset_day = snap.last_reset_day;
}
pub fn reset_session(&mut self) {
tracing::info!(
target: "pnl",
symbol = %self.symbol,
trades = self.trades,
net_usdt = format!("{:.4}", self.net_pnl()),
"session reset — rolling over",
);
self.realised = 0.0;
self.fees = 0.0;
self.trades = 0;
self.wins = 0;
self.losses = 0;
self.breakevens = 0;
self.halted = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::ManualClock;
fn cfg(limit: f64) -> SessionPnlConfig {
SessionPnlConfig { loss_limit: limit }
}
#[test]
fn classifies_on_net_not_gross() {
let mut p = SessionPnl::new("TEST", cfg(-1000.0));
p.record_close(1.0, 3.0); assert_eq!(p.losses, 1);
assert_eq!(p.wins, 0);
}
#[test]
fn halts_when_limit_breached() {
let mut p = SessionPnl::new("TEST", cfg(-10.0));
p.record_close(-5.0, 1.0); assert!(!p.is_session_halted());
p.record_close(-5.0, 1.0); assert!(p.is_session_halted());
}
#[test]
fn reset_clears_halt_and_totals() {
let mut p = SessionPnl::new("TEST", cfg(-10.0));
p.record_close(-20.0, 0.0);
assert!(p.is_session_halted());
p.reset_session();
assert!(!p.is_session_halted());
assert_eq!(p.trades, 0);
assert!((p.net_pnl()).abs() < 1e-9);
}
#[test]
fn win_rate_excludes_breakevens() {
let mut p = SessionPnl::new("TEST", cfg(-1000.0));
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);
}
#[test]
fn utc_rollover_resets_session_via_tick() {
let day = 100u64;
let clock = Arc::new(ManualClock::new(day * 86_400));
let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
clock.advance_secs(3_600); p.record_close(-20.0, 0.0);
assert!(p.is_session_halted());
assert_eq!(p.trades, 1);
clock.advance_secs(3_600); p.tick();
assert!(
p.is_session_halted(),
"should still be halted before midnight"
);
clock.set((day + 1) * 86_400 + 5);
p.tick();
assert!(!p.is_session_halted(), "rollover must clear the halt");
assert_eq!(p.trades, 0);
assert!((p.net_pnl()).abs() < 1e-9);
}
#[test]
fn tick_within_same_day_is_a_noop() {
let clock = Arc::new(ManualClock::new(100 * 86_400 + 10));
let mut p = SessionPnl::with_clock("TEST", cfg(-1000.0), clock.clone());
p.record_close(5.0, 1.0); let before = p.net_pnl();
clock.advance_secs(60 * 60 * 12); p.tick();
assert!(
(p.net_pnl() - before).abs() < 1e-9,
"intra-day tick must not reset session totals"
);
assert_eq!(p.trades, 1);
}
#[test]
fn snapshot_restore_roundtrips_state() {
let mut p = SessionPnl::new("TEST", cfg(-100.0));
p.record_close(10.0, 1.0); p.record_close(-30.0, 2.0); let snap = p.snapshot();
let mut q = SessionPnl::new("TEST", cfg(-100.0));
q.restore(snap.clone());
assert_eq!(q.snapshot(), snap);
assert!((q.net_pnl() - p.net_pnl()).abs() < 1e-9);
assert_eq!(q.trades, 2);
assert_eq!(q.wins, 1);
assert_eq!(q.losses, 1);
}
#[test]
fn restore_preserves_halt_within_same_day() {
let clock = Arc::new(ManualClock::new(200 * 86_400 + 100));
let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
p.record_close(-20.0, 0.0);
assert!(p.is_session_halted());
let snap = p.snapshot();
let mut q = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
q.restore(snap);
q.tick(); assert!(
q.is_session_halted(),
"halt must survive a same-day restore"
);
}
#[test]
fn restore_then_tick_rolls_over_stale_day() {
let day = 300u64;
let clock = Arc::new(ManualClock::new(day * 86_400 + 100));
let mut p = SessionPnl::with_clock("TEST", cfg(-10.0), clock.clone());
p.record_close(-50.0, 0.0);
assert!(p.is_session_halted());
let snap = p.snapshot();
let next = Arc::new(ManualClock::new((day + 1) * 86_400 + 5));
let mut q = SessionPnl::with_clock("TEST", cfg(-10.0), next);
q.restore(snap);
q.tick();
assert!(!q.is_session_halted(), "stale day must roll over to fresh");
assert_eq!(q.trades, 0);
assert!(q.net_pnl().abs() < 1e-9);
}
}