use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Trade {
pub entry_time: i64,
pub exit_time: i64,
pub entry_price: f64,
pub exit_price: f64,
pub qty: f64,
pub pnl: f64,
pub return_pct: f64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct Portfolio {
pub cash: f64,
pub qty: f64,
pub entry_price: f64,
pub entry_time: i64,
entry_fee: f64,
pub trades: Vec<Trade>,
pub fees_paid: f64,
}
impl Portfolio {
pub fn new(cash: f64) -> Self {
Self {
cash,
qty: 0.0,
entry_price: 0.0,
entry_time: 0,
entry_fee: 0.0,
trades: Vec::new(),
fees_paid: 0.0,
}
}
pub fn in_position(&self) -> bool {
self.qty.abs() > f64::EPSILON
}
pub fn is_long(&self) -> bool {
self.qty > 0.0
}
pub fn equity(&self, mark: f64) -> f64 {
self.cash + self.qty * mark
}
pub fn apply_funding(&mut self, payment: f64) {
self.cash -= payment;
self.fees_paid += payment;
}
pub fn enter(&mut self, qty: f64, price: f64, time: i64, fee: f64) {
self.cash -= qty * price + fee;
self.qty = qty;
self.entry_price = price;
self.entry_time = time;
self.entry_fee = fee;
self.fees_paid += fee;
}
pub fn exit(&mut self, price: f64, time: i64, fee: f64, reason: &str) {
let qty = self.qty;
self.cash += qty * price - fee;
self.fees_paid += fee;
let notional = qty.abs() * self.entry_price;
let pnl = qty * (price - self.entry_price) - self.entry_fee - fee;
let return_pct = if notional.abs() < f64::EPSILON {
0.0
} else {
pnl / notional * 100.0
};
self.trades.push(Trade {
entry_time: self.entry_time,
exit_time: time,
entry_price: self.entry_price,
exit_price: price,
qty,
pnl,
return_pct,
reason: reason.to_string(),
});
self.qty = 0.0;
self.entry_fee = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_pnl_nets_fees() {
let mut pf = Portfolio::new(1000.0);
pf.enter(10.0, 10.0, 1, 1.0); assert!(pf.in_position());
assert!((pf.cash - 899.0).abs() < 1e-9);
pf.exit(12.0, 2, 1.0, "signal"); assert!(!pf.in_position());
assert!((pf.cash - 1018.0).abs() < 1e-9);
let t = &pf.trades[0];
assert!((t.pnl - 18.0).abs() < 1e-9);
assert!((pf.fees_paid - 2.0).abs() < 1e-9);
}
#[test]
fn short_round_trip_profits_when_price_falls() {
let mut pf = Portfolio::new(1000.0);
pf.enter(-10.0, 10.0, 1, 0.0); assert!(pf.in_position());
assert!(!pf.is_long());
assert!((pf.cash - 1100.0).abs() < 1e-9);
pf.exit(8.0, 2, 0.0, "signal"); assert!((pf.cash - 1020.0).abs() < 1e-9);
let t = &pf.trades[0];
assert!((t.pnl - 20.0).abs() < 1e-9);
assert!((t.return_pct - 20.0).abs() < 1e-9); }
}