hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
use crate::position_manager::PositionManager;
use hyper_risk::risk::AccountState;

/// Build AccountState from live exchange clearinghouse state.
///
/// Queries Hyperliquid's `clearinghouseState` endpoint and converts the
/// response into an `AccountState` suitable for risk checks in live mode.
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))?;

    // Parse margin summary
    let margin_summary = &resp["marginSummary"];
    let equity: f64 = margin_summary["accountValue"]
        .as_str()
        .unwrap_or("0")
        .parse()
        .unwrap_or(0.0);

    // Parse positions
    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;
        }
    }

    // Query today's fills for realized PnL
    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| {
            // Filter fills from today
            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| {
            // Calculate realized PnL from fill: closedPnl field
            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,
    })
}

/// Build AccountState from paper trading positions in SQLite.
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;
    }

    // Calculate daily loss from positions closed TODAY only
    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();

        // Open and close a position TODAY (should count)
        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();

        // Open and close a position with a PAST date (should NOT count)
        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();

        // Manually backdate the old-loss closed_at to yesterday
        {
            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;
        // Only today's loss should count: long 1.0 BTC at 60000, closed at 59000 => pnl = -1000
        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); // 0.5 * 60000
        assert_eq!(*state.position_by_symbol.get("BTC-PERP").unwrap(), 30000.0);
    }

    /// Verify position parsing from a synthetic clearinghouse response.
    #[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);
        // BTC: 0.5 * 60000 = 30000, ETH: 2.0 * 3000 = 6000, DOGE skipped (size=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"));
    }

    /// Verify realized PnL parsing from a synthetic userFills response.
    #[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();

        // Only today's negative fills: -150 + -50 = -200, abs = 200
        // The +300 ETH fill and -999 yesterday fill are excluded
        assert!(
            (daily_realized_loss - 200.0).abs() < 1e-6,
            "expected 200.0, got {}",
            daily_realized_loss
        );
    }
}