hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// A unified trade record aggregated from thinking logs and paper fills.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TradeRecord {
    pub time: i64,    // unix timestamp seconds
    pub side: String, // "buy" or "sell"
    pub price: f64,
    pub size: f64,
    pub symbol: String,
    pub is_paper: bool,
    pub agent_id: String,
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Parse an ISO-8601 / RFC-3339 timestamp string to a Unix timestamp in seconds.
/// Returns `None` if the string cannot be parsed.
pub fn parse_timestamp_to_unix(ts: &str) -> Option<i64> {
    // Try RFC-3339 first (e.g. "2026-03-09T14:32:00Z" or with offset).
    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
        return Some(dt.timestamp());
    }
    // Try a common format without timezone (assume UTC).
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S") {
        return Some(dt.and_utc().timestamp());
    }
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.f") {
        return Some(dt.and_utc().timestamp());
    }
    None
}

/// Map a decision action string to a normalised side ("buy" or "sell").
/// Returns `None` for actions that are not trades (e.g. "hold").
pub fn action_to_side(action: &str) -> Option<String> {
    let lower = action.to_lowercase();
    if lower == "buy" || lower == "long" {
        Some("buy".to_string())
    } else if lower == "sell" || lower == "short" {
        Some("sell".to_string())
    } else {
        None
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_timestamp_rfc3339() {
        let ts = "2026-03-09T14:32:00Z";
        let unix = parse_timestamp_to_unix(ts).unwrap();
        assert!(unix > 0);
    }

    #[test]
    fn test_parse_timestamp_with_offset() {
        let ts = "2026-03-09T14:32:00+08:00";
        let unix = parse_timestamp_to_unix(ts).unwrap();
        assert!(unix > 0);
    }

    #[test]
    fn test_parse_timestamp_naive() {
        let ts = "2026-03-09T14:32:00";
        let unix = parse_timestamp_to_unix(ts).unwrap();
        assert!(unix > 0);
    }

    #[test]
    fn test_parse_timestamp_invalid() {
        assert!(parse_timestamp_to_unix("not-a-date").is_none());
    }

    #[test]
    fn test_action_to_side_buy() {
        assert_eq!(action_to_side("buy"), Some("buy".to_string()));
        assert_eq!(action_to_side("Buy"), Some("buy".to_string()));
        assert_eq!(action_to_side("long"), Some("buy".to_string()));
    }

    #[test]
    fn test_action_to_side_sell() {
        assert_eq!(action_to_side("sell"), Some("sell".to_string()));
        assert_eq!(action_to_side("Sell"), Some("sell".to_string()));
        assert_eq!(action_to_side("short"), Some("sell".to_string()));
    }

    #[test]
    fn test_action_to_side_none() {
        assert_eq!(action_to_side("hold"), None);
        assert_eq!(action_to_side(""), None);
    }

    #[test]
    fn test_trade_record_serialization() {
        let record = TradeRecord {
            time: 1741520000,
            side: "buy".to_string(),
            price: 95000.0,
            size: 0.05,
            symbol: "BTC-PERP".to_string(),
            is_paper: false,
            agent_id: "agent-1".to_string(),
        };
        let json = serde_json::to_value(&record).unwrap();
        assert_eq!(json["time"], 1741520000);
        assert_eq!(json["side"], "buy");
        assert_eq!(json["price"], 95000.0);
        assert_eq!(json["size"], 0.05);
        assert_eq!(json["symbol"], "BTC-PERP");
        assert_eq!(json["isPaper"], false);
        assert_eq!(json["agentId"], "agent-1");
    }

    #[test]
    fn test_trade_record_deserialization() {
        let json = serde_json::json!({
            "time": 1741520000,
            "side": "sell",
            "price": 96000.0,
            "size": 0.1,
            "symbol": "ETH-PERP",
            "isPaper": true,
            "agentId": "agent-2"
        });
        let record: TradeRecord = serde_json::from_value(json).unwrap();
        assert_eq!(record.time, 1741520000);
        assert_eq!(record.side, "sell");
        assert_eq!(record.price, 96000.0);
        assert_eq!(record.size, 0.1);
        assert_eq!(record.symbol, "ETH-PERP");
        assert!(record.is_paper);
        assert_eq!(record.agent_id, "agent-2");
    }
}