1use crate::client::IndodaxClient;
2use crate::commands::helpers;
3use crate::config::IndodaxConfig;
4use crate::errors::IndodaxError;
5use crate::output::CommandOutput;
6use futures_util::future::join_all;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10pub const DEFAULT_BALANCE_IDR: f64 = 100_000_000.0;
11pub const DEFAULT_BALANCE_BTC: f64 = 1.0;
12const TAKER_FEE: f64 = 0.0026; const BALANCE_EPSILON: f64 = 1e-8;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PaperOrder {
17 pub id: u64,
18 pub pair: String,
19 pub side: String,
20 pub price: f64,
21 pub amount: f64,
22 pub remaining: f64,
23 pub order_type: String,
24 pub status: String,
25 pub created_at: u64,
26 #[serde(default)]
27 pub fees_paid: f64,
28 #[serde(default)]
29 pub filled_price: f64,
30 #[serde(default)]
31 pub total_spent: f64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PaperState {
36 pub balances: HashMap<String, f64>,
37 pub orders: Vec<PaperOrder>,
38 pub next_order_id: u64,
39 pub trade_count: u64,
40 #[serde(default)]
41 pub total_fees_paid: f64,
42 #[serde(default)]
43 pub initial_balances: Option<HashMap<String, f64>>,
44}
45
46impl Default for PaperState {
47 fn default() -> Self {
48 let mut balances = HashMap::new();
49 balances.insert("idr".into(), DEFAULT_BALANCE_IDR);
50 balances.insert("btc".into(), DEFAULT_BALANCE_BTC);
51 Self {
52 initial_balances: Some(balances.clone()),
53 balances,
54 orders: Vec::new(),
55 next_order_id: 1,
56 trade_count: 0,
57 total_fees_paid: 0.0,
58 }
59 }
60}
61
62impl PaperState {
63 pub fn initial_balance(&self, currency: &str) -> f64 {
64 self.initial_balances
65 .as_ref()
66 .and_then(|b| b.get(currency).copied())
67 .unwrap_or(0.0)
68 }
69}
70
71impl PaperState {
72 pub fn load(config: &IndodaxConfig) -> Self {
73 let mut result: Option<PaperState> = config
74 .paper_balances
75 .as_ref()
76 .and_then(|v| serde_json::from_value(v.clone()).ok());
77 if config.paper_balances.is_some() && result.is_none() {
78 eprintln!("[PAPER] Warning: Failed to deserialize saved paper state, resetting to defaults");
79 }
80 if let Some(ref mut state) = result {
81 if state.initial_balances.is_none() {
82 eprintln!("[PAPER] Warning: Saved state predates balance tracking. Snapshotting current balances as initial (P&L will reflect only future changes).");
83 state.initial_balances = Some(state.balances.clone());
84 }
85 }
86 result.unwrap_or_default()
87 }
88
89 pub fn save(&self, config: &mut IndodaxConfig) -> Result<(), IndodaxError> {
90 config.paper_balances = Some(serde_json::to_value(self).map_err(|e| IndodaxError::Other(e.to_string()))?);
91 config.save().map_err(|e| IndodaxError::Other(e.to_string()))?;
92 Ok(())
93 }
94}
95
96#[derive(Debug, clap::Subcommand)]
97pub enum PaperCommand {
98 #[command(name = "init", about = "Initialize paper trading with custom or default balances")]
99 Init {
100 #[arg(long, help = "Initial IDR balance (default: 100000000)")]
101 idr: Option<f64>,
102 #[arg(long, help = "Initial BTC balance (default: 1.0)")]
103 btc: Option<f64>,
104 },
105
106 #[command(name = "reset", about = "Reset paper trading state")]
107 Reset,
108
109 #[command(name = "topup", about = "Add balance to a currency")]
110 Topup {
111 #[arg(short = 'c', long, help = "Currency to topup (e.g. idr, btc)")]
112 currency: String,
113 #[arg(short = 'a', long, help = "Amount to add")]
114 amount: f64,
115 },
116
117 #[command(name = "balance", about = "Show paper trading balances")]
118 Balance,
119
120 #[command(name = "buy", about = "Place a simulated buy order")]
121 Buy {
122 #[arg(short = 'p', long, help = "Trading pair (e.g. btc_idr)")]
123 pair: String,
124 #[arg(short = 'i', long, help = "The total IDR amount to spend.")]
125 idr: Option<f64>,
126 #[arg(short = 'a', long, help = "Amount in base currency (e.g. BTC) (alternative to --idr)")]
127 amount: Option<f64>,
128 #[arg(short = 'r', long, help = "Limit price. If omitted, a market order will be placed.")]
129 price: Option<f64>,
130 },
131
132 #[command(name = "sell", about = "Place a simulated sell order")]
133 Sell {
134 #[arg(short = 'p', long, help = "Trading pair (e.g. btc_idr)")]
135 pair: String,
136 #[arg(short = 'a', long, help = "Amount in base currency (e.g. BTC)")]
137 amount: f64,
138 #[arg(short = 'r', long, help = "Limit price. If omitted, a market order will be placed.")]
139 price: Option<f64>,
140 },
141
142 #[command(name = "orders", about = "List open paper orders (use history for all orders)")]
143 Orders {
144 #[arg(short = 'p', long, help = "Filter by trading pair (e.g. btc_idr)")]
145 pair: Option<String>,
146 #[arg(long, help = "Sort by field: id, pair, side, price, amount, remaining, status")]
147 sort_by: Option<String>,
148 #[arg(long, default_value = "asc", help = "Sort order: asc or desc")]
149 sort_order: Option<String>,
150 },
151
152 #[command(name = "cancel", about = "Cancel a paper order")]
153 Cancel {
154 #[arg(short = 'i', long, help = "Order ID to cancel")]
155 order_id: u64,
156 },
157
158 #[command(name = "cancel-all", about = "Cancel all paper orders")]
159 CancelAll,
160
161 #[command(name = "fill", about = "Fill an open paper order")]
162 Fill {
163 #[arg(short = 'i', long, help = "Order ID to fill (required unless --all is set)")]
164 order_id: Option<u64>,
165 #[arg(short = 'r', long, help = "Fill price (defaults to order price)")]
166 price: Option<f64>,
167 #[arg(short = 'a', long, help = "Fill all open orders at once")]
168 all: bool,
169 #[arg(short = 'f', long, help = "Fetch live prices from Indodax to fill matching orders")]
170 fetch: bool,
171 },
172
173 #[command(name = "history", about = "Show paper trading history")]
174 History {
175 #[arg(long, help = "Sort by field: id, pair, side, price, amount, status")]
176 sort_by: Option<String>,
177 #[arg(long, default_value = "desc", help = "Sort order: asc or desc (default: newest first)")]
178 sort_order: Option<String>,
179 },
180
181 #[command(name = "check-fills", about = "Auto-fill open orders when market conditions match")]
182 CheckFills {
183 #[arg(short = 'p', long, help = "JSON object of current market prices, e.g. '{\"btc_idr\": 100000000}'")]
184 prices: Option<String>,
185 #[arg(long, help = "Auto-fetch current market prices from Indodax API for relevant pairs")]
186 fetch: bool,
187 },
188
189 #[command(name = "status", about = "Show paper trading status summary")]
190 Status,
191
192 #[command(name = "watch", about = "Automatically watch market and fill matching orders in real-time")]
193 Watch {
194 #[arg(short, long, default_value = "30", help = "Polling interval in seconds")]
195 interval: u64,
196 #[arg(long, help = "Stop after first fill")]
197 once: bool,
198 },
199}
200
201pub async fn execute(
202 client: &IndodaxClient,
203 config: &mut IndodaxConfig,
204 cmd: &PaperCommand,
205) -> Result<CommandOutput, IndodaxError> {
206 let mut state = PaperState::load(config);
207 let result = dispatch_paper(client, &mut state, config, cmd).await;
208 state.save(config)?;
209 result
210}
211
212async fn dispatch_paper(
213 client: &IndodaxClient,
214 state: &mut PaperState,
215 config: &mut IndodaxConfig,
216 cmd: &PaperCommand,
217) -> Result<CommandOutput, IndodaxError> {
218 match cmd {
219 PaperCommand::Init { idr, btc } => paper_init(state, *idr, *btc),
220 PaperCommand::Reset => paper_reset(state),
221 PaperCommand::Topup { currency, amount } => paper_topup(state, currency, *amount),
222 PaperCommand::Balance => paper_balance(state),
223 PaperCommand::Buy { pair, idr, amount, price } => {
224 let pair = helpers::normalize_pair(pair);
225 if let Some(idr_val) = idr {
226 place_paper_order_idr(state, &pair, "buy", *idr_val, *price)
227 } else if let Some(amt) = amount {
228 place_paper_order(state, &pair, "buy", *price, *amt)
229 } else {
230 Err(IndodaxError::Other("Either --idr or --amount must be specified".to_string()))
231 }
232 }
233 PaperCommand::Sell { pair, price, amount } => {
234 let pair = helpers::normalize_pair(pair);
235 place_paper_order(state, &pair, "sell", *price, *amount)
236 }
237 PaperCommand::Orders { pair, sort_by, sort_order } => {
238 let pair = pair.as_ref().map(|p| helpers::normalize_pair(p));
239 paper_orders(state, pair.as_deref(), sort_by.as_deref(), sort_order.as_deref())
240 }
241 PaperCommand::Cancel { order_id } => paper_cancel(state, *order_id),
242 PaperCommand::CancelAll => paper_cancel_all(state),
243 PaperCommand::Fill { order_id, price, all, fetch } => paper_fill(state, *order_id, *price, *all, Some(client), *fetch).await,
244 PaperCommand::CheckFills { prices, fetch } => paper_check_fills(client, state, prices.as_deref(), *fetch).await,
245 PaperCommand::History { sort_by, sort_order } => {
246 paper_history(state, sort_by.as_deref(), sort_order.as_deref())
247 }
248 PaperCommand::Status => paper_status(state),
249 PaperCommand::Watch { interval, once } => paper_watch(client, config, *interval, *once).await,
250 }
251}
252
253pub fn init_paper_state(idr: Option<f64>, btc: Option<f64>) -> PaperState {
254 let mut balances = HashMap::new();
255 balances.insert("idr".into(), idr.unwrap_or(DEFAULT_BALANCE_IDR));
256 balances.insert("btc".into(), btc.unwrap_or(DEFAULT_BALANCE_BTC));
257 let initial = balances.clone();
258 PaperState {
259 balances,
260 orders: Vec::new(),
261 next_order_id: 1,
262 trade_count: 0,
263 total_fees_paid: 0.0,
264 initial_balances: Some(initial),
265 }
266}
267
268fn paper_init(state: &mut PaperState, idr: Option<f64>, btc: Option<f64>) -> Result<CommandOutput, IndodaxError> {
269 *state = init_paper_state(idr, btc);
270 let data = serde_json::json!({
271 "mode": "paper",
272 "status": "initialized",
273 "balances": state.balances,
274 });
275 Ok(CommandOutput::json(data).with_addendum("[PAPER] Trading initialized with virtual balances"))
276}
277
278fn paper_reset(state: &mut PaperState) -> Result<CommandOutput, IndodaxError> {
279 *state = PaperState::default();
280 let data = serde_json::json!({
281 "mode": "paper",
282 "status": "reset"
283 });
284 Ok(CommandOutput::json(data).with_addendum("[PAPER] Trading state reset"))
285}
286
287fn paper_topup(state: &mut PaperState, currency: &str, amount: f64) -> Result<CommandOutput, IndodaxError> {
288 if amount <= 0.0 {
289 return Err(IndodaxError::Other(
290 format!("[PAPER] Amount must be positive, got {}", amount)
291 ));
292 }
293 let balance_val = {
294 let balance = state.balances.entry(currency.to_lowercase()).or_insert(0.0);
295 *balance += amount;
296 *balance
297 };
298 round_balance(&mut state.balances, ¤cy.to_lowercase());
299 let current_balance = *state.balances.get(¤cy.to_lowercase()).unwrap_or(&balance_val);
300 let data = serde_json::json!({
301 "mode": "paper",
302 "currency": currency.to_uppercase(),
303 "amount_added": amount,
304 "new_balance": current_balance,
305 });
306 Ok(CommandOutput::json(data).with_addendum(format!(
307 "[PAPER] Added {} to {} balance. New balance: {}",
308 format_balance(currency, amount),
309 currency.to_uppercase(),
310 format_balance(currency, current_balance)
311 )))
312}
313
314fn is_fiat_or_stable(currency: &str) -> bool {
315 match currency.to_lowercase().as_str() {
316 "idr" | "usdt" | "usdc" | "dai" | "busd" | "pax" | "usde" | "gusd" | "tusd" => true,
317 _ => false,
318 }
319}
320
321pub fn format_balance(currency: &str, value: f64) -> String {
322 if is_fiat_or_stable(currency) {
323 format!("{:.2}", value)
324 } else {
325 format!("{:.8}", value)
326 }
327}
328
329fn paper_balance(state: &PaperState) -> Result<CommandOutput, IndodaxError> {
330 let headers = vec!["Currency".into(), "Balance".into()];
331 let mut rows_with_balance: Vec<(f64, Vec<String>)> = state
332 .balances
333 .iter()
334 .map(|(k, v)| (*v, vec![k.to_uppercase(), format_balance(k, *v)]))
335 .collect();
336 rows_with_balance.sort_by(|a, b| {
337 b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)
338 });
339 let rows: Vec<Vec<String>> = rows_with_balance.into_iter().map(|(_, r)| r).collect();
340
341 let data = paper_balance_value(state);
342 let balance_count = state.balances.len();
343 Ok(CommandOutput::new(data, headers, rows)
344 .with_addendum(format!("[PAPER] {} balance(s) tracked", balance_count)))
345}
346
347pub fn place_paper_order(
348 state: &mut PaperState,
349 pair: &str,
350 side: &str,
351 price: Option<f64>,
352 amount: f64,
353) -> Result<CommandOutput, IndodaxError> {
354 if amount <= 0.0 {
355 return Err(IndodaxError::Other(
356 format!("[PAPER] Amount must be positive, got {}", amount)
357 ));
358 }
359 let is_market = price.is_none();
360 let order_price = price.unwrap_or(0.0);
361 if !is_market && order_price <= 0.0 {
362 return Err(IndodaxError::Other(
363 format!("[PAPER] Price must be positive, got {}", order_price)
364 ));
365 }
366 let base = pair.split('_').next().unwrap_or(pair);
367 let quote = pair.split('_').next_back().unwrap_or("idr");
368 let total_cost = order_price * amount;
369
370 if side == "buy" {
371 if is_market {
372 let quote_balance = state.balances.entry(quote.to_string()).or_insert(0.0);
373 if *quote_balance <= 0.0 {
374 return Err(IndodaxError::Other(
375 format!("[PAPER] Insufficient {} balance for market buy. Need positive balance, have {}",
376 quote.to_uppercase(), format_balance(quote, *quote_balance))
377 ));
378 }
379 } else {
380 let quote_balance = state.balances.entry(quote.to_string()).or_insert(0.0);
381 if *quote_balance + BALANCE_EPSILON < total_cost {
382 return Err(IndodaxError::Other(
383 format!("[PAPER] Insufficient {} balance. Need {}, have {}",
384 quote.to_uppercase(), format_balance(quote, total_cost), format_balance(quote, *quote_balance))
385 ));
386 }
387 *quote_balance -= total_cost;
388 round_balance(&mut state.balances, quote);
389 }
390 } else {
391 let base_balance = state.balances.entry(base.to_string()).or_insert(0.0);
392 if *base_balance + BALANCE_EPSILON < amount {
393 return Err(IndodaxError::Other(
394 format!("[PAPER] Insufficient {} balance. Need {}, have {}",
395 base.to_uppercase(), format_balance(base, amount), format_balance(base, *base_balance))
396 ));
397 }
398 *base_balance -= amount;
399 }
400
401 let order_id = state.next_order_id;
402 state.next_order_id += 1;
403
404 let now = std::time::SystemTime::now()
405 .duration_since(std::time::UNIX_EPOCH)
406 .unwrap_or_default()
407 .as_millis() as u64;
408
409 state.orders.push(PaperOrder {
410 id: order_id,
411 pair: pair.to_string(),
412 side: side.to_string(),
413 price: order_price,
414 amount,
415 remaining: amount,
416 order_type: if is_market { "market".into() } else { "limit".into() },
417 status: "open".into(),
418 created_at: now,
419 fees_paid: 0.0,
420 filled_price: 0.0,
421 total_spent: if side == "buy" && !is_market { total_cost } else { 0.0 },
422 });
423
424 state.trade_count += 1;
425
426 let price_display = if is_market { "market".to_string() } else { order_price.to_string() };
427 let data = serde_json::json!({
428 "mode": "paper",
429 "order_id": order_id,
430 "pair": pair,
431 "side": side,
432 "price": order_price,
433 "amount": amount,
434 "order_type": if is_market { "market" } else { "limit" },
435 "status": "open",
436 });
437
438 let headers = vec!["Field".into(), "Value".into()];
439 let rows = vec![
440 vec!["Order ID".into(), order_id.to_string()],
441 vec!["Pair".into(), pair.to_string()],
442 vec!["Side".into(), side.to_string()],
443 vec!["Price".into(), price_display.clone()],
444 vec!["Amount".into(), amount.to_string()],
445 vec!["Type".into(), if is_market { "market".into() } else { "limit".into() }],
446 vec!["Status".into(), "open".into()],
447 ];
448
449 Ok(CommandOutput::new(data, headers, rows)
450 .with_addendum(format!("[PAPER] {} {} {} @ {} — open", side, amount, pair, price_display)))
451}
452
453pub fn place_paper_order_idr(
454 state: &mut PaperState,
455 pair: &str,
456 side: &str,
457 idr_amount: f64,
458 price: Option<f64>,
459) -> Result<CommandOutput, IndodaxError> {
460 if idr_amount <= 0.0 {
461 return Err(IndodaxError::Other(
462 format!("[PAPER] IDR amount must be positive, got {}", idr_amount)
463 ));
464 }
465 if side != "buy" {
466 return Err(IndodaxError::Other(
467 "[PAPER] --idr is only valid for buy orders".to_string()
468 ));
469 }
470 if price.is_none() {
471 return Err(IndodaxError::Other(
472 "[PAPER] Market buy via --idr requires a limit price (simulation cannot guess the fill price)".to_string()
473 ));
474 }
475 let order_price = price.unwrap_or(0.0);
476 if order_price <= 0.0 {
477 return Err(IndodaxError::Other(
478 format!("[PAPER] Price must be positive, got {}", order_price)
479 ));
480 }
481 let quote = pair.split('_').next_back().unwrap_or("idr");
482
483 let amount = {
484 let quote_balance = state.balances.entry(quote.to_string()).or_insert(0.0);
485 if *quote_balance + BALANCE_EPSILON < idr_amount {
486 return Err(IndodaxError::Other(
487 format!("[PAPER] Insufficient {} balance. Need {}, have {}",
488 quote.to_uppercase(), format_balance(quote, idr_amount), format_balance(quote, *quote_balance))
489 ));
490 }
491 *quote_balance -= idr_amount;
492 round_balance(&mut state.balances, quote);
493 idr_amount / order_price
494 };
495
496 let order_id = state.next_order_id;
497 state.next_order_id += 1;
498
499 let now = std::time::SystemTime::now()
500 .duration_since(std::time::UNIX_EPOCH)
501 .unwrap_or_default()
502 .as_millis() as u64;
503
504 state.orders.push(PaperOrder {
505 id: order_id,
506 pair: pair.to_string(),
507 side: side.to_string(),
508 price: order_price,
509 amount,
510 remaining: amount,
511 order_type: "limit".into(),
512 status: "open".into(),
513 created_at: now,
514 fees_paid: 0.0,
515 filled_price: 0.0,
516 total_spent: idr_amount,
517 });
518
519 state.trade_count += 1;
520
521 let price_display = order_price.to_string();
522 let data = serde_json::json!({
523 "mode": "paper",
524 "order_id": order_id,
525 "pair": pair,
526 "side": side,
527 "price": order_price,
528 "amount": amount,
529 "order_type": "limit",
530 "status": "open",
531 });
532
533 let headers = vec!["Field".into(), "Value".into()];
534 let rows = vec![
535 vec!["Order ID".into(), order_id.to_string()],
536 vec!["Pair".into(), pair.to_string()],
537 vec!["Side".into(), side.to_string()],
538 vec!["Price".into(), price_display.clone()],
539 vec!["Amount".into(), format!("{:.8}", amount)],
540 vec!["IDR Spent".into(), format!("{:.2}", idr_amount)],
541 vec!["Type".into(), "limit".into()],
542 vec!["Status".into(), "open".into()],
543 ];
544
545 let base = pair.split('_').next().unwrap_or("btc");
546 Ok(CommandOutput::new(data, headers, rows)
547 .with_addendum(format!("[PAPER] buy {} {} for {} IDR @ {} — open", amount, base, idr_amount, price_display)))
548}
549
550fn round_balance(balances: &mut HashMap<String, f64>, currency: &str) {
551 if let Some(balance) = balances.get_mut(currency) {
552 if is_fiat_or_stable(currency) {
553 *balance = (*balance * 100.0).round() / 100.0;
554 } else {
555 *balance = (*balance * 100_000_000.0).round() / 100_000_000.0;
556 }
557 }
558}
559
560fn execute_fill(
561 state: &mut PaperState,
562 order_id: u64,
563 base: &str,
564 quote: &str,
565 side: &str,
566 price: f64,
567 amount: f64,
568) -> Result<(), IndodaxError> {
569 let total = price * amount;
570 let fee = total * TAKER_FEE;
571 if side == "buy" {
572 let quote_balance = state.balances.entry(quote.to_string()).or_insert(0.0);
573 if *quote_balance < fee {
574 return Err(IndodaxError::Other(format!(
575 "[PAPER] Insufficient {} balance to pay fee of {:.2}. Need {:.2}, have {:.2}",
576 quote.to_uppercase(), fee, fee, *quote_balance
577 )));
578 }
579 *quote_balance -= fee;
580 round_balance(&mut state.balances, quote);
581 let base_balance = state.balances.entry(base.to_string()).or_insert(0.0);
582 *base_balance += amount;
583 } else {
584 let quote_balance = state.balances.entry(quote.to_string()).or_insert(0.0);
585 *quote_balance += total - fee;
586 round_balance(&mut state.balances, quote);
587 }
588
589 if let Some(order) = state.orders.iter_mut().find(|o| o.id == order_id) {
590 order.remaining = 0.0;
591 order.status = "filled".to_string();
592 order.fees_paid = fee;
593 order.filled_price = price;
594 }
595 state.total_fees_paid += fee;
596 Ok(())
597}
598
599fn sort_paper_orders(orders: &mut Vec<&PaperOrder>, sort_by: Option<&str>, sort_order: Option<&str>) {
600 let desc = sort_order.map(|o| o == "desc" || o == "d").unwrap_or(false);
601 let by = sort_by.unwrap_or("id");
602 orders.sort_by(|a, b| {
603 let cmp = match by {
604 "id" => a.id.cmp(&b.id),
605 "pair" => a.pair.cmp(&b.pair),
606 "side" => a.side.cmp(&b.side),
607 "price" => a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal),
608 "amount" => a.amount.partial_cmp(&b.amount).unwrap_or(std::cmp::Ordering::Equal),
609 "remaining" => a.remaining.partial_cmp(&b.remaining).unwrap_or(std::cmp::Ordering::Equal),
610 "status" => a.status.cmp(&b.status),
611 _ => a.id.cmp(&b.id),
612 };
613 if desc { cmp.reverse() } else { cmp }
614 });
615}
616
617fn paper_orders(state: &PaperState, filter_pair: Option<&str>, sort_by: Option<&str>, sort_order: Option<&str>) -> Result<CommandOutput, IndodaxError> {
618 let mut filtered: Vec<&PaperOrder> = state
619 .orders
620 .iter()
621 .filter(|o| o.status == "open")
622 .filter(|o| filter_pair.map_or(true, |p| o.pair == p))
623 .collect();
624
625 sort_paper_orders(&mut filtered, sort_by, sort_order);
626
627 let headers = vec![
628 "Order ID".into(), "Pair".into(), "Side".into(), "Price".into(),
629 "Amount".into(), "Remaining".into(), "Status".into(),
630 ];
631 let rows: Vec<Vec<String>> = filtered
632 .iter()
633 .map(|o| {
634 vec![
635 o.id.to_string(),
636 o.pair.clone(),
637 o.side.clone(),
638 o.price.to_string(),
639 o.amount.to_string(),
640 o.remaining.to_string(),
641 o.status.clone(),
642 ]
643 })
644 .collect();
645
646 let data = serde_json::json!({
647 "mode": "paper",
648 "orders": filtered,
649 "count": filtered.len(),
650 });
651
652 let msg = match filter_pair {
653 Some(p) => format!("[PAPER] {} orders for {}", filtered.len(), p),
654 None => format!("[PAPER] {} orders", filtered.len()),
655 };
656
657 Ok(CommandOutput::new(data, headers, rows).with_addendum(msg))
658}
659
660fn paper_cancel(state: &mut PaperState, order_id: u64) -> Result<CommandOutput, IndodaxError> {
661 refund_and_cancel(state, order_id)?;
662
663 let data = serde_json::json!({
664 "mode": "paper",
665 "order_id": order_id,
666 "status": "cancelled"
667 });
668 Ok(CommandOutput::json(data).with_addendum(format!("[PAPER] Order {} cancelled", order_id)))
669}
670
671fn paper_cancel_all(state: &mut PaperState) -> Result<CommandOutput, IndodaxError> {
672 let (count, failures) = cancel_all_paper_orders(state);
673
674 let mut data = serde_json::json!({
675 "mode": "paper",
676 "cancelled_count": count,
677 "failed_count": failures.len(),
678 });
679 if !failures.is_empty() {
680 data["failures"] = serde_json::json!(failures.iter().map(|(id, e)| serde_json::json!({
681 "order_id": id,
682 "error": e,
683 })).collect::<Vec<_>>());
684 }
685
686 let addendum = if failures.is_empty() {
687 format!("[PAPER] Cancelled {} orders", count)
688 } else {
689 let reasons: Vec<String> = failures.iter().map(|(id, e)| format!("{}: {}", id, e)).collect();
690 format!("[PAPER] Cancelled {} orders, {} failed: {}", count, failures.len(), reasons.join("; "))
691 };
692
693 Ok(CommandOutput::json(data).with_addendum(addendum))
694}
695
696pub async fn paper_fill(
697 state: &mut PaperState,
698 order_id: Option<u64>,
699 fill_price: Option<f64>,
700 fill_all: bool,
701 client: Option<&IndodaxClient>,
702 fetch: bool,
703) -> Result<CommandOutput, IndodaxError> {
704 let mut fetched_prices: HashMap<String, f64> = HashMap::new();
705
706 if fetch {
707 let client = client.ok_or_else(|| IndodaxError::Other("client required when fetch is true".to_string()))?;
708 let pairs_to_fetch: std::collections::HashSet<String> = if let Some(id) = order_id {
709 state.orders.iter()
710 .filter(|o| o.id == id && o.status == "open")
711 .map(|o| o.pair.clone())
712 .collect()
713 } else if fill_all {
714 state.orders.iter()
715 .filter(|o| o.status == "open")
716 .map(|o| o.pair.clone())
717 .collect()
718 } else {
719 std::collections::HashSet::new()
720 };
721
722 if !pairs_to_fetch.is_empty() {
723 eprintln!("[PAPER] Fetching live prices for {} pair(s)...", pairs_to_fetch.len());
724 let futures: Vec<_> = pairs_to_fetch.iter().map(|p| async move {
725 let ticker: serde_json::Value = client.public_get(&format!("/api/ticker/{}", p)).await?;
726 let price = ticker["ticker"]["last"].as_str()
727 .and_then(|s| s.parse::<f64>().ok())
728 .or_else(|| ticker["ticker"]["last"].as_f64())
729 .ok_or_else(|| IndodaxError::Other(format!("Failed to parse price for {}", p)))?;
730 Ok::<(String, f64), IndodaxError>((p.clone(), price))
731 }).collect();
732
733 for res in join_all(futures).await {
734 if let Ok((pair, price)) = res {
735 fetched_prices.insert(pair, price);
736 }
737 }
738 }
739 }
740
741 if fill_all {
742 let open_ids: Vec<u64> = state.orders.iter()
743 .filter(|o| o.status == "open")
744 .map(|o| o.id)
745 .collect();
746
747 if open_ids.is_empty() {
748 return Ok(CommandOutput::json(serde_json::json!({
749 "mode": "paper",
750 "filled_count": 0,
751 })).with_addendum("[PAPER] No open orders to fill"));
752 }
753
754 let mut skipped = 0u64;
755 let mut filled = 0u64;
756 let mut errors: Vec<String> = Vec::new();
757 for id in &open_ids {
758 let order = match state.orders.iter().find(|o| o.id == *id) {
759 Some(o) => o.clone(),
760 None => { skipped += 1; continue; }
761 };
762
763 let price = fetched_prices.get(&order.pair).copied()
765 .or(fill_price)
766 .unwrap_or(order.price);
767
768 if !price.is_finite() {
769 errors.push(format!("Order {}: invalid fill price {}", id, price));
770 skipped += 1;
771 continue;
772 }
773
774 let should_fill = if fetch {
775 if let Some(&live_price) = fetched_prices.get(&order.pair) {
776 match order.side.as_str() {
777 "buy" => live_price <= order.price,
778 "sell" => live_price >= order.price,
779 _ => false,
780 }
781 } else {
782 false }
784 } else {
785 match fill_price {
786 Some(fp) => match order.side.as_str() {
787 "buy" => fp <= order.price,
788 "sell" => fp >= order.price,
789 _ => false,
790 },
791 None => true,
792 }
793 };
794
795 if !should_fill { skipped += 1; continue; }
796
797 let base = order.pair.split('_').next().unwrap_or("btc").to_string();
798 let quote = order.pair.split('_').next_back().unwrap_or("idr").to_string();
799 match execute_fill(state, *id, &base, "e, &order.side, price, order.remaining) {
800 Ok(()) => filled += 1,
801 Err(e) => {
802 errors.push(format!("Order {}: {}", id, e));
803 skipped += 1;
804 }
805 }
806 }
807
808 let data = serde_json::json!({
809 "mode": "paper",
810 "filled_count": filled,
811 "skipped_count": skipped,
812 "error_count": errors.len(),
813 "errors": errors,
814 });
815
816 let addendum = if !errors.is_empty() {
817 format!("[PAPER] Filled {} order(s), {} errors: {}", filled, errors.len(), errors.join("; "))
818 } else if skipped > 0 {
819 let skip_reason = if fill_price.is_some() {
820 " (orders not matching fill price condition)"
821 } else {
822 ""
823 };
824 format!("[PAPER] Filled {} order(s), skipped {}{}", filled, skipped, skip_reason)
825 } else {
826 format!("[PAPER] Filled {} order(s)", filled)
827 };
828
829 return Ok(CommandOutput::json(data).with_addendum(addendum));
830 }
831
832 let order_id = order_id.ok_or_else(|| IndodaxError::Other("[PAPER] Either --order-id or --all must be specified".into()))?;
833
834 let (status, side, pair, order_price, amount, remaining) = {
835 let order = state.orders.iter().find(|o| o.id == order_id)
836 .ok_or_else(|| IndodaxError::Other(format!("[PAPER] Order {} not found", order_id)))?;
837 (order.status.clone(), order.side.clone(), order.pair.clone(), order.price, order.amount, order.remaining)
838 };
839
840 if status != "open" {
841 return Err(IndodaxError::Other(format!("[PAPER] Order {} status is '{}', only open orders can be filled", order_id, status)));
842 }
843
844 let price = fetched_prices.get(&pair).copied()
846 .or(fill_price)
847 .unwrap_or(order_price);
848
849 if !price.is_finite() {
850 return Err(IndodaxError::Other(format!(
851 "[PAPER] Invalid fill price: {}. Ensure order price or explicit fill price is valid.",
852 price
853 )));
854 }
855
856 if fetch {
857 if let Some(&live_price) = fetched_prices.get(&pair) {
858 let should_fill = match side.as_str() {
859 "buy" => live_price <= order_price,
860 "sell" => live_price >= order_price,
861 _ => false,
862 };
863 if !should_fill {
864 return Err(IndodaxError::Other(format!(
865 "[PAPER] Live price {} does not match order condition ({} side, limit {})",
866 live_price, side, order_price
867 )));
868 }
869 } else {
870 return Err(IndodaxError::Other(format!("[PAPER] Failed to fetch live price for {}", pair)));
871 }
872 } else if let Some(fp) = fill_price {
873 let should_fill = match side.as_str() {
874 "buy" => fp <= order_price,
875 "sell" => fp >= order_price,
876 _ => false,
877 };
878 if !should_fill {
879 return Err(IndodaxError::Other(format!(
880 "[PAPER] Fill price {} does not match order condition ({} side, limit {}). Use --all to skip non-matching orders.",
881 fp, side, order_price
882 )));
883 }
884 }
885 let base = pair.split('_').next().unwrap_or("btc");
886 let quote = pair.split('_').next_back().unwrap_or("idr");
887
888 execute_fill(state, order_id, base, quote, &side, price, remaining)?;
889
890 let data = serde_json::json!({
891 "mode": "paper",
892 "order_id": order_id,
893 "pair": pair,
894 "side": side,
895 "price": price,
896 "amount": amount,
897 "status": "filled",
898 });
899
900 let headers = vec!["Field".into(), "Value".into()];
901 let rows = vec![
902 vec!["Order ID".into(), order_id.to_string()],
903 vec!["Pair".into(), pair],
904 vec!["Side".into(), side],
905 vec!["Price".into(), price.to_string()],
906 vec!["Amount".into(), amount.to_string()],
907 vec!["Total".into(), (price * amount).to_string()],
908 vec!["Status".into(), "filled".into()],
909 ];
910
911 Ok(CommandOutput::new(data, headers, rows)
912 .with_addendum(format!("[PAPER] Order {} filled at {}", order_id, price)))
913}
914
915fn paper_history(state: &PaperState, sort_by: Option<&str>, sort_order: Option<&str>) -> Result<CommandOutput, IndodaxError> {
916 let mut sorted: Vec<&PaperOrder> = state.orders.iter().collect();
917 sort_paper_orders(&mut sorted, sort_by, sort_order);
918
919 let headers = vec![
920 "Order ID".into(),
921 "Pair".into(),
922 "Side".into(),
923 "Price".into(),
924 "Amount".into(),
925 "Status".into(),
926 ];
927 let rows: Vec<Vec<String>> = sorted
928 .iter()
929 .map(|o| {
930 vec![
931 o.id.to_string(),
932 o.pair.clone(),
933 o.side.clone(),
934 o.price.to_string(),
935 o.amount.to_string(),
936 o.status.clone(),
937 ]
938 })
939 .collect();
940
941 let data = paper_history_value(state);
942 let order_count = state.orders.len();
943 Ok(CommandOutput::new(data, headers, rows)
944 .with_addendum(format!("[PAPER] {} order(s) total", order_count)))
945}
946
947fn paper_status(state: &PaperState) -> Result<CommandOutput, IndodaxError> {
948 let data = paper_status_value(state);
949 let filled_count = data["filled_count"].as_u64().unwrap_or(0);
950 let open_count = data["open_count"].as_u64().unwrap_or(0);
951 let cancelled_count = data["cancelled_count"].as_u64().unwrap_or(0);
952
953 let pnl_parts: Vec<(String, String)> = state
954 .balances
955 .iter()
956 .filter_map(|(k, v)| {
957 let init = state.initial_balance(k);
958 if init > 0.0 || *v > 0.0 {
959 let diff = *v - init;
960 Some((
961 k.to_uppercase(),
962 format!("{} ({})", format_balance(k, *v), format!("{:+.8}", diff)),
963 ))
964 } else {
965 None
966 }
967 })
968 .collect();
969
970 let headers = vec!["Metric".into(), "Value".into()];
971 let mut rows = vec![
972 vec!["Total trades".into(), state.trade_count.to_string()],
973 vec!["Orders filled".into(), filled_count.to_string()],
974 vec!["Orders open".into(), open_count.to_string()],
975 vec!["Orders cancelled".into(), cancelled_count.to_string()],
976 vec![
977 "Total fees paid".into(),
978 format!("{:.8}", state.total_fees_paid),
979 ],
980 ];
981 for (currency, bal) in &pnl_parts {
982 rows.push(vec![format!("{} balance", currency), bal.clone()]);
983 }
984
985 Ok(CommandOutput::new(data, headers, rows).with_addendum(format!(
986 "[PAPER] {} trades, {} filled, {} open, {} cancelled",
987 state.trade_count, filled_count, open_count, cancelled_count
988 )))
989}
990
991async fn paper_watch(
992 client: &IndodaxClient,
993 config: &mut IndodaxConfig,
994 interval_secs: u64,
995 once: bool,
996) -> Result<CommandOutput, IndodaxError> {
997 use tokio::time::{sleep, Duration};
998
999 eprintln!("[PAPER] Monitoring market for open orders (interval: {}s)...", interval_secs);
1000 eprintln!("[PAPER] Press Ctrl+C to stop.");
1001
1002 loop {
1003 let mut state = PaperState::load(config);
1004 let open_count = state.orders.iter().filter(|o| o.status == "open").count();
1005
1006 if open_count == 0 {
1007 eprintln!("[PAPER] No open orders. Waiting {}s...", interval_secs);
1008 sleep(Duration::from_secs(interval_secs)).await;
1009 continue;
1010 }
1011
1012 match paper_fill(&mut state, None, None, true, Some(client), true).await {
1013 Ok(output) => {
1014 let data = &output.data;
1015 let filled = data["filled_count"].as_u64().unwrap_or(0);
1016
1017 if filled > 0 {
1018 eprintln!("{}", output.addendum.as_deref().unwrap_or("[PAPER] Orders filled"));
1019 state.save(config)?;
1020 if once {
1021 return Ok(output);
1022 }
1023 }
1024 }
1025 Err(e) => {
1026 eprintln!("[PAPER] Error during auto-fill: {}", e);
1027 }
1028 }
1029
1030 sleep(Duration::from_secs(interval_secs)).await;
1031 }
1032}
1033
1034async fn fetch_market_prices(
1035client: &IndodaxClient, state: &PaperState) -> Result<HashMap<String, f64>, IndodaxError> {
1036 let pairs: std::collections::BTreeSet<String> = state.orders.iter()
1037 .filter(|o| o.status == "open")
1038 .map(|o| o.pair.clone())
1039 .collect();
1040
1041 if pairs.is_empty() {
1042 return Ok(HashMap::new());
1043 }
1044
1045 let tasks: Vec<_> = pairs.iter().map(|pair| {
1046 let pair = pair.clone();
1047 let path = format!("/api/ticker/{}", pair);
1048 async move {
1049 match client.public_get::<serde_json::Value>(&path).await {
1050 Ok(data) => {
1051 if let Some(ticker) = data.get("ticker") {
1052 let last = ticker.get("last")
1053 .and_then(|v| v.as_str())
1054 .and_then(|s| s.parse::<f64>().ok())
1055 .or_else(|| ticker.get("last").and_then(|v| v.as_f64()));
1056 if let Some(price) = last {
1057 Some((pair, price))
1058 } else {
1059 None
1060 }
1061 } else {
1062 None
1063 }
1064 }
1065 Err(e) => {
1066 eprintln!("[PAPER] Warning: Failed to fetch price for {}: {}", pair, e);
1067 None
1068 }
1069 }
1070 }
1071 }).collect();
1072
1073 let results = join_all(tasks).await;
1074 let mut prices = HashMap::new();
1075 for result in results.into_iter().flatten() {
1076 prices.insert(result.0, result.1);
1077 }
1078 Ok(prices)
1079}
1080
1081pub async fn paper_check_fills(client: &IndodaxClient, state: &mut PaperState, prices: Option<&str>, fetch: bool) -> Result<CommandOutput, IndodaxError> {
1082 let market_prices: HashMap<String, f64> = if fetch {
1083 fetch_market_prices(client, state).await?
1084 } else if let Some(p) = prices {
1085 serde_json::from_str(p)
1086 .map_err(|e| IndodaxError::Other(format!("[PAPER] Invalid prices JSON: {}", e)))?
1087 } else {
1088 return Err(IndodaxError::Other("[PAPER] Either --prices or --fetch must be specified".into()));
1089 };
1090
1091 let market_prices: HashMap<String, f64> = market_prices
1093 .into_iter()
1094 .map(|(k, v)| (helpers::normalize_pair(&k), v))
1095 .collect();
1096
1097 let mut filled_ids: Vec<u64> = Vec::new();
1098 let mut errors: Vec<String> = Vec::new();
1099
1100 let open_ids: Vec<(u64, String, String, f64, f64)> = state.orders.iter()
1101 .filter(|o| o.status == "open")
1102 .map(|o| (o.id, o.pair.clone(), o.side.clone(), o.price, o.remaining))
1103 .collect();
1104
1105 for (order_id, pair, side, order_price, remaining) in &open_ids {
1106 let current_price = match market_prices.get(pair) {
1107 Some(p) => *p,
1108 None => continue,
1109 };
1110
1111 let should_fill = match side.as_str() {
1112 "buy" => current_price <= *order_price,
1113 "sell" => current_price >= *order_price,
1114 _ => false,
1115 };
1116
1117 if should_fill {
1118 let base = pair.split('_').next().unwrap_or("btc");
1119 let quote = pair.split('_').next_back().unwrap_or("idr");
1120 match execute_fill(state, *order_id, base, quote, side, current_price, *remaining) {
1121 Ok(()) => filled_ids.push(*order_id),
1122 Err(e) => errors.push(format!("Order {}: {}", order_id, e)),
1123 }
1124 }
1125 }
1126
1127 let data = serde_json::json!({
1128 "mode": "paper",
1129 "filled_count": filled_ids.len(),
1130 "filled_ids": filled_ids,
1131 "error_count": errors.len(),
1132 "errors": errors,
1133 });
1134
1135 let msg = if !errors.is_empty() {
1136 format!("[PAPER] Filled {} order(s) with {} error(s): {}",
1137 filled_ids.len(), errors.len(), errors.join("; "))
1138 } else if filled_ids.is_empty() {
1139 "[PAPER] No orders matched market conditions".to_string()
1140 } else {
1141 format!("[PAPER] Filled {} order(s): {}",
1142 filled_ids.len(),
1143 filled_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(", "))
1144 };
1145
1146 Ok(CommandOutput::json(data).with_addendum(msg))
1147}
1148
1149pub fn paper_balance_value(state: &PaperState) -> serde_json::Value {
1154 let rounded: std::collections::HashMap<String, f64> = state.balances.iter()
1155 .map(|(k, v)| {
1156 let val = match k.as_str() {
1157 "idr" | "usdt" | "usdc" => (*v * 100.0).round() / 100.0,
1158 _ => (*v * 100_000_000.0).round() / 100_000_000.0,
1159 };
1160 (k.clone(), val)
1161 })
1162 .collect();
1163 serde_json::json!({
1164 "mode": "paper",
1165 "balances": rounded,
1166 })
1167}
1168
1169pub fn paper_orders_value(state: &PaperState) -> serde_json::Value {
1170 let open_orders: Vec<&PaperOrder> = state
1171 .orders
1172 .iter()
1173 .filter(|o| o.status == "open")
1174 .collect();
1175 let count = open_orders.len();
1176 let orders: Vec<serde_json::Value> = open_orders
1177 .iter()
1178 .map(|o| serde_json::json!({
1179 "id": o.id,
1180 "pair": o.pair,
1181 "side": o.side,
1182 "price": o.price,
1183 "amount": o.amount,
1184 "remaining": o.remaining,
1185 "status": o.status,
1186 }))
1187 .collect();
1188 serde_json::json!({
1189 "mode": "paper",
1190 "count": count,
1191 "orders": orders,
1192 })
1193}
1194
1195pub fn paper_history_value(state: &PaperState) -> serde_json::Value {
1196 serde_json::json!({
1197 "mode": "paper",
1198 "orders": state.orders,
1199 "count": state.orders.len(),
1200 })
1201}
1202
1203pub fn paper_status_value(state: &PaperState) -> serde_json::Value {
1204 let filled = state.orders.iter().filter(|o| o.status == "filled").count();
1205 let open = state.orders.iter().filter(|o| o.status == "open").count();
1206 let cancelled = state.orders.iter().filter(|o| o.status == "cancelled").count();
1207 let pnl: std::collections::HashMap<String, serde_json::Value> = state
1208 .balances
1209 .iter()
1210 .filter_map(|(k, v)| {
1211 let init = state.initial_balance(k);
1212 if init > 0.0 || *v > 0.0 {
1213 Some((k.to_uppercase(), serde_json::json!({
1214 "current": v,
1215 "initial": init,
1216 "diff": v - init,
1217 })))
1218 } else {
1219 None
1220 }
1221 })
1222 .collect();
1223 serde_json::json!({
1224 "mode": "paper",
1225 "trade_count": state.trade_count,
1226 "filled_count": filled,
1227 "open_count": open,
1228 "cancelled_count": cancelled,
1229 "total_fees_paid": state.total_fees_paid,
1230 "balances": state.balances,
1231 "initial_balances": state.initial_balances,
1232 "pnl": pnl,
1233 })
1234}
1235
1236pub fn cancel_paper_order(state: &mut PaperState, order_id: u64) -> Result<(), IndodaxError> {
1242 refund_and_cancel(state, order_id)
1243}
1244
1245pub fn cancel_all_paper_orders(state: &mut PaperState) -> (usize, Vec<(u64, String)>) {
1248 let active_ids: Vec<u64> = state
1249 .orders
1250 .iter()
1251 .filter(|o| o.status == "open")
1252 .map(|o| o.id)
1253 .collect();
1254
1255 let mut success_count = 0usize;
1256 let mut failures = Vec::new();
1257 for id in &active_ids {
1258 match refund_and_cancel(state, *id) {
1259 Ok(()) => success_count += 1,
1260 Err(e) => failures.push((*id, e.to_string())),
1261 }
1262 }
1263 (success_count, failures)
1264}
1265
1266fn refund_and_cancel(state: &mut PaperState, order_id: u64) -> Result<(), IndodaxError> {
1267 let order = state.orders.iter().find(|o| o.id == order_id)
1268 .ok_or_else(|| IndodaxError::Other(format!("[PAPER] Order {} not found", order_id)))?;
1269
1270 if order.status == "filled" || order.status == "cancelled" {
1271 return Err(IndodaxError::Other(format!("[PAPER] Order {} already {}", order_id, order.status)));
1272 }
1273
1274 let base = order.pair.split('_').next().unwrap_or("btc");
1275 let quote = order.pair.split('_').next_back().unwrap_or("idr");
1276 let refund = order.price * order.remaining;
1277 let remaining = order.remaining;
1278
1279 if order.side == "buy" {
1280 *state.balances.entry(quote.to_string()).or_insert(0.0) += refund;
1281 round_balance(&mut state.balances, quote);
1282 } else {
1283 *state.balances.entry(base.to_string()).or_insert(0.0) += remaining;
1284 }
1285
1286 if let Some(order) = state.orders.iter_mut().find(|o| o.id == order_id) {
1287 order.status = "cancelled".to_string();
1288 order.remaining = 0.0;
1289 }
1290 Ok(())
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use crate::config::IndodaxConfig;
1297 use serde_json::json;
1298
1299 #[test]
1300 fn test_paper_state_default() {
1301 let state = PaperState::default();
1302 assert_eq!(state.balances.get("idr"), Some(&100_000_000.0));
1303 assert_eq!(state.balances.get("btc"), Some(&1.0));
1304 assert!(state.orders.is_empty());
1305 assert_eq!(state.next_order_id, 1);
1306 assert_eq!(state.trade_count, 0);
1307 assert_eq!(state.total_fees_paid, 0.0);
1308 assert!(state.initial_balances.is_some());
1309 }
1310
1311 #[test]
1312 fn test_paper_state_load_none() {
1313 let config = IndodaxConfig::default();
1314 let state = PaperState::load(&config);
1315 assert_eq!(state.balances.get("idr"), Some(&100_000_000.0));
1316 }
1317
1318 #[test]
1319 fn test_paper_state_load_some() {
1320 let mut config = IndodaxConfig::default();
1321 let state_json = json!({
1322 "balances": {"btc": 2.0, "idr": 50_000_000.0},
1323 "orders": [],
1324 "next_order_id": 5,
1325 "trade_count": 3,
1326 "total_fees_paid": 0.0,
1327 "initial_balances": {"btc": 2.0, "idr": 50_000_000.0}
1328 });
1329 config.paper_balances = Some(state_json);
1330
1331 let state = PaperState::load(&config);
1332 assert_eq!(state.balances.get("btc"), Some(&2.0));
1333 assert_eq!(state.next_order_id, 5);
1334 assert_eq!(state.trade_count, 3);
1335 assert_eq!(state.total_fees_paid, 0.0);
1336 }
1337
1338 #[test]
1339 fn test_paper_state_save() {
1340 let mut config = IndodaxConfig::default();
1341 let mut state = PaperState::default();
1342 state.balances.insert("eth".into(), 10.0);
1343 state.next_order_id = 42;
1344
1345 let result = state.save(&mut config);
1346 assert!(result.is_ok());
1347 assert!(config.paper_balances.is_some());
1348 }
1349
1350 #[test]
1351 fn test_paper_init() {
1352 let mut state = PaperState::default();
1353 state.balances.insert("eth".into(), 100.0);
1354 state.next_order_id = 99;
1355
1356 let output = paper_init(&mut state, None, None).unwrap();
1357 assert_eq!(state.balances.get("idr"), Some(&100_000_000.0));
1358 assert_eq!(state.balances.get("btc"), Some(&1.0));
1359 assert_eq!(state.next_order_id, 1);
1360 assert_eq!(state.total_fees_paid, 0.0);
1361 assert!(state.initial_balances.is_some());
1362 assert!(output.render().contains("initialized"));
1363 }
1364
1365 #[test]
1366 fn test_paper_reset() {
1367 let mut state = PaperState {
1368 balances: { let mut m = std::collections::HashMap::new(); m.insert("custom".into(), 999.0); m },
1369 orders: vec![PaperOrder {
1370 id: 1, pair: "test".into(), side: "buy".into(), price: 1.0,
1371 amount: 1.0, remaining: 0.0, order_type: "limit".into(),
1372 status: "filled".into(), created_at: 0, fees_paid: 0.0, filled_price: 0.0,
1373 total_spent: 0.0,
1374 }],
1375 next_order_id: 50,
1376 trade_count: 10,
1377 total_fees_paid: 0.0,
1378 initial_balances: None,
1379 };
1380
1381 let output = paper_reset(&mut state).unwrap();
1382 assert_eq!(state.balances.get("idr"), Some(&100_000_000.0));
1383 assert_eq!(state.next_order_id, 1);
1384 assert_eq!(state.trade_count, 0);
1385 assert!(output.render().contains("reset"));
1386 }
1387
1388 #[test]
1389 fn test_paper_balance() {
1390 let mut state = PaperState::default();
1391 state.balances.insert("eth".into(), 5.0);
1392
1393 let output = paper_balance(&state).unwrap();
1394 let rendered = output.render();
1395 assert!(rendered.contains("IDR") || rendered.contains("BTC") || rendered.contains("ETH"));
1396 }
1397
1398 #[test]
1399 fn test_place_paper_order_buy() {
1400 let mut state = PaperState::default();
1401 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5);
1402
1403 assert!(result.is_ok());
1404 assert_eq!(state.balances.get("idr").unwrap(), &99950000.0);
1405 assert_eq!(state.balances.get("btc").unwrap(), &1.0);
1406 assert_eq!(state.orders.len(), 1);
1407 assert_eq!(state.trade_count, 1);
1408 assert_eq!(state.orders[0].status, "open");
1409 }
1410
1411 #[test]
1412 fn test_place_paper_order_sell() {
1413 let mut state = PaperState::default();
1414 let result = place_paper_order(&mut state, "btc_idr", "sell", Some(100_000_000.0), 0.5);
1415
1416 assert!(result.is_ok());
1417 assert_eq!(state.balances.get("btc").unwrap(), &0.5);
1418 assert_eq!(state.balances.get("idr").unwrap(), &100000000.0);
1419 assert_eq!(state.orders[0].status, "open");
1420 }
1421
1422 #[test]
1423 fn test_place_paper_order_insufficient_quote() {
1424 let mut state = PaperState::default();
1425 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(200_000_000.0), 1.0);
1427
1428 assert!(result.is_err());
1429 assert!(result.unwrap_err().to_string().contains("Insufficient"));
1430 }
1431
1432 #[test]
1433 fn test_place_paper_order_insufficient_base() {
1434 let mut state = PaperState::default();
1435 let result = place_paper_order(&mut state, "btc_idr", "sell", Some(100_000_000.0), 2.0);
1437
1438 assert!(result.is_err());
1439 assert!(result.unwrap_err().to_string().contains("Insufficient"));
1440 }
1441
1442 #[test]
1443 fn test_paper_orders_empty() {
1444 let state = PaperState::default();
1445 let output = paper_orders(&state, None, None, None).unwrap();
1446 assert!(output.render().len() > 0);
1447 }
1448
1449 #[test]
1450 fn test_paper_orders_with_orders() {
1451 let mut state = PaperState::default();
1452 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1453 place_paper_order(&mut state, "btc_idr", "sell", Some(110_000_000.0), 0.3).unwrap();
1454
1455 let output = paper_orders(&state, None, None, None).unwrap();
1456 let rendered = output.render();
1457 assert!(rendered.contains("btc_idr"));
1458 }
1459
1460 #[test]
1461 fn test_paper_orders_filter_by_pair() {
1462 let mut state = PaperState::default();
1463 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1464 place_paper_order(&mut state, "eth_idr", "buy", Some(10_000_000.0), 1.0).unwrap();
1465
1466 let output = paper_orders(&state, Some("btc_idr"), None, None).unwrap();
1467 let rendered = output.render();
1468 assert!(rendered.contains("btc_idr"));
1469 assert!(!rendered.contains("eth_idr"));
1470 }
1471
1472 #[test]
1473 fn test_paper_cancel() {
1474 let mut state = PaperState::default();
1475 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1476 let order_id = state.orders[0].id;
1477
1478 let output = paper_cancel(&mut state, order_id);
1480 assert!(output.is_ok());
1481 assert_eq!(state.orders[0].status, "cancelled");
1482 assert_eq!(state.balances.get("idr").unwrap(), &100000000.0);
1483 }
1484
1485 #[test]
1486 fn test_paper_cancel_not_found() {
1487 let mut state = PaperState::default();
1488 let output = paper_cancel(&mut state, 999);
1489 assert!(output.is_err());
1490 }
1491
1492 #[test]
1493 fn test_paper_cancel_already_filled() {
1494 let mut state = PaperState::default();
1495 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1496 let order_id = state.orders[0].id;
1497
1498 paper_cancel(&mut state, order_id).unwrap();
1500 let output = paper_cancel(&mut state, order_id);
1502 assert!(output.is_err());
1503 assert!(output.unwrap_err().to_string().contains("already cancelled"));
1504 }
1505
1506 #[test]
1507 fn test_paper_cancel_all() {
1508 let mut state = PaperState::default();
1509 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1510 place_paper_order(&mut state, "eth_idr", "buy", Some(10_000_000.0), 1.0).unwrap();
1511
1512 let output = paper_cancel_all(&mut state);
1514 assert!(output.is_ok());
1515 assert_eq!(state.orders[0].status, "cancelled");
1516 assert_eq!(state.orders[1].status, "cancelled");
1517 }
1518
1519 #[test]
1520 fn test_paper_cancel_all_no_orders() {
1521 let mut state = PaperState::default();
1522 let output = paper_cancel_all(&mut state);
1523 assert!(output.is_ok());
1524 }
1525
1526 #[test]
1527 fn test_paper_history() {
1528 let mut state = PaperState::default();
1529 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1530
1531 let output = paper_history(&state, None, None).unwrap();
1532 assert!(output.render().len() > 0);
1533 }
1534
1535 #[test]
1536 fn test_paper_status() {
1537 let mut state = PaperState::default();
1538 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1539
1540 let output = paper_status(&state).unwrap();
1541 let rendered = output.render();
1542 assert!(rendered.contains("trade_count") || rendered.contains("Trade") || rendered.contains("BTC"));
1543 }
1544
1545 #[tokio::test]
1546 async fn test_paper_fill_buy() {
1547 let mut state = PaperState::default();
1548 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1549 let order_id = state.orders[0].id;
1550
1551 let result = paper_fill(&mut state, Some(order_id), None, false, None, false).await;
1552 assert!(result.is_ok());
1553 assert_eq!(state.orders[0].status, "filled");
1554 assert_eq!(state.orders[0].remaining, 0.0);
1555 }
1556
1557 #[tokio::test]
1558 async fn test_paper_fill_sell() {
1559 let mut state = PaperState::default();
1560 place_paper_order(&mut state, "btc_idr", "sell", Some(100_000_000.0), 0.5).unwrap();
1561 let order_id = state.orders[0].id;
1562
1563 let result = paper_fill(&mut state, Some(order_id), None, false, None, false).await;
1564 assert!(result.is_ok());
1565 assert_eq!(state.orders[0].status, "filled");
1566 }
1567
1568 #[tokio::test]
1569 async fn test_paper_fill_not_found() {
1570 let mut state = PaperState::default();
1571 let result = paper_fill(&mut state, Some(999), None, false, None, false).await;
1572 assert!(result.is_err());
1573 }
1574
1575 #[tokio::test]
1576 async fn test_paper_fill_already_filled() {
1577 let mut state = PaperState::default();
1578 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1579 let order_id = state.orders[0].id;
1580
1581 paper_fill(&mut state, Some(order_id), None, false, None, false).await.unwrap();
1582 let result = paper_fill(&mut state, Some(order_id), None, false, None, false).await;
1583 assert!(result.is_err());
1584 }
1585
1586 #[tokio::test]
1587 async fn test_paper_fill_with_custom_price() {
1588 let mut state = PaperState::default();
1589 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1590 let order_id = state.orders[0].id;
1591
1592 let result = paper_fill(&mut state, Some(order_id), Some(90_000.0), false, None, false).await;
1594 assert!(result.is_ok());
1595 assert_eq!(state.orders[0].status, "filled");
1596 assert_eq!(state.orders[0].filled_price, 90_000.0);
1597
1598 let result2 = paper_fill(&mut state, Some(order_id), Some(80_000.0), false, None, false).await;
1600 assert!(result2.is_err());
1601 }
1602
1603 #[tokio::test]
1604 async fn test_paper_fill_invalid_price_condition() {
1605 let mut state = PaperState::default();
1606 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1607 let order_id = state.orders[0].id;
1608
1609 let result = paper_fill(&mut state, Some(order_id), Some(110_000.0), false, None, false).await;
1611 assert!(result.is_err());
1612 assert_eq!(state.orders[0].status, "open");
1613 }
1614
1615 #[tokio::test]
1616 async fn test_paper_fill_all() {
1617 let mut state = PaperState::default();
1618 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5).unwrap();
1619 place_paper_order(&mut state, "btc_idr", "sell", Some(110_000_000.0), 0.3).unwrap();
1620
1621 let result = paper_fill(&mut state, None, None, true, None, false).await;
1622 assert!(result.is_ok());
1623 assert_eq!(state.orders[0].status, "filled");
1624 assert_eq!(state.orders[1].status, "filled");
1625 }
1626
1627 #[tokio::test]
1628 async fn test_paper_fill_all_no_open_orders() {
1629 let mut state = PaperState::default();
1630 let result = paper_fill(&mut state, None, None, true, None, false).await;
1631 assert!(result.is_ok());
1632 }
1633
1634 #[test]
1635 fn test_paper_topup_negative() {
1636 let mut state = PaperState::default();
1637 let result = paper_topup(&mut state, "idr", -5000.0);
1638 assert!(result.is_err(), "Negative topup should be rejected");
1639 assert!(result.unwrap_err().to_string().contains("positive"));
1640 }
1641
1642 #[test]
1643 fn test_place_paper_order_negative_amount() {
1644 let mut state = PaperState::default();
1645 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), -0.5);
1646 assert!(result.is_err());
1647 assert!(result.unwrap_err().to_string().contains("positive"));
1648 }
1649
1650 #[test]
1651 fn test_place_paper_order_negative_price() {
1652 let mut state = PaperState::default();
1653 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(-100.0), 0.5);
1654 assert!(result.is_err());
1655 assert!(result.unwrap_err().to_string().contains("positive"));
1656 }
1657
1658 #[test]
1659 fn test_place_paper_order_zero_amount() {
1660 let mut state = PaperState::default();
1661 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.0);
1662 assert!(result.is_err());
1663 assert!(result.unwrap_err().to_string().contains("positive"));
1664 }
1665
1666 #[test]
1667 fn test_execute_fill_buy() {
1668 let mut state = PaperState::default();
1669 state.balances.insert("btc".into(), 0.0);
1670 state.balances.insert("idr".into(), 100_000_000.0);
1671
1672 let result = execute_fill(&mut state, 1, "btc", "idr", "buy", 100_000.0, 0.5);
1673 assert!(result.is_ok());
1674 assert_eq!(state.balances.get("btc").unwrap(), &0.5);
1675 }
1676
1677 #[test]
1678 fn test_execute_fill_sell() {
1679 let mut state = PaperState::default();
1680 state.balances.insert("btc".into(), 1.0);
1681 state.balances.insert("idr".into(), 0.0);
1682
1683 let result = execute_fill(&mut state, 1, "btc", "idr", "sell", 100_000_000.0, 0.5);
1684 assert!(result.is_ok());
1685 assert_eq!(state.balances.get("idr").unwrap(), &49870000.0);
1686 }
1687
1688 #[test]
1689 fn test_paper_order_fields() {
1690 let order = PaperOrder {
1691 id: 1,
1692 pair: "btc_idr".into(),
1693 side: "buy".into(),
1694 price: 100_000.0,
1695 amount: 0.5,
1696 remaining: 0.0,
1697 order_type: "limit".into(),
1698 status: "filled".into(),
1699 created_at: 12345,
1700 fees_paid: 0.0,
1701 filled_price: 100_000.0,
1702 total_spent: 0.0,
1703 };
1704
1705 assert_eq!(order.id, 1);
1706 assert_eq!(order.pair, "btc_idr");
1707 assert_eq!(order.side, "buy");
1708 assert_eq!(order.total_spent, 0.0);
1709 }
1710
1711 #[tokio::test]
1712 async fn test_dispatch_paper_init() {
1713 let client = IndodaxClient::new(None).unwrap();
1714 let mut state = PaperState::default();
1715 let mut config = IndodaxConfig::default();
1716 let cmd = PaperCommand::Init { idr: None, btc: None };
1717 let result = dispatch_paper(&client, &mut state, &mut config, &cmd).await;
1718 assert!(result.is_ok());
1719 }
1720
1721 #[tokio::test]
1722 async fn test_dispatch_paper_balance() {
1723 let client = IndodaxClient::new(None).unwrap();
1724 let state = PaperState::default();
1725 let mut config = IndodaxConfig::default();
1726 let cmd = PaperCommand::Balance;
1727 let result = dispatch_paper(&client, &mut state.clone(), &mut config, &cmd).await;
1728 assert!(result.is_ok());
1729 }
1730
1731 #[tokio::test]
1732 async fn test_paper_check_fills_buy_match() {
1733 let client = IndodaxClient::new(None).unwrap();
1734 let mut state = PaperState::default();
1735 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1736 let prices = r#"{"btc_idr": 90000000}"#;
1737
1738 let result = paper_check_fills(&client, &mut state, Some(prices), false).await;
1739 assert!(result.is_ok());
1740 assert_eq!(state.orders[0].status, "filled");
1741 }
1742
1743 #[tokio::test]
1744 async fn test_paper_check_fills_buy_no_match() {
1745 let client = IndodaxClient::new(None).unwrap();
1746 let mut state = PaperState::default();
1747 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1748 let prices = r#"{"btc_idr": 110000000}"#;
1749
1750 let result = paper_check_fills(&client, &mut state, Some(prices), false).await;
1751 assert!(result.is_ok());
1752 assert_eq!(state.orders[0].status, "open");
1753 }
1754
1755 #[tokio::test]
1756 async fn test_paper_check_fills_sell_match() {
1757 let client = IndodaxClient::new(None).unwrap();
1758 let mut state = PaperState::default();
1759 place_paper_order(&mut state, "btc_idr", "sell", Some(100_000_000.0), 0.5).unwrap();
1760 let prices = r#"{"btc_idr": 110000000}"#;
1761
1762 let result = paper_check_fills(&client, &mut state, Some(prices), false).await;
1763 assert!(result.is_ok());
1764 assert_eq!(state.orders[0].status, "filled");
1765 }
1766
1767 #[tokio::test]
1768 async fn test_paper_check_fills_multiple_orders() {
1769 let client = IndodaxClient::new(None).unwrap();
1770 let mut state = PaperState::default();
1771 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1772 place_paper_order(&mut state, "eth_idr", "buy", Some(10_000_000.0), 1.0).unwrap();
1773 place_paper_order(&mut state, "btc_idr", "sell", Some(120_000_000.0), 0.3).unwrap();
1774 let prices = r#"{"btc_idr": 90000000, "eth_idr": 15000000}"#;
1775
1776 let result = paper_check_fills(&client, &mut state, Some(prices), false).await;
1777 assert!(result.is_ok());
1778 assert_eq!(state.orders[0].status, "filled");
1780 assert_eq!(state.orders[1].status, "open");
1781 assert_eq!(state.orders[2].status, "open");
1782 }
1783
1784 #[tokio::test]
1785 async fn test_paper_check_fills_invalid_json() {
1786 let client = IndodaxClient::new(None).unwrap();
1787 let mut state = PaperState::default();
1788 let result = paper_check_fills(&client, &mut state, Some("not-json"), false).await;
1789 assert!(result.is_err());
1790 }
1791
1792 #[tokio::test]
1793 async fn test_paper_check_fills_empty_prices() {
1794 let client = IndodaxClient::new(None).unwrap();
1795 let mut state = PaperState::default();
1796 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1797 let result = paper_check_fills(&client, &mut state, Some(r#"{}"#), false).await;
1798 assert!(result.is_ok());
1799 assert_eq!(state.orders[0].status, "open");
1800 }
1801
1802 #[tokio::test]
1803 async fn test_paper_check_fills_no_open_orders() {
1804 let client = IndodaxClient::new(None).unwrap();
1805 let mut state = PaperState::default();
1806 let result = paper_check_fills(&client, &mut state, Some(r#"{"btc_idr": 90000000}"#), false).await;
1807 assert!(result.is_ok());
1808 }
1809
1810 #[tokio::test]
1811 async fn test_paper_check_fills_fetch_not_available() {
1812 let client = IndodaxClient::new(None).unwrap();
1813 let mut state = PaperState::default();
1814 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1815 let result = paper_check_fills(&client, &mut state, None, true).await;
1818 assert!(result.is_ok(), "Should handle fetch failure without error: {:?}", result.err());
1819 assert_eq!(state.orders[0].status, "open", "Order should remain open when prices unavailable");
1820 assert_eq!(state.orders[0].remaining, 0.5, "Remaining amount should be unchanged");
1821 }
1822
1823 #[tokio::test]
1824 async fn test_paper_lifecycle_buy_fill_cancel() {
1825 let mut state = PaperState::default();
1826 let initial_idr = *state.balances.get("idr").unwrap();
1827 let initial_btc = *state.balances.get("btc").unwrap();
1828
1829 let result = place_paper_order(&mut state, "btc_idr", "buy", Some(100_000.0), 0.5);
1831 assert!(result.is_ok());
1832 let order_id = state.orders[0].id;
1833 assert_eq!(state.orders[0].status, "open");
1834 assert!(*state.balances.get("idr").unwrap() < initial_idr);
1836
1837 let result = paper_fill(&mut state, Some(order_id), None, false, None, false).await;
1839 assert!(result.is_ok());
1840 assert_eq!(state.orders[0].status, "filled");
1841 assert!(*state.balances.get("btc").unwrap() > initial_btc);
1843
1844 let result = paper_cancel(&mut state, order_id);
1846 assert!(result.is_err());
1847
1848 let output = paper_orders(&state, None, None, None).unwrap();
1850 assert!(!output.render().contains("filled"));
1851 let history = paper_history(&state, None, None).unwrap();
1853 assert!(history.render().contains("filled"));
1854 }
1855
1856 #[test]
1857 fn test_paper_lifecycle_sell_cancel_topup() {
1858 let mut state = PaperState::default();
1859
1860 let result = place_paper_order(&mut state, "btc_idr", "sell", Some(100_000_000.0), 0.5);
1862 assert!(result.is_ok());
1863 let order_id = state.orders[0].id;
1864 assert_eq!(state.orders[0].status, "open");
1865
1866 let btc_before = *state.balances.get("btc").unwrap();
1868 let result = paper_cancel(&mut state, order_id);
1869 assert!(result.is_ok());
1870 assert_eq!(state.orders[0].status, "cancelled");
1871 assert!(*state.balances.get("btc").unwrap() > btc_before);
1872
1873 let result = paper_topup(&mut state, "usdt", 1000.0);
1875 assert!(result.is_ok());
1876 assert_eq!(*state.balances.get("usdt").unwrap(), 1000.0);
1877
1878 let output = paper_status(&state).unwrap();
1880 let rendered = output.render();
1881 assert!(rendered.contains("cancelled") || rendered.contains("Cancelled"));
1882 }
1883
1884 #[tokio::test]
1885 async fn test_paper_lifecycle_multiple_orders_and_check_fills() {
1886 let client = IndodaxClient::new(None).unwrap();
1887 let mut state = PaperState::default();
1888
1889 state.balances.insert("eth".into(), 5.0);
1891
1892 place_paper_order(&mut state, "btc_idr", "buy", Some(100_000_000.0), 0.5).unwrap();
1894 place_paper_order(&mut state, "eth_idr", "sell", Some(10_000_000.0), 2.0).unwrap();
1895 place_paper_order(&mut state, "btc_idr", "buy", Some(90_000_000.0), 0.3).unwrap();
1896
1897 let prices = r#"{"btc_idr": 95000000, "eth_idr": 12000000}"#;
1899 let result = paper_check_fills(&client, &mut state, Some(prices), false).await;
1900 assert!(result.is_ok());
1901
1902 assert_eq!(state.orders[0].status, "filled");
1904 assert_eq!(state.orders[1].status, "filled");
1906 assert_eq!(state.orders[2].status, "open");
1908
1909 let result = paper_fill(&mut state, None, None, true, None, false).await;
1911 assert!(result.is_ok());
1912 assert_eq!(state.orders[2].status, "filled");
1913 }
1914}