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 data: Value = client.public_get(&format!("/api/trades/{}", pair)).await?;
200    let headers = vec!["TID".into(), "Date".into(), "Price".into(), "Amount".into(), "Type".into()];
201    let mut rows: Vec<Vec<String>> = Vec::new();
202    if let Value::Array(arr) = &data {
203        for trade in arr.iter().take(50) {
204            let ts = trade["date"].as_str()
205                .and_then(|s| s.parse::<u64>().ok())
206                .or_else(|| trade["date"].as_u64())
207                .unwrap_or(0);
208            rows.push(vec![
209                helpers::value_to_string(&trade["tid"]),
210                helpers::format_timestamp(ts, false),
211                helpers::value_to_string(&trade["price"]),
212                helpers::value_to_string(&trade["amount"]),
213                helpers::value_to_string(&trade["type"]),
214            ]);
215        }
216    }
217    Ok(CommandOutput::new(data, headers, rows))
218}
219
220async fn ohlc(
221    client: &IndodaxClient,
222    symbol: &str,
223    timeframe: &str,
224    from: Option<u64>,
225    to: Option<u64>,
226) -> Result<CommandOutput> {
227    if let Some(v) = from {
228        if v > 1_000_000_000_000 {
229            eprintln!("[MARKET] Warning: --from timestamp ({}) looks like milliseconds. OHLC API expects seconds.", v);
230        }
231    }
232    if let Some(v) = to {
233        if v > 1_000_000_000_000 {
234            eprintln!("[MARKET] Warning: --to timestamp ({}) looks like milliseconds. OHLC API expects seconds.", v);
235        }
236    }
237
238    let now_secs = crate::auth::Signer::now_millis() / 1000;
239    let from_val = from.map(|v| v.to_string()).unwrap_or_else(|| {
240        (now_secs - crate::commands::helpers::ONE_DAY_SECS).to_string()
241    });
242    let to_val = to.map(|v| v.to_string()).unwrap_or_else(|| {
243        now_secs.to_string()
244    });
245
246    let data: Value = client.public_get_v2(
247        "/tradingview/history_v2",
248        &[
249            ("symbol", symbol),
250            ("tf", timeframe),
251            ("from", &from_val),
252            ("to", &to_val),
253        ],
254    ).await?;
255
256    let headers = vec![
257        "Time".into(), "Open".into(), "High".into(), "Low".into(),
258        "Close".into(), "Volume".into(),
259    ];
260    let mut rows: Vec<Vec<String>> = Vec::new();
261
262    if let Value::Object(ref map) = data {
263        let times = map.get("t").and_then(|v| v.as_array());
264        let opens = map.get("o").and_then(|v| v.as_array());
265        let highs = map.get("h").and_then(|v| v.as_array());
266        let lows = map.get("l").and_then(|v| v.as_array());
267        let closes = map.get("c").and_then(|v| v.as_array());
268        let volumes = map.get("v").and_then(|v| v.as_array());
269
270        if let (Some(t), Some(o), Some(h), Some(l), Some(c), Some(vol)) =
271            (times, opens, highs, lows, closes, volumes)
272        {
273            let len = t.len().min(o.len()).min(h.len()).min(l.len()).min(c.len()).min(vol.len());
274            for i in 0..len {
275                rows.push(vec![
276                    helpers::format_timestamp(t[i].as_u64().unwrap_or(0), false),
277                    helpers::value_to_string(&o[i]),
278                    helpers::value_to_string(&h[i]),
279                    helpers::value_to_string(&l[i]),
280                    helpers::value_to_string(&c[i]),
281                    helpers::value_to_string(&vol[i]),
282                ]);
283            }
284        }
285    }
286
287    Ok(CommandOutput::new(data, headers, rows))
288}
289
290async fn price_increments(client: &IndodaxClient) -> Result<CommandOutput> {
291    let data: Value = client.public_get("/api/price_increments").await?;
292    if data.is_object() {
293        let headers = vec!["Pair".into(), "Increment".into()];
294        let mut rows: Vec<Vec<String>> = Vec::new();
295        if let Value::Object(map) = &data["increments"] {
296            for (key, val) in map {
297                rows.push(vec![key.clone(), helpers::value_to_string(val)]);
298            }
299        }
300        rows.sort_by(|a, b| a[0].cmp(&b[0]));
301        Ok(CommandOutput::new(data, headers, rows))
302    } else {
303        let (headers, rows) = helpers::flatten_json_to_table(&data);
304        Ok(CommandOutput::new(data, headers, rows))
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312
313    #[test]
314    fn test_market_command_variants() {
315        let _cmd1 = MarketCommand::ServerTime;
316        let _cmd2 = MarketCommand::Pairs;
317        let _cmd3 = MarketCommand::Ticker { pair: "btc_idr".into() };
318        let _cmd4 = MarketCommand::TickerAll;
319        let _cmd5 = MarketCommand::Summaries;
320        let _cmd6 = MarketCommand::Orderbook { pair: "btcidr".into() };
321        let _cmd7 = MarketCommand::Trades { pair: "btcidr".into() };
322        let _cmd8 = MarketCommand::Ohlc { 
323            symbol: "BTCIDR".into(), 
324            timeframe: "60".into(), 
325            from: None, 
326            to: None 
327        };
328        let _cmd9 = MarketCommand::PriceIncrements;
329    }
330
331    #[test]
332    fn test_first_of_with_json_null() {
333        let val = json!(null);
334        let result = helpers::first_of(&val, &["key"]);
335        assert_eq!(result, &serde_json::Value::Null);
336    }
337
338    #[test]
339    fn test_first_of_empty_keys() {
340        let val = json!({"a": 1});
341        let result = helpers::first_of(&val, &[]);
342        assert_eq!(result, &serde_json::Value::Null);
343    }
344}