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