Skip to main content

hyper_agent_ai/
adjustment_history.rs

1//! Persistent storage for agent strategy adjustment history.
2//!
3//! Stores `AdjustmentRecord` entries in a local SQLite database so that
4//! past adjustments can be queried via the daemon API or CLI.
5
6use rusqlite::{params, Connection};
7use serde::{Deserialize, Serialize};
8
9/// A single adjustment record persisted in SQLite.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AdjustmentRecord {
12    pub id: i64,
13    pub timestamp: String,
14    pub strategy_id: String,
15    /// Serialized `StrategyAdjustment` JSON.
16    pub adjustment_json: String,
17    /// Claude's reasoning text (if available).
18    pub reasoning: Option<String>,
19    /// Human-readable summary of parameter changes, e.g. "stop_loss_pct 3.0->5.0".
20    pub changes_summary: String,
21}
22
23/// Error type for adjustment history operations.
24#[derive(Debug, thiserror::Error)]
25pub enum AdjustmentHistoryError {
26    #[error("Database error: {0}")]
27    Db(#[from] rusqlite::Error),
28}
29
30/// SQLite-backed store for agent adjustment history.
31pub struct AdjustmentHistoryStore {
32    conn: Connection,
33}
34
35impl AdjustmentHistoryStore {
36    /// Open (or create) the adjustment history database at the given path.
37    ///
38    /// Creates the `adjustment_history` table and index if they do not exist.
39    pub fn new(db_path: &str) -> Result<Self, AdjustmentHistoryError> {
40        let conn = Connection::open(db_path)?;
41
42        conn.execute_batch(
43            "CREATE TABLE IF NOT EXISTS adjustment_history (
44                id              INTEGER PRIMARY KEY AUTOINCREMENT,
45                timestamp       TEXT    NOT NULL DEFAULT (datetime('now')),
46                strategy_id     TEXT    NOT NULL,
47                adjustment_json TEXT    NOT NULL,
48                reasoning       TEXT,
49                changes_summary TEXT    NOT NULL
50            );
51            CREATE INDEX IF NOT EXISTS idx_adj_strategy
52                ON adjustment_history(strategy_id, id DESC);",
53        )?;
54
55        Ok(Self { conn })
56    }
57
58    /// Insert a single adjustment record.
59    pub fn insert(
60        &self,
61        strategy_id: &str,
62        adjustment_json: &str,
63        reasoning: Option<&str>,
64        changes_summary: &str,
65    ) -> Result<(), AdjustmentHistoryError> {
66        self.conn.execute(
67            "INSERT INTO adjustment_history
68                (strategy_id, adjustment_json, reasoning, changes_summary)
69             VALUES (?1, ?2, ?3, ?4)",
70            params![strategy_id, adjustment_json, reasoning, changes_summary],
71        )?;
72        Ok(())
73    }
74
75    /// List the most recent adjustments for a strategy, ordered newest-first.
76    pub fn list(
77        &self,
78        strategy_id: &str,
79        limit: usize,
80    ) -> Result<Vec<AdjustmentRecord>, AdjustmentHistoryError> {
81        let mut stmt = self.conn.prepare(
82            "SELECT id, timestamp, strategy_id, adjustment_json, reasoning, changes_summary
83             FROM adjustment_history
84             WHERE strategy_id = ?1
85             ORDER BY id DESC
86             LIMIT ?2",
87        )?;
88
89        let rows = stmt.query_map(params![strategy_id, limit as i64], |row| {
90            Ok(AdjustmentRecord {
91                id: row.get(0)?,
92                timestamp: row.get(1)?,
93                strategy_id: row.get(2)?,
94                adjustment_json: row.get(3)?,
95                reasoning: row.get(4)?,
96                changes_summary: row.get(5)?,
97            })
98        })?;
99
100        let mut records = Vec::new();
101        for row in rows {
102            records.push(row?);
103        }
104        Ok(records)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn insert_and_list() {
114        let store = AdjustmentHistoryStore::new(":memory:").unwrap();
115        store
116            .insert(
117                "strategy-1",
118                r#"{"regime_rules": null}"#,
119                Some("Market is volatile"),
120                "stop_loss_pct 3.0->5.0",
121            )
122            .unwrap();
123        store
124            .insert("strategy-1", "{}", Some("No changes needed"), "no changes")
125            .unwrap();
126        store
127            .insert("strategy-2", "{}", None, "no changes")
128            .unwrap();
129
130        let records = store.list("strategy-1", 10).unwrap();
131        assert_eq!(records.len(), 2);
132        // Newest first
133        assert_eq!(records[0].changes_summary, "no changes");
134        assert_eq!(records[1].changes_summary, "stop_loss_pct 3.0->5.0");
135
136        let records2 = store.list("strategy-2", 10).unwrap();
137        assert_eq!(records2.len(), 1);
138    }
139
140    #[test]
141    fn list_respects_limit() {
142        let store = AdjustmentHistoryStore::new(":memory:").unwrap();
143        for i in 0..10 {
144            store
145                .insert("s1", "{}", None, &format!("change {}", i))
146                .unwrap();
147        }
148        let records = store.list("s1", 3).unwrap();
149        assert_eq!(records.len(), 3);
150        assert_eq!(records[0].changes_summary, "change 9");
151    }
152
153    #[test]
154    fn empty_list() {
155        let store = AdjustmentHistoryStore::new(":memory:").unwrap();
156        let records = store.list("nonexistent", 10).unwrap();
157        assert!(records.is_empty());
158    }
159
160    #[test]
161    fn timestamp_is_auto_populated() {
162        let store = AdjustmentHistoryStore::new(":memory:").unwrap();
163        store.insert("s1", "{}", None, "test").unwrap();
164        let records = store.list("s1", 1).unwrap();
165        assert!(!records[0].timestamp.is_empty());
166    }
167}