1use crate::position_manager::PositionManager;
2use hyper_risk::risk::AccountState;
3
4pub 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 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 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 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 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 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
94pub 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 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 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 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 {
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 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); assert_eq!(*state.position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
233 }
234
235 #[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 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 #[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 assert!(
349 (daily_realized_loss - 200.0).abs() < 1e-6,
350 "expected 200.0, got {}",
351 daily_realized_loss
352 );
353 }
354}