Skip to main content

hyper_agent_runtime/
client.rs

1use crate::daemon;
2use fs2::FileExt;
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6#[derive(Serialize)]
7pub struct RunStrategyParams {
8    pub id: String,
9    pub symbol: Option<String>,
10    pub symbols: Option<Vec<String>>,
11    pub paper: Option<bool>,
12    pub confirm_live: Option<bool>,
13    pub agent_loop: Option<bool>,
14    pub agent_interval: Option<u64>,
15    pub tick_interval: Option<u64>,
16}
17
18#[derive(Serialize)]
19pub struct PlaceOrderParams {
20    pub market: String,
21    pub side: String,
22    pub size: f64,
23    pub price: Option<f64>,
24    pub live: Option<bool>, // true = live, false/None = paper (safest default)
25}
26
27#[deprecated(note = "Use hyper_agent_client::HyperAgentClient instead")]
28pub struct DaemonClient {
29    base_url: String,
30    http: reqwest::Client,
31    token: String,
32}
33
34#[derive(Debug)]
35pub enum ClientError {
36    NotRunning,
37    Http(reqwest::Error),
38    Api(String),
39}
40
41impl std::fmt::Display for ClientError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::NotRunning => write!(f, "Daemon is not running"),
45            Self::Http(e) => write!(f, "HTTP error: {}", e),
46            Self::Api(msg) => write!(f, "API error: {}", msg),
47        }
48    }
49}
50impl std::error::Error for ClientError {}
51
52#[allow(deprecated)]
53impl DaemonClient {
54    pub fn new(base_url: &str, token: &str) -> Self {
55        Self {
56            base_url: base_url.to_string(),
57            http: reqwest::Client::new(),
58            token: token.to_string(),
59        }
60    }
61
62    pub fn from_port_file() -> Result<Self, ClientError> {
63        let port = daemon::read_port().ok_or(ClientError::NotRunning)?;
64        let token = daemon::read_token().ok_or(ClientError::NotRunning)?;
65        Ok(Self::new(&format!("http://127.0.0.1:{}", port), &token))
66    }
67
68    pub async fn health(&self) -> Result<(), ClientError> {
69        let resp = self
70            .http
71            .get(format!("{}/health", self.base_url))
72            .send()
73            .await
74            .map_err(ClientError::Http)?;
75        if resp.status().is_success() {
76            Ok(())
77        } else {
78            Err(ClientError::Api("unhealthy".into()))
79        }
80    }
81
82    pub async fn status(&self) -> Result<serde_json::Value, ClientError> {
83        self.get("/status").await
84    }
85
86    pub async fn shutdown(&self) -> Result<(), ClientError> {
87        self.post("/shutdown", &())
88            .await
89            .map(|_: serde_json::Value| ())
90    }
91
92    pub async fn run_strategy(
93        &self,
94        params: &RunStrategyParams,
95    ) -> Result<serde_json::Value, ClientError> {
96        self.post("/strategies/run", params).await
97    }
98
99    pub async fn stop_strategy(&self, id: &str) -> Result<serde_json::Value, ClientError> {
100        self.post(&format!("/strategies/{}/stop", id), &()).await
101    }
102
103    pub async fn list_strategies(&self) -> Result<Vec<serde_json::Value>, ClientError> {
104        self.get("/strategies").await
105    }
106
107    pub async fn strategy_health(&self, id: &str) -> Result<serde_json::Value, ClientError> {
108        self.get(&format!("/strategies/{}/health", id)).await
109    }
110
111    pub async fn place_order(
112        &self,
113        params: &PlaceOrderParams,
114    ) -> Result<serde_json::Value, ClientError> {
115        self.post("/orders", params).await
116    }
117
118    pub async fn cancel_order(&self, id: &str) -> Result<(), ClientError> {
119        self.delete(&format!("/orders/{}", id)).await
120    }
121
122    pub async fn get_positions(&self) -> Result<Vec<serde_json::Value>, ClientError> {
123        self.get("/positions").await
124    }
125
126    pub async fn close_all_positions(&self, live: bool) -> Result<serde_json::Value, ClientError> {
127        self.post("/positions/close-all", &serde_json::json!({"live": live}))
128            .await
129    }
130
131    pub async fn get_position_history(&self) -> Result<Vec<serde_json::Value>, ClientError> {
132        self.get("/positions/history").await
133    }
134
135    pub async fn get_pnl(&self) -> Result<serde_json::Value, ClientError> {
136        self.get("/dashboard/pnl").await
137    }
138
139    pub async fn dashboard_stats(&self) -> Result<serde_json::Value, ClientError> {
140        self.get("/dashboard/stats").await
141    }
142
143    pub async fn get_equity(&self, days: u32) -> Result<serde_json::Value, ClientError> {
144        self.get(&format!("/dashboard/equity?days={}", days)).await
145    }
146
147    pub async fn get_risk_exposure(&self) -> Result<serde_json::Value, ClientError> {
148        self.get("/risk/exposure").await
149    }
150
151    pub async fn get_execution_quality(&self) -> Result<serde_json::Value, ClientError> {
152        self.get("/dashboard/execution-quality").await
153    }
154
155    pub async fn get_circuit_breaker(&self) -> Result<serde_json::Value, ClientError> {
156        self.get("/risk/circuit-breaker").await
157    }
158
159    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
160        let resp = self
161            .http
162            .get(format!("{}{}", self.base_url, path))
163            .header("Authorization", format!("Bearer {}", self.token))
164            .send()
165            .await
166            .map_err(ClientError::Http)?;
167        if !resp.status().is_success() {
168            return Err(ClientError::Api(format!("status {}", resp.status())));
169        }
170        resp.json().await.map_err(ClientError::Http)
171    }
172
173    async fn post<B: serde::Serialize, T: DeserializeOwned>(
174        &self,
175        path: &str,
176        body: &B,
177    ) -> Result<T, ClientError> {
178        let resp = self
179            .http
180            .post(format!("{}{}", self.base_url, path))
181            .header("Authorization", format!("Bearer {}", self.token))
182            .json(body)
183            .send()
184            .await
185            .map_err(ClientError::Http)?;
186        if !resp.status().is_success() {
187            let text = resp.text().await.unwrap_or_default();
188            return Err(ClientError::Api(text));
189        }
190        // Handle empty response body
191        let text = resp.text().await.map_err(ClientError::Http)?;
192        if text.is_empty() {
193            serde_json::from_str("null").map_err(|e| ClientError::Api(e.to_string()))
194        } else {
195            serde_json::from_str(&text).map_err(|e| ClientError::Api(e.to_string()))
196        }
197    }
198
199    async fn delete(&self, path: &str) -> Result<(), ClientError> {
200        let resp = self
201            .http
202            .delete(format!("{}{}", self.base_url, path))
203            .header("Authorization", format!("Bearer {}", self.token))
204            .send()
205            .await
206            .map_err(ClientError::Http)?;
207        if !resp.status().is_success() {
208            return Err(ClientError::Api(format!("status {}", resp.status())));
209        }
210        Ok(())
211    }
212}
213
214#[allow(deprecated)]
215pub async fn ensure_daemon() -> Result<DaemonClient, Box<dyn std::error::Error>> {
216    if let Ok(client) = DaemonClient::from_port_file() {
217        if client.health().await.is_ok() {
218            return Ok(client);
219        }
220    }
221
222    // File lock to prevent concurrent callers from spawning multiple daemons
223    let lock_path = daemon::daemon_dir().join("daemon.lock");
224    std::fs::create_dir_all(daemon::daemon_dir())?;
225    let lock_file = std::fs::File::create(&lock_path)?;
226
227    if lock_file.try_lock_exclusive().is_err() {
228        // Another process is starting the daemon, just wait
229        for _ in 0..20 {
230            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
231            if let Ok(client) = DaemonClient::from_port_file() {
232                if client.health().await.is_ok() {
233                    return Ok(client);
234                }
235            }
236        }
237        return Err("Daemon startup locked by another process".into());
238    }
239
240    // We have the lock — check again in case daemon started between first check and lock
241    if let Ok(client) = DaemonClient::from_port_file() {
242        if client.health().await.is_ok() {
243            return Ok(client);
244        }
245    }
246
247    // Start daemon in background
248    let exe = std::env::current_exe()?;
249    // Look for hyper-agent-daemon binary next to current exe
250    let daemon_exe = exe.parent().unwrap().join("hyper-agent-daemon");
251    if !daemon_exe.exists() {
252        return Err(format!("Daemon binary not found at {:?}", daemon_exe).into());
253    }
254
255    std::fs::create_dir_all(daemon::log_dir())?;
256    std::process::Command::new(&daemon_exe)
257        .arg(daemon::DEFAULT_PORT.to_string())
258        .stdout(std::process::Stdio::null())
259        .stderr(std::fs::File::create(daemon::log_dir().join("daemon.log"))?)
260        .spawn()?;
261
262    // Wait for health
263    for _ in 0..20 {
264        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
265        if let Ok(client) = DaemonClient::from_port_file() {
266            if client.health().await.is_ok() {
267                // Lock is released when lock_file is dropped
268                return Ok(client);
269            }
270        }
271    }
272    Err("Daemon failed to start within 10 seconds".into())
273}