use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquitySnapshot {
pub timestamp: String,
pub equity_usdc: f64,
pub realized_pnl: f64,
pub unrealized_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquitySummary {
pub current_equity_usdc: f64,
pub peak_equity_usdc: f64,
pub current_drawdown_usdc: f64,
pub current_drawdown_pct: f64,
pub max_drawdown_usdc: f64,
pub max_drawdown_pct: f64,
pub snapshots: Vec<EquitySnapshot>,
}
pub struct EquityTracker {
db: Connection,
}
impl EquityTracker {
pub fn new(db_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let db = Connection::open(db_path)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS equity_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
equity_usdc REAL NOT NULL,
realized_pnl REAL NOT NULL,
unrealized_pnl REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_equity_ts ON equity_snapshots(timestamp);",
)?;
Ok(Self { db })
}
pub fn record_snapshot(
&self,
snapshot: &EquitySnapshot,
) -> Result<(), Box<dyn std::error::Error>> {
self.db.execute(
"INSERT INTO equity_snapshots (timestamp, equity_usdc, realized_pnl, unrealized_pnl)
VALUES (?1, ?2, ?3, ?4)",
params![
snapshot.timestamp,
snapshot.equity_usdc,
snapshot.realized_pnl,
snapshot.unrealized_pnl,
],
)?;
Ok(())
}
pub fn get_snapshots(
&self,
days: u32,
) -> Result<Vec<EquitySnapshot>, Box<dyn std::error::Error>> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
let cutoff_str = cutoff.to_rfc3339();
let mut stmt = self.db.prepare(
"SELECT timestamp, equity_usdc, realized_pnl, unrealized_pnl
FROM equity_snapshots
WHERE timestamp >= ?1
ORDER BY timestamp ASC",
)?;
let rows = stmt.query_map(params![cutoff_str], |row| {
Ok(EquitySnapshot {
timestamp: row.get(0)?,
equity_usdc: row.get(1)?,
realized_pnl: row.get(2)?,
unrealized_pnl: row.get(3)?,
})
})?;
let mut snapshots = Vec::new();
for row in rows {
snapshots.push(row?);
}
Ok(snapshots)
}
pub fn get_summary(&self, days: u32) -> Result<EquitySummary, Box<dyn std::error::Error>> {
let snapshots = self.get_snapshots(days)?;
if snapshots.is_empty() {
return Ok(EquitySummary {
current_equity_usdc: 0.0,
peak_equity_usdc: 0.0,
current_drawdown_usdc: 0.0,
current_drawdown_pct: 0.0,
max_drawdown_usdc: 0.0,
max_drawdown_pct: 0.0,
snapshots,
});
}
let current_equity = snapshots.last().map(|s| s.equity_usdc).unwrap_or(0.0);
let mut peak = f64::NEG_INFINITY;
let mut max_dd_usdc: f64 = 0.0;
let mut max_dd_pct: f64 = 0.0;
for snap in &snapshots {
if snap.equity_usdc > peak {
peak = snap.equity_usdc;
}
let dd_usdc = peak - snap.equity_usdc;
let dd_pct = if peak > 0.0 {
dd_usdc / peak * 100.0
} else {
0.0
};
if dd_usdc > max_dd_usdc {
max_dd_usdc = dd_usdc;
max_dd_pct = dd_pct;
}
}
let current_dd_usdc = peak - current_equity;
let current_dd_pct = if peak > 0.0 {
current_dd_usdc / peak * 100.0
} else {
0.0
};
Ok(EquitySummary {
current_equity_usdc: current_equity,
peak_equity_usdc: peak,
current_drawdown_usdc: current_dd_usdc,
current_drawdown_pct: current_dd_pct,
max_drawdown_usdc: max_dd_usdc,
max_drawdown_pct: max_dd_pct,
snapshots,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tracker() -> EquityTracker {
EquityTracker::new(":memory:").unwrap()
}
#[test]
fn empty_summary_returns_zeros() {
let tracker = make_tracker();
let summary = tracker.get_summary(7).unwrap();
assert_eq!(summary.current_equity_usdc, 0.0);
assert_eq!(summary.max_drawdown_usdc, 0.0);
assert!(summary.snapshots.is_empty());
}
#[test]
fn records_and_retrieves_snapshots() {
let tracker = make_tracker();
let now = chrono::Utc::now();
for i in 0..3 {
let ts = (now + chrono::Duration::seconds(i)).to_rfc3339();
tracker
.record_snapshot(&EquitySnapshot {
timestamp: ts,
equity_usdc: 10000.0 + (i as f64) * 100.0,
realized_pnl: (i as f64) * 50.0,
unrealized_pnl: (i as f64) * 50.0,
})
.unwrap();
}
let snaps = tracker.get_snapshots(1).unwrap();
assert_eq!(snaps.len(), 3);
}
#[test]
fn drawdown_calculation() {
let tracker = make_tracker();
let now = chrono::Utc::now();
let equities = [10000.0, 10500.0, 10000.0, 10200.0];
for (i, &eq) in equities.iter().enumerate() {
let ts = (now + chrono::Duration::seconds(i as i64)).to_rfc3339();
tracker
.record_snapshot(&EquitySnapshot {
timestamp: ts,
equity_usdc: eq,
realized_pnl: 0.0,
unrealized_pnl: 0.0,
})
.unwrap();
}
let summary = tracker.get_summary(1).unwrap();
assert_eq!(summary.peak_equity_usdc, 10500.0);
assert_eq!(summary.current_equity_usdc, 10200.0);
assert!((summary.max_drawdown_usdc - 500.0).abs() < 0.01);
assert!((summary.max_drawdown_pct - (500.0 / 10500.0 * 100.0)).abs() < 0.01);
assert!((summary.current_drawdown_usdc - 300.0).abs() < 0.01);
}
}