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 {
277 if std::io::stdin().is_terminal() {
278 use dialoguer::Confirm;
279 let confirmed = Confirm::new()
280 .with_prompt("No --pair filter specified. This will cancel ALL orders across ALL pairs. Continue?")
281 .default(false)
282 .interact()
283 .unwrap_or(false);
284 if !confirmed {
285 return Ok(CommandOutput::json(serde_json::json!({
286 "cancelled": false,
287 "reason": "user_cancelled",
288 })).with_addendum("Cancel all orders aborted by user."));
289 }
290 } else {
291 return Err(anyhow::anyhow!("No --pair filter specified and --force not used in non-interactive mode. Refusing to cancel all orders across all pairs."));
292 }
293 }
294
295 let (cancelled_ids, failed_ids) = helpers::cancel_all_open_orders(client, pair)
296 .await
297 .map_err(|e| anyhow::anyhow!("{}", e))?;
298
299 let headers = vec!["Metric".into(), "Value".into()];
300 let mut rows = vec![
301 vec!["Cancelled".into(), cancelled_ids.len().to_string()],
302 vec!["Order IDs".into(), cancelled_ids.join(", ")],
303 ];
304 if !failed_ids.is_empty() {
305 rows.push(vec!["Failed".into(), failed_ids.len().to_string()]);
306 rows.push(vec!["Failed IDs".into(), failed_ids.join(", ")]);
307 }
308
309 let data = serde_json::json!({
310 "cancelled_count": cancelled_ids.len(),
311 "cancelled_ids": cancelled_ids,
312 "failed_count": failed_ids.len(),
313 "failed_ids": failed_ids,
314 });
315
316 let addendum = if failed_ids.is_empty() {
317 format!("Cancelled {} order(s)", cancelled_ids.len())
318 } else {
319 format!("Cancelled {} order(s), {} failed", cancelled_ids.len(), failed_ids.len())
320 };
321
322 Ok(CommandOutput::new(data, headers, rows)
323 .with_addendum(addendum))
324}
325
326async fn countdown_cancel_all(
327 client: &IndodaxClient,
328 pair: Option<&str>,
329 countdown_time: u64,
330) -> Result<CommandOutput> {
331 let data = client.countdown_cancel_all(pair, countdown_time).await
332 .map_err(|e| anyhow::anyhow!("{}", e))?;
333
334 let msg = if countdown_time == 0 {
335 "Deadman switch disabled".into()
336 } else {
337 format!("Deadman switch active: {}ms countdown", countdown_time)
338 };
339
340 Ok(CommandOutput::json(data).with_addendum(msg))
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_trade_command_variants() {
349 let _cmd1 = TradeCommand::Buy {
350 pair: "btc_idr".into(),
351 idr: 100_000.0,
352 price: Some(100_000_000.0)
353 };
354 let _cmd2 = TradeCommand::Sell {
355 pair: "btc_idr".into(),
356 price: Some(100_000_000.0),
357 amount: 0.5,
358 };
359 let _cmd3 = TradeCommand::Cancel {
360 order_id: 123,
361 pair: "btc_idr".into(),
362 order_type: "buy".into()
363 };
364 let _cmd4 = TradeCommand::CancelByClientId {
365 client_order_id: "client_123".into()
366 };
367 let _cmd5 = TradeCommand::CancelAll {
368 pair: Some("btc_idr".into()),
369 force: false,
370 };
371 let _cmd6 = TradeCommand::CountdownCancelAll {
372 pair: Some("btc_idr".into()),
373 countdown_time: 60000
374 };
375 }
376
377 #[test]
378 fn test_trade_command_buy_market_order() {
379 let cmd = TradeCommand::Buy {
380 pair: "btc_idr".into(),
381 idr: 100_000.0,
382 price: None
383 };
384 match cmd {
385 TradeCommand::Buy { pair, idr, price } => {
386 assert_eq!(pair, "btc_idr");
387 assert_eq!(idr, 100_000.0);
388 assert!(price.is_none());
389 }
390 _ => assert!(false, "Expected Buy command, got {:?}", cmd),
391 }
392 }
393
394 #[test]
395 fn test_trade_command_sell_market_order() {
396 let cmd = TradeCommand::Sell {
397 pair: "eth_idr".into(),
398 price: None,
399 amount: 1.0,
400 };
401 match cmd {
402 TradeCommand::Sell { price, .. } => {
403 assert!(price.is_none());
404 }
405 _ => assert!(false, "Expected Sell command, got {:?}", cmd),
406 }
407 }
408
409 #[test]
410 fn test_trade_command_sell_limit_order() {
411 let cmd = TradeCommand::Sell {
412 pair: "btc_idr".into(),
413 price: Some(100_000_000.0),
414 amount: 0.5,
415 };
416 match cmd {
417 TradeCommand::Sell { price, .. } => {
418 assert_eq!(price, Some(100_000_000.0));
419 }
420 _ => assert!(false, "Expected Sell command, got {:?}", cmd),
421 }
422 }
423
424 #[test]
425 fn test_trade_command_cancel_all_no_pair() {
426 let cmd = TradeCommand::CountdownCancelAll {
427 pair: None,
428 countdown_time: 0
429 };
430 match cmd {
431 TradeCommand::CountdownCancelAll { pair, countdown_time } => {
432 assert!(pair.is_none());
433 assert_eq!(countdown_time, 0);
434 }
435 _ => assert!(false, "Expected CountdownCancelAll command, got {:?}", cmd),
436 }
437 }
438
439 #[test]
440 fn test_trade_cancel_all_parse() {
441 let cmd = TradeCommand::CancelAll {
442 pair: Some("btc_idr".into()),
443 force: false,
444 };
445 match cmd {
446 TradeCommand::CancelAll { pair, force } => {
447 assert_eq!(pair, Some("btc_idr".into()));
448 assert!(!force);
449 }
450 _ => assert!(false, "Expected CancelAll command, got {:?}", cmd),
451 }
452 }
453
454 #[test]
455 fn test_trade_cancel_all_no_pair_filter() {
456 let cmd = TradeCommand::CancelAll {
457 pair: None,
458 force: false,
459 };
460 match cmd {
461 TradeCommand::CancelAll { pair, force } => {
462 assert!(pair.is_none());
463 assert!(!force);
464 }
465 _ => assert!(false, "Expected CancelAll command, got {:?}", cmd),
466 }
467 }
468}