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