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