Skip to main content

indodax_cli/commands/
account.rs

1use crate::auth::Signer;
2use crate::client::IndodaxClient;
3use crate::commands::helpers;
4use crate::config::IndodaxConfig;
5use crate::output::CommandOutput;
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, clap::Subcommand)]
11pub enum AccountCommand {
12    #[command(name = "info", about = "Get account information and balances")]
13    Info,
14
15    #[command(name = "balance", about = "Show account balances")]
16    Balance,
17
18    #[command(name = "open-orders", about = "List open orders")]
19    OpenOrders {
20        #[arg(short, long, help = "Filter by trading pair")]
21        pair: Option<String>,
22    },
23
24    #[command(name = "order-history", about = "Get order history (v2 API)")]
25    OrderHistory {
26        #[arg(short, long, default_value = "btc_idr")]
27        symbol: String,
28        #[arg(short, long, default_value = "100")]
29        limit: u32,
30    },
31
32    #[command(name = "trade-history", about = "Get trade fill history (v2 API)")]
33    TradeHistory {
34        #[arg(short, long, default_value = "btc_idr")]
35        symbol: String,
36        #[arg(short, long, default_value = "100")]
37        limit: u32,
38    },
39
40    #[command(name = "trans-history", about = "Get deposit and withdrawal history")]
41    TransHistory,
42
43    #[command(name = "get-order", about = "Get order details by order ID")]
44    GetOrder {
45        #[arg(long)]
46        order_id: u64,
47        #[arg(long)]
48        pair: String,
49    },
50
51    #[command(name = "equity-snap", about = "Record a portfolio equity snapshot")]
52    EquitySnap,
53
54    #[command(name = "equity-history", about = "View equity snapshot history")]
55    EquityHistory {
56        #[arg(short, long, default_value = "20", help = "Number of snapshots to show")]
57        limit: usize,
58        #[arg(long, help = "Show all snapshots")]
59        all: bool,
60    },
61}
62
63pub async fn execute(
64    client: &IndodaxClient,
65    cmd: &AccountCommand,
66) -> Result<CommandOutput> {
67    match cmd {
68        AccountCommand::Info => info(client).await,
69        AccountCommand::Balance => balance(client).await,
70        AccountCommand::OpenOrders { pair } => {
71            let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
72            open_orders(client, pair.as_deref()).await
73        }
74        AccountCommand::OrderHistory { symbol, limit } => {
75            let symbol = helpers::normalize_pair(symbol);
76            order_history(client, &symbol, *limit).await
77        }
78        AccountCommand::TradeHistory { symbol, limit } => {
79            let symbol = helpers::normalize_pair(symbol);
80            trade_history(client, &symbol, *limit).await
81        }
82        AccountCommand::TransHistory => trans_history(client).await,
83        AccountCommand::GetOrder { order_id, pair } => {
84            let pair = helpers::normalize_pair(pair);
85            get_order(client, *order_id, &pair).await
86        }
87        AccountCommand::EquitySnap => equity_snap(client).await,
88        AccountCommand::EquityHistory { limit, all } => {
89            equity_history(*limit, *all)
90        }
91    }
92}
93
94async fn info(client: &IndodaxClient) -> Result<CommandOutput> {
95    let data: serde_json::Value =
96        client.private_post_v1("getInfo", &HashMap::new()).await?;
97
98    let headers = vec![
99        "Field".into(), "Value".into(),
100    ];
101    let mut rows: Vec<Vec<String>> = vec![
102        vec!["Name".into(), helpers::value_to_string(data.get("name").unwrap_or(&serde_json::Value::Null))],
103        vec!["User ID".into(), helpers::value_to_string(data.get("user_id").unwrap_or(&serde_json::Value::Null))],
104        vec!["Server Time".into(), helpers::format_timestamp(data["server_time"].as_u64().unwrap_or(0), true)],
105        vec!["Vip Level".into(), helpers::value_to_string(data.get("vip_level").unwrap_or(&serde_json::Value::Null))],
106        vec!["Verified".into(), helpers::value_to_string(data.get("verified_user").unwrap_or(&serde_json::Value::Null))],
107    ];
108
109    let balance = &data["balance"];
110    if let serde_json::Value::Object(bal_map) = balance {
111        let mut entries: Vec<(&String, &serde_json::Value)> = bal_map.iter().collect();
112        entries.sort_by(|a, b| a.0.cmp(b.0));
113        for (k, v) in entries {
114            rows.push(vec![k.clone(), helpers::value_to_string(v)]);
115        }
116    }
117
118    Ok(CommandOutput::new(data, headers, rows))
119}
120
121async fn balance(client: &IndodaxClient) -> Result<CommandOutput> {
122    let data: serde_json::Value =
123        client.private_post_v1("getInfo", &HashMap::new()).await?;
124
125    let balance = &data["balance"];
126    let headers = vec!["Currency".into(), "Balance".into()];
127    let mut rows: Vec<Vec<String>> = Vec::new();
128
129    if let serde_json::Value::Object(bal_map) = balance {
130        let mut entries: Vec<(String, f64)> = bal_map
131            .iter()
132            .map(|(k, v)| {
133                let val = v.as_str().and_then(|s| s.parse::<f64>().ok())
134                    .or_else(|| v.as_f64())
135                    .unwrap_or(0.0);
136                (k.clone(), val)
137            })
138            .collect();
139        entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
140        for (currency, amount) in entries {
141            rows.push(vec![currency, amount.to_string()]);
142        }
143    }
144
145    Ok(CommandOutput::new(data, headers, rows))
146}
147
148async fn open_orders(
149    client: &IndodaxClient,
150    pair: Option<&str>,
151) -> Result<CommandOutput> {
152    let mut params = HashMap::new();
153    if let Some(p) = pair {
154        params.insert("pair".into(), p.to_string());
155    }
156    let data: serde_json::Value =
157        client.private_post_v1("openOrders", &params).await?;
158
159    let orders = &data["orders"];
160    let headers = vec![
161        "Order ID".into(), "Pair".into(), "Type".into(), "Side".into(),
162        "Price".into(), "Amount".into(), "Remaining".into(), "Time".into(),
163    ];
164    let mut rows: Vec<Vec<String>> = Vec::new();
165
166    if let serde_json::Value::Object(orders_map) = orders {
167        for (order_id, order_val) in orders_map {
168            let pair = helpers::value_to_string(
169                priv_get(order_val, &["pair", "market", "symbol"]),
170            );
171            let order_type = helpers::value_to_string(
172                priv_get(order_val, &["type", "order_type"]),
173            );
174            let side = if order_type.to_lowercase().contains("sell") {
175                "SELL"
176            } else {
177                "BUY"
178            };
179
180            let remaining = helpers::value_to_string(
181                priv_get(order_val, &["remaining", "remain_volume", "remaining_volume"]),
182            );
183            let base_amount = order_val.get("order_btc")
184                .or_else(|| order_val.get("order_base"))
185                .or_else(|| order_val.get("amount"))
186                .map(helpers::value_to_string)
187                .unwrap_or_default();
188
189            let time_val = order_val.get("submit_time")
190                .or_else(|| order_val.get("created_at"))
191                .or_else(|| order_val.get("time"))
192                .map(|v| {
193                    let ts = v.as_u64().unwrap_or(0);
194                    if ts > 1_000_000_000_000 {
195                        helpers::format_timestamp(ts, true)
196                    } else {
197                        helpers::format_timestamp(ts, false)
198                    }
199                })
200                .unwrap_or_default();
201
202            rows.push(vec![
203                order_id.to_string(),
204                pair,
205                order_type,
206                side.into(),
207                helpers::value_to_string(
208                    priv_get(order_val, &["price", "order_price"]),
209                ),
210                base_amount,
211                remaining,
212                time_val,
213            ]);
214        }
215    }
216
217    rows.sort_by(|a, b| {
218        match (b[0].parse::<u64>().ok(), a[0].parse::<u64>().ok()) {
219            (Some(bv), Some(av)) => bv.cmp(&av),
220            _ => b[0].cmp(&a[0]),
221        }
222    });
223    let count = rows.len();
224    Ok(CommandOutput::new(data, headers, rows)
225        .with_addendum(format!("{} open orders", count)))
226}
227
228async fn order_history(
229    client: &IndodaxClient,
230    symbol: &str,
231    limit: u32,
232) -> Result<CommandOutput> {
233    let now = Signer::now_millis();
234    let start = now - crate::commands::helpers::ONE_DAY_MS;
235
236    let mut params = HashMap::new();
237    params.insert("symbol".into(), crate::commands::helpers::normalize_pair_v2(symbol));
238    params.insert("limit".into(), limit.max(10).to_string());
239    params.insert("startTime".into(), start.to_string());
240    params.insert("endTime".into(), now.to_string());
241
242    let data: serde_json::Value =
243        client.private_get_v2("/api/v2/order/histories", &params).await?;
244
245    let headers = vec![
246        "Order ID".into(), "Symbol".into(), "Side".into(), "Type".into(),
247        "Price".into(), "Qty".into(), "Status".into(), "Time".into(),
248    ];
249    let mut rows: Vec<Vec<String>> = Vec::new();
250
251    if let serde_json::Value::Array(arr) = &data {
252        for order in arr.iter().take(limit as usize) {
253            rows.push(vec![
254                helpers::value_to_string(priv_get(order, &["orderId", "order_id"])),
255                helpers::value_to_string(priv_get(order, &["symbol", "pair"])),
256                helpers::value_to_string(priv_get(order, &["side", "order_side"])),
257                helpers::value_to_string(priv_get(order, &["type", "order_type"])),
258                helpers::value_to_string(priv_get(order, &["price", "order_price"])),
259                helpers::value_to_string(priv_get(order, &["origQty", "orig_qty", "qty"])),
260                helpers::value_to_string(priv_get(order, &["status", "order_status"])),
261                helpers::value_to_string(priv_get(order, &["time", "created_at"])),
262            ]);
263        }
264    }
265
266    Ok(CommandOutput::new(data, headers, rows))
267}
268
269async fn trade_history(
270    client: &IndodaxClient,
271    symbol: &str,
272    limit: u32,
273) -> Result<CommandOutput> {
274    let now = Signer::now_millis();
275    let start = now - crate::commands::helpers::ONE_DAY_MS;
276
277    let mut params = HashMap::new();
278    params.insert("symbol".into(), crate::commands::helpers::normalize_pair_v2(symbol));
279    params.insert("limit".into(), limit.max(10).to_string());
280    params.insert("startTime".into(), start.to_string());
281    params.insert("endTime".into(), now.to_string());
282
283    let data: serde_json::Value =
284        client.private_get_v2("/api/v2/myTrades", &params).await?;
285
286    let headers = vec![
287        "Trade ID".into(), "Order ID".into(), "Symbol".into(), "Side".into(),
288        "Price".into(), "Qty".into(), "Fee".into(), "Time".into(),
289    ];
290    let mut rows: Vec<Vec<String>> = Vec::new();
291
292    if let serde_json::Value::Array(arr) = &data {
293        for trade in arr.iter().take(limit as usize) {
294            rows.push(vec![
295                helpers::value_to_string(priv_get(trade, &["id", "tradeId", "trade_id"])),
296                helpers::value_to_string(priv_get(trade, &["orderId", "order_id"])),
297                helpers::value_to_string(priv_get(trade, &["symbol", "pair"])),
298                helpers::value_to_string(priv_get(trade, &["side"])),
299                helpers::value_to_string(priv_get(trade, &["price"])),
300                helpers::value_to_string(priv_get(trade, &["qty", "quantity"])),
301                helpers::value_to_string(priv_get(trade, &["commission", "fee"])),
302                helpers::value_to_string(priv_get(trade, &["time", "timestamp"])),
303            ]);
304        }
305    }
306
307    Ok(CommandOutput::new(data, headers, rows))
308}
309
310async fn trans_history(client: &IndodaxClient) -> Result<CommandOutput> {
311    let data: serde_json::Value =
312        client.private_post_v1("transHistory", &HashMap::new()).await?;
313
314    let headers = vec![
315        "ID".into(), "Type".into(), "Currency".into(), "Amount".into(),
316        "Fee".into(), "Status".into(), "Time".into(),
317    ];
318    let mut rows: Vec<Vec<String>> = Vec::new();
319
320    let mut all_trans = Vec::new();
321    
322    if let Some(obj) = data.get("withdraw").and_then(|v| v.as_object()) {
323        for (id, val) in obj {
324            all_trans.push((id, "WITHDRAW", val));
325        }
326    }
327    if let Some(obj) = data.get("deposit").and_then(|v| v.as_object()) {
328        for (id, val) in obj {
329            all_trans.push((id, "DEPOSIT", val));
330        }
331    }
332    if let Some(obj) = data.get("transactions").and_then(|v| v.as_object()) {
333        for (id, val) in obj {
334            let tx_type = if id.contains("withdraw") {
335                "WITHDRAW"
336            } else if id.contains("deposit") {
337                "DEPOSIT"
338            } else {
339                "TRANS"
340            };
341            all_trans.push((id, tx_type, val));
342        }
343    }
344
345    for (id, tx_type, entry) in all_trans {
346        rows.push(vec![
347            id.clone(),
348            tx_type.into(),
349            helpers::value_to_string(
350                priv_get(entry, &["currency", "asset", "coin"]),
351            ),
352            helpers::value_to_string(
353                priv_get(entry, &["amount", "value"]),
354            ),
355            helpers::value_to_string(
356                priv_get(entry, &["fee", "withdraw_fee"]),
357            ),
358            helpers::value_to_string(
359                priv_get(entry, &["status", "state"]),
360            ),
361            helpers::value_to_string(
362                priv_get(entry, &["submit_time", "timestamp", "time", "submitted"]),
363            ),
364        ]);
365    }
366
367    rows.sort_by(|a, b| b[0].cmp(&a[0]));
368    Ok(CommandOutput::new(data, headers, rows))
369}
370
371async fn get_order(
372    client: &IndodaxClient,
373    order_id: u64,
374    pair: &str,
375) -> Result<CommandOutput> {
376    let mut params = HashMap::new();
377    params.insert("order_id".into(), order_id.to_string());
378    params.insert("pair".into(), pair.to_string());
379
380    let data: serde_json::Value =
381        client.private_post_v1("getOrder", &params).await?;
382
383    let (headers, rows) = helpers::flatten_json_to_table(&data);
384    Ok(CommandOutput::new(data, headers, rows))
385}
386
387fn priv_get<'a>(val: &'a serde_json::Value, keys: &[&str]) -> &'a serde_json::Value {
388    helpers::first_of(val, keys)
389}
390
391// ---------------------------------------------------------------------------
392// Equity snapshot history
393// ---------------------------------------------------------------------------
394
395#[derive(Debug, Serialize, Deserialize)]
396struct EquitySnapshot {
397    timestamp: u64,
398    equity: f64,
399}
400
401#[derive(Debug, Serialize, Deserialize)]
402struct EquityHistoryData {
403    snapshots: Vec<EquitySnapshot>,
404}
405
406fn equity_history_path() -> std::path::PathBuf {
407    IndodaxConfig::config_dir().join("equity_history.json")
408}
409
410fn load_equity_history() -> EquityHistoryData {
411    let path = equity_history_path();
412    if path.exists() {
413        std::fs::read_to_string(&path)
414            .ok()
415            .and_then(|s| serde_json::from_str(&s).ok())
416            .unwrap_or(EquityHistoryData { snapshots: vec![] })
417    } else {
418        EquityHistoryData { snapshots: vec![] }
419    }
420}
421
422fn save_equity_history(data: &EquityHistoryData) -> Result<()> {
423    let dir = IndodaxConfig::config_dir();
424    std::fs::create_dir_all(&dir)?;
425    let content = serde_json::to_string_pretty(data)?;
426    #[cfg(unix)]
427    {
428        use std::io::Write;
429        use std::os::unix::fs::OpenOptionsExt;
430        let mut file = std::fs::OpenOptions::new()
431            .write(true)
432            .create(true)
433            .truncate(true)
434            .mode(0o600)
435            .open(equity_history_path())?;
436        file.write_all(content.as_bytes())?;
437    }
438    #[cfg(not(unix))]
439    {
440        std::fs::write(equity_history_path(), content)?;
441    }
442    Ok(())
443}
444
445async fn calculate_equity(client: &IndodaxClient) -> Result<f64> {
446    let info: serde_json::Value = client.private_post_v1("getInfo", &HashMap::new()).await?;
447
448    let mut balances: HashMap<String, f64> = HashMap::new();
449    if let Some(bal_map) = info["balance"].as_object() {
450        for (k, v) in bal_map {
451            let val = v
452                .as_str()
453                .and_then(|s| s.parse::<f64>().ok())
454                .or_else(|| v.as_f64())
455                .unwrap_or(0.0);
456            if val > 0.0 {
457                balances.insert(k.clone(), val);
458            }
459        }
460    }
461
462    let tickers: serde_json::Value = client.public_get("/api/ticker_all").await?;
463    let mut prices: HashMap<String, f64> = HashMap::new();
464    if let Some(t) = tickers["tickers"].as_object() {
465        for (k, v) in t {
466            let last = v["last"]
467                .as_str()
468                .and_then(|s| s.parse::<f64>().ok())
469                .or_else(|| v["last"].as_f64())
470                .unwrap_or(0.0);
471            prices.insert(k.clone(), last);
472        }
473    }
474
475    let mut total = 0.0;
476    let btc_idr = prices.get("btc_idr").copied().unwrap_or(0.0);
477    let usdt_idr = prices.get("usdt_idr").copied().unwrap_or(0.0);
478    let eth_idr = prices.get("eth_idr").copied().unwrap_or(0.0);
479
480    for (currency, amount) in &balances {
481        if currency == "idr" {
482            total += amount;
483        } else if currency == "btc" {
484            total += amount * btc_idr;
485        } else if currency == "usdt" {
486            total += amount * usdt_idr;
487        } else {
488            let pair_idr = format!("{}_{}", currency, "idr");
489            let pair_btc = format!("{}_{}", currency, "btc");
490            let pair_usdt = format!("{}_{}", currency, "usdt");
491            let pair_eth = format!("{}_{}", currency, "eth");
492
493            if let Some(price) = prices.get(&pair_idr) {
494                total += amount * price;
495            } else if let Some(price) = prices.get(&pair_btc) {
496                total += amount * price * btc_idr;
497            } else if let Some(price) = prices.get(&pair_usdt) {
498                total += amount * price * usdt_idr;
499            } else if let Some(price) = prices.get(&pair_eth) {
500                total += amount * price * eth_idr;
501            } else {
502                eprintln!("[EQUITY] Warning: No known price pair for {} (value: {}). Contribution set to 0.", currency.to_uppercase(), amount);
503            }
504        }
505    }
506
507    Ok(total)
508}
509
510async fn equity_snap(client: &IndodaxClient) -> Result<CommandOutput> {
511    let equity = calculate_equity(client).await?;
512    let timestamp = Signer::now_millis();
513
514    let snap = EquitySnapshot { timestamp, equity };
515    let mut history = load_equity_history();
516    history.snapshots.push(snap);
517
518    if history.snapshots.len() > 1000 {
519        let keep = history.snapshots.split_off(history.snapshots.len() - 1000);
520        history.snapshots = keep;
521    }
522
523    save_equity_history(&history)?;
524
525    let count = history.snapshots.len();
526    let first_equity = history.snapshots.first().map(|s| s.equity).unwrap_or(equity);
527    let peak = history.snapshots.iter().map(|s| s.equity).fold(0.0_f64, f64::max);
528    let change = equity - first_equity;
529    let change_pct = if first_equity > 0.0 { (change / first_equity) * 100.0 } else { 0.0 };
530    let dd_pct = if peak > 0.0 { ((equity / peak) - 1.0) * 100.0 } else { 0.0 };
531
532    let headers = vec!["Metric".into(), "Value".into()];
533    let formatted_time = helpers::format_timestamp(timestamp, true);
534    let rows = vec![
535        vec!["Time".into(), formatted_time],
536        vec!["Equity (IDR)".into(), format_equity(equity)],
537        vec!["Change".into(), format_change(change)],
538        vec!["Change %".into(), format_change_pct(change_pct)],
539        vec!["Peak (IDR)".into(), format_equity(peak)],
540        vec!["Drawdown %".into(), format_change_pct(dd_pct)],
541        vec!["Total Snapshots".into(), count.to_string()],
542    ];
543
544    let data = serde_json::json!({
545        "timestamp": timestamp,
546        "equity": equity,
547        "change": change,
548        "change_pct": change_pct,
549        "peak": peak,
550        "drawdown_pct": dd_pct,
551        "total_snapshots": count,
552    });
553
554    Ok(CommandOutput::new(data, headers, rows))
555}
556
557fn equity_history(limit: usize, all: bool) -> Result<CommandOutput> {
558    let history = load_equity_history();
559
560    if history.snapshots.is_empty() {
561        return Ok(CommandOutput::json(serde_json::json!({
562            "status": "ok",
563            "message": "No equity snapshots. Use `indodax account equity-snap` to record one.",
564            "snapshots": [],
565        })));
566    }
567
568    let first_equity = history.snapshots.first().map(|s| s.equity).unwrap_or(0.0);
569
570    let headers = vec![
571        "Time".into(),
572        "Equity (IDR)".into(),
573        "Change".into(),
574        "Change %".into(),
575        "Peak (IDR)".into(),
576        "DD %".into(),
577    ];
578
579    let snapshots_to_show: Vec<&EquitySnapshot> = if all {
580        history.snapshots.iter().collect()
581    } else {
582        let take = limit.min(history.snapshots.len());
583        history.snapshots[history.snapshots.len() - take..]
584            .iter()
585            .collect()
586    };
587
588    let mut rows: Vec<Vec<String>> = Vec::new();
589    let mut peak = 0.0_f64;
590
591    for snap in &snapshots_to_show {
592        if snap.equity > peak {
593            peak = snap.equity;
594        }
595        let change = snap.equity - first_equity;
596        let change_pct = if first_equity > 0.0 {
597            (change / first_equity) * 100.0
598        } else {
599            0.0
600        };
601        let dd_pct = if peak > 0.0 {
602            ((snap.equity / peak) - 1.0) * 100.0
603        } else {
604            0.0
605        };
606
607        rows.push(vec![
608            format_timestamp_short(snap.timestamp),
609            format_equity(snap.equity),
610            format_change(change),
611            format_change_pct(change_pct),
612            format_equity(peak),
613            format_change_pct(dd_pct),
614        ]);
615    }
616
617    let data = serde_json::json!({
618        "count": history.snapshots.len(),
619        "first_equity": first_equity,
620        "snapshots": history.snapshots.iter().map(|s| serde_json::json!({
621            "timestamp": s.timestamp,
622            "equity": s.equity,
623        })).collect::<Vec<_>>(),
624    });
625
626    let count = history.snapshots.len();
627    Ok(CommandOutput::new(data, headers, rows)
628        .with_addendum(format!("[EQUITY] {} snapshot(s) total", count)))
629}
630
631fn format_equity(val: f64) -> String {
632    format!("{:>14.2}", val)
633}
634
635fn format_change(val: f64) -> String {
636    if val >= 0.0 {
637        format!("+{:>10.2}", val)
638    } else {
639        format!("{:>11.2}", val)
640    }
641}
642
643fn format_change_pct(val: f64) -> String {
644    if val >= 0.0 {
645        format!("+{:>7.2}%", val)
646    } else {
647        format!("{:>8.2}%", val)
648    }
649}
650
651fn format_timestamp_short(ts: u64) -> String {
652    let ts_sec = ts / 1000;
653    chrono::DateTime::from_timestamp(ts_sec.min(i64::MAX as u64) as i64, 0)
654        .map(|dt| dt.format("%b %d  %H:%M:%S").to_string())
655        .unwrap_or_else(|| ts.to_string())
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use serde_json::json;
662    #[test]
663    fn test_priv_get_existing_key() {
664        let val = json!({"name": "Alice", "age": 30});
665        let result = priv_get(&val, &["name"]);
666        assert_eq!(result, &json!("Alice"));
667    }
668
669    #[test]
670    fn test_priv_get_first_key_exists() {
671        let val = json!({"a": 1, "b": 2});
672        let result = priv_get(&val, &["a", "b"]);
673        assert_eq!(result, &json!(1));
674    }
675
676    #[test]
677    fn test_priv_get_second_key_exists() {
678        let val = json!({"a": null, "b": "2"});
679        let result = priv_get(&val, &["a", "b"]);
680        // priv_get delegates to first_of which skips null values
681        assert_eq!(result, &json!("2"));
682    }
683
684    #[test]
685    fn test_priv_get_no_keys_exist() {
686        let val = json!({"a": 1});
687        let result = priv_get(&val, &["x", "y", "z"]);
688        assert_eq!(result, &serde_json::Value::Null);
689    }
690
691    #[test]
692    fn test_priv_get_with_json_null() {
693        let val = json!(null);
694        let result = priv_get(&val, &["key"]);
695        assert_eq!(result, &serde_json::Value::Null);
696    }
697
698    #[test]
699    fn test_priv_get_empty_keys() {
700        let val = json!({"a": 1});
701        let result = priv_get(&val, &[]);
702        assert_eq!(result, &serde_json::Value::Null);
703    }
704
705    #[test]
706    fn test_priv_get_nested_value() {
707        let val = json!({"data": {"name": "Bob"}});
708        let result = priv_get(&val, &["data"]);
709        assert_eq!(result, &json!({"name": "Bob"}));
710    }
711
712    #[test]
713    fn test_account_command_variants() {
714        let _cmd1 = AccountCommand::Info;
715        let _cmd2 = AccountCommand::Balance;
716        let _cmd3 = AccountCommand::OpenOrders { pair: Some("btc_idr".into()) };
717        let _cmd4 = AccountCommand::OrderHistory { symbol: "btc_idr".into(), limit: 100 };
718        let _cmd5 = AccountCommand::TradeHistory { symbol: "btc_idr".into(), limit: 100 };
719        let _cmd6 = AccountCommand::TransHistory;
720        let _cmd7 = AccountCommand::GetOrder { order_id: 123, pair: "btc_idr".into() };
721        let _cmd8 = AccountCommand::EquitySnap;
722        let _cmd9 = AccountCommand::EquityHistory { limit: 10, all: false };
723    }
724
725    #[test]
726    fn test_priv_get_with_null_first() {
727        let val = json!({"first": null, "second": "value"});
728        let result = priv_get(&val, &["first", "second"]);
729        // priv_get delegates to first_of which skips null values
730        assert_eq!(result, &json!("value"));
731    }
732
733    #[test]
734    fn test_priv_get_array_value() {
735        let val = json!({"arr": [1, 2, 3]});
736        let result = priv_get(&val, &["arr"]);
737        assert_eq!(result, &json!([1, 2, 3]));
738    }
739
740    #[test]
741    fn test_priv_get_number_value() {
742        let val = json!({"num": 42.5});
743        let result = priv_get(&val, &["num"]);
744        assert_eq!(result, &json!(42.5));
745    }
746
747    #[test]
748    fn test_priv_get_bool_value() {
749        let val = json!({"flag": true});
750        let result = priv_get(&val, &["flag"]);
751        assert_eq!(result, &json!(true));
752    }
753}