1use chrono::DateTime;
2use serde_json::Value;
3use crate::client::IndodaxClient;
4use crate::errors::IndodaxError;
5
6pub const PUBLIC_WS_TOKEN_URL: &str = "https://indodax.com/api/ws/v1/generate_token";
7
8pub const DEFAULT_STATIC_WS_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5NDY2MTg0MTV9.UR1lBM6Eqh0yWz-PVirw1uPCxe60FdchR8eNVdsskeo";
11
12pub async fn fetch_public_ws_token(client: &IndodaxClient) -> Result<String, anyhow::Error> {
14 let resp_res = client.http_client().get(PUBLIC_WS_TOKEN_URL).send().await;
16 if let Ok(resp) = resp_res {
17 if let Ok(text) = resp.text().await {
18 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
19 if let Some(token) = val.get("token").and_then(|t| t.as_str())
20 .or_else(|| val.get("data").and_then(|d| d.get("token")).and_then(|t| t.as_str())) {
21 return Ok(token.to_string());
22 }
23 }
24 }
25 }
26
27 if let Some(token) = client.ws_token() {
29 return Ok(token.to_string());
30 }
31
32 Ok(DEFAULT_STATIC_WS_TOKEN.to_string())
34}
35
36pub const ONE_DAY_MS: u64 = 24 * 60 * 60 * 1000;
37pub const ONE_DAY_SECS: u64 = 24 * 60 * 60;
38
39pub fn flatten_json_to_table(json: &serde_json::Value) -> (Vec<String>, Vec<Vec<String>>) {
40 match json {
41 serde_json::Value::Object(map) => {
42 let mut headers: Vec<String> = map.keys().cloned().collect();
43 headers.sort();
44 let row: Vec<String> = headers
45 .iter()
46 .map(|k| value_to_string(&map[k]))
47 .collect();
48 (headers, vec![row])
49 }
50 serde_json::Value::Array(arr) if !arr.is_empty() => {
51 let mut all_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
53 let mut is_obj = false;
54 for item in arr {
55 if let serde_json::Value::Object(map) = item {
56 is_obj = true;
57 for k in map.keys() {
58 all_keys.insert(k.clone());
59 }
60 }
61 }
62 if is_obj {
63 let headers: Vec<String> = all_keys.into_iter().collect();
64 let rows: Vec<Vec<String>> = arr
65 .iter()
66 .map(|item| {
67 headers
68 .iter()
69 .map(|k| value_to_string(&item[k]))
70 .collect()
71 })
72 .collect();
73 (headers, rows)
74 } else {
75 (vec!["Value".into()], arr.iter().map(|v| vec![value_to_string(v)]).collect())
76 }
77 }
78 _ => (vec!["Value".into()], vec![vec![value_to_string(json)]]),
79 }
80}
81
82pub fn value_to_string(v: &serde_json::Value) -> String {
83 match v {
84 serde_json::Value::Null => String::new(),
85 serde_json::Value::Bool(b) => b.to_string(),
86 serde_json::Value::Number(n) => n.to_string(),
87 serde_json::Value::String(s) => s.clone(),
88 serde_json::Value::Array(arr) => {
89 let items: Vec<String> = arr.iter().map(value_to_string).collect();
90 items.join(", ")
91 }
92 serde_json::Value::Object(_) => serde_json::to_string(v).unwrap_or_default(),
93 }
94}
95
96pub fn format_timestamp(ts: u64, millis: bool) -> String {
97 let ts_sec = if millis { ts / 1000 } else { ts };
98 if let Some(dt) = DateTime::from_timestamp(ts_sec.min(i64::MAX as u64) as i64, 0) {
99 dt.format("%Y-%m-%d %H:%M:%S").to_string()
100 } else {
101 ts.to_string()
102 }
103}
104
105pub fn normalize_pair(pair: &str) -> String {
106 let pair = pair.to_lowercase().replace('-', "_");
107 if pair.contains('_') || pair.is_empty() {
108 return pair;
109 }
110 let quote_currencies = ["usdt", "idr", "btc"];
111 for quote in "e_currencies {
112 if let Some(base) = pair.strip_suffix(quote) {
113 if !base.is_empty() {
114 return format!("{}_{}", base, quote);
115 }
116 }
117 }
118 pair
119}
120
121pub fn normalize_pair_v2(pair: &str) -> String {
122 normalize_pair(pair).replace('_', "")
123}
124
125pub fn first_of<'a>(val: &'a Value, keys: &[&str]) -> &'a Value {
126 for k in keys {
127 if let Some(v) = val.get(*k) {
128 match v {
129 Value::Null => continue,
130 Value::String(s) if s.is_empty() || s == "null" => continue,
131 _ => return v,
132 }
133 }
134 }
135 &Value::Null
136}
137
138pub fn parse_balance(info: &serde_json::Value, currency: &str) -> f64 {
140 info["balance"][currency]
141 .as_str()
142 .and_then(|s| s.parse::<f64>().ok())
143 .or_else(|| info["balance"][currency].as_f64())
144 .unwrap_or(0.0)
145}
146
147pub fn build_withdraw_params(
149 currency: &str,
150 amount: f64,
151 address: &str,
152 to_username: bool,
153 memo: Option<&str>,
154 network: Option<&str>,
155 callback_url: Option<&str>,
156) -> std::collections::HashMap<String, String> {
157 let mut params = std::collections::HashMap::new();
158 params.insert("currency".into(), currency.to_string());
159 params.insert("amount".into(), amount.to_string());
160
161 if to_username {
162 params.insert(
163 "request_id".into(),
164 chrono::Utc::now().timestamp_millis().to_string(),
165 );
166 params.insert("withdraw_to".into(), address.to_string());
167 } else {
168 params.insert("address".into(), address.to_string());
169 }
170
171 if let Some(m) = memo {
172 params.insert("memo".into(), m.to_string());
173 }
174 if let Some(n) = network {
175 params.insert("network".into(), n.to_string());
176 }
177 if let Some(u) = callback_url {
178 params.insert("callback_url".into(), u.to_string());
179 }
180 params
181}
182
183pub async fn cancel_all_open_orders(
186 client: &IndodaxClient,
187 pair: Option<&str>,
188) -> Result<(Vec<String>, Vec<String>), IndodaxError> {
189 use std::collections::HashMap;
190 let mut params = HashMap::new();
191 if let Some(p) = pair {
192 params.insert("pair".to_string(), p.to_string());
193 }
194 let data: serde_json::Value = client.private_post_v1("openOrders", ¶ms).await?;
195 let orders = &data["orders"];
196 let mut cancelled_ids: Vec<String> = Vec::new();
197 let mut failed_ids: Vec<String> = Vec::new();
198
199 if let serde_json::Value::Object(orders_map) = orders {
200 for (order_id, order_val) in orders_map {
201 let order_pair = value_to_string(
202 order_val
203 .get("pair")
204 .or_else(|| order_val.get("market"))
205 .or_else(|| order_val.get("symbol"))
206 .unwrap_or(&serde_json::Value::Null),
207 );
208 let order_type = order_val
209 .get("type")
210 .or_else(|| order_val.get("order_type"))
211 .and_then(|v| v.as_str())
212 .unwrap_or("")
213 .to_string();
214
215 let mut cancel_params = HashMap::new();
216 cancel_params.insert("order_id".to_string(), order_id.clone());
217 cancel_params.insert("pair".to_string(), order_pair);
218 cancel_params.insert("type".to_string(), order_type);
219 match client
220 .private_post_v1::<serde_json::Value>("cancelOrder", &cancel_params)
221 .await
222 {
223 Ok(_) => cancelled_ids.push(order_id.clone()),
224 Err(e) => failed_ids.push(format!("{} ({})", order_id, e)),
225 }
226 }
227 }
228
229 Ok((cancelled_ids, failed_ids))
230}
231
232pub fn extract_pairs(data: &serde_json::Value) -> Vec<(String, String)> {
233 let mut pairs: Vec<(String, String)> = Vec::new();
234 if let serde_json::Value::Object(map) = data {
235 for (key, value) in map {
236 if let Some(obj) = value.as_object() {
237 let base = obj.get("traded_currency")
238 .or_else(|| obj.get("tradedCurrency"))
239 .and_then(|v| v.as_str())
240 .unwrap_or("");
241 let quote = obj.get("base_currency")
242 .or_else(|| obj.get("baseCurrency"))
243 .and_then(|v| v.as_str())
244 .unwrap_or("");
245 let symbol = obj.get("symbol").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
246 pairs.push((key.clone(), format!("{}/{} ({})", base, quote, symbol)));
247 }
248 }
249 } else if let serde_json::Value::Array(arr) = data {
250 for item in arr {
251 if let Some(obj) = item.as_object() {
252 let id = obj.get("id").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
253 let base = obj.get("traded_currency")
254 .or_else(|| obj.get("tradedCurrency"))
255 .and_then(|v| v.as_str())
256 .unwrap_or("");
257 let quote = obj.get("base_currency")
258 .or_else(|| obj.get("baseCurrency"))
259 .and_then(|v| v.as_str())
260 .unwrap_or("");
261 let symbol = obj.get("symbol").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
262 if !id.is_empty() {
263 pairs.push((id.to_string(), format!("{}/{} ({})", base, quote, symbol)));
264 }
265 }
266 }
267 }
268 pairs.sort_by(|a, b| a.0.cmp(&b.0));
269 pairs
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use serde_json::json;
276
277 #[test]
278 fn test_normalize_pair_already_normalized() {
279 assert_eq!(normalize_pair("btc_idr"), "btc_idr");
280 assert_eq!(normalize_pair("eth_btc"), "eth_btc");
281 assert_eq!(normalize_pair("usdt_idr"), "usdt_idr");
282 }
283
284 #[test]
285 fn test_normalize_pair_no_underscore() {
286 assert_eq!(normalize_pair("btcidr"), "btc_idr");
287 assert_eq!(normalize_pair("ethidr"), "eth_idr");
288 assert_eq!(normalize_pair("ethbtc"), "eth_btc");
289 assert_eq!(normalize_pair("solusdt"), "sol_usdt");
290 }
291
292 #[test]
293 fn test_normalize_pair_uppercase() {
294 assert_eq!(normalize_pair("BTC_IDR"), "btc_idr");
295 assert_eq!(normalize_pair("BTCIDR"), "btc_idr");
296 assert_eq!(normalize_pair("ETH_BTC"), "eth_btc");
297 }
298
299 #[test]
300 fn test_normalize_pair_dash_separator() {
301 assert_eq!(normalize_pair("btc-idr"), "btc_idr");
302 assert_eq!(normalize_pair("ETH-IDR"), "eth_idr");
303 assert_eq!(normalize_pair("sol-usdt"), "sol_usdt");
304 }
305
306 #[test]
307 fn test_normalize_pair_v2() {
308 assert_eq!(normalize_pair_v2("btc_idr"), "btcidr");
309 assert_eq!(normalize_pair_v2("BTCIDR"), "btcidr");
310 assert_eq!(normalize_pair_v2("sol-usdt"), "solusdt");
311 }
312
313 #[test]
314 fn test_normalize_pair_empty() {
315 assert_eq!(normalize_pair(""), "");
316 }
317
318 #[test]
319 fn test_normalize_pair_single_token() {
320 assert_eq!(normalize_pair("foobar"), "foobar");
322 }
323
324 #[test]
325 fn test_normalize_pair_btc_as_quote() {
326 assert_eq!(normalize_pair("ethbtc"), "eth_btc");
328 assert_eq!(normalize_pair("btc"), "btc");
330 }
331
332 #[test]
333 fn test_normalize_pair_idr_not_treated_as_base() {
334 assert_eq!(normalize_pair("idrbtc"), "idr_btc");
336 }
337
338 #[test]
339 fn test_flatten_json_to_table_object() {
340 let json = json!({"name": "Alice", "age": 30, "city": "NYC"});
341 let (headers, rows) = flatten_json_to_table(&json);
342
343 assert_eq!(headers.len(), 3);
344 assert_eq!(rows.len(), 1);
345 assert!(headers.contains(&"name".into()));
346 assert!(headers.contains(&"age".into()));
347 assert!(headers.contains(&"city".into()));
348 }
349
350 #[test]
351 fn test_flatten_json_to_table_array() {
352 let json = json!([
353 {"id": 1, "val": 100},
354 {"id": 2, "val": 200}
355 ]);
356 let (headers, rows) = flatten_json_to_table(&json);
357
358 assert_eq!(headers.len(), 2);
359 assert_eq!(rows.len(), 2);
360 assert!(headers.contains(&"id".into()));
361 assert!(headers.contains(&"val".into()));
362 }
363
364 #[test]
365 fn test_flatten_json_to_table_empty_array() {
366 let json = json!([]);
367 let (headers, rows) = flatten_json_to_table(&json);
368
369 assert_eq!(headers.len(), 1);
371 assert_eq!(rows.len(), 1);
372 }
373
374 #[test]
375 fn test_flatten_json_to_table_primitive() {
376 let json = json!("hello");
377 let (headers, rows) = flatten_json_to_table(&json);
378
379 assert_eq!(headers.len(), 1);
380 assert_eq!(rows.len(), 1);
381 assert_eq!(rows[0][0], "hello");
382 }
383
384 #[test]
385 fn test_flatten_json_to_table_number() {
386 let json = json!(42);
387 let (_headers, rows) = flatten_json_to_table(&json);
388
389 assert_eq!(rows[0][0], "42");
390 }
391
392 #[test]
393 fn test_flatten_json_to_table_bool() {
394 let json = json!(true);
395 let (_headers, rows) = flatten_json_to_table(&json);
396
397 assert_eq!(rows[0][0], "true");
398 }
399
400 #[test]
401 fn test_first_of_first_key() {
402 let val = json!({"a": "1", "b": "2"});
403 assert_eq!(first_of(&val, &["a", "b"]), &json!("1"));
404 }
405
406 #[test]
407 fn test_first_of_second_key() {
408 let val = json!({"a": null, "b": "2"});
409 assert_eq!(first_of(&val, &["a", "b"]), &json!("2"));
410 }
411
412 #[test]
413 fn test_first_of_skips_null() {
414 let val = json!({"a": null, "b": null});
415 assert_eq!(first_of(&val, &["a", "b"]), &serde_json::Value::Null);
416 }
417
418 #[test]
419 fn test_first_of_skips_empty() {
420 let val = json!({"a": "", "b": "value"});
421 assert_eq!(first_of(&val, &["a", "b"]), &json!("value"));
422 }
423
424 #[test]
425 fn test_first_of_skips_null_string() {
426 let val = json!({"a": "null", "b": "value"});
427 assert_eq!(first_of(&val, &["a", "b"]), &json!("value"));
428 }
429
430 #[test]
431 fn test_flatten_json_to_table_null() {
432 let json = json!(null);
433 let (_headers, rows) = flatten_json_to_table(&json);
434
435 assert_eq!(rows[0][0], "");
436 }
437
438 #[test]
439 fn test_value_to_string_string() {
440 let v = json!("hello");
441 assert_eq!(value_to_string(&v), "hello");
442 }
443
444 #[test]
445 fn test_value_to_string_number() {
446 let v = json!(42);
447 assert_eq!(value_to_string(&v), "42");
448 }
449
450 #[test]
451 fn test_value_to_string_bool() {
452 let v = json!(true);
453 assert_eq!(value_to_string(&v), "true");
454 }
455
456 #[test]
457 fn test_value_to_string_null() {
458 let v = json!(null);
459 assert_eq!(value_to_string(&v), "");
460 }
461
462 #[test]
463 fn test_value_to_string_array() {
464 let v = json!([1, 2, 3]);
465 let result = value_to_string(&v);
466 assert!(result.contains("1"));
467 assert!(result.contains("2"));
468 assert!(result.contains("3"));
469 }
470
471 #[test]
472 fn test_value_to_string_object() {
473 let v = json!({"a": 1});
474 let result = value_to_string(&v);
475 assert!(result.contains("a") || result.contains("1"));
476 }
477
478 #[test]
479 fn test_format_timestamp_millis() {
480 let ts = 1704067200000u64;
482 let result = format_timestamp(ts, true);
483 assert!(result.contains("2024") || result.contains("01-01"));
484 }
485
486 #[test]
487 fn test_format_timestamp_seconds() {
488 let ts = 1704067200u64;
490 let result = format_timestamp(ts, false);
491 assert!(result.contains("2024") || result.contains("01-01"));
492 }
493
494 #[test]
495 fn test_format_timestamp_invalid() {
496 let result = format_timestamp(0, false);
497 assert!(result.contains("1970") || result.contains("01-01"));
499 }
500
501 #[test]
502 fn test_extract_pairs() {
503 let data_obj = json!({
505 "btcidr": {
506 "traded_currency": "btc",
507 "base_currency": "idr",
508 "symbol": "BTC/IDR"
509 },
510 "ethidr": {
511 "traded_currency": "eth",
512 "base_currency": "idr",
513 "symbol": "ETH/IDR"
514 }
515 });
516
517 let pairs_obj = extract_pairs(&data_obj);
518 assert_eq!(pairs_obj.len(), 2);
519 assert!(pairs_obj.iter().any(|(k, _)| k == "btcidr"));
520 assert!(pairs_obj.iter().any(|(k, _)| k == "ethidr"));
521
522 let data_arr = json!([
524 {
525 "id": "btcidr",
526 "traded_currency": "btc",
527 "base_currency": "idr",
528 "symbol": "BTCIDR"
529 },
530 {
531 "id": "ethidr",
532 "traded_currency": "eth",
533 "base_currency": "idr",
534 "symbol": "ETHIDR"
535 }
536 ]);
537 let pairs_arr = extract_pairs(&data_arr);
538 assert_eq!(pairs_arr.len(), 2);
539 assert!(pairs_arr.iter().any(|(k, _)| k == "btcidr"));
540 assert!(pairs_arr.iter().any(|(k, _)| k == "ethidr"));
541 }
542
543 #[test]
544 fn test_extract_pairs_with_base_currency() {
545 let data = json!({
546 "btcidr": {
547 "tradedCurrency": "btc",
548 "baseCurrency": "idr"
549 }
550 });
551
552 let pairs = extract_pairs(&data);
553 assert_eq!(pairs.len(), 1);
554 assert!(pairs[0].1.contains("btc"));
555 assert!(pairs[0].1.contains("idr"));
556 }
557
558 #[test]
559 fn test_extract_pairs_empty() {
560 let data = json!({});
561 let pairs = extract_pairs(&data);
562 assert!(pairs.is_empty());
563 }
564
565 #[test]
566 fn test_extract_pairs_not_object() {
567 let data = json!([]);
568 let pairs = extract_pairs(&data);
569 assert!(pairs.is_empty());
570 }
571
572 #[tokio::test]
573 async fn test_fetch_public_ws_token_default() {
574 let client = IndodaxClient::new(None).unwrap();
575 let token = fetch_public_ws_token(&client).await.unwrap();
576 assert!(!token.is_empty());
579 }
580}