Skip to main content

hyper_agent_core/
live_executor.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5
6use crate::config::AppConfig;
7use crate::credentials::CredentialResolver;
8use crate::executor::{ExecutorError, OrderParams, OrderResult, OrderSubmitter};
9use hyper_exchange::{sign_l1_action, ExchangeClient, Signer};
10use hyper_keyring::simple::SimpleKeyring;
11use hyper_keyring::Keyring;
12
13/// Cache of asset name → index mappings from the Hyperliquid exchange meta endpoint.
14///
15/// Used by [`LiveExecutor`] to resolve human-readable market symbols (e.g. "BTC-PERP")
16/// into the numeric asset indices required by the L1 order action.
17#[derive(Clone)]
18pub struct AssetMetaCache {
19    coin_to_index: HashMap<String, u32>,
20}
21
22impl AssetMetaCache {
23    /// Fetch asset metadata from the exchange info endpoint and build the cache.
24    pub async fn fetch(client: &ExchangeClient) -> Result<Self, String> {
25        let meta = client
26            .post_info(serde_json::json!({ "type": "meta" }))
27            .await
28            .map_err(|e| format!("Failed to fetch exchange meta: {}", e))?;
29        let universe = meta
30            .get("universe")
31            .and_then(|u| u.as_array())
32            .ok_or_else(|| "Invalid meta response: missing universe".to_string())?;
33        let mut coin_to_index = HashMap::new();
34        for (idx, asset) in universe.iter().enumerate() {
35            if let Some(name) = asset.get("name").and_then(|n| n.as_str()) {
36                coin_to_index.insert(name.to_uppercase(), idx as u32);
37            }
38        }
39        Ok(Self { coin_to_index })
40    }
41
42    /// Create a cache from a pre-built map (useful for testing).
43    pub fn from_map(map: HashMap<String, u32>) -> Self {
44        Self { coin_to_index: map }
45    }
46
47    /// Resolve a market symbol to its asset index.
48    ///
49    /// Strips common suffixes (-PERP, -USDC, -USD) and uppercases before lookup.
50    pub fn resolve(&self, symbol: &str) -> Option<u32> {
51        let coin = symbol
52            .to_uppercase()
53            .replace("-PERP", "")
54            .replace("-USDC", "")
55            .replace("-USD", "");
56        self.coin_to_index.get(&coin).copied()
57    }
58}
59
60/// Live order executor that submits EIP-712 signed actions to Hyperliquid L1.
61pub struct LiveExecutor {
62    signer: Arc<dyn Signer>,
63    agent_address: String,
64    vault_address: Option<String>,
65    is_mainnet: bool,
66    client: ExchangeClient,
67    meta_cache: AssetMetaCache,
68}
69
70impl LiveExecutor {
71    pub fn new(
72        signer: Arc<dyn Signer>,
73        agent_address: String,
74        vault_address: Option<String>,
75        is_mainnet: bool,
76        client: ExchangeClient,
77        meta_cache: AssetMetaCache,
78    ) -> Self {
79        Self {
80            signer,
81            agent_address,
82            vault_address,
83            is_mainnet,
84            client,
85            meta_cache,
86        }
87    }
88
89    /// Place a trigger order (stop-loss or take-profit) on Hyperliquid.
90    ///
91    /// `tpsl` should be `"sl"` for stop-loss or `"tp"` for take-profit.
92    /// The order fires as a market order when the trigger price is hit.
93    pub async fn place_trigger_order(
94        &self,
95        symbol: &str,
96        side: &str, // "buy" or "sell" (opposite of position side)
97        size: f64,
98        trigger_price: f64,
99        tpsl: &str, // "sl" or "tp"
100    ) -> Result<OrderResult, ExecutorError> {
101        let asset_idx = self.meta_cache.resolve(symbol).ok_or_else(|| {
102            ExecutorError::Execution(format!("Asset '{}' not found in exchange universe", symbol))
103        })?;
104
105        let is_buy = side == "buy";
106        let nonce = std::time::SystemTime::now()
107            .duration_since(std::time::UNIX_EPOCH)
108            .unwrap()
109            .as_millis() as u64;
110
111        let action = serde_json::json!({
112            "type": "order",
113            "orders": [{
114                "a": asset_idx,
115                "b": is_buy,
116                "p": format!("{}", trigger_price),
117                "s": format!("{}", size),
118                "r": true,
119                "t": {
120                    "trigger": {
121                        "triggerPx": format!("{}", trigger_price),
122                        "isMarket": true,
123                        "tpsl": tpsl
124                    }
125                }
126            }],
127            "grouping": "na"
128        });
129
130        let signature = sign_l1_action(
131            self.signer.as_ref(),
132            &self.agent_address,
133            &action,
134            nonce,
135            self.is_mainnet,
136            self.vault_address.as_deref(),
137        )
138        .map_err(|e| ExecutorError::Execution(format!("Signing failed: {}", e)))?;
139
140        let result = self
141            .client
142            .post_action(action, &signature, nonce, self.vault_address.as_deref())
143            .await
144            .map_err(|e| ExecutorError::Execution(format!("Exchange API error: {}", e)))?;
145
146        let api_status = result
147            .get("status")
148            .and_then(|s| s.as_str())
149            .unwrap_or("unknown");
150
151        if api_status != "ok" {
152            return Err(ExecutorError::Execution(format!(
153                "Trigger order rejected: {}",
154                result
155            )));
156        }
157
158        // Parse fill info from exchange response
159        let status_entry = result
160            .get("response")
161            .and_then(|r| r.get("data"))
162            .and_then(|d| d.get("statuses"))
163            .and_then(|s| s.as_array())
164            .and_then(|a| a.first());
165
166        let (actual_order_id, actual_fill_price, actual_fill_size) =
167            if let Some(entry) = status_entry {
168                if let Some(filled) = entry.get("filled") {
169                    let oid = filled
170                        .get("oid")
171                        .and_then(|o| o.as_u64())
172                        .map(|o| o.to_string());
173                    let avg_px = filled
174                        .get("avgPx")
175                        .and_then(|p| p.as_str())
176                        .and_then(|s| s.parse::<f64>().ok());
177                    let total_sz = filled
178                        .get("totalSz")
179                        .and_then(|s| s.as_str())
180                        .and_then(|s| s.parse::<f64>().ok());
181                    (
182                        oid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
183                        avg_px.unwrap_or(trigger_price),
184                        total_sz.unwrap_or(size),
185                    )
186                } else if let Some(resting) = entry.get("resting") {
187                    let oid = resting
188                        .get("oid")
189                        .and_then(|o| o.as_u64())
190                        .map(|o| o.to_string());
191                    (
192                        oid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
193                        trigger_price, // Not filled yet, use trigger price
194                        0.0,           // Resting order has zero fill
195                    )
196                } else {
197                    (uuid::Uuid::new_v4().to_string(), trigger_price, size)
198                }
199            } else {
200                (uuid::Uuid::new_v4().to_string(), trigger_price, size)
201            };
202
203        // Zero-fill (actual_fill_size == 0.0) is excluded from partial_fill because
204        // trigger orders are expected to rest unfilled on the book until the trigger
205        // price is hit. A zero-fill simply means the order is resting, not partially filled.
206        let status = if actual_fill_size < size * 0.99 && actual_fill_size > 0.0 {
207            tracing::warn!(
208                order_id = %actual_order_id,
209                filled = actual_fill_size,
210                requested = size,
211                "Partial fill detected on trigger order"
212            );
213            "partial_fill".to_string()
214        } else if actual_fill_size == 0.0 {
215            "resting".to_string()
216        } else {
217            format!("trigger_{}", tpsl)
218        };
219
220        Ok(OrderResult {
221            order_id: actual_order_id,
222            filled_price: actual_fill_price,
223            filled_size: actual_fill_size,
224            requested_size: size,
225            status,
226        })
227    }
228
229    /// Fetch the mid price for a given symbol from the L2 orderbook.
230    async fn fetch_mid_price(&self, symbol: &str) -> Result<f64, String> {
231        let coin = symbol
232            .to_uppercase()
233            .replace("-PERP", "")
234            .replace("-USDC", "")
235            .replace("-USD", "");
236
237        let body = serde_json::json!({ "type": "l2Book", "coin": coin });
238        let resp = self
239            .client
240            .post_info(body)
241            .await
242            .map_err(|e| format!("L2 book fetch failed: {}", e))?;
243
244        let levels = resp
245            .get("levels")
246            .and_then(|l| l.as_array())
247            .ok_or("Invalid L2 book response")?;
248
249        if levels.len() < 2 {
250            return Err("Incomplete L2 book".into());
251        }
252
253        let best_bid = levels[0]
254            .as_array()
255            .and_then(|bids| bids.first())
256            .and_then(|b| b.get("px"))
257            .and_then(|p| p.as_str())
258            .and_then(|s| s.parse::<f64>().ok())
259            .ok_or("Cannot parse best bid")?;
260
261        let best_ask = levels[1]
262            .as_array()
263            .and_then(|asks| asks.first())
264            .and_then(|a| a.get("px"))
265            .and_then(|p| p.as_str())
266            .and_then(|s| s.parse::<f64>().ok())
267            .ok_or("Cannot parse best ask")?;
268
269        Ok((best_bid + best_ask) / 2.0)
270    }
271}
272
273#[async_trait]
274impl OrderSubmitter for LiveExecutor {
275    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError> {
276        let asset_idx = self.meta_cache.resolve(&params.market).ok_or_else(|| {
277            ExecutorError::Execution(format!(
278                "Asset '{}' not found in exchange universe",
279                params.market
280            ))
281        })?;
282
283        let is_buy = params.side == "buy";
284        let nonce = std::time::SystemTime::now()
285            .duration_since(std::time::UNIX_EPOCH)
286            .unwrap()
287            .as_millis() as u64;
288
289        // Determine price and order type
290        let (effective_price, order_type) = if let Some(p) = params.price {
291            // Limit order with explicit price -> GTC
292            (p, serde_json::json!({ "limit": { "tif": "Gtc" } }))
293        } else {
294            // Market order -> IOC with slippage
295            let mid_price = self.fetch_mid_price(&params.market).await.map_err(|e| {
296                ExecutorError::Execution(format!(
297                    "Failed to fetch mid price for market order: {}",
298                    e
299                ))
300            })?;
301            let slippage = 0.005; // 0.5%
302            let slipped = if is_buy {
303                mid_price * (1.0 + slippage)
304            } else {
305                mid_price * (1.0 - slippage)
306            };
307            (slipped, serde_json::json!({ "limit": { "tif": "Ioc" } }))
308        };
309
310        let action = serde_json::json!({
311            "type": "order",
312            "orders": [{
313                "a": asset_idx,
314                "b": is_buy,
315                "p": format!("{}", effective_price),
316                "s": format!("{}", params.size),
317                "r": false,
318                "t": order_type
319            }],
320            "grouping": "na"
321        });
322
323        let signature = sign_l1_action(
324            self.signer.as_ref(),
325            &self.agent_address,
326            &action,
327            nonce,
328            self.is_mainnet,
329            self.vault_address.as_deref(),
330        )
331        .map_err(|e| ExecutorError::Execution(format!("Signing failed: {}", e)))?;
332
333        let result = self
334            .client
335            .post_action(action, &signature, nonce, self.vault_address.as_deref())
336            .await
337            .map_err(|e| ExecutorError::Execution(format!("Exchange API error: {}", e)))?;
338
339        let api_status = result
340            .get("status")
341            .and_then(|s| s.as_str())
342            .unwrap_or("unknown");
343
344        if api_status != "ok" {
345            return Err(ExecutorError::Execution(format!(
346                "Exchange rejected order: {}",
347                result
348            )));
349        }
350
351        // Parse fill info from exchange response
352        let status_entry = result
353            .get("response")
354            .and_then(|r| r.get("data"))
355            .and_then(|d| d.get("statuses"))
356            .and_then(|s| s.as_array())
357            .and_then(|a| a.first());
358
359        let (actual_order_id, actual_fill_price, actual_fill_size) =
360            if let Some(entry) = status_entry {
361                if let Some(filled) = entry.get("filled") {
362                    let oid = filled
363                        .get("oid")
364                        .and_then(|o| o.as_u64())
365                        .map(|o| o.to_string());
366                    let avg_px = filled
367                        .get("avgPx")
368                        .and_then(|p| p.as_str())
369                        .and_then(|s| s.parse::<f64>().ok());
370                    let total_sz = filled
371                        .get("totalSz")
372                        .and_then(|s| s.as_str())
373                        .and_then(|s| s.parse::<f64>().ok());
374                    (
375                        oid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
376                        avg_px.unwrap_or(effective_price),
377                        total_sz.unwrap_or(params.size),
378                    )
379                } else if let Some(resting) = entry.get("resting") {
380                    let oid = resting
381                        .get("oid")
382                        .and_then(|o| o.as_u64())
383                        .map(|o| o.to_string());
384                    (
385                        oid.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
386                        effective_price, // Not filled yet, use limit price
387                        0.0,             // Resting order has zero fill
388                    )
389                } else {
390                    (
391                        uuid::Uuid::new_v4().to_string(),
392                        effective_price,
393                        params.size,
394                    )
395                }
396            } else {
397                (
398                    uuid::Uuid::new_v4().to_string(),
399                    effective_price,
400                    params.size,
401                )
402            };
403
404        let status = if actual_fill_size < params.size * 0.99 && actual_fill_size > 0.0 {
405            tracing::warn!(
406                order_id = %actual_order_id,
407                filled = actual_fill_size,
408                requested = params.size,
409                "Partial fill detected"
410            );
411            "partial_fill".to_string()
412        } else if actual_fill_size >= params.size * 0.99 {
413            "filled".to_string()
414        } else if actual_fill_size == 0.0 {
415            "resting".to_string()
416        } else {
417            api_status.to_string()
418        };
419
420        Ok(OrderResult {
421            order_id: actual_order_id,
422            filled_price: actual_fill_price,
423            filled_size: actual_fill_size,
424            requested_size: params.size,
425            status,
426        })
427    }
428
429    async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutorError> {
430        // Parse order_id as u64 (Hyperliquid uses numeric OIDs)
431        let oid: u64 = order_id.parse().map_err(|_| {
432            ExecutorError::Execution(format!("Invalid order ID '{}': must be numeric", order_id))
433        })?;
434
435        let nonce = std::time::SystemTime::now()
436            .duration_since(std::time::UNIX_EPOCH)
437            .unwrap()
438            .as_millis() as u64;
439
440        // Query open orders to find the asset index for this order
441        let body = serde_json::json!({
442            "type": "openOrders",
443            "user": self.agent_address,
444        });
445        let orders =
446            self.client.post_info(body).await.map_err(|e| {
447                ExecutorError::Execution(format!("Failed to fetch open orders: {}", e))
448            })?;
449
450        // Find the order to get its coin/asset index
451        let order = orders
452            .as_array()
453            .and_then(|arr| {
454                arr.iter()
455                    .find(|o| o.get("oid").and_then(|id| id.as_u64()) == Some(oid))
456            })
457            .ok_or_else(|| {
458                ExecutorError::Execution(format!("Order {} not found in open orders", order_id))
459            })?;
460
461        let coin = order.get("coin").and_then(|c| c.as_str()).unwrap_or("");
462        let asset_idx = self
463            .meta_cache
464            .resolve(&format!("{}-PERP", coin))
465            .ok_or_else(|| {
466                ExecutorError::Execution(format!("Asset '{}' not in meta cache", coin))
467            })?;
468
469        let action = serde_json::json!({
470            "type": "cancel",
471            "cancels": [{ "a": asset_idx, "o": oid }]
472        });
473
474        let signature = sign_l1_action(
475            self.signer.as_ref(),
476            &self.agent_address,
477            &action,
478            nonce,
479            self.is_mainnet,
480            self.vault_address.as_deref(),
481        )
482        .map_err(|e| ExecutorError::Execution(format!("Signing failed: {}", e)))?;
483
484        let result = self
485            .client
486            .post_action(action, &signature, nonce, self.vault_address.as_deref())
487            .await
488            .map_err(|e| ExecutorError::Execution(format!("Cancel API error: {}", e)))?;
489
490        let status = result
491            .get("status")
492            .and_then(|s| s.as_str())
493            .unwrap_or("unknown");
494        if status != "ok" {
495            return Err(ExecutorError::Execution(format!(
496                "Cancel rejected: {}",
497                result
498            )));
499        }
500
501        tracing::info!(order_id = %order_id, "[live] order cancelled");
502        Ok(())
503    }
504}
505
506/// Build a [`LiveExecutor`] from the app configuration.
507///
508/// Resolves the Hyperliquid private key from environment variables or TOML
509/// config, derives the agent address, constructs a signer via
510/// [`SimpleKeyring`], fetches the exchange asset metadata, and returns a
511/// ready-to-use [`LiveExecutor`] (which also implements [`OrderSubmitter`]).
512pub async fn build_live_executor(
513    config: &AppConfig,
514) -> Result<Arc<LiveExecutor>, Box<dyn std::error::Error>> {
515    let resolver = CredentialResolver::new(config.credentials.clone());
516
517    // 1. Get private key
518    let private_key = resolver.hyperliquid_key().ok_or(
519        "Live mode requires HYPERLIQUID_PRIVATE_KEY env var or credentials.hyperliquid_private_key in config.toml",
520    )?;
521
522    // 2. Get address from key
523    let address = resolver
524        .hyperliquid_address()
525        .ok_or("No Hyperliquid private key configured")?
526        .map_err(|e| format!("Address derivation failed: {}", e))?;
527
528    // 3. Build signer (SimpleKeyring implements hyper_exchange::Signer)
529    let mut keyring = SimpleKeyring::new();
530    keyring
531        .add_accounts(&[private_key])
532        .map_err(|e| format!("Failed to import key: {}", e))?;
533    let signer: Arc<dyn hyper_exchange::Signer> = Arc::new(keyring);
534
535    // 4. Build exchange client
536    let client = ExchangeClient::new(config.exchange.is_mainnet);
537
538    // 5. Fetch asset meta cache
539    let meta_cache = AssetMetaCache::fetch(&client)
540        .await
541        .map_err(|e| format!("Failed to fetch exchange meta: {}", e))?;
542
543    // 6. Construct LiveExecutor
544    Ok(Arc::new(LiveExecutor::new(
545        signer,
546        address,
547        config.exchange.vault_address.clone(),
548        config.exchange.is_mainnet,
549        client,
550        meta_cache,
551    )))
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn meta_cache_resolve_btc_perp() {
560        let mut map = HashMap::new();
561        map.insert("BTC".to_string(), 0);
562        map.insert("ETH".to_string(), 1);
563        let cache = AssetMetaCache::from_map(map);
564        assert_eq!(cache.resolve("BTC-PERP"), Some(0));
565        assert_eq!(cache.resolve("ETH-PERP"), Some(1));
566        assert_eq!(cache.resolve("SOL-PERP"), None);
567    }
568
569    #[test]
570    fn meta_cache_resolve_strips_suffixes() {
571        let mut map = HashMap::new();
572        map.insert("BTC".to_string(), 0);
573        let cache = AssetMetaCache::from_map(map);
574        assert_eq!(cache.resolve("BTC-USD"), Some(0));
575        assert_eq!(cache.resolve("BTC-USDC"), Some(0));
576        assert_eq!(cache.resolve("BTC-PERP"), Some(0));
577    }
578
579    #[test]
580    fn meta_cache_resolve_case_insensitive() {
581        let mut map = HashMap::new();
582        map.insert("BTC".to_string(), 0);
583        let cache = AssetMetaCache::from_map(map);
584        assert_eq!(cache.resolve("btc-perp"), Some(0));
585    }
586
587    #[test]
588    fn meta_cache_resolve_for_cancel() {
589        let mut map = HashMap::new();
590        map.insert("BTC".to_string(), 0);
591        let cache = AssetMetaCache::from_map(map);
592        assert_eq!(cache.resolve("BTC-PERP"), Some(0));
593    }
594
595    #[test]
596    fn invalid_order_id_is_error() {
597        let result: Result<u64, _> = "not-a-number".parse();
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn market_order_slippage_buy() {
603        let mid = 50000.0_f64;
604        let slippage = 0.005;
605        let slipped = mid * (1.0 + slippage);
606        assert!((slipped - 50250.0).abs() < 0.01);
607    }
608
609    #[test]
610    fn market_order_slippage_sell() {
611        let mid = 50000.0_f64;
612        let slippage = 0.005;
613        let slipped = mid * (1.0 - slippage);
614        assert!((slipped - 49750.0).abs() < 0.01);
615    }
616
617    #[test]
618    fn parse_filled_response_extracts_real_price_and_size() {
619        let response: serde_json::Value = serde_json::json!({
620            "status": "ok",
621            "response": {
622                "type": "order",
623                "data": {
624                    "statuses": [{
625                        "filled": {
626                            "totalSz": "0.01",
627                            "avgPx": "65123.5",
628                            "oid": 12345
629                        }
630                    }]
631                }
632            }
633        });
634
635        let status_entry = response
636            .get("response")
637            .and_then(|r| r.get("data"))
638            .and_then(|d| d.get("statuses"))
639            .and_then(|s| s.as_array())
640            .and_then(|a| a.first());
641
642        let entry = status_entry.unwrap();
643        let filled = entry.get("filled").unwrap();
644        let avg_px: f64 = filled
645            .get("avgPx")
646            .unwrap()
647            .as_str()
648            .unwrap()
649            .parse()
650            .unwrap();
651        let total_sz: f64 = filled
652            .get("totalSz")
653            .unwrap()
654            .as_str()
655            .unwrap()
656            .parse()
657            .unwrap();
658        let oid = filled.get("oid").unwrap().as_u64().unwrap();
659
660        assert!((avg_px - 65123.5).abs() < 0.01);
661        assert!((total_sz - 0.01).abs() < 0.0001);
662        assert_eq!(oid, 12345);
663    }
664
665    #[test]
666    fn parse_resting_response_uses_fallback_price() {
667        let response: serde_json::Value = serde_json::json!({
668            "status": "ok",
669            "response": {
670                "type": "order",
671                "data": {
672                    "statuses": [{
673                        "resting": {
674                            "oid": 67890
675                        }
676                    }]
677                }
678            }
679        });
680
681        let status_entry = response
682            .get("response")
683            .and_then(|r| r.get("data"))
684            .and_then(|d| d.get("statuses"))
685            .and_then(|s| s.as_array())
686            .and_then(|a| a.first());
687
688        let entry = status_entry.unwrap();
689        assert!(entry.get("filled").is_none());
690        let resting = entry.get("resting").unwrap();
691        let oid = resting.get("oid").unwrap().as_u64().unwrap();
692        assert_eq!(oid, 67890);
693        // avgPx not available for resting orders -> should fall back to effective_price
694        assert!(resting.get("avgPx").is_none());
695    }
696
697    #[test]
698    fn parse_empty_statuses_uses_fallback() {
699        let response: serde_json::Value = serde_json::json!({
700            "status": "ok",
701            "response": {
702                "type": "order",
703                "data": {
704                    "statuses": []
705                }
706            }
707        });
708
709        let status_entry = response
710            .get("response")
711            .and_then(|r| r.get("data"))
712            .and_then(|d| d.get("statuses"))
713            .and_then(|s| s.as_array())
714            .and_then(|a| a.first());
715
716        assert!(status_entry.is_none());
717    }
718}