Skip to main content

indodax_cli/commands/
trade.rs

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", &params).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", &params).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", &params).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", &params).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", &params).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}