Skip to main content

indodax_cli/commands/
market.rs

1use crate::client::IndodaxClient;
2use crate::commands::helpers;
3use crate::output::CommandOutput;
4use anyhow::Result;
5use serde_json::Value;
6
7#[derive(Debug, clap::Subcommand)]
8pub enum MarketCommand {
9    #[command(name = "server-time", about = "Get server time")]
10    ServerTime,
11
12    #[command(name = "pairs", about = "List available trading pairs")]
13    Pairs,
14
15    #[command(name = "ticker", about = "Get ticker for a pair")]
16    Ticker {
17        #[arg(default_value = "btc_idr")]
18        pair: String,
19    },
20
21    #[command(name = "ticker-all", about = "Get tickers for all pairs")]
22    TickerAll,
23
24    #[command(name = "summaries", about = "Get 24h and 7d summaries for all pairs")]
25    Summaries,
26
27    #[command(name = "orderbook", about = "Get order book for a pair")]
28    Orderbook {
29        #[arg(default_value = "btc_idr")]
30        pair: String,
31        #[arg(long, default_value = "20", help = "Number of bid/ask levels to show")]
32        levels: usize,
33    },
34
35    #[command(name = "trades", about = "Get recent trades for a pair")]
36    Trades {
37        #[arg(default_value = "btc_idr")]
38        pair: String,
39    },
40
41    #[command(name = "ohlc", about = "Get OHLCV candle data (default --from is 24h ago)")]
42    Ohlc {
43        #[arg(short, long, default_value = "btc_idr")]
44        symbol: String,
45        #[arg(long, default_value = "60")]
46        timeframe: String,
47        #[arg(short, long, help = "Start timestamp in seconds (default: 24h ago)")]
48        from: Option<u64>,
49        #[arg(long, help = "End timestamp in seconds (default: now)")]
50        to: Option<u64>,
51    },
52
53    #[command(name = "price-increments", about = "Get price increments (tick sizes)")]
54    PriceIncrements,
55}
56
57pub async fn execute(
58    client: &IndodaxClient,
59    cmd: &MarketCommand,
60) -> Result<CommandOutput> {
61    match cmd {
62        MarketCommand::ServerTime => server_time(client).await,
63        MarketCommand::Pairs => pairs(client).await,
64        MarketCommand::Ticker { pair: p } => {
65            let pair = helpers::normalize_pair(p);
66            ticker(client, &pair).await
67        }
68        MarketCommand::TickerAll => ticker_all(client).await,
69        MarketCommand::Summaries => summaries(client).await,
70        MarketCommand::Orderbook { pair: p, levels } => {
71            let pair = helpers::normalize_pair(p);
72            orderbook(client, &pair, *levels).await
73        }
74        MarketCommand::Trades { pair: p } => {
75            let pair = helpers::normalize_pair(p);
76            trades(client, &pair).await
77        }
78        MarketCommand::Ohlc { symbol, timeframe, from, to } => {
79            // Indodax history API requires symbols like BTCIDR (no underscore, uppercase)
80            let sym = helpers::normalize_pair_v2(symbol).to_uppercase();
81            ohlc(client, &sym, timeframe, *from, *to).await
82        }
83        MarketCommand::PriceIncrements => price_increments(client).await,
84    }
85}
86
87async fn server_time(client: &IndodaxClient) -> Result<CommandOutput> {
88    let data: Value = client.public_get("/api/server_time").await?;
89    let (headers, rows) = helpers::flatten_json_to_table(&data);
90    Ok(CommandOutput::new(data, headers, rows))
91}
92
93async fn pairs(client: &IndodaxClient) -> Result<CommandOutput> {
94    let data: Value = client.public_get("/api/pairs").await?;
95    let pairs_info = helpers::extract_pairs(&data);
96    let headers = vec!["Pair ID".into(), "Info".into()];
97    let rows: Vec<Vec<String>> = pairs_info
98        .into_iter()
99        .map(|(id, info)| vec![id, info])
100        .collect();
101    Ok(CommandOutput::new(data, headers, rows))
102}
103
104async fn ticker(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
105    let data: Value = client.public_get(&format!("/api/ticker/{}", pair)).await?;
106    let ticker = &data["ticker"];
107    if ticker.is_object() {
108        let (headers, rows) = helpers::flatten_json_to_table(ticker);
109        Ok(CommandOutput::new(data, headers, rows))
110    } else {
111        let (headers, rows) = helpers::flatten_json_to_table(&data);
112        Ok(CommandOutput::new(data, headers, rows))
113    }
114}
115
116async fn ticker_all(client: &IndodaxClient) -> Result<CommandOutput> {
117    let data: Value = client.public_get("/api/ticker_all").await?;
118    let tickers = &data["tickers"];
119    if tickers.is_object() {
120        let headers = vec![
121            "Pair".into(), "Last".into(), "High".into(), "Low".into(),
122            "Buy".into(), "Sell".into(), "Vol (base)".into(), "Vol (quote)".into(),
123        ];
124        let mut rows: Vec<Vec<String>> = Vec::new();
125        if let Value::Object(map) = tickers {
126            for (key, val) in map {
127                rows.push(vec![
128                    key.clone(),
129                    helpers::value_to_string(&val["last"]),
130                    helpers::value_to_string(&val["high"]),
131                    helpers::value_to_string(&val["low"]),
132                    helpers::value_to_string(&val["buy"]),
133                    helpers::value_to_string(&val["sell"]),
134                    helpers::value_to_string(helpers::first_of(val, &["vol_btc", "vol_base"])),
135                    helpers::value_to_string(helpers::first_of(val, &["vol_idr", "vol_traded"])),
136                ]);
137            }
138        }
139        rows.sort_by(|a, b| a[0].cmp(&b[0]));
140        Ok(CommandOutput::new(data, headers, rows))
141    } else {
142        let (headers, rows) = helpers::flatten_json_to_table(&data);
143        Ok(CommandOutput::new(data, headers, rows))
144    }
145}
146
147async fn summaries(client: &IndodaxClient) -> Result<CommandOutput> {
148    let data: Value = client.public_get("/api/summaries").await?;
149    let summaries = &data["summaries"];
150    if summaries.is_object() {
151        let headers = vec![
152            "Pair".into(), "Last".into(), "High".into(), "Low".into(),
153            "Vol (base)".into(), "Vol (quote)".into(),
154        ];
155        let mut rows: Vec<Vec<String>> = Vec::new();
156        if let Value::Object(map) = summaries {
157            for (key, val) in map {
158                rows.push(vec![
159                    key.clone(),
160                    helpers::value_to_string(&val["last"]),
161                    helpers::value_to_string(&val["high"]),
162                    helpers::value_to_string(&val["low"]),
163                    helpers::value_to_string(helpers::first_of(val, &["vol_btc", "vol_base"])),
164                    helpers::value_to_string(helpers::first_of(val, &["vol_idr", "vol_traded"])),
165                ]);
166            }
167        }
168        rows.sort_by(|a, b| a[0].cmp(&b[0]));
169        Ok(CommandOutput::new(data, headers, rows))
170    } else {
171        let (headers, rows) = helpers::flatten_json_to_table(&data);
172        Ok(CommandOutput::new(data, headers, rows))
173    }
174}
175
176async fn orderbook(client: &IndodaxClient, pair: &str, levels: usize) -> Result<CommandOutput> {
177    let data: Value = client.public_get(&format!("/api/depth/{}", pair)).await?;
178    let headers = vec!["Side".into(), "Price".into(), "Amount".into()];
179    let mut rows: Vec<Vec<String>> = Vec::new();
180    let buys = &data["buy"];
181    let sells = &data["sell"];
182    if let Value::Array(arr) = buys {
183        for entry in arr.iter().take(levels) {
184            if let Some(row_arr) = entry.as_array().filter(|a| a.len() >= 2) {
185                rows.push(vec!["BUY".into(), helpers::value_to_string(&row_arr[0]), helpers::value_to_string(&row_arr[1])]);
186            }
187        }
188    }
189    if let Value::Array(arr) = sells {
190        for entry in arr.iter().rev().take(levels) {
191            if let Some(row_arr) = entry.as_array().filter(|a| a.len() >= 2) {
192                rows.push(vec!["SELL".into(), helpers::value_to_string(&row_arr[0]), helpers::value_to_string(&row_arr[1])]);
193            }
194        }
195    }
196    let level_count = rows.len() / 2;
197    Ok(CommandOutput::new(data, headers, rows)
198        .with_addendum(format!("Showing {} bid/ask levels", level_count)))
199}
200
201async fn trades(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
202    let pair_v2 = pair.replace('_', "");
203    let data: Value = client.public_get(&format!("/api/trades/{}", pair_v2)).await?;
204    let headers = vec!["TID".into(), "Date".into(), "Price".into(), "Amount".into(), "Type".into()];
205    let mut rows: Vec<Vec<String>> = Vec::new();
206    if let Value::Array(arr) = &data {
207        for trade in arr.iter().take(50) {
208            let ts = trade["date"].as_str()
209                .and_then(|s| s.parse::<u64>().ok())
210                .or_else(|| trade["date"].as_u64())
211                .unwrap_or(0);
212            let ts = if ts > 1_000_000_000_000 { ts / 1000 } else { ts };
213            rows.push(vec![
214                helpers::value_to_string(&trade["tid"]),
215                helpers::format_timestamp(ts, false),
216                helpers::value_to_string(&trade["price"]),
217                helpers::value_to_string(&trade["amount"]),
218                helpers::value_to_string(&trade["type"]),
219            ]);
220        }
221    }
222    Ok(CommandOutput::new(data, headers, rows))
223}
224
225async fn ohlc(
226    client: &IndodaxClient,
227    symbol: &str,
228    timeframe: &str,
229    from: Option<u64>,
230    to: Option<u64>,
231) -> Result<CommandOutput> {
232    let mut ohlc_warnings: Vec<String> = Vec::new();
233    fn normalize_ohlc_ts(ts: u64, label: &str, warnings: &mut Vec<String>) -> u64 {
234        let mut ts = ts;
235        if ts > 1_000_000_000_000 {
236            warnings.push(format!("[MARKET] Warning: {} timestamp ({}) looks like milliseconds. Converting to seconds.", label, ts));
237            ts /= 1000;
238        }
239        ts
240    }
241
242    let now_secs = crate::commands::helpers::now_millis() / 1000;
243    let from = from.map(|v| normalize_ohlc_ts(v, "--from", &mut ohlc_warnings));
244    let to = to.map(|v| normalize_ohlc_ts(v, "--to", &mut ohlc_warnings));
245    let from_val = from.map(|v| v.to_string()).unwrap_or_else(|| {
246        (now_secs - crate::commands::helpers::ONE_DAY_SECS).to_string()
247    });
248    let to_val = to.map(|v| v.to_string()).unwrap_or_else(|| {
249        now_secs.to_string()
250    });
251
252    let data: Value = client.public_get_v2(
253        "/tradingview/history_v2",
254        &[
255            ("symbol", symbol),
256            ("tf", timeframe),
257            ("from", &from_val),
258            ("to", &to_val),
259        ],
260    ).await?;
261
262    let headers = vec![
263        "Time".into(), "Open".into(), "High".into(), "Low".into(),
264        "Close".into(), "Volume".into(),
265    ];
266    let mut rows: Vec<Vec<String>> = Vec::new();
267
268    if let Value::Array(ref arr) = data {
269        // Handle array of objects format (modern)
270        for item in arr {
271            rows.push(vec![
272                helpers::format_timestamp(
273                    item.get("Time").or(item.get("t")).and_then(|v| v.as_u64()).unwrap_or(0),
274                    false
275                ),
276                helpers::value_to_string(item.get("Open").or(item.get("o")).unwrap_or(&Value::Null)),
277                helpers::value_to_string(item.get("High").or(item.get("h")).unwrap_or(&Value::Null)),
278                helpers::value_to_string(item.get("Low").or(item.get("l")).unwrap_or(&Value::Null)),
279                helpers::value_to_string(item.get("Close").or(item.get("c")).unwrap_or(&Value::Null)),
280                helpers::value_to_string(item.get("Volume").or(item.get("v")).unwrap_or(&Value::Null)),
281            ]);
282        }
283    } else if let Value::Object(ref map) = data {
284        // Handle parallel arrays format (legacy)
285        let times = map.get("t").and_then(|v| v.as_array());
286        let opens = map.get("o").and_then(|v| v.as_array());
287        let highs = map.get("h").and_then(|v| v.as_array());
288        let lows = map.get("l").and_then(|v| v.as_array());
289        let closes = map.get("c").and_then(|v| v.as_array());
290        let volumes = map.get("v").and_then(|v| v.as_array());
291
292        if let (Some(t), Some(o), Some(h), Some(l), Some(c), Some(vol)) =
293            (times, opens, highs, lows, closes, volumes)
294        {
295            let len = t.len().min(o.len()).min(h.len()).min(l.len()).min(c.len()).min(vol.len());
296            for i in 0..len {
297                rows.push(vec![
298                    helpers::format_timestamp(t[i].as_u64().unwrap_or(0), false),
299                    helpers::value_to_string(&o[i]),
300                    helpers::value_to_string(&h[i]),
301                    helpers::value_to_string(&l[i]),
302                    helpers::value_to_string(&c[i]),
303                    helpers::value_to_string(&vol[i]),
304                ]);
305            }
306        }
307    }
308
309    let mut output = CommandOutput::new(data, headers, rows);
310    for w in ohlc_warnings {
311        output = output.with_warning(w);
312    }
313    Ok(output)
314}
315
316async fn price_increments(client: &IndodaxClient) -> Result<CommandOutput> {
317    let data: Value = client.public_get("/api/price_increments").await?;
318    if data.is_object() {
319        let headers = vec!["Pair".into(), "Increment".into()];
320        let mut rows: Vec<Vec<String>> = Vec::new();
321        if let Value::Object(map) = &data["increments"] {
322            for (key, val) in map {
323                rows.push(vec![key.clone(), helpers::value_to_string(val)]);
324            }
325        }
326        rows.sort_by(|a, b| a[0].cmp(&b[0]));
327        Ok(CommandOutput::new(data, headers, rows))
328    } else {
329        let (headers, rows) = helpers::flatten_json_to_table(&data);
330        Ok(CommandOutput::new(data, headers, rows))
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn test_market_command_variants() {
341        let _cmd1 = MarketCommand::ServerTime;
342        let _cmd2 = MarketCommand::Pairs;
343        let _cmd3 = MarketCommand::Ticker { pair: "btc_idr".into() };
344        let _cmd4 = MarketCommand::TickerAll;
345        let _cmd5 = MarketCommand::Summaries;
346        let _cmd6 = MarketCommand::Orderbook { pair: "btcidr".into(), levels: 20 };
347        let _cmd7 = MarketCommand::Trades { pair: "btcidr".into() };
348        let _cmd8 = MarketCommand::Ohlc { 
349            symbol: "BTCIDR".into(), 
350            timeframe: "60".into(), 
351            from: None, 
352            to: None 
353        };
354        let _cmd9 = MarketCommand::PriceIncrements;
355    }
356
357    #[test]
358    fn test_first_of_with_json_null() {
359        let val = json!(null);
360        let result = helpers::first_of(&val, &["key"]);
361        assert_eq!(result, &serde_json::Value::Null);
362    }
363
364    #[test]
365    fn test_first_of_empty_keys() {
366        let val = json!({"a": 1});
367        let result = helpers::first_of(&val, &[]);
368        assert_eq!(result, &serde_json::Value::Null);
369    }
370}