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