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