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