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 if let Ok(token) = std::env::var("INDODAX_WS_TOKEN") {
34 if !token.is_empty() {
35 return Ok(token);
36 }
37 }
38
39 eprintln!("[WS] Warning: Could not fetch dynamic WebSocket token and no configured token found. Using built-in fallback token (may expire). Set INDODAX_WS_TOKEN env var to override.");
41 Ok(DEFAULT_STATIC_WS_TOKEN.to_string())
42}
43
44pub const ONE_DAY_MS: u64 = 24 * 60 * 60 * 1000;
45pub const ONE_DAY_SECS: u64 = 24 * 60 * 60;
46pub const BALANCE_EPSILON: f64 = 1e-8;
47
48pub fn now_millis() -> u64 {
49 std::time::SystemTime::now()
50 .duration_since(std::time::UNIX_EPOCH)
51 .unwrap_or_default()
52 .as_millis() as u64
53}
54
55pub fn flatten_json_to_table(json: &serde_json::Value) -> (Vec<String>, Vec<Vec<String>>) {
56 match json {
57 serde_json::Value::Object(map) => {
58 let mut headers: Vec<String> = map.keys().cloned().collect();
59 headers.sort();
60 let row: Vec<String> = headers
61 .iter()
62 .map(|k| value_to_string(&map[k]))
63 .collect();
64 (headers, vec![row])
65 }
66 serde_json::Value::Array(arr) if !arr.is_empty() => {
67 let mut all_keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
69 let mut is_obj = false;
70 for item in arr {
71 if let serde_json::Value::Object(map) = item {
72 is_obj = true;
73 for k in map.keys() {
74 all_keys.insert(k.clone());
75 }
76 }
77 }
78 if is_obj {
79 let headers: Vec<String> = all_keys.into_iter().collect();
80 let rows: Vec<Vec<String>> = arr
81 .iter()
82 .map(|item| {
83 headers
84 .iter()
85 .map(|k| value_to_string(&item[k]))
86 .collect()
87 })
88 .collect();
89 (headers, rows)
90 } else {
91 (vec!["Value".into()], arr.iter().map(|v| vec![value_to_string(v)]).collect())
92 }
93 }
94 _ => (vec!["Value".into()], vec![vec![value_to_string(json)]]),
95 }
96}
97
98pub fn value_to_string(v: &serde_json::Value) -> String {
99 match v {
100 serde_json::Value::Null => String::new(),
101 serde_json::Value::Bool(b) => b.to_string(),
102 serde_json::Value::Number(n) => n.to_string(),
103 serde_json::Value::String(s) => s.clone(),
104 serde_json::Value::Array(arr) => {
105 let items: Vec<String> = arr.iter().map(value_to_string).collect();
106 items.join(", ")
107 }
108 serde_json::Value::Object(_) => serde_json::to_string(v).unwrap_or_else(|_| "<serialization_error>".to_string()),
109 }
110}
111
112pub fn format_timestamp(ts: u64, millis: bool) -> String {
113 let ts_sec = if millis { ts / 1000 } else { ts };
114 if let Some(dt) = DateTime::from_timestamp(ts_sec.min(i64::MAX as u64) as i64, 0) {
115 dt.format("%Y-%m-%d %H:%M:%S").to_string()
116 } else {
117 ts.to_string()
118 }
119}
120
121pub fn normalize_pair(pair: &str) -> String {
122 let pair = pair.to_lowercase().replace(['-', '/'], "_");
123 if pair.contains('_') || pair.is_empty() {
124 return pair;
125 }
126 let quote_currencies = ["usdt", "idr", "btc", "usdc", "eth", "sol", "bnb", "xrp", "ada"];
127 for quote in "e_currencies {
128 if let Some(base) = pair.strip_suffix(quote) {
129 if !base.is_empty() {
130 return format!("{}_{}", base, quote);
131 }
132 }
133 }
134 pair
135}
136
137pub fn normalize_pair_v2(pair: &str) -> String {
138 normalize_pair(pair).replace('_', "")
139}
140
141pub fn first_of<'a>(val: &'a Value, keys: &[&str]) -> &'a Value {
142 for k in keys {
143 if let Some(v) = val.get(*k) {
144 match v {
145 Value::Null => continue,
146 Value::String(s) if s.is_empty() || s == "null" => continue,
147 _ => return v,
148 }
149 }
150 }
151 &Value::Null
152}
153
154pub fn is_fiat_or_stable(currency: &str) -> bool {
156 matches!(
157 currency.to_lowercase().as_str(),
158 "idr" | "usdt" | "usdc" | "dai" | "busd" | "pax" | "usde" | "gusd" | "tusd"
159 )
160}
161
162pub fn format_balance(currency: &str, value: f64) -> String {
165 if is_fiat_or_stable(currency) {
166 if value.abs() < 0.01 && value != 0.0 {
167 "<0.01".to_string()
168 } else {
169 format!("{:.2}", value)
170 }
171 } else {
172 if value.abs() < 1e-8 && value != 0.0 {
173 "<1e-8".to_string()
174 } else {
175 format!("{:.8}", value)
176 }
177 }
178}
179
180pub fn parse_balance(info: &serde_json::Value, currency: &str) -> f64 {
182 info["balance"][currency]
183 .as_str()
184 .and_then(|s| s.parse::<f64>().ok())
185 .or_else(|| info["balance"][currency].as_f64())
186 .unwrap_or(0.0)
187}
188
189pub fn build_withdraw_params(
191 currency: &str,
192 amount: f64,
193 address: &str,
194 to_username: bool,
195 memo: Option<&str>,
196 network: Option<&str>,
197 callback_url: Option<&str>,
198) -> std::collections::HashMap<String, String> {
199 let mut params = std::collections::HashMap::new();
200 params.insert("currency".into(), currency.to_string());
201 params.insert("amount".into(), amount.to_string());
202
203 if to_username {
204 params.insert(
205 "request_id".into(),
206 chrono::Utc::now().timestamp_millis().to_string(),
207 );
208 params.insert("withdraw_to".into(), address.to_string());
209 } else {
210 params.insert("address".into(), address.to_string());
211 }
212
213 if let Some(m) = memo {
214 params.insert("memo".into(), m.to_string());
215 }
216 if let Some(n) = network {
217 params.insert("network".into(), n.to_string());
218 }
219 if let Some(u) = callback_url {
220 params.insert("callback_url".into(), u.to_string());
221 }
222 params
223}
224
225pub async fn validate_tick_size(
228 client: &IndodaxClient,
229 pair: &str,
230 price: f64,
231) -> Option<String> {
232 let Ok(data) = client.public_get::<serde_json::Value>("/api/price_increments").await else {
233 return None;
234 };
235 let increments = data.get("increments").and_then(|v| v.as_object())?;
236 let normalized_pair = pair.to_lowercase().replace('-', "_");
237 let inc_entry = increments.get(&normalized_pair)
238 .or_else(|| increments.get(&normalized_pair.replace('_', "")))
239 .or_else(|| {
240 let alt = normalized_pair.replace('_', "");
241 increments.keys().find(|k| k.replace('_', "") == alt)
242 .and_then(|k| increments.get(k))
243 })?;
244 let inc_str = inc_entry.as_str()?;
245 let inc: f64 = inc_str.parse().ok()?;
246 if inc <= 0.0 {
247 return None;
248 }
249 let price_rounded = (price / inc).round() * inc;
250 if (price_rounded - price).abs() <= inc * 1e-6 {
251 return None;
252 }
253 Some(format!(
254 "[TRADE] Warning: Price {} does not conform to the tick size (increment: {}) for pair {}. The API may reject this order.",
255 price, inc_str, pair
256 ))
257}
258
259pub async fn cancel_all_open_orders(
262 client: &IndodaxClient,
263 pair: Option<&str>,
264) -> Result<(Vec<String>, Vec<String>), IndodaxError> {
265 use std::collections::HashMap;
266 let mut params = HashMap::new();
267 if let Some(p) = pair {
268 params.insert("pair".to_string(), p.to_string());
269 }
270 let data: serde_json::Value = client.private_post_v1("openOrders", ¶ms).await?;
271 let orders = &data["orders"];
272 let mut cancelled_ids: Vec<String> = Vec::new();
273 let mut failed_ids: Vec<String> = Vec::new();
274
275 if let serde_json::Value::Object(orders_map) = orders {
276 for (order_id, order_val) in orders_map {
277 let order_pair = value_to_string(
278 order_val
279 .get("pair")
280 .or_else(|| order_val.get("market"))
281 .or_else(|| order_val.get("symbol"))
282 .unwrap_or(&serde_json::Value::Null),
283 );
284 let order_type = order_val
285 .get("type")
286 .or_else(|| order_val.get("order_type"))
287 .and_then(|v| v.as_str())
288 .unwrap_or("")
289 .to_string();
290
291 let mut cancel_params = HashMap::new();
292 cancel_params.insert("order_id".to_string(), order_id.clone());
293 cancel_params.insert("pair".to_string(), order_pair);
294 cancel_params.insert("type".to_string(), order_type);
295 match client
296 .private_post_v1::<serde_json::Value>("cancelOrder", &cancel_params)
297 .await
298 {
299 Ok(_) => cancelled_ids.push(order_id.clone()),
300 Err(e) => failed_ids.push(format!("{} ({})", order_id, e)),
301 }
302 }
303 }
304
305 Ok((cancelled_ids, failed_ids))
306}
307
308pub fn extract_pairs(data: &serde_json::Value) -> Vec<(String, String)> {
309 let mut pairs: Vec<(String, String)> = Vec::new();
310 if let serde_json::Value::Object(map) = data {
311 for (key, value) in map {
312 if let Some(obj) = value.as_object() {
313 let base = obj.get("traded_currency")
314 .or_else(|| obj.get("tradedCurrency"))
315 .and_then(|v| v.as_str())
316 .unwrap_or("");
317 let quote = obj.get("base_currency")
318 .or_else(|| obj.get("baseCurrency"))
319 .and_then(|v| v.as_str())
320 .unwrap_or("");
321 let symbol = obj.get("symbol").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
322 pairs.push((key.clone(), format!("{}/{} ({})", base, quote, symbol)));
323 }
324 }
325 } else if let serde_json::Value::Array(arr) = data {
326 for item in arr {
327 if let Some(obj) = item.as_object() {
328 let id = obj.get("id").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
329 let base = obj.get("traded_currency")
330 .or_else(|| obj.get("tradedCurrency"))
331 .and_then(|v| v.as_str())
332 .unwrap_or("");
333 let quote = obj.get("base_currency")
334 .or_else(|| obj.get("baseCurrency"))
335 .and_then(|v| v.as_str())
336 .unwrap_or("");
337 let symbol = obj.get("symbol").or_else(|| obj.get("ticker_id")).and_then(|v| v.as_str()).unwrap_or("");
338 if !id.is_empty() {
339 pairs.push((id.to_string(), format!("{}/{} ({})", base, quote, symbol)));
340 }
341 }
342 }
343 }
344 pairs.sort_by(|a, b| a.0.cmp(&b.0));
345 pairs
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use serde_json::json;
352
353 #[test]
354 fn test_normalize_pair_already_normalized() {
355 assert_eq!(normalize_pair("btc_idr"), "btc_idr");
356 assert_eq!(normalize_pair("eth_btc"), "eth_btc");
357 assert_eq!(normalize_pair("usdt_idr"), "usdt_idr");
358 }
359
360 #[test]
361 fn test_normalize_pair_no_underscore() {
362 assert_eq!(normalize_pair("btcidr"), "btc_idr");
363 assert_eq!(normalize_pair("ethidr"), "eth_idr");
364 assert_eq!(normalize_pair("ethbtc"), "eth_btc");
365 assert_eq!(normalize_pair("solusdt"), "sol_usdt");
366 }
367
368 #[test]
369 fn test_normalize_pair_uppercase() {
370 assert_eq!(normalize_pair("BTC_IDR"), "btc_idr");
371 assert_eq!(normalize_pair("BTCIDR"), "btc_idr");
372 assert_eq!(normalize_pair("ETH_BTC"), "eth_btc");
373 }
374
375 #[test]
376 fn test_normalize_pair_dash_separator() {
377 assert_eq!(normalize_pair("btc-idr"), "btc_idr");
378 assert_eq!(normalize_pair("ETH-IDR"), "eth_idr");
379 assert_eq!(normalize_pair("sol-usdt"), "sol_usdt");
380 }
381
382 #[test]
383 fn test_normalize_pair_slash_separator() {
384 assert_eq!(normalize_pair("btc/idr"), "btc_idr");
385 assert_eq!(normalize_pair("ETH/BTC"), "eth_btc");
386 assert_eq!(normalize_pair("sol/usdt"), "sol_usdt");
387 }
388
389 #[test]
390 fn test_normalize_pair_v2() {
391 assert_eq!(normalize_pair_v2("btc_idr"), "btcidr");
392 assert_eq!(normalize_pair_v2("BTCIDR"), "btcidr");
393 assert_eq!(normalize_pair_v2("sol-usdt"), "solusdt");
394 }
395
396 #[test]
397 fn test_normalize_pair_empty() {
398 assert_eq!(normalize_pair(""), "");
399 }
400
401 #[test]
402 fn test_normalize_pair_single_token() {
403 assert_eq!(normalize_pair("foobar"), "foobar");
405 }
406
407 #[test]
408 fn test_normalize_pair_btc_as_quote() {
409 assert_eq!(normalize_pair("ethbtc"), "eth_btc");
411 assert_eq!(normalize_pair("btc"), "btc");
413 }
414
415 #[test]
416 fn test_normalize_pair_idr_not_treated_as_base() {
417 assert_eq!(normalize_pair("idrbtc"), "idr_btc");
419 }
420
421 #[test]
422 fn test_flatten_json_to_table_object() {
423 let json = json!({"name": "Alice", "age": 30, "city": "NYC"});
424 let (headers, rows) = flatten_json_to_table(&json);
425
426 assert_eq!(headers.len(), 3);
427 assert_eq!(rows.len(), 1);
428 assert!(headers.contains(&"name".into()));
429 assert!(headers.contains(&"age".into()));
430 assert!(headers.contains(&"city".into()));
431 }
432
433 #[test]
434 fn test_flatten_json_to_table_array() {
435 let json = json!([
436 {"id": 1, "val": 100},
437 {"id": 2, "val": 200}
438 ]);
439 let (headers, rows) = flatten_json_to_table(&json);
440
441 assert_eq!(headers.len(), 2);
442 assert_eq!(rows.len(), 2);
443 assert!(headers.contains(&"id".into()));
444 assert!(headers.contains(&"val".into()));
445 }
446
447 #[test]
448 fn test_flatten_json_to_table_empty_array() {
449 let json = json!([]);
450 let (headers, rows) = flatten_json_to_table(&json);
451
452 assert_eq!(headers.len(), 1);
454 assert_eq!(rows.len(), 1);
455 }
456
457 #[test]
458 fn test_flatten_json_to_table_primitive() {
459 let json = json!("hello");
460 let (headers, rows) = flatten_json_to_table(&json);
461
462 assert_eq!(headers.len(), 1);
463 assert_eq!(rows.len(), 1);
464 assert_eq!(rows[0][0], "hello");
465 }
466
467 #[test]
468 fn test_flatten_json_to_table_number() {
469 let json = json!(42);
470 let (_headers, rows) = flatten_json_to_table(&json);
471
472 assert_eq!(rows[0][0], "42");
473 }
474
475 #[test]
476 fn test_flatten_json_to_table_bool() {
477 let json = json!(true);
478 let (_headers, rows) = flatten_json_to_table(&json);
479
480 assert_eq!(rows[0][0], "true");
481 }
482
483 #[test]
484 fn test_first_of_first_key() {
485 let val = json!({"a": "1", "b": "2"});
486 assert_eq!(first_of(&val, &["a", "b"]), &json!("1"));
487 }
488
489 #[test]
490 fn test_first_of_second_key() {
491 let val = json!({"a": null, "b": "2"});
492 assert_eq!(first_of(&val, &["a", "b"]), &json!("2"));
493 }
494
495 #[test]
496 fn test_first_of_skips_null() {
497 let val = json!({"a": null, "b": null});
498 assert_eq!(first_of(&val, &["a", "b"]), &serde_json::Value::Null);
499 }
500
501 #[test]
502 fn test_first_of_skips_empty() {
503 let val = json!({"a": "", "b": "value"});
504 assert_eq!(first_of(&val, &["a", "b"]), &json!("value"));
505 }
506
507 #[test]
508 fn test_first_of_skips_null_string() {
509 let val = json!({"a": "null", "b": "value"});
510 assert_eq!(first_of(&val, &["a", "b"]), &json!("value"));
511 }
512
513 #[test]
514 fn test_flatten_json_to_table_null() {
515 let json = json!(null);
516 let (_headers, rows) = flatten_json_to_table(&json);
517
518 assert_eq!(rows[0][0], "");
519 }
520
521 #[test]
522 fn test_value_to_string_string() {
523 let v = json!("hello");
524 assert_eq!(value_to_string(&v), "hello");
525 }
526
527 #[test]
528 fn test_value_to_string_number() {
529 let v = json!(42);
530 assert_eq!(value_to_string(&v), "42");
531 }
532
533 #[test]
534 fn test_value_to_string_bool() {
535 let v = json!(true);
536 assert_eq!(value_to_string(&v), "true");
537 }
538
539 #[test]
540 fn test_value_to_string_null() {
541 let v = json!(null);
542 assert_eq!(value_to_string(&v), "");
543 }
544
545 #[test]
546 fn test_value_to_string_array() {
547 let v = json!([1, 2, 3]);
548 let result = value_to_string(&v);
549 assert!(result.contains("1"));
550 assert!(result.contains("2"));
551 assert!(result.contains("3"));
552 }
553
554 #[test]
555 fn test_value_to_string_object() {
556 let v = json!({"a": 1});
557 let result = value_to_string(&v);
558 assert!(result.contains("a") || result.contains("1"));
559 }
560
561 #[test]
562 fn test_format_timestamp_millis() {
563 let ts = 1704067200000u64;
565 let result = format_timestamp(ts, true);
566 assert!(result.contains("2024") || result.contains("01-01"));
567 }
568
569 #[test]
570 fn test_format_timestamp_seconds() {
571 let ts = 1704067200u64;
573 let result = format_timestamp(ts, false);
574 assert!(result.contains("2024") || result.contains("01-01"));
575 }
576
577 #[test]
578 fn test_format_timestamp_invalid() {
579 let result = format_timestamp(0, false);
580 assert!(result.contains("1970") || result.contains("01-01"));
582 }
583
584 #[test]
585 fn test_extract_pairs() {
586 let data_obj = json!({
588 "btcidr": {
589 "traded_currency": "btc",
590 "base_currency": "idr",
591 "symbol": "BTC/IDR"
592 },
593 "ethidr": {
594 "traded_currency": "eth",
595 "base_currency": "idr",
596 "symbol": "ETH/IDR"
597 }
598 });
599
600 let pairs_obj = extract_pairs(&data_obj);
601 assert_eq!(pairs_obj.len(), 2);
602 assert!(pairs_obj.iter().any(|(k, _)| k == "btcidr"));
603 assert!(pairs_obj.iter().any(|(k, _)| k == "ethidr"));
604
605 let data_arr = json!([
607 {
608 "id": "btcidr",
609 "traded_currency": "btc",
610 "base_currency": "idr",
611 "symbol": "BTCIDR"
612 },
613 {
614 "id": "ethidr",
615 "traded_currency": "eth",
616 "base_currency": "idr",
617 "symbol": "ETHIDR"
618 }
619 ]);
620 let pairs_arr = extract_pairs(&data_arr);
621 assert_eq!(pairs_arr.len(), 2);
622 assert!(pairs_arr.iter().any(|(k, _)| k == "btcidr"));
623 assert!(pairs_arr.iter().any(|(k, _)| k == "ethidr"));
624 }
625
626 #[test]
627 fn test_extract_pairs_with_base_currency() {
628 let data = json!({
629 "btcidr": {
630 "tradedCurrency": "btc",
631 "baseCurrency": "idr"
632 }
633 });
634
635 let pairs = extract_pairs(&data);
636 assert_eq!(pairs.len(), 1);
637 assert!(pairs[0].1.contains("btc"));
638 assert!(pairs[0].1.contains("idr"));
639 }
640
641 #[test]
642 fn test_extract_pairs_empty() {
643 let data = json!({});
644 let pairs = extract_pairs(&data);
645 assert!(pairs.is_empty());
646 }
647
648 #[test]
649 fn test_extract_pairs_not_object() {
650 let data = json!([]);
651 let pairs = extract_pairs(&data);
652 assert!(pairs.is_empty());
653 }
654
655 #[tokio::test]
656 async fn test_fetch_public_ws_token_default() {
657 let client = IndodaxClient::new(None).unwrap();
658 let token = fetch_public_ws_token(&client).await.unwrap();
659 assert!(!token.is_empty());
662 }
663}