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