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 pair_v2 = pair.replace('_', "");
200 let data: Value = client.public_get(&format!("/api/trades/{}", pair_v2)).await?;
201 let headers = vec!["TID".into(), "Date".into(), "Price".into(), "Amount".into(), "Type".into()];
202 let mut rows: Vec<Vec<String>> = Vec::new();
203 if let Value::Array(arr) = &data {
204 for trade in arr.iter().take(50) {
205 let ts = trade["date"].as_str()
206 .and_then(|s| s.parse::<u64>().ok())
207 .or_else(|| trade["date"].as_u64())
208 .unwrap_or(0);
209 rows.push(vec![
210 helpers::value_to_string(&trade["tid"]),
211 helpers::format_timestamp(ts, false),
212 helpers::value_to_string(&trade["price"]),
213 helpers::value_to_string(&trade["amount"]),
214 helpers::value_to_string(&trade["type"]),
215 ]);
216 }
217 }
218 Ok(CommandOutput::new(data, headers, rows))
219}
220
221async fn ohlc(
222 client: &IndodaxClient,
223 symbol: &str,
224 timeframe: &str,
225 from: Option<u64>,
226 to: Option<u64>,
227) -> Result<CommandOutput> {
228 if let Some(v) = from {
229 if v > 1_000_000_000_000 {
230 eprintln!("[MARKET] Warning: --from timestamp ({}) looks like milliseconds. OHLC API expects seconds.", v);
231 }
232 }
233 if let Some(v) = to {
234 if v > 1_000_000_000_000 {
235 eprintln!("[MARKET] Warning: --to timestamp ({}) looks like milliseconds. OHLC API expects seconds.", v);
236 }
237 }
238
239 let now_secs = crate::auth::Signer::now_millis() / 1000;
240 let from_val = from.map(|v| v.to_string()).unwrap_or_else(|| {
241 (now_secs - crate::commands::helpers::ONE_DAY_SECS).to_string()
242 });
243 let to_val = to.map(|v| v.to_string()).unwrap_or_else(|| {
244 now_secs.to_string()
245 });
246
247 let data: Value = client.public_get_v2(
248 "/tradingview/history_v2",
249 &[
250 ("symbol", symbol),
251 ("tf", timeframe),
252 ("from", &from_val),
253 ("to", &to_val),
254 ],
255 ).await?;
256
257 let headers = vec![
258 "Time".into(), "Open".into(), "High".into(), "Low".into(),
259 "Close".into(), "Volume".into(),
260 ];
261 let mut rows: Vec<Vec<String>> = Vec::new();
262
263 if let Value::Array(ref arr) = data {
264 for item in arr {
266 rows.push(vec![
267 helpers::format_timestamp(
268 item.get("Time").or(item.get("t")).and_then(|v| v.as_u64()).unwrap_or(0),
269 false
270 ),
271 helpers::value_to_string(item.get("Open").or(item.get("o")).unwrap_or(&Value::Null)),
272 helpers::value_to_string(item.get("High").or(item.get("h")).unwrap_or(&Value::Null)),
273 helpers::value_to_string(item.get("Low").or(item.get("l")).unwrap_or(&Value::Null)),
274 helpers::value_to_string(item.get("Close").or(item.get("c")).unwrap_or(&Value::Null)),
275 helpers::value_to_string(item.get("Volume").or(item.get("v")).unwrap_or(&Value::Null)),
276 ]);
277 }
278 } else if let Value::Object(ref map) = data {
279 let times = map.get("t").and_then(|v| v.as_array());
281 let opens = map.get("o").and_then(|v| v.as_array());
282 let highs = map.get("h").and_then(|v| v.as_array());
283 let lows = map.get("l").and_then(|v| v.as_array());
284 let closes = map.get("c").and_then(|v| v.as_array());
285 let volumes = map.get("v").and_then(|v| v.as_array());
286
287 if let (Some(t), Some(o), Some(h), Some(l), Some(c), Some(vol)) =
288 (times, opens, highs, lows, closes, volumes)
289 {
290 let len = t.len().min(o.len()).min(h.len()).min(l.len()).min(c.len()).min(vol.len());
291 for i in 0..len {
292 rows.push(vec![
293 helpers::format_timestamp(t[i].as_u64().unwrap_or(0), false),
294 helpers::value_to_string(&o[i]),
295 helpers::value_to_string(&h[i]),
296 helpers::value_to_string(&l[i]),
297 helpers::value_to_string(&c[i]),
298 helpers::value_to_string(&vol[i]),
299 ]);
300 }
301 }
302 }
303
304 Ok(CommandOutput::new(data, headers, rows))
305}
306
307async fn price_increments(client: &IndodaxClient) -> Result<CommandOutput> {
308 let data: Value = client.public_get("/api/price_increments").await?;
309 if data.is_object() {
310 let headers = vec!["Pair".into(), "Increment".into()];
311 let mut rows: Vec<Vec<String>> = Vec::new();
312 if let Value::Object(map) = &data["increments"] {
313 for (key, val) in map {
314 rows.push(vec![key.clone(), helpers::value_to_string(val)]);
315 }
316 }
317 rows.sort_by(|a, b| a[0].cmp(&b[0]));
318 Ok(CommandOutput::new(data, headers, rows))
319 } else {
320 let (headers, rows) = helpers::flatten_json_to_table(&data);
321 Ok(CommandOutput::new(data, headers, rows))
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use serde_json::json;
329
330 #[test]
331 fn test_market_command_variants() {
332 let _cmd1 = MarketCommand::ServerTime;
333 let _cmd2 = MarketCommand::Pairs;
334 let _cmd3 = MarketCommand::Ticker { pair: "btc_idr".into() };
335 let _cmd4 = MarketCommand::TickerAll;
336 let _cmd5 = MarketCommand::Summaries;
337 let _cmd6 = MarketCommand::Orderbook { pair: "btcidr".into() };
338 let _cmd7 = MarketCommand::Trades { pair: "btcidr".into() };
339 let _cmd8 = MarketCommand::Ohlc {
340 symbol: "BTCIDR".into(),
341 timeframe: "60".into(),
342 from: None,
343 to: None
344 };
345 let _cmd9 = MarketCommand::PriceIncrements;
346 }
347
348 #[test]
349 fn test_first_of_with_json_null() {
350 let val = json!(null);
351 let result = helpers::first_of(&val, &["key"]);
352 assert_eq!(result, &serde_json::Value::Null);
353 }
354
355 #[test]
356 fn test_first_of_empty_keys() {
357 let val = json!({"a": 1});
358 let result = helpers::first_of(&val, &[]);
359 assert_eq!(result, &serde_json::Value::Null);
360 }
361}