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    let now_secs = crate::auth::Signer::now_millis() / 1000;
228    let from_val = from.map(|v| v.to_string()).unwrap_or_else(|| {
229        (now_secs - crate::commands::helpers::ONE_DAY_SECS).to_string()
230    });
231    let to_val = to.map(|v| v.to_string()).unwrap_or_else(|| {
232        now_secs.to_string()
233    });
234
235    let data: Value = client.public_get_v2(
236        "/tradingview/history_v2",
237        &[
238            ("symbol", symbol),
239            ("tf", timeframe),
240            ("from", &from_val),
241            ("to", &to_val),
242        ],
243    ).await?;
244
245    let headers = vec![
246        "Time".into(), "Open".into(), "High".into(), "Low".into(),
247        "Close".into(), "Volume".into(),
248    ];
249    let mut rows: Vec<Vec<String>> = Vec::new();
250
251    if let Value::Object(ref map) = data {
252        let times = map.get("t").and_then(|v| v.as_array());
253        let opens = map.get("o").and_then(|v| v.as_array());
254        let highs = map.get("h").and_then(|v| v.as_array());
255        let lows = map.get("l").and_then(|v| v.as_array());
256        let closes = map.get("c").and_then(|v| v.as_array());
257        let volumes = map.get("v").and_then(|v| v.as_array());
258
259        if let (Some(t), Some(o), Some(h), Some(l), Some(c), Some(vol)) =
260            (times, opens, highs, lows, closes, volumes)
261        {
262            let len = t.len().min(o.len()).min(h.len()).min(l.len()).min(c.len()).min(vol.len());
263            for i in 0..len {
264                rows.push(vec![
265                    helpers::format_timestamp(t[i].as_u64().unwrap_or(0), false),
266                    helpers::value_to_string(&o[i]),
267                    helpers::value_to_string(&h[i]),
268                    helpers::value_to_string(&l[i]),
269                    helpers::value_to_string(&c[i]),
270                    helpers::value_to_string(&vol[i]),
271                ]);
272            }
273        }
274    }
275
276    Ok(CommandOutput::new(data, headers, rows))
277}
278
279async fn price_increments(client: &IndodaxClient) -> Result<CommandOutput> {
280    let data: Value = client.public_get("/api/price_increments").await?;
281    if data.is_object() {
282        let headers = vec!["Pair".into(), "Increment".into()];
283        let mut rows: Vec<Vec<String>> = Vec::new();
284        if let Value::Object(map) = &data["increments"] {
285            for (key, val) in map {
286                rows.push(vec![key.clone(), helpers::value_to_string(val)]);
287            }
288        }
289        rows.sort_by(|a, b| a[0].cmp(&b[0]));
290        Ok(CommandOutput::new(data, headers, rows))
291    } else {
292        let (headers, rows) = helpers::flatten_json_to_table(&data);
293        Ok(CommandOutput::new(data, headers, rows))
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use serde_json::json;
301
302    #[test]
303    fn test_market_command_variants() {
304        let _cmd1 = MarketCommand::ServerTime;
305        let _cmd2 = MarketCommand::Pairs;
306        let _cmd3 = MarketCommand::Ticker { pair: "btc_idr".into() };
307        let _cmd4 = MarketCommand::TickerAll;
308        let _cmd5 = MarketCommand::Summaries;
309        let _cmd6 = MarketCommand::Orderbook { pair: "btcidr".into() };
310        let _cmd7 = MarketCommand::Trades { pair: "btcidr".into() };
311        let _cmd8 = MarketCommand::Ohlc { 
312            symbol: "BTCIDR".into(), 
313            timeframe: "60".into(), 
314            from: None, 
315            to: None 
316        };
317        let _cmd9 = MarketCommand::PriceIncrements;
318    }
319
320    #[test]
321    fn test_first_of_with_json_null() {
322        let val = json!(null);
323        let result = helpers::first_of(&val, &["key"]);
324        assert_eq!(result, &serde_json::Value::Null);
325    }
326
327    #[test]
328    fn test_first_of_empty_keys() {
329        let val = json!({"a": 1});
330        let result = helpers::first_of(&val, &[]);
331        assert_eq!(result, &serde_json::Value::Null);
332    }
333}