hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
//! Equity curve tracking with SQLite persistence.
//!
//! Records periodic equity snapshots and computes drawdown metrics
//! (current drawdown, max drawdown) over a configurable lookback window.

use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};

/// A single point-in-time equity measurement.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquitySnapshot {
    pub timestamp: String,
    pub equity_usdc: f64,
    pub realized_pnl: f64,
    pub unrealized_pnl: f64,
}

/// Aggregated equity and drawdown summary over a lookback window.
#[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>,
}

/// Persists equity snapshots to SQLite and computes drawdown metrics.
pub struct EquityTracker {
    db: Connection,
}

impl EquityTracker {
    /// Open (or create) the equity database at `db_path`.
    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 })
    }

    /// Record a new equity snapshot.
    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(())
    }

    /// Fetch snapshots from the last `days` days, ordered oldest-first.
    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)
    }

    /// Compute an equity summary with drawdown metrics over the last `days` days.
    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);

        // Walk forward computing running peak and max drawdown.
        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();

        // Equity: 10000 -> 10500 -> 10000 -> 10200
        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);
        // Max drawdown was 500 (from 10500 to 10000)
        assert!((summary.max_drawdown_usdc - 500.0).abs() < 0.01);
        assert!((summary.max_drawdown_pct - (500.0 / 10500.0 * 100.0)).abs() < 0.01);
        // Current drawdown is 300 (from 10500 to 10200)
        assert!((summary.current_drawdown_usdc - 300.0).abs() < 0.01);
    }
}