Skip to main content

indodax_cli/commands/
account.rs

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