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