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