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    #[command(name = "webdata", about = "Get market webdata for a pair")]
57    WebData {
58        #[arg(default_value = "btc_idr")]
59        pair: String,
60    },
61
62    #[command(name = "chatroom-history", about = "Get chatroom history")]
63    ChatHistory,
64
65    #[command(name = "pairs-v2", about = "Get detailed pairs info (V2)")]
66    PairsV2 {
67        #[arg(short, long)]
68        pair: Option<String>,
69    },
70
71    #[command(name = "search-v2", about = "Search markets (TradingView Search V2)")]
72    SearchV2,
73
74    #[command(name = "terminal-trade", about = "Get terminal trading data")]
75    TerminalTrade {
76        #[arg(default_value = "btc_idr")]
77        pair: String,
78    },
79
80    #[command(name = "terminal-market", about = "Get terminal market data")]
81    TerminalMarket {
82        #[arg(default_value = "btc_idr")]
83        pair: String,
84    },
85
86    #[command(name = "terminal-categories", about = "Get terminal market categories")]
87    TerminalCategories,
88
89    #[command(name = "onramp-config", about = "Get onramp config for a pair")]
90    OnrampConfig {
91        #[arg(default_value = "usdt_idr")]
92        pair: String,
93    },
94
95    #[command(name = "news", about = "Get news for an asset")]
96    News {
97        #[arg(default_value = "btc")]
98        asset: String,
99        #[arg(short, long, default_value = "1")]
100        page: u32,
101    },
102}
103
104pub async fn execute(
105    client: &IndodaxClient,
106    cmd: &MarketCommand,
107) -> Result<CommandOutput> {
108    match cmd {
109        MarketCommand::ServerTime => server_time(client).await,
110        MarketCommand::Pairs => pairs(client).await,
111        MarketCommand::Ticker { pair: p } => {
112            let pair = helpers::normalize_pair(p);
113            ticker(client, &pair).await
114        }
115        MarketCommand::TickerAll => ticker_all(client).await,
116        MarketCommand::Summaries => summaries(client).await,
117        MarketCommand::Orderbook { pair: p, levels } => {
118            let pair = helpers::normalize_pair(p);
119            orderbook(client, &pair, *levels).await
120        }
121        MarketCommand::Trades { pair: p } => {
122            let pair = helpers::normalize_pair(p);
123            trades(client, &pair).await
124        }
125        MarketCommand::Ohlc { symbol, timeframe, from, to } => {
126            // Indodax history API requires symbols like BTCIDR (no underscore, uppercase)
127            let sym = helpers::normalize_pair_v2(symbol).to_uppercase();
128            ohlc(client, &sym, timeframe, *from, *to).await
129        }
130        MarketCommand::PriceIncrements => price_increments(client).await,
131        MarketCommand::WebData { pair } => {
132            let sym = helpers::normalize_pair_v2(pair).to_uppercase();
133            webdata(client, &sym).await
134        }
135        MarketCommand::ChatHistory => chat_history(client).await,
136        MarketCommand::PairsV2 { pair } => pairs_v2(client, pair.as_deref()).await,
137        MarketCommand::SearchV2 => search_v2(client).await,
138        MarketCommand::TerminalTrade { pair } => {
139            let sym = helpers::normalize_pair_v2(pair).to_lowercase();
140            terminal_trade(client, &sym).await
141        }
142        MarketCommand::TerminalMarket { pair } => {
143            let sym = helpers::normalize_pair_v2(pair).to_lowercase();
144            terminal_market(client, &sym).await
145        }
146        MarketCommand::TerminalCategories => terminal_categories(client).await,
147        MarketCommand::OnrampConfig { pair } => {
148            let sym = helpers::normalize_pair_v2(pair).to_lowercase();
149            onramp_config(client, &sym).await
150        }
151        MarketCommand::News { asset, page } => news(client, asset, *page).await,
152    }
153}
154
155async fn server_time(client: &IndodaxClient) -> Result<CommandOutput> {
156    let data: Value = client.public_get("/api/server_time").await?;
157    let (headers, rows) = helpers::flatten_json_to_table(&data);
158    Ok(CommandOutput::new(data, headers, rows))
159}
160
161async fn pairs(client: &IndodaxClient) -> Result<CommandOutput> {
162    let data: Value = client.public_get("/api/pairs").await?;
163    let pairs_info = helpers::extract_pairs(&data);
164    let headers = vec!["Pair ID".into(), "Info".into()];
165    let rows: Vec<Vec<String>> = pairs_info
166        .into_iter()
167        .map(|(id, info)| vec![id, info])
168        .collect();
169    Ok(CommandOutput::new(data, headers, rows))
170}
171
172async fn ticker(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
173    let data: Value = client.public_get(&format!("/api/ticker/{}", pair)).await?;
174    let ticker = &data["ticker"];
175    if ticker.is_object() {
176        let (headers, rows) = helpers::flatten_json_to_table(ticker);
177        Ok(CommandOutput::new(data, headers, rows))
178    } else {
179        let (headers, rows) = helpers::flatten_json_to_table(&data);
180        Ok(CommandOutput::new(data, headers, rows))
181    }
182}
183
184async fn ticker_all(client: &IndodaxClient) -> Result<CommandOutput> {
185    let data: Value = client.public_get("/api/ticker_all").await?;
186    let tickers = &data["tickers"];
187    if tickers.is_object() {
188        let headers = vec![
189            "Pair".into(), "Last".into(), "High".into(), "Low".into(),
190            "Buy".into(), "Sell".into(), "Vol (base)".into(), "Vol (quote)".into(),
191        ];
192        let mut rows: Vec<Vec<String>> = Vec::new();
193        if let Value::Object(map) = tickers {
194            for (key, val) in map {
195                rows.push(vec![
196                    key.clone(),
197                    helpers::value_to_string(&val["last"]),
198                    helpers::value_to_string(&val["high"]),
199                    helpers::value_to_string(&val["low"]),
200                    helpers::value_to_string(&val["buy"]),
201                    helpers::value_to_string(&val["sell"]),
202                    helpers::value_to_string(helpers::first_of(val, &["vol_btc", "vol_base"])),
203                    helpers::value_to_string(helpers::first_of(val, &["vol_idr", "vol_traded"])),
204                ]);
205            }
206        }
207        rows.sort_by(|a, b| a[0].cmp(&b[0]));
208        Ok(CommandOutput::new(data, headers, rows))
209    } else {
210        let (headers, rows) = helpers::flatten_json_to_table(&data);
211        Ok(CommandOutput::new(data, headers, rows))
212    }
213}
214
215async fn summaries(client: &IndodaxClient) -> Result<CommandOutput> {
216    let data: Value = client.public_get("/api/summaries").await?;
217    let summaries = &data["summaries"];
218    if summaries.is_object() {
219        let headers = vec![
220            "Pair".into(), "Last".into(), "High".into(), "Low".into(),
221            "Vol (base)".into(), "Vol (quote)".into(),
222        ];
223        let mut rows: Vec<Vec<String>> = Vec::new();
224        if let Value::Object(map) = summaries {
225            for (key, val) in map {
226                rows.push(vec![
227                    key.clone(),
228                    helpers::value_to_string(&val["last"]),
229                    helpers::value_to_string(&val["high"]),
230                    helpers::value_to_string(&val["low"]),
231                    helpers::value_to_string(helpers::first_of(val, &["vol_btc", "vol_base"])),
232                    helpers::value_to_string(helpers::first_of(val, &["vol_idr", "vol_traded"])),
233                ]);
234            }
235        }
236        rows.sort_by(|a, b| a[0].cmp(&b[0]));
237        Ok(CommandOutput::new(data, headers, rows))
238    } else {
239        let (headers, rows) = helpers::flatten_json_to_table(&data);
240        Ok(CommandOutput::new(data, headers, rows))
241    }
242}
243
244async fn orderbook(client: &IndodaxClient, pair: &str, levels: usize) -> Result<CommandOutput> {
245    let data: Value = client.public_get(&format!("/api/depth/{}", pair)).await?;
246    let headers = vec!["Side".into(), "Price".into(), "Amount".into()];
247    let mut rows: Vec<Vec<String>> = Vec::new();
248    let buys = &data["buy"];
249    let sells = &data["sell"];
250    if let Value::Array(arr) = buys {
251        for entry in arr.iter().take(levels) {
252            if let Some(row_arr) = entry.as_array().filter(|a| a.len() >= 2) {
253                rows.push(vec!["BUY".into(), helpers::value_to_string(&row_arr[0]), helpers::value_to_string(&row_arr[1])]);
254            }
255        }
256    }
257    if let Value::Array(arr) = sells {
258        for entry in arr.iter().rev().take(levels) {
259            if let Some(row_arr) = entry.as_array().filter(|a| a.len() >= 2) {
260                rows.push(vec!["SELL".into(), helpers::value_to_string(&row_arr[0]), helpers::value_to_string(&row_arr[1])]);
261            }
262        }
263    }
264    let level_count = rows.len() / 2;
265    Ok(CommandOutput::new(data, headers, rows)
266        .with_addendum(format!("Showing {} bid/ask levels", level_count)))
267}
268
269async fn trades(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
270    let pair_v2 = pair.replace('_', "");
271    let data: Value = client.public_get(&format!("/api/trades/{}", pair_v2)).await?;
272    let headers = vec!["TID".into(), "Date".into(), "Price".into(), "Amount".into(), "Type".into()];
273    let mut rows: Vec<Vec<String>> = Vec::new();
274    if let Value::Array(arr) = &data {
275        for trade in arr.iter().take(50) {
276            let ts = trade["date"].as_str()
277                .and_then(|s| s.parse::<u64>().ok())
278                .or_else(|| trade["date"].as_u64())
279                .unwrap_or(0);
280            let ts = if ts > 1_000_000_000_000 { ts / 1000 } else { ts };
281            rows.push(vec![
282                helpers::value_to_string(&trade["tid"]),
283                helpers::format_timestamp(ts, false),
284                helpers::value_to_string(&trade["price"]),
285                helpers::value_to_string(&trade["amount"]),
286                helpers::value_to_string(&trade["type"]),
287            ]);
288        }
289    }
290    Ok(CommandOutput::new(data, headers, rows))
291}
292
293async fn ohlc(
294    client: &IndodaxClient,
295    symbol: &str,
296    timeframe: &str,
297    from: Option<u64>,
298    to: Option<u64>,
299) -> Result<CommandOutput> {
300    let mut ohlc_warnings: Vec<String> = Vec::new();
301    fn normalize_ohlc_ts(ts: u64, label: &str, warnings: &mut Vec<String>) -> u64 {
302        let mut ts = ts;
303        if ts > 1_000_000_000_000 {
304            warnings.push(format!("[MARKET] Warning: {} timestamp ({}) looks like milliseconds. Converting to seconds.", label, ts));
305            ts /= 1000;
306        }
307        ts
308    }
309
310    let now_secs = crate::commands::helpers::now_millis() / 1000;
311    let from = from.map(|v| normalize_ohlc_ts(v, "--from", &mut ohlc_warnings));
312    let to = to.map(|v| normalize_ohlc_ts(v, "--to", &mut ohlc_warnings));
313    let from_val = from.map(|v| v.to_string()).unwrap_or_else(|| {
314        (now_secs - crate::commands::helpers::ONE_DAY_SECS).to_string()
315    });
316    let to_val = to.map(|v| v.to_string()).unwrap_or_else(|| {
317        now_secs.to_string()
318    });
319
320    let data: Value = client.public_get_v2(
321        "/tradingview/history_v2",
322        &[
323            ("symbol", symbol),
324            ("tf", timeframe),
325            ("from", &from_val),
326            ("to", &to_val),
327        ],
328    ).await?;
329
330    let headers = vec![
331        "Time".into(), "Open".into(), "High".into(), "Low".into(),
332        "Close".into(), "Volume".into(),
333    ];
334    let mut rows: Vec<Vec<String>> = Vec::new();
335
336    if let Value::Array(ref arr) = data {
337        // Handle array of objects format (modern)
338        for item in arr {
339            rows.push(vec![
340                helpers::format_timestamp(
341                    item.get("Time").or(item.get("t")).and_then(|v| v.as_u64()).unwrap_or(0),
342                    false
343                ),
344                helpers::value_to_string(item.get("Open").or(item.get("o")).unwrap_or(&Value::Null)),
345                helpers::value_to_string(item.get("High").or(item.get("h")).unwrap_or(&Value::Null)),
346                helpers::value_to_string(item.get("Low").or(item.get("l")).unwrap_or(&Value::Null)),
347                helpers::value_to_string(item.get("Close").or(item.get("c")).unwrap_or(&Value::Null)),
348                helpers::value_to_string(item.get("Volume").or(item.get("v")).unwrap_or(&Value::Null)),
349            ]);
350        }
351    } else if let Value::Object(ref map) = data {
352        // Handle parallel arrays format (legacy)
353        let times = map.get("t").and_then(|v| v.as_array());
354        let opens = map.get("o").and_then(|v| v.as_array());
355        let highs = map.get("h").and_then(|v| v.as_array());
356        let lows = map.get("l").and_then(|v| v.as_array());
357        let closes = map.get("c").and_then(|v| v.as_array());
358        let volumes = map.get("v").and_then(|v| v.as_array());
359
360        if let (Some(t), Some(o), Some(h), Some(l), Some(c), Some(vol)) =
361            (times, opens, highs, lows, closes, volumes)
362        {
363            let len = t.len().min(o.len()).min(h.len()).min(l.len()).min(c.len()).min(vol.len());
364            for i in 0..len {
365                rows.push(vec![
366                    helpers::format_timestamp(t[i].as_u64().unwrap_or(0), false),
367                    helpers::value_to_string(&o[i]),
368                    helpers::value_to_string(&h[i]),
369                    helpers::value_to_string(&l[i]),
370                    helpers::value_to_string(&c[i]),
371                    helpers::value_to_string(&vol[i]),
372                ]);
373            }
374        }
375    }
376
377    let mut output = CommandOutput::new(data, headers, rows);
378    for w in ohlc_warnings {
379        output = output.with_warning(w);
380    }
381    Ok(output)
382}
383
384async fn price_increments(client: &IndodaxClient) -> Result<CommandOutput> {
385    let data: Value = client.public_get("/api/price_increments").await?;
386    if data.is_object() {
387        let headers = vec!["Pair".into(), "Increment".into()];
388        let mut rows: Vec<Vec<String>> = Vec::new();
389        if let Value::Object(map) = &data["increments"] {
390            for (key, val) in map {
391                rows.push(vec![key.clone(), helpers::value_to_string(val)]);
392            }
393        }
394        rows.sort_by(|a, b| a[0].cmp(&b[0]));
395        Ok(CommandOutput::new(data, headers, rows))
396    } else {
397        let (headers, rows) = helpers::flatten_json_to_table(&data);
398        Ok(CommandOutput::new(data, headers, rows))
399    }
400}
401
402async fn webdata(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
403    let data = client.get_webdata(pair).await?;
404    let (headers, rows) = helpers::flatten_json_to_table(&data);
405    Ok(CommandOutput::new(data, headers, rows))
406}
407
408async fn chat_history(client: &IndodaxClient) -> Result<CommandOutput> {
409    let data = client.get_chatroom_history().await?;
410    let headers = vec!["User".into(), "Message".into(), "Time".into()];
411    let mut rows: Vec<Vec<String>> = Vec::new();
412    if let Some(arr) = data.as_array() {
413        for msg in arr {
414            rows.push(vec![
415                helpers::value_to_string(&msg["username"]),
416                helpers::value_to_string(&msg["message"]),
417                helpers::value_to_string(&msg["time"]),
418            ]);
419        }
420    }
421    Ok(CommandOutput::new(data, headers, rows))
422}
423
424async fn pairs_v2(client: &IndodaxClient, pair: Option<&str>) -> Result<CommandOutput> {
425    let data = client.get_pairs_v2(pair).await?;
426    let (headers, rows) = helpers::flatten_json_to_table(&data);
427    Ok(CommandOutput::new(data, headers, rows))
428}
429
430async fn search_v2(client: &IndodaxClient) -> Result<CommandOutput> {
431    let data = client.get_tv_search().await?;
432    let (headers, rows) = helpers::flatten_json_to_table(&data);
433    Ok(CommandOutput::new(data, headers, rows))
434}
435
436async fn terminal_trade(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
437    let data = client.get_terminal_trade(pair).await?;
438    let (headers, rows) = helpers::flatten_json_to_table(&data);
439    Ok(CommandOutput::new(data, headers, rows))
440}
441
442async fn terminal_market(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
443    let data = client.get_terminal_market_data(pair).await?;
444    let (headers, rows) = helpers::flatten_json_to_table(&data);
445    Ok(CommandOutput::new(data, headers, rows))
446}
447
448async fn terminal_categories(client: &IndodaxClient) -> Result<CommandOutput> {
449    let data = client.get_terminal_market_category().await?;
450    let (headers, rows) = helpers::flatten_json_to_table(&data);
451    Ok(CommandOutput::new(data, headers, rows))
452}
453
454async fn onramp_config(client: &IndodaxClient, pair: &str) -> Result<CommandOutput> {
455    let data = client.get_onramp_config(pair).await?;
456    let (headers, rows) = helpers::flatten_json_to_table(&data);
457    Ok(CommandOutput::new(data, headers, rows))
458}
459
460async fn news(client: &IndodaxClient, asset: &str, page: u32) -> Result<CommandOutput> {
461    let html = client.get_news(asset, page).await?;
462    let data = serde_json::json!({ "html_summary": html.chars().take(200).collect::<String>() + "..." });
463    let headers = vec!["News Content".into()];
464    let rows = vec![vec![html]];
465    Ok(CommandOutput::new(data, headers, rows))
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use serde_json::json;
472
473    #[test]
474    fn test_market_command_variants() {
475        let _cmd1 = MarketCommand::ServerTime;
476        let _cmd2 = MarketCommand::Pairs;
477        let _cmd3 = MarketCommand::Ticker { pair: "btc_idr".into() };
478        let _cmd4 = MarketCommand::TickerAll;
479        let _cmd5 = MarketCommand::Summaries;
480        let _cmd6 = MarketCommand::Orderbook { pair: "btcidr".into(), levels: 20 };
481        let _cmd7 = MarketCommand::Trades { pair: "btcidr".into() };
482        let _cmd8 = MarketCommand::Ohlc { 
483            symbol: "BTCIDR".into(), 
484            timeframe: "60".into(), 
485            from: None, 
486            to: None 
487        };
488        let _cmd9 = MarketCommand::PriceIncrements;
489    }
490
491    #[test]
492    fn test_first_of_with_json_null() {
493        let val = json!(null);
494        let result = helpers::first_of(&val, &["key"]);
495        assert_eq!(result, &serde_json::Value::Null);
496    }
497
498    #[test]
499    fn test_first_of_empty_keys() {
500        let val = json!({"a": 1});
501        let result = helpers::first_of(&val, &[]);
502        assert_eq!(result, &serde_json::Value::Null);
503    }
504}