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(¤cy, 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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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#[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("e_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 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 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}