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#[derive(Clone)]
18pub struct AssetMetaCache {
19 coin_to_index: HashMap<String, u32>,
20}
21
22impl AssetMetaCache {
23 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 pub fn from_map(map: HashMap<String, u32>) -> Self {
44 Self { coin_to_index: map }
45 }
46
47 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
60pub 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 pub async fn place_trigger_order(
94 &self,
95 symbol: &str,
96 side: &str, size: f64,
98 trigger_price: f64,
99 tpsl: &str, ) -> 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 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, 0.0, )
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 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 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(¶ms.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 let (effective_price, order_type) = if let Some(p) = params.price {
291 (p, serde_json::json!({ "limit": { "tif": "Gtc" } }))
293 } else {
294 let mid_price = self.fetch_mid_price(¶ms.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; 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 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, 0.0, )
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 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 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 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
506pub 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 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 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 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 let client = ExchangeClient::new(config.exchange.is_mainnet);
537
538 let meta_cache = AssetMetaCache::fetch(&client)
540 .await
541 .map_err(|e| format!("Failed to fetch exchange meta: {}", e))?;
542
543 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 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}