Skip to main content

hyper_agent_core/
trade_markers.rs

1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// Types
5// ---------------------------------------------------------------------------
6
7/// A unified trade record aggregated from thinking logs and paper fills.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct TradeRecord {
11    pub time: i64,    // unix timestamp seconds
12    pub side: String, // "buy" or "sell"
13    pub price: f64,
14    pub size: f64,
15    pub symbol: String,
16    pub is_paper: bool,
17    pub agent_id: String,
18}
19
20// ---------------------------------------------------------------------------
21// Helpers
22// ---------------------------------------------------------------------------
23
24/// Parse an ISO-8601 / RFC-3339 timestamp string to a Unix timestamp in seconds.
25/// Returns `None` if the string cannot be parsed.
26pub fn parse_timestamp_to_unix(ts: &str) -> Option<i64> {
27    // Try RFC-3339 first (e.g. "2026-03-09T14:32:00Z" or with offset).
28    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
29        return Some(dt.timestamp());
30    }
31    // Try a common format without timezone (assume UTC).
32    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S") {
33        return Some(dt.and_utc().timestamp());
34    }
35    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.f") {
36        return Some(dt.and_utc().timestamp());
37    }
38    None
39}
40
41/// Map a decision action string to a normalised side ("buy" or "sell").
42/// Returns `None` for actions that are not trades (e.g. "hold").
43pub fn action_to_side(action: &str) -> Option<String> {
44    let lower = action.to_lowercase();
45    if lower == "buy" || lower == "long" {
46        Some("buy".to_string())
47    } else if lower == "sell" || lower == "short" {
48        Some("sell".to_string())
49    } else {
50        None
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Tests
56// ---------------------------------------------------------------------------
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn test_parse_timestamp_rfc3339() {
64        let ts = "2026-03-09T14:32:00Z";
65        let unix = parse_timestamp_to_unix(ts).unwrap();
66        assert!(unix > 0);
67    }
68
69    #[test]
70    fn test_parse_timestamp_with_offset() {
71        let ts = "2026-03-09T14:32:00+08:00";
72        let unix = parse_timestamp_to_unix(ts).unwrap();
73        assert!(unix > 0);
74    }
75
76    #[test]
77    fn test_parse_timestamp_naive() {
78        let ts = "2026-03-09T14:32:00";
79        let unix = parse_timestamp_to_unix(ts).unwrap();
80        assert!(unix > 0);
81    }
82
83    #[test]
84    fn test_parse_timestamp_invalid() {
85        assert!(parse_timestamp_to_unix("not-a-date").is_none());
86    }
87
88    #[test]
89    fn test_action_to_side_buy() {
90        assert_eq!(action_to_side("buy"), Some("buy".to_string()));
91        assert_eq!(action_to_side("Buy"), Some("buy".to_string()));
92        assert_eq!(action_to_side("long"), Some("buy".to_string()));
93    }
94
95    #[test]
96    fn test_action_to_side_sell() {
97        assert_eq!(action_to_side("sell"), Some("sell".to_string()));
98        assert_eq!(action_to_side("Sell"), Some("sell".to_string()));
99        assert_eq!(action_to_side("short"), Some("sell".to_string()));
100    }
101
102    #[test]
103    fn test_action_to_side_none() {
104        assert_eq!(action_to_side("hold"), None);
105        assert_eq!(action_to_side(""), None);
106    }
107
108    #[test]
109    fn test_trade_record_serialization() {
110        let record = TradeRecord {
111            time: 1741520000,
112            side: "buy".to_string(),
113            price: 95000.0,
114            size: 0.05,
115            symbol: "BTC-PERP".to_string(),
116            is_paper: false,
117            agent_id: "agent-1".to_string(),
118        };
119        let json = serde_json::to_value(&record).unwrap();
120        assert_eq!(json["time"], 1741520000);
121        assert_eq!(json["side"], "buy");
122        assert_eq!(json["price"], 95000.0);
123        assert_eq!(json["size"], 0.05);
124        assert_eq!(json["symbol"], "BTC-PERP");
125        assert_eq!(json["isPaper"], false);
126        assert_eq!(json["agentId"], "agent-1");
127    }
128
129    #[test]
130    fn test_trade_record_deserialization() {
131        let json = serde_json::json!({
132            "time": 1741520000,
133            "side": "sell",
134            "price": 96000.0,
135            "size": 0.1,
136            "symbol": "ETH-PERP",
137            "isPaper": true,
138            "agentId": "agent-2"
139        });
140        let record: TradeRecord = serde_json::from_value(json).unwrap();
141        assert_eq!(record.time, 1741520000);
142        assert_eq!(record.side, "sell");
143        assert_eq!(record.price, 96000.0);
144        assert_eq!(record.size, 0.1);
145        assert_eq!(record.symbol, "ETH-PERP");
146        assert!(record.is_paper);
147        assert_eq!(record.agent_id, "agent-2");
148    }
149}