Skip to main content

hyper_agent_core/
account_state_ext.rs

1use crate::position_manager::PositionManager;
2use hyper_risk::risk::AccountState;
3
4/// Build AccountState from live exchange clearinghouse state.
5///
6/// Queries Hyperliquid's `clearinghouseState` endpoint and converts the
7/// response into an `AccountState` suitable for risk checks in live mode.
8pub async fn account_state_from_exchange(
9    client: &hyper_exchange::ExchangeClient,
10    address: &str,
11) -> Result<AccountState, String> {
12    let body = serde_json::json!({
13        "type": "clearinghouseState",
14        "user": address,
15    });
16    let resp = client
17        .post_info(body)
18        .await
19        .map_err(|e| format!("Failed to fetch clearinghouse state: {}", e))?;
20
21    // Parse margin summary
22    let margin_summary = &resp["marginSummary"];
23    let equity: f64 = margin_summary["accountValue"]
24        .as_str()
25        .unwrap_or("0")
26        .parse()
27        .unwrap_or(0.0);
28
29    // Parse positions
30    let mut total_position_value = 0.0;
31    let mut position_by_symbol = std::collections::HashMap::new();
32
33    if let Some(asset_positions) = resp["assetPositions"].as_array() {
34        for pos in asset_positions {
35            let p = &pos["position"];
36            let size: f64 = p["szi"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
37            if size.abs() < 1e-12 {
38                continue;
39            }
40            let entry_price: f64 = p["entryPx"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
41            let notional = size.abs() * entry_price;
42            let coin = p["coin"].as_str().unwrap_or("???");
43            let symbol = format!("{}-PERP", coin);
44            total_position_value += notional;
45            *position_by_symbol.entry(symbol).or_insert(0.0) += notional;
46        }
47    }
48
49    // Query today's fills for realized PnL
50    let fills_body = serde_json::json!({
51        "type": "userFills",
52        "user": address,
53    });
54    let fills_resp = client
55        .post_info(fills_body)
56        .await
57        .unwrap_or(serde_json::json!([]));
58
59    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
60    let daily_realized_loss: f64 = fills_resp
61        .as_array()
62        .unwrap_or(&vec![])
63        .iter()
64        .filter(|fill| {
65            // Filter fills from today
66            fill.get("time")
67                .and_then(|t| t.as_u64())
68                .map(|ts| {
69                    let dt = chrono::DateTime::from_timestamp((ts / 1000) as i64, 0);
70                    dt.map(|d| d.format("%Y-%m-%d").to_string() == today)
71                        .unwrap_or(false)
72                })
73                .unwrap_or(false)
74        })
75        .filter_map(|fill| {
76            // Calculate realized PnL from fill: closedPnl field
77            fill.get("closedPnl")
78                .and_then(|p| p.as_str())
79                .and_then(|s| s.parse::<f64>().ok())
80        })
81        .filter(|pnl| *pnl < 0.0)
82        .sum::<f64>()
83        .abs();
84
85    Ok(AccountState {
86        total_position_value,
87        position_by_symbol,
88        daily_realized_loss,
89        daily_starting_equity: equity,
90        windowed_loss: daily_realized_loss,
91    })
92}
93
94/// Build AccountState from paper trading positions in SQLite.
95pub async fn account_state_from_paper(pm: &PositionManager) -> AccountState {
96    let positions = pm.list_open().await.unwrap_or_default();
97    let total_position_value: f64 = positions.iter().map(|p| p.size * p.entry_price).sum();
98
99    let mut position_by_symbol = std::collections::HashMap::new();
100    for p in &positions {
101        *position_by_symbol.entry(p.market.clone()).or_insert(0.0) += p.size * p.entry_price;
102    }
103
104    // Calculate daily loss from positions closed TODAY only
105    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
106    let closed = pm.list_closed(1000).await.unwrap_or_default();
107    let daily_realized_loss: f64 = closed
108        .iter()
109        .filter(|p| {
110            p.closed_at
111                .as_ref()
112                .map(|dt| dt.starts_with(&today))
113                .unwrap_or(false)
114        })
115        .filter_map(|p| p.pnl)
116        .filter(|pnl| *pnl < 0.0)
117        .sum::<f64>()
118        .abs();
119
120    AccountState {
121        total_position_value,
122        position_by_symbol,
123        daily_realized_loss,
124        daily_starting_equity: 0.0,
125        windowed_loss: daily_realized_loss,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[tokio::test]
134    async fn account_state_from_empty_db() {
135        let pm = PositionManager::in_memory().unwrap();
136        let state = account_state_from_paper(&pm).await;
137        assert_eq!(state.total_position_value, 0.0);
138        assert!(state.position_by_symbol.is_empty());
139        assert_eq!(state.daily_realized_loss, 0.0);
140    }
141
142    #[tokio::test]
143    async fn daily_realized_loss_filters_by_today() {
144        let pm = PositionManager::in_memory().unwrap();
145
146        // Open and close a position TODAY (should count)
147        let pos_today = crate::position_manager::Position {
148            id: "today-loss".to_string(),
149            market: "BTC-PERP".to_string(),
150            side: "long".to_string(),
151            size: 1.0,
152            entry_price: 60000.0,
153            current_price: Some(60000.0),
154            status: "open".to_string(),
155            pnl: None,
156            mode: "paper".to_string(),
157            strategy: None,
158            opened_at: chrono::Utc::now().to_rfc3339(),
159            closed_at: None,
160            close_reason: None,
161        };
162        pm.open_position(&pos_today).await.unwrap();
163        pm.close_position("today-loss", 59000.0, "stop-loss")
164            .await
165            .unwrap();
166
167        // Open and close a position with a PAST date (should NOT count)
168        let pos_old = crate::position_manager::Position {
169            id: "old-loss".to_string(),
170            market: "ETH-PERP".to_string(),
171            side: "long".to_string(),
172            size: 1.0,
173            entry_price: 4000.0,
174            current_price: Some(4000.0),
175            status: "open".to_string(),
176            pnl: None,
177            mode: "paper".to_string(),
178            strategy: None,
179            opened_at: "2020-01-01T00:00:00Z".to_string(),
180            closed_at: None,
181            close_reason: None,
182        };
183        pm.open_position(&pos_old).await.unwrap();
184        pm.close_position("old-loss", 3000.0, "stop-loss")
185            .await
186            .unwrap();
187
188        // Manually backdate the old-loss closed_at to yesterday
189        {
190            let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
191                .format("%Y-%m-%dT%H:%M:%SZ")
192                .to_string();
193            let db = pm.lock_db_for_test().await;
194            db.execute(
195                "UPDATE positions SET closed_at = ?1 WHERE id = 'old-loss'",
196                rusqlite::params![yesterday],
197            )
198            .unwrap();
199        }
200
201        let state = account_state_from_paper(&pm).await;
202        // Only today's loss should count: long 1.0 BTC at 60000, closed at 59000 => pnl = -1000
203        assert!(
204            (state.daily_realized_loss - 1000.0).abs() < 1e-6,
205            "expected 1000.0, got {}",
206            state.daily_realized_loss
207        );
208    }
209
210    #[tokio::test]
211    async fn account_state_with_open_positions() {
212        let pm = PositionManager::in_memory().unwrap();
213        let pos = crate::position_manager::Position {
214            id: "p1".to_string(),
215            market: "BTC-PERP".to_string(),
216            side: "long".to_string(),
217            size: 0.5,
218            entry_price: 60000.0,
219            current_price: Some(60000.0),
220            status: "open".to_string(),
221            pnl: Some(0.0),
222            mode: "paper".to_string(),
223            strategy: None,
224            opened_at: chrono::Utc::now().to_rfc3339(),
225            closed_at: None,
226            close_reason: None,
227        };
228        pm.open_position(&pos).await.unwrap();
229
230        let state = account_state_from_paper(&pm).await;
231        assert_eq!(state.total_position_value, 30000.0); // 0.5 * 60000
232        assert_eq!(*state.position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
233    }
234
235    /// Verify position parsing from a synthetic clearinghouse response.
236    #[test]
237    fn parse_exchange_positions() {
238        let resp: serde_json::Value = serde_json::json!({
239            "marginSummary": {
240                "accountValue": "50000.00",
241                "totalMarginUsed": "10000.00"
242            },
243            "assetPositions": [
244                {
245                    "position": {
246                        "coin": "BTC",
247                        "szi": "0.5",
248                        "entryPx": "60000.0",
249                        "unrealizedPnl": "-200.0"
250                    }
251                },
252                {
253                    "position": {
254                        "coin": "ETH",
255                        "szi": "-2.0",
256                        "entryPx": "3000.0",
257                        "unrealizedPnl": "100.0"
258                    }
259                },
260                {
261                    "position": {
262                        "coin": "DOGE",
263                        "szi": "0.0",
264                        "entryPx": "0.1",
265                        "unrealizedPnl": "0.0"
266                    }
267                }
268            ]
269        });
270
271        let margin_summary = &resp["marginSummary"];
272        let equity: f64 = margin_summary["accountValue"]
273            .as_str()
274            .unwrap_or("0")
275            .parse()
276            .unwrap_or(0.0);
277
278        let mut total_position_value = 0.0;
279        let mut position_by_symbol = std::collections::HashMap::new();
280
281        if let Some(asset_positions) = resp["assetPositions"].as_array() {
282            for pos in asset_positions {
283                let p = &pos["position"];
284                let size: f64 = p["szi"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
285                if size.abs() < 1e-12 {
286                    continue;
287                }
288                let entry_price: f64 = p["entryPx"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
289                let notional = size.abs() * entry_price;
290                let coin = p["coin"].as_str().unwrap_or("???");
291                let symbol = format!("{}-PERP", coin);
292                total_position_value += notional;
293                *position_by_symbol.entry(symbol).or_insert(0.0) += notional;
294            }
295        }
296
297        assert_eq!(equity, 50000.0);
298        // BTC: 0.5 * 60000 = 30000, ETH: 2.0 * 3000 = 6000, DOGE skipped (size=0)
299        assert!(
300            (total_position_value - 36000.0).abs() < 1e-6,
301            "expected 36000.0, got {}",
302            total_position_value
303        );
304        assert_eq!(*position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
305        assert_eq!(*position_by_symbol.get("ETH-PERP").unwrap(), 6000.0);
306        assert!(!position_by_symbol.contains_key("DOGE-PERP"));
307    }
308
309    /// Verify realized PnL parsing from a synthetic userFills response.
310    #[test]
311    fn parse_realized_pnl_from_fills() {
312        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
313        let yesterday_ms = now_ms - 86_400_000;
314        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
315
316        let fills_resp: serde_json::Value = serde_json::json!([
317            { "time": now_ms, "closedPnl": "-150.0", "coin": "BTC", "side": "B" },
318            { "time": now_ms, "closedPnl": "300.0", "coin": "ETH", "side": "A" },
319            { "time": now_ms, "closedPnl": "-50.0", "coin": "SOL", "side": "B" },
320            { "time": yesterday_ms, "closedPnl": "-999.0", "coin": "BTC", "side": "A" }
321        ]);
322
323        let daily_realized_loss: f64 = fills_resp
324            .as_array()
325            .unwrap_or(&vec![])
326            .iter()
327            .filter(|fill| {
328                fill.get("time")
329                    .and_then(|t| t.as_u64())
330                    .map(|ts| {
331                        let dt = chrono::DateTime::from_timestamp((ts / 1000) as i64, 0);
332                        dt.map(|d| d.format("%Y-%m-%d").to_string() == today)
333                            .unwrap_or(false)
334                    })
335                    .unwrap_or(false)
336            })
337            .filter_map(|fill| {
338                fill.get("closedPnl")
339                    .and_then(|p| p.as_str())
340                    .and_then(|s| s.parse::<f64>().ok())
341            })
342            .filter(|pnl| *pnl < 0.0)
343            .sum::<f64>()
344            .abs();
345
346        // Only today's negative fills: -150 + -50 = -200, abs = 200
347        // The +300 ETH fill and -999 yesterday fill are excluded
348        assert!(
349            (daily_realized_loss - 200.0).abs() < 1e-6,
350            "expected 200.0, got {}",
351            daily_realized_loss
352        );
353    }
354}