rustrade_backtest/metrics.rs
1//! Trade-level outcome captured during the replay loop.
2//!
3//! [`BacktestResult`](crate::BacktestResult) aggregates these into
4//! summary statistics. Closing more than one position per trade (e.g.
5//! flipping from long to short) emits multiple [`TradeOutcome`]s — one
6//! per closed quantity.
7
8use chrono::{DateTime, Utc};
9use rustrade_core::Side;
10use serde::{Deserialize, Serialize};
11
12/// A single realised trade — open → close — recorded by the engine.
13///
14/// `gross_pnl` is in quote currency, before `fee`. `net_pnl = gross_pnl
15/// - fee + funding`. The "side" is the side of the *closing* fill (so a
16/// long position is closed with `Side::Sell`).
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct TradeOutcome {
19 /// Symbol the trade was on.
20 pub symbol: String,
21 /// Side of the *closing* fill (`Sell` to close a long, etc.).
22 pub close_side: Side,
23 /// Closed quantity.
24 pub qty: f64,
25 /// Average entry price of the closed quantity.
26 pub entry_price: f64,
27 /// Fill price of the close.
28 pub exit_price: f64,
29 /// Gross PnL on the closed quantity, in quote currency, before fees.
30 pub gross_pnl: f64,
31 /// Fee charged to this close, in quote currency.
32 pub fee: f64,
33 /// Net perp funding cashflow attributed to the closed quantity over
34 /// its hold, in quote currency. Positive = the position collected
35 /// funding, negative = it paid. Always `0.0` when
36 /// [`FundingModel::None`](crate::FundingModel::None) (the default)
37 /// is configured. `#[serde(default)]` so results serialized before
38 /// funding modelling shipped still deserialize.
39 #[serde(default)]
40 pub funding: f64,
41 /// When the close fill occurred.
42 pub closed_at: DateTime<Utc>,
43}
44
45impl TradeOutcome {
46 /// Net of fees and funding: `gross_pnl - fee + funding`. With
47 /// funding modelling off this is `gross_pnl - fee` exactly, as
48 /// before.
49 pub fn net_pnl(&self) -> f64 {
50 self.gross_pnl - self.fee + self.funding
51 }
52
53 /// Win (`true`) / loss / breakeven on net PnL.
54 pub fn outcome(&self) -> Outcome {
55 let n = self.net_pnl();
56 if n > 0.0 {
57 Outcome::Win
58 } else if n < 0.0 {
59 Outcome::Loss
60 } else {
61 Outcome::Breakeven
62 }
63 }
64}
65
66/// Trade classification on net PnL — used by metric aggregation.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Outcome {
69 /// Net PnL > 0.
70 Win,
71 /// Net PnL < 0.
72 Loss,
73 /// Net PnL == 0.
74 Breakeven,
75}