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 && 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}