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