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. Try INDODAX_WS_TOKEN env var
33    if let Ok(token) = std::env::var("INDODAX_WS_TOKEN") {
34        if !token.is_empty() {
35            return Ok(token);
36        }
37    }
38
39    // 4. Fallback to hardcoded default
40    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            // Collect all unique keys from all array elements
68            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 &quote_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
154/// Check if a currency is fiat (IDR) or a stablecoin.
155pub 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
162/// Currency-aware balance formatting: 2 decimals for IDR/fiat/stablecoins, 8 for crypto.
163/// Handles extreme values gracefully (no scientific notation).
164pub 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
180/// Parse a balance value for a given currency from API account info response.
181pub 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
189/// Build withdrawal parameters HashMap shared between CLI and MCP.
190pub 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
225/// Validate a price against the price increment (tick size) for a pair.
226/// Returns a warning string if validation fails, or None if the price is valid or can't be checked.
227pub 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
259/// Fetch all open orders and cancel them one by one.
260/// Returns (cancelled_ids, failed_ids).
261pub 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", &params).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        // Pairs that don't match known quote currencies pass through
404        assert_eq!(normalize_pair("foobar"), "foobar");
405    }
406
407    #[test]
408    fn test_normalize_pair_btc_as_quote() {
409        // ethbtc -> eth_btc (btc as suffix)
410        assert_eq!(normalize_pair("ethbtc"), "eth_btc");
411        // btc alone -> stays as btc (base would be empty, so skipped)
412        assert_eq!(normalize_pair("btc"), "btc");
413    }
414
415    #[test]
416    fn test_normalize_pair_idr_not_treated_as_base() {
417        // idrbtc -> idr_btc (btc as suffix)
418        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        // For empty array, returns single "Value" header and one row
453        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        // 2024-01-01 00:00:00 UTC in millis
564        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        // 2024-01-01 00:00:00 UTC in seconds
572        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        // Timestamp 0 is valid (1970-01-01), so it returns a formatted date
581        assert!(result.contains("1970") || result.contains("01-01"));
582    }
583
584    #[test]
585    fn test_extract_pairs() {
586        // Test object format
587        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        // Test array format
606        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        // Since we are not in an environment where the dynamic URL necessarily works,
660        // it should at least return the default token if it fails.
661        assert!(!token.is_empty());
662    }
663}