Skip to main content

indodax_cli/commands/
helpers.rs

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
8/// Default static token from official Indodax Market Data WebSocket documentation.
9/// Used for authenticating with the Public Market Data WebSocket (ws3).
10pub const DEFAULT_STATIC_WS_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5NDY2MTg0MTV9.UR1lBM6Eqh0yWz-PVirw1uPCxe60FdchR8eNVdsskeo";
11
12/// Fetch a public WebSocket token, with fallback to user configuration and then a hardcoded default.
13pub async fn fetch_public_ws_token(client: &IndodaxClient) -> Result<String, anyhow::Error> {
14    // 1. Try to fetch dynamically (some Indodax environments support this)
15    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    // 2. Try to use user-configured token from config/env
28    if let Some(token) = client.ws_token() {
29        return Ok(token.to_string());
30    }
31    
32    // 3. Fallback to hardcoded default
33    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            // Collect all unique keys from all array elements
52            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 &quote_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
138/// Parse a balance value for a given currency from API account info response.
139pub 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
147/// Build withdrawal parameters HashMap shared between CLI and MCP.
148pub 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
183/// Fetch all open orders and cancel them one by one.
184/// Returns (cancelled_ids, failed_ids).
185pub 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", &params).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        // Pairs that don't match known quote currencies pass through
321        assert_eq!(normalize_pair("foobar"), "foobar");
322    }
323
324    #[test]
325    fn test_normalize_pair_btc_as_quote() {
326        // ethbtc -> eth_btc (btc as suffix)
327        assert_eq!(normalize_pair("ethbtc"), "eth_btc");
328        // btc alone -> stays as btc (base would be empty, so skipped)
329        assert_eq!(normalize_pair("btc"), "btc");
330    }
331
332    #[test]
333    fn test_normalize_pair_idr_not_treated_as_base() {
334        // idrbtc -> idr_btc (btc as suffix)
335        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        // For empty array, returns single "Value" header and one row
370        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        // 2024-01-01 00:00:00 UTC in millis
481        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        // 2024-01-01 00:00:00 UTC in seconds
489        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        // Timestamp 0 is valid (1970-01-01), so it returns a formatted date
498        assert!(result.contains("1970") || result.contains("01-01"));
499    }
500
501    #[test]
502    fn test_extract_pairs() {
503        // Test object format
504        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        // Test array format
523        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        // Since we are not in an environment where the dynamic URL necessarily works,
577        // it should at least return the default token if it fails.
578        assert!(!token.is_empty());
579    }
580}