1use std::io::IsTerminal;
2
3use crate::client::IndodaxClient;
4use crate::commands::helpers;
5use crate::output::CommandOutput;
6use anyhow::Result;
7use std::collections::HashMap;
8
9const BALANCE_EPSILON: f64 = 1e-8;
10
11async fn get_account_info(client: &IndodaxClient) -> Result<serde_json::Value> {
12 let params = HashMap::new();
13 Ok(client.private_post_v1("getInfo", ¶ms).await?)
14}
15
16#[derive(Debug, clap::Subcommand)]
17pub enum TradeCommand {
18 #[command(name = "buy", about = "Place a buy order")]
19 Buy {
20 #[arg(short, long)]
21 pair: String,
22 #[arg(short = 'i', long, help = "The total IDR amount to spend.")]
23 idr: f64,
24 #[arg(long, help = "Limit price. If omitted, a market order will be placed.")]
25 price: Option<f64>,
26 },
27
28 #[command(name = "sell", about = "Place a sell order")]
29 Sell {
30 #[arg(short, long)]
31 pair: String,
32 #[arg(short = 'a', long, help = "Amount in base currency (e.g. BTC)")]
33 amount: f64,
34 #[arg(short = 'r', long, help = "Limit price. If omitted, a market order will be placed.")]
35 price: Option<f64>,
36 },
37
38 #[command(name = "cancel", about = "Cancel an order by ID")]
39 Cancel {
40 #[arg(short = 'i', long)]
41 order_id: u64,
42 #[arg(short = 'p', long)]
43 pair: String,
44 #[arg(short = 't', long, help = "Order side: buy or sell")]
45 order_type: String,
46 },
47
48 #[command(name = "cancel-by-client-id", about = "Cancel an order by client order ID")]
49 CancelByClientId {
50 #[arg(long)]
51 client_order_id: String,
52 },
53
54 #[command(name = "cancel-all", about = "Cancel all open orders, optionally filtered by pair")]
55 CancelAll {
56 #[arg(short, long, help = "Only cancel orders for this trading pair (e.g. btc_idr)")]
57 pair: Option<String>,
58 #[arg(long, help = "Skip confirmation prompt (required in non-interactive mode)")]
59 force: bool,
60 },
61
62 #[command(name = "countdown", about = "Start deadman switch countdown")]
63 CountdownCancelAll {
64 #[arg(short, long)]
65 pair: Option<String>,
66 #[arg(short, long, help = "Countdown in milliseconds (0 to disable)")]
67 countdown_time: u64,
68 },
69}
70
71pub async fn execute(
72 client: &IndodaxClient,
73 cmd: &TradeCommand,
74) -> Result<CommandOutput> {
75 match cmd {
76 TradeCommand::Buy { pair, idr, price } => {
77 let pair = helpers::normalize_pair(pair);
78 place_buy_order(client, &pair, *idr, *price).await
79 }
80 TradeCommand::Sell { pair, price, amount } => {
81 let pair = helpers::normalize_pair(pair);
82 place_sell_order(client, &pair, *price, *amount).await
83 }
84 TradeCommand::Cancel { order_id, pair, order_type } => {
85 let pair = helpers::normalize_pair(pair);
86 cancel_order(client, *order_id, &pair, order_type).await
87 }
88 TradeCommand::CancelByClientId { client_order_id } => {
89 cancel_by_client_id(client, client_order_id).await
90 }
91 TradeCommand::CancelAll { pair, force } => {
92 let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
93 cancel_all_orders(client, pair.as_deref(), *force).await
94 }
95 TradeCommand::CountdownCancelAll { pair, countdown_time } => {
96 let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
97 countdown_cancel_all(client, pair.as_deref(), *countdown_time).await
98 }
99 }
100}
101
102async fn place_buy_order(
103 client: &IndodaxClient,
104 pair: &str,
105 idr_amount: f64,
106 price: Option<f64>,
107) -> Result<CommandOutput> {
108 let info = get_account_info(client).await?;
109
110 if idr_amount <= 0.0 {
111 return Err(anyhow::anyhow!("IDR amount must be positive, got {}", idr_amount));
112 }
113
114 let idr_balance = helpers::parse_balance(&info, "idr");
115
116 if idr_balance + BALANCE_EPSILON < idr_amount {
117 return Err(anyhow::anyhow!(
118 "Insufficient IDR balance. Need {:.2}, have {:.2}",
119 idr_amount, idr_balance
120 ));
121 }
122
123 let mut params = HashMap::new();
124 params.insert("pair".to_string(), pair.to_string());
125 params.insert("type".to_string(), "buy".to_string());
126 params.insert("idr".to_string(), idr_amount.to_string());
127
128 let order_type_str = if let Some(p) = price {
129 if p <= 0.0 {
130 return Err(anyhow::anyhow!("Price must be positive, got {}", p));
131 }
132 params.insert("price".to_string(), p.to_string());
133 "limit"
134 } else {
135 params.insert("order_type".to_string(), "market".to_string());
136 "market"
137 };
138
139 let data: serde_json::Value =
140 client.private_post_v1("trade", ¶ms).await?;
141
142 let headers = vec!["Field".into(), "Value".into()];
143 let mut rows: Vec<Vec<String>> = Vec::new();
144 if let serde_json::Value::Object(ref map) = data {
145 for (k, v) in map {
146 rows.push(vec![k.clone(), helpers::value_to_string(v)]);
147 }
148 }
149
150 Ok(CommandOutput::new(data, headers, rows)
151 .with_addendum(format!("Buy order ({}) placed for {} IDR on pair {}", order_type_str, idr_amount, pair)))
152}
153
154async fn place_sell_order(
155 client: &IndodaxClient,
156 pair: &str,
157 price: Option<f64>,
158 amount: f64,
159) -> Result<CommandOutput> {
160 let base_currency = pair.split('_').next().unwrap_or_default();
161 if base_currency.is_empty() {
162 return Err(anyhow::anyhow!("Invalid pair format: {}", pair));
163 }
164
165 let info = get_account_info(client).await?;
166
167 if amount <= 0.0 {
168 return Err(anyhow::anyhow!("Amount must be positive, got {}", amount));
169 }
170
171 let base_balance = helpers::parse_balance(&info, base_currency);
172
173 if base_balance + BALANCE_EPSILON < amount {
174 return Err(anyhow::anyhow!(
175 "Insufficient {} balance. Need {:.8}, have {:.8}",
176 base_currency.to_uppercase(), amount, base_balance
177 ));
178 }
179
180 let mut params = HashMap::new();
181 params.insert("pair".to_string(), pair.to_string());
182 params.insert("type".to_string(), "sell".to_string());
183 params.insert(base_currency.to_string(), amount.to_string());
184
185 let order_type = if let Some(p) = price {
186 if p <= 0.0 {
187 return Err(anyhow::anyhow!("Price must be positive, got {}", p));
188 }
189 params.insert("price".to_string(), p.to_string());
190 "limit"
191 } else {
192 params.insert("order_type".to_string(), "market".to_string());
193 "market"
194 };
195
196 let data: serde_json::Value =
197 client.private_post_v1("trade", ¶ms).await?;
198
199 let headers = vec!["Field".into(), "Value".into()];
200 let mut rows: Vec<Vec<String>> = Vec::new();
201 if let serde_json::Value::Object(ref map) = data {
202 for (k, v) in map {
203 rows.push(vec![k.clone(), helpers::value_to_string(v)]);
204 }
205 }
206
207 let addendum = if let Some(p) = price {
208 format!("Sell order placed: {} {} @ {} ({})", amount, pair, p, order_type)
209 } else {
210 format!("Sell order ({}) placed for {} {} on pair {}", order_type, amount, pair.split('_').next().unwrap_or(""), pair)
211 };
212
213 Ok(CommandOutput::new(data, headers, rows).with_addendum(addendum))
214}
215
216async fn cancel_order(
217 client: &IndodaxClient,
218 order_id: u64,
219 pair: &str,
220 order_type: &str,
221) -> Result<CommandOutput> {
222 let normalized = order_type.to_lowercase();
223 if normalized != "buy" && normalized != "sell" {
224 return Err(anyhow::anyhow!(
225 "Invalid order type '{}'. Must be 'buy' or 'sell'", order_type
226 ));
227 }
228
229 let mut params = HashMap::new();
230 params.insert("order_id".into(), order_id.to_string());
231 params.insert("pair".into(), pair.to_string());
232 params.insert("type".into(), normalized);
233
234 let data: serde_json::Value =
235 client.private_post_v1("cancelOrder", ¶ms).await?;
236
237 let headers = vec!["Field".into(), "Value".into()];
238 let mut rows: Vec<Vec<String>> = Vec::new();
239 if let serde_json::Value::Object(ref map) = data {
240 for (k, v) in map {
241 rows.push(vec![k.clone(), helpers::value_to_string(v)]);
242 }
243 }
244
245 Ok(CommandOutput::new(data, headers, rows)
246 .with_addendum(format!("Cancelled order {} on {}", order_id, pair)))
247}
248
249async fn cancel_by_client_id(
250 client: &IndodaxClient,
251 client_order_id: &str,
252) -> Result<CommandOutput> {
253 let mut params = HashMap::new();
254 params.insert("client_order_id".into(), client_order_id.to_string());
255
256 let data: serde_json::Value =
257 client.private_post_v1("cancelByClientOrderId", ¶ms).await?;
258
259 let headers = vec!["Field".into(), "Value".into()];
260 let mut rows: Vec<Vec<String>> = Vec::new();
261 if let serde_json::Value::Object(ref map) = data {
262 for (k, v) in map {
263 rows.push(vec![k.clone(), helpers::value_to_string(v)]);
264 }
265 }
266
267 Ok(CommandOutput::new(data, headers, rows)
268 .with_addendum(format!("Cancelled order by client order ID: {}", client_order_id)))
269}
270
271async fn cancel_all_orders(
272 client: &IndodaxClient,
273 pair: Option<&str>,
274 force: bool,
275) -> Result<CommandOutput> {
276 if pair.is_none() && !force && std::io::stdin().is_terminal() {
277 use dialoguer::Confirm;
278 let confirmed = Confirm::new()
279 .with_prompt("No --pair filter specified. This will cancel ALL orders across ALL pairs. Continue?")
280 .default(false)
281 .interact()
282 .unwrap_or(false);
283 if !confirmed {
284 return Ok(CommandOutput::json(serde_json::json!({
285 "cancelled": false,
286 "reason": "user_cancelled",
287 })).with_addendum("Cancel all orders aborted by user."));
288 }
289 }
290
291 let (cancelled_ids, failed_ids) = helpers::cancel_all_open_orders(client, pair)
292 .await
293 .map_err(|e| anyhow::anyhow!("{}", e))?;
294
295 let headers = vec!["Metric".into(), "Value".into()];
296 let mut rows = vec![
297 vec!["Cancelled".into(), cancelled_ids.len().to_string()],
298 vec!["Order IDs".into(), cancelled_ids.join(", ")],
299 ];
300 if !failed_ids.is_empty() {
301 rows.push(vec!["Failed".into(), failed_ids.len().to_string()]);
302 rows.push(vec!["Failed IDs".into(), failed_ids.join(", ")]);
303 }
304
305 let data = serde_json::json!({
306 "cancelled_count": cancelled_ids.len(),
307 "cancelled_ids": cancelled_ids,
308 "failed_count": failed_ids.len(),
309 "failed_ids": failed_ids,
310 });
311
312 let addendum = if failed_ids.is_empty() {
313 format!("Cancelled {} order(s)", cancelled_ids.len())
314 } else {
315 format!("Cancelled {} order(s), {} failed", cancelled_ids.len(), failed_ids.len())
316 };
317
318 Ok(CommandOutput::new(data, headers, rows)
319 .with_addendum(addendum))
320}
321
322async fn countdown_cancel_all(
323 client: &IndodaxClient,
324 pair: Option<&str>,
325 countdown_time: u64,
326) -> Result<CommandOutput> {
327 let data = client.countdown_cancel_all(pair, countdown_time).await
328 .map_err(|e| anyhow::anyhow!("{}", e))?;
329
330 let msg = if countdown_time == 0 {
331 "Deadman switch disabled".into()
332 } else {
333 format!("Deadman switch active: {}ms countdown", countdown_time)
334 };
335
336 Ok(CommandOutput::json(data).with_addendum(msg))
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_trade_command_variants() {
345 let _cmd1 = TradeCommand::Buy {
346 pair: "btc_idr".into(),
347 idr: 100_000.0,
348 price: Some(100_000_000.0)
349 };
350 let _cmd2 = TradeCommand::Sell {
351 pair: "btc_idr".into(),
352 price: Some(100_000_000.0),
353 amount: 0.5,
354 };
355 let _cmd3 = TradeCommand::Cancel {
356 order_id: 123,
357 pair: "btc_idr".into(),
358 order_type: "buy".into()
359 };
360 let _cmd4 = TradeCommand::CancelByClientId {
361 client_order_id: "client_123".into()
362 };
363 let _cmd5 = TradeCommand::CancelAll {
364 pair: Some("btc_idr".into()),
365 force: false,
366 };
367 let _cmd6 = TradeCommand::CountdownCancelAll {
368 pair: Some("btc_idr".into()),
369 countdown_time: 60000
370 };
371 }
372
373 #[test]
374 fn test_trade_command_buy_market_order() {
375 let cmd = TradeCommand::Buy {
376 pair: "btc_idr".into(),
377 idr: 100_000.0,
378 price: None
379 };
380 match cmd {
381 TradeCommand::Buy { pair, idr, price } => {
382 assert_eq!(pair, "btc_idr");
383 assert_eq!(idr, 100_000.0);
384 assert!(price.is_none());
385 }
386 _ => assert!(false, "Expected Buy command, got {:?}", cmd),
387 }
388 }
389
390 #[test]
391 fn test_trade_command_sell_market_order() {
392 let cmd = TradeCommand::Sell {
393 pair: "eth_idr".into(),
394 price: None,
395 amount: 1.0,
396 };
397 match cmd {
398 TradeCommand::Sell { price, .. } => {
399 assert!(price.is_none());
400 }
401 _ => assert!(false, "Expected Sell command, got {:?}", cmd),
402 }
403 }
404
405 #[test]
406 fn test_trade_command_sell_limit_order() {
407 let cmd = TradeCommand::Sell {
408 pair: "btc_idr".into(),
409 price: Some(100_000_000.0),
410 amount: 0.5,
411 };
412 match cmd {
413 TradeCommand::Sell { price, .. } => {
414 assert_eq!(price, Some(100_000_000.0));
415 }
416 _ => assert!(false, "Expected Sell command, got {:?}", cmd),
417 }
418 }
419
420 #[test]
421 fn test_trade_command_cancel_all_no_pair() {
422 let cmd = TradeCommand::CountdownCancelAll {
423 pair: None,
424 countdown_time: 0
425 };
426 match cmd {
427 TradeCommand::CountdownCancelAll { pair, countdown_time } => {
428 assert!(pair.is_none());
429 assert_eq!(countdown_time, 0);
430 }
431 _ => assert!(false, "Expected CountdownCancelAll command, got {:?}", cmd),
432 }
433 }
434
435 #[test]
436 fn test_trade_cancel_all_parse() {
437 let cmd = TradeCommand::CancelAll {
438 pair: Some("btc_idr".into()),
439 force: false,
440 };
441 match cmd {
442 TradeCommand::CancelAll { pair, force } => {
443 assert_eq!(pair, Some("btc_idr".into()));
444 assert!(!force);
445 }
446 _ => assert!(false, "Expected CancelAll command, got {:?}", cmd),
447 }
448 }
449
450 #[test]
451 fn test_trade_cancel_all_no_pair_filter() {
452 let cmd = TradeCommand::CancelAll {
453 pair: None,
454 force: false,
455 };
456 match cmd {
457 TradeCommand::CancelAll { pair, force } => {
458 assert!(pair.is_none());
459 assert!(!force);
460 }
461 _ => assert!(false, "Expected CancelAll command, got {:?}", cmd),
462 }
463 }
464}