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 merged = serde_json::Map::new();
321    for key in &["withdraw", "deposit", "transactions"] {
322        if let Some(obj) = data.get(*key).and_then(|v| v.as_object()) {
323            merged.extend(obj.clone());
324        }
325    }
326    let trans_list = if merged.is_empty() { None } else { Some(serde_json::Value::Object(merged)) };
327
328    if let Some(serde_json::Value::Object(map)) = trans_list {
329        for (id, entry) in map {
330            let tx_type = if id.contains("withdraw") || entry.get("withdraw_id").is_some() {
331                "WITHDRAW"
332            } else {
333                "DEPOSIT"
334            };
335            rows.push(vec![
336                id.clone(),
337                tx_type.into(),
338                helpers::value_to_string(
339                    priv_get(&entry, &["currency", "asset", "coin"]),
340                ),
341                helpers::value_to_string(
342                    priv_get(&entry, &["amount", "value"]),
343                ),
344                helpers::value_to_string(
345                    priv_get(&entry, &["fee", "withdraw_fee"]),
346                ),
347                helpers::value_to_string(
348                    priv_get(&entry, &["status", "state"]),
349                ),
350                helpers::value_to_string(
351                    priv_get(&entry, &["submit_time", "timestamp", "time", "submitted"]),
352                ),
353            ]);
354        }
355    }
356
357    rows.sort_by(|a, b| b[0].cmp(&a[0]));
358    Ok(CommandOutput::new(data, headers, rows))
359}
360
361async fn get_order(
362    client: &IndodaxClient,
363    order_id: u64,
364    pair: &str,
365) -> Result<CommandOutput> {
366    let mut params = HashMap::new();
367    params.insert("order_id".into(), order_id.to_string());
368    params.insert("pair".into(), pair.to_string());
369
370    let data: serde_json::Value =
371        client.private_post_v1("getOrder", &params).await?;
372
373    let (headers, rows) = helpers::flatten_json_to_table(&data);
374    Ok(CommandOutput::new(data, headers, rows))
375}
376
377fn priv_get<'a>(val: &'a serde_json::Value, keys: &[&str]) -> &'a serde_json::Value {
378    helpers::first_of(val, keys)
379}
380
381// ---------------------------------------------------------------------------
382// Equity snapshot history
383// ---------------------------------------------------------------------------
384
385#[derive(Debug, Serialize, Deserialize)]
386struct EquitySnapshot {
387    timestamp: u64,
388    equity: f64,
389}
390
391#[derive(Debug, Serialize, Deserialize)]
392struct EquityHistoryData {
393    snapshots: Vec<EquitySnapshot>,
394}
395
396fn equity_history_path() -> std::path::PathBuf {
397    IndodaxConfig::config_dir().join("equity_history.json")
398}
399
400fn load_equity_history() -> EquityHistoryData {
401    let path = equity_history_path();
402    if path.exists() {
403        std::fs::read_to_string(&path)
404            .ok()
405            .and_then(|s| serde_json::from_str(&s).ok())
406            .unwrap_or(EquityHistoryData { snapshots: vec![] })
407    } else {
408        EquityHistoryData { snapshots: vec![] }
409    }
410}
411
412fn save_equity_history(data: &EquityHistoryData) -> Result<()> {
413    let dir = IndodaxConfig::config_dir();
414    std::fs::create_dir_all(&dir)?;
415    let content = serde_json::to_string_pretty(data)?;
416    std::fs::write(equity_history_path(), content)?;
417    Ok(())
418}
419
420async fn calculate_equity(client: &IndodaxClient) -> Result<f64> {
421    let info: serde_json::Value = client.private_post_v1("getInfo", &HashMap::new()).await?;
422
423    let mut balances: HashMap<String, f64> = HashMap::new();
424    if let Some(bal_map) = info["balance"].as_object() {
425        for (k, v) in bal_map {
426            let val = v
427                .as_str()
428                .and_then(|s| s.parse::<f64>().ok())
429                .or_else(|| v.as_f64())
430                .unwrap_or(0.0);
431            if val > 0.0 {
432                balances.insert(k.clone(), val);
433            }
434        }
435    }
436
437    let tickers: serde_json::Value = client.public_get("/api/ticker_all").await?;
438    let mut prices: HashMap<String, f64> = HashMap::new();
439    if let Some(t) = tickers["tickers"].as_object() {
440        for (k, v) in t {
441            let last = v["last"]
442                .as_str()
443                .and_then(|s| s.parse::<f64>().ok())
444                .or_else(|| v["last"].as_f64())
445                .unwrap_or(0.0);
446            prices.insert(k.clone(), last);
447        }
448    }
449
450    let mut total = 0.0;
451    let btc_idr = prices.get("btc_idr").copied().unwrap_or(0.0);
452    let usdt_idr = prices.get("usdt_idr").copied().unwrap_or(0.0);
453    let eth_idr = prices.get("eth_idr").copied().unwrap_or(0.0);
454
455    for (currency, amount) in &balances {
456        if currency == "idr" {
457            total += amount;
458        } else if currency == "btc" {
459            total += amount * btc_idr;
460        } else if currency == "usdt" {
461            total += amount * usdt_idr;
462        } else {
463            let pair_idr = format!("{}_{}", currency, "idr");
464            let pair_btc = format!("{}_{}", currency, "btc");
465            let pair_usdt = format!("{}_{}", currency, "usdt");
466            let pair_eth = format!("{}_{}", currency, "eth");
467
468            if let Some(price) = prices.get(&pair_idr) {
469                total += amount * price;
470            } else if let Some(price) = prices.get(&pair_btc) {
471                total += amount * price * btc_idr;
472            } else if let Some(price) = prices.get(&pair_usdt) {
473                total += amount * price * usdt_idr;
474            } else if let Some(price) = prices.get(&pair_eth) {
475                total += amount * price * eth_idr;
476            }
477        }
478    }
479
480    Ok(total)
481}
482
483async fn equity_snap(client: &IndodaxClient) -> Result<CommandOutput> {
484    let equity = calculate_equity(client).await?;
485    let timestamp = Signer::now_millis();
486
487    let snap = EquitySnapshot { timestamp, equity };
488    let mut history = load_equity_history();
489    history.snapshots.push(snap);
490
491    if history.snapshots.len() > 1000 {
492        let keep = history.snapshots.split_off(history.snapshots.len() - 1000);
493        history.snapshots = keep;
494    }
495
496    save_equity_history(&history)?;
497
498    let count = history.snapshots.len();
499    let first_equity = history.snapshots.first().map(|s| s.equity).unwrap_or(equity);
500    let peak = history.snapshots.iter().map(|s| s.equity).fold(0.0_f64, f64::max);
501    let change = equity - first_equity;
502    let change_pct = if first_equity > 0.0 { (change / first_equity) * 100.0 } else { 0.0 };
503    let dd_pct = if peak > 0.0 { ((equity / peak) - 1.0) * 100.0 } else { 0.0 };
504
505    let headers = vec!["Metric".into(), "Value".into()];
506    let formatted_time = helpers::format_timestamp(timestamp, true);
507    let rows = vec![
508        vec!["Time".into(), formatted_time],
509        vec!["Equity (IDR)".into(), format_equity(equity)],
510        vec!["Change".into(), format_change(change)],
511        vec!["Change %".into(), format_change_pct(change_pct)],
512        vec!["Peak (IDR)".into(), format_equity(peak)],
513        vec!["Drawdown %".into(), format_change_pct(dd_pct)],
514        vec!["Total Snapshots".into(), count.to_string()],
515    ];
516
517    let data = serde_json::json!({
518        "timestamp": timestamp,
519        "equity": equity,
520        "change": change,
521        "change_pct": change_pct,
522        "peak": peak,
523        "drawdown_pct": dd_pct,
524        "total_snapshots": count,
525    });
526
527    Ok(CommandOutput::new(data, headers, rows))
528}
529
530fn equity_history(limit: usize, all: bool) -> Result<CommandOutput> {
531    let history = load_equity_history();
532
533    if history.snapshots.is_empty() {
534        return Ok(CommandOutput::json(serde_json::json!({
535            "status": "ok",
536            "message": "No equity snapshots. Use `indodax account equity-snap` to record one.",
537            "snapshots": [],
538        })));
539    }
540
541    let first_equity = history.snapshots.first().map(|s| s.equity).unwrap_or(0.0);
542
543    let headers = vec![
544        "Time".into(),
545        "Equity (IDR)".into(),
546        "Change".into(),
547        "Change %".into(),
548        "Peak (IDR)".into(),
549        "DD %".into(),
550    ];
551
552    let snapshots_to_show: Vec<&EquitySnapshot> = if all {
553        history.snapshots.iter().collect()
554    } else {
555        let take = limit.min(history.snapshots.len());
556        history.snapshots[history.snapshots.len() - take..]
557            .iter()
558            .collect()
559    };
560
561    let mut rows: Vec<Vec<String>> = Vec::new();
562    let mut peak = 0.0_f64;
563
564    for snap in &snapshots_to_show {
565        if snap.equity > peak {
566            peak = snap.equity;
567        }
568        let change = snap.equity - first_equity;
569        let change_pct = if first_equity > 0.0 {
570            (change / first_equity) * 100.0
571        } else {
572            0.0
573        };
574        let dd_pct = if peak > 0.0 {
575            ((snap.equity / peak) - 1.0) * 100.0
576        } else {
577            0.0
578        };
579
580        rows.push(vec![
581            format_timestamp_short(snap.timestamp),
582            format_equity(snap.equity),
583            format_change(change),
584            format_change_pct(change_pct),
585            format_equity(peak),
586            format_change_pct(dd_pct),
587        ]);
588    }
589
590    let data = serde_json::json!({
591        "count": history.snapshots.len(),
592        "first_equity": first_equity,
593        "snapshots": history.snapshots.iter().map(|s| serde_json::json!({
594            "timestamp": s.timestamp,
595            "equity": s.equity,
596        })).collect::<Vec<_>>(),
597    });
598
599    let count = history.snapshots.len();
600    Ok(CommandOutput::new(data, headers, rows)
601        .with_addendum(format!("[EQUITY] {} snapshot(s) total", count)))
602}
603
604fn format_equity(val: f64) -> String {
605    format!("{:>14.2}", val)
606}
607
608fn format_change(val: f64) -> String {
609    if val >= 0.0 {
610        format!("+{:>10.2}", val)
611    } else {
612        format!("{:>11.2}", val)
613    }
614}
615
616fn format_change_pct(val: f64) -> String {
617    if val >= 0.0 {
618        format!("+{:>7.2}%", val)
619    } else {
620        format!("{:>8.2}%", val)
621    }
622}
623
624fn format_timestamp_short(ts: u64) -> String {
625    let ts_sec = ts / 1000;
626    chrono::DateTime::from_timestamp(ts_sec.min(i64::MAX as u64) as i64, 0)
627        .map(|dt| dt.format("%b %d  %H:%M:%S").to_string())
628        .unwrap_or_else(|| ts.to_string())
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use serde_json::json;
635    #[test]
636    fn test_priv_get_existing_key() {
637        let val = json!({"name": "Alice", "age": 30});
638        let result = priv_get(&val, &["name"]);
639        assert_eq!(result, &json!("Alice"));
640    }
641
642    #[test]
643    fn test_priv_get_first_key_exists() {
644        let val = json!({"a": 1, "b": 2});
645        let result = priv_get(&val, &["a", "b"]);
646        assert_eq!(result, &json!(1));
647    }
648
649    #[test]
650    fn test_priv_get_second_key_exists() {
651        let val = json!({"a": null, "b": "2"});
652        let result = priv_get(&val, &["a", "b"]);
653        // priv_get delegates to first_of which skips null values
654        assert_eq!(result, &json!("2"));
655    }
656
657    #[test]
658    fn test_priv_get_no_keys_exist() {
659        let val = json!({"a": 1});
660        let result = priv_get(&val, &["x", "y", "z"]);
661        assert_eq!(result, &serde_json::Value::Null);
662    }
663
664    #[test]
665    fn test_priv_get_with_json_null() {
666        let val = json!(null);
667        let result = priv_get(&val, &["key"]);
668        assert_eq!(result, &serde_json::Value::Null);
669    }
670
671    #[test]
672    fn test_priv_get_empty_keys() {
673        let val = json!({"a": 1});
674        let result = priv_get(&val, &[]);
675        assert_eq!(result, &serde_json::Value::Null);
676    }
677
678    #[test]
679    fn test_priv_get_nested_value() {
680        let val = json!({"data": {"name": "Bob"}});
681        let result = priv_get(&val, &["data"]);
682        assert_eq!(result, &json!({"name": "Bob"}));
683    }
684
685    #[test]
686    fn test_account_command_variants() {
687        let _cmd1 = AccountCommand::Info;
688        let _cmd2 = AccountCommand::Balance;
689        let _cmd3 = AccountCommand::OpenOrders { pair: Some("btc_idr".into()) };
690        let _cmd4 = AccountCommand::OrderHistory { symbol: "btc_idr".into(), limit: 100 };
691        let _cmd5 = AccountCommand::TradeHistory { symbol: "btc_idr".into(), limit: 100 };
692        let _cmd6 = AccountCommand::TransHistory;
693        let _cmd7 = AccountCommand::GetOrder { order_id: 123, pair: "btc_idr".into() };
694        let _cmd8 = AccountCommand::EquitySnap;
695        let _cmd9 = AccountCommand::EquityHistory { limit: 10, all: false };
696    }
697
698    #[test]
699    fn test_priv_get_with_null_first() {
700        let val = json!({"first": null, "second": "value"});
701        let result = priv_get(&val, &["first", "second"]);
702        // priv_get delegates to first_of which skips null values
703        assert_eq!(result, &json!("value"));
704    }
705
706    #[test]
707    fn test_priv_get_array_value() {
708        let val = json!({"arr": [1, 2, 3]});
709        let result = priv_get(&val, &["arr"]);
710        assert_eq!(result, &json!([1, 2, 3]));
711    }
712
713    #[test]
714    fn test_priv_get_number_value() {
715        let val = json!({"num": 42.5});
716        let result = priv_get(&val, &["num"]);
717        assert_eq!(result, &json!(42.5));
718    }
719
720    #[test]
721    fn test_priv_get_bool_value() {
722        let val = json!({"flag": true});
723        let result = priv_get(&val, &["flag"]);
724        assert_eq!(result, &json!(true));
725    }
726}