use crate::position_manager::PositionManager;
use hyper_risk::risk::AccountState;
pub async fn account_state_from_exchange(
client: &hyper_exchange::ExchangeClient,
address: &str,
) -> Result<AccountState, String> {
let body = serde_json::json!({
"type": "clearinghouseState",
"user": address,
});
let resp = client
.post_info(body)
.await
.map_err(|e| format!("Failed to fetch clearinghouse state: {}", e))?;
let margin_summary = &resp["marginSummary"];
let equity: f64 = margin_summary["accountValue"]
.as_str()
.unwrap_or("0")
.parse()
.unwrap_or(0.0);
let mut total_position_value = 0.0;
let mut position_by_symbol = std::collections::HashMap::new();
if let Some(asset_positions) = resp["assetPositions"].as_array() {
for pos in asset_positions {
let p = &pos["position"];
let size: f64 = p["szi"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
if size.abs() < 1e-12 {
continue;
}
let entry_price: f64 = p["entryPx"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
let notional = size.abs() * entry_price;
let coin = p["coin"].as_str().unwrap_or("???");
let symbol = format!("{}-PERP", coin);
total_position_value += notional;
*position_by_symbol.entry(symbol).or_insert(0.0) += notional;
}
}
let fills_body = serde_json::json!({
"type": "userFills",
"user": address,
});
let fills_resp = client
.post_info(fills_body)
.await
.unwrap_or(serde_json::json!([]));
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let daily_realized_loss: f64 = fills_resp
.as_array()
.unwrap_or(&vec![])
.iter()
.filter(|fill| {
fill.get("time")
.and_then(|t| t.as_u64())
.map(|ts| {
let dt = chrono::DateTime::from_timestamp((ts / 1000) as i64, 0);
dt.map(|d| d.format("%Y-%m-%d").to_string() == today)
.unwrap_or(false)
})
.unwrap_or(false)
})
.filter_map(|fill| {
fill.get("closedPnl")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok())
})
.filter(|pnl| *pnl < 0.0)
.sum::<f64>()
.abs();
Ok(AccountState {
total_position_value,
position_by_symbol,
daily_realized_loss,
daily_starting_equity: equity,
windowed_loss: daily_realized_loss,
})
}
pub async fn account_state_from_paper(pm: &PositionManager) -> AccountState {
let positions = pm.list_open().await.unwrap_or_default();
let total_position_value: f64 = positions.iter().map(|p| p.size * p.entry_price).sum();
let mut position_by_symbol = std::collections::HashMap::new();
for p in &positions {
*position_by_symbol.entry(p.market.clone()).or_insert(0.0) += p.size * p.entry_price;
}
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let closed = pm.list_closed(1000).await.unwrap_or_default();
let daily_realized_loss: f64 = closed
.iter()
.filter(|p| {
p.closed_at
.as_ref()
.map(|dt| dt.starts_with(&today))
.unwrap_or(false)
})
.filter_map(|p| p.pnl)
.filter(|pnl| *pnl < 0.0)
.sum::<f64>()
.abs();
AccountState {
total_position_value,
position_by_symbol,
daily_realized_loss,
daily_starting_equity: 0.0,
windowed_loss: daily_realized_loss,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn account_state_from_empty_db() {
let pm = PositionManager::in_memory().unwrap();
let state = account_state_from_paper(&pm).await;
assert_eq!(state.total_position_value, 0.0);
assert!(state.position_by_symbol.is_empty());
assert_eq!(state.daily_realized_loss, 0.0);
}
#[tokio::test]
async fn daily_realized_loss_filters_by_today() {
let pm = PositionManager::in_memory().unwrap();
let pos_today = crate::position_manager::Position {
id: "today-loss".to_string(),
market: "BTC-PERP".to_string(),
side: "long".to_string(),
size: 1.0,
entry_price: 60000.0,
current_price: Some(60000.0),
status: "open".to_string(),
pnl: None,
mode: "paper".to_string(),
strategy: None,
opened_at: chrono::Utc::now().to_rfc3339(),
closed_at: None,
close_reason: None,
};
pm.open_position(&pos_today).await.unwrap();
pm.close_position("today-loss", 59000.0, "stop-loss")
.await
.unwrap();
let pos_old = crate::position_manager::Position {
id: "old-loss".to_string(),
market: "ETH-PERP".to_string(),
side: "long".to_string(),
size: 1.0,
entry_price: 4000.0,
current_price: Some(4000.0),
status: "open".to_string(),
pnl: None,
mode: "paper".to_string(),
strategy: None,
opened_at: "2020-01-01T00:00:00Z".to_string(),
closed_at: None,
close_reason: None,
};
pm.open_position(&pos_old).await.unwrap();
pm.close_position("old-loss", 3000.0, "stop-loss")
.await
.unwrap();
{
let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
let db = pm.lock_db_for_test().await;
db.execute(
"UPDATE positions SET closed_at = ?1 WHERE id = 'old-loss'",
rusqlite::params![yesterday],
)
.unwrap();
}
let state = account_state_from_paper(&pm).await;
assert!(
(state.daily_realized_loss - 1000.0).abs() < 1e-6,
"expected 1000.0, got {}",
state.daily_realized_loss
);
}
#[tokio::test]
async fn account_state_with_open_positions() {
let pm = PositionManager::in_memory().unwrap();
let pos = crate::position_manager::Position {
id: "p1".to_string(),
market: "BTC-PERP".to_string(),
side: "long".to_string(),
size: 0.5,
entry_price: 60000.0,
current_price: Some(60000.0),
status: "open".to_string(),
pnl: Some(0.0),
mode: "paper".to_string(),
strategy: None,
opened_at: chrono::Utc::now().to_rfc3339(),
closed_at: None,
close_reason: None,
};
pm.open_position(&pos).await.unwrap();
let state = account_state_from_paper(&pm).await;
assert_eq!(state.total_position_value, 30000.0); assert_eq!(*state.position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
}
#[test]
fn parse_exchange_positions() {
let resp: serde_json::Value = serde_json::json!({
"marginSummary": {
"accountValue": "50000.00",
"totalMarginUsed": "10000.00"
},
"assetPositions": [
{
"position": {
"coin": "BTC",
"szi": "0.5",
"entryPx": "60000.0",
"unrealizedPnl": "-200.0"
}
},
{
"position": {
"coin": "ETH",
"szi": "-2.0",
"entryPx": "3000.0",
"unrealizedPnl": "100.0"
}
},
{
"position": {
"coin": "DOGE",
"szi": "0.0",
"entryPx": "0.1",
"unrealizedPnl": "0.0"
}
}
]
});
let margin_summary = &resp["marginSummary"];
let equity: f64 = margin_summary["accountValue"]
.as_str()
.unwrap_or("0")
.parse()
.unwrap_or(0.0);
let mut total_position_value = 0.0;
let mut position_by_symbol = std::collections::HashMap::new();
if let Some(asset_positions) = resp["assetPositions"].as_array() {
for pos in asset_positions {
let p = &pos["position"];
let size: f64 = p["szi"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
if size.abs() < 1e-12 {
continue;
}
let entry_price: f64 = p["entryPx"].as_str().unwrap_or("0").parse().unwrap_or(0.0);
let notional = size.abs() * entry_price;
let coin = p["coin"].as_str().unwrap_or("???");
let symbol = format!("{}-PERP", coin);
total_position_value += notional;
*position_by_symbol.entry(symbol).or_insert(0.0) += notional;
}
}
assert_eq!(equity, 50000.0);
assert!(
(total_position_value - 36000.0).abs() < 1e-6,
"expected 36000.0, got {}",
total_position_value
);
assert_eq!(*position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
assert_eq!(*position_by_symbol.get("ETH-PERP").unwrap(), 6000.0);
assert!(!position_by_symbol.contains_key("DOGE-PERP"));
}
#[test]
fn parse_realized_pnl_from_fills() {
let now_ms = chrono::Utc::now().timestamp_millis() as u64;
let yesterday_ms = now_ms - 86_400_000;
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let fills_resp: serde_json::Value = serde_json::json!([
{ "time": now_ms, "closedPnl": "-150.0", "coin": "BTC", "side": "B" },
{ "time": now_ms, "closedPnl": "300.0", "coin": "ETH", "side": "A" },
{ "time": now_ms, "closedPnl": "-50.0", "coin": "SOL", "side": "B" },
{ "time": yesterday_ms, "closedPnl": "-999.0", "coin": "BTC", "side": "A" }
]);
let daily_realized_loss: f64 = fills_resp
.as_array()
.unwrap_or(&vec![])
.iter()
.filter(|fill| {
fill.get("time")
.and_then(|t| t.as_u64())
.map(|ts| {
let dt = chrono::DateTime::from_timestamp((ts / 1000) as i64, 0);
dt.map(|d| d.format("%Y-%m-%d").to_string() == today)
.unwrap_or(false)
})
.unwrap_or(false)
})
.filter_map(|fill| {
fill.get("closedPnl")
.and_then(|p| p.as_str())
.and_then(|s| s.parse::<f64>().ok())
})
.filter(|pnl| *pnl < 0.0)
.sum::<f64>()
.abs();
assert!(
(daily_realized_loss - 200.0).abs() < 1e-6,
"expected 200.0, got {}",
daily_realized_loss
);
}
}