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 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 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 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}