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