Skip to main content

indodax_cli/commands/
paper.rs

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; // 0.26% taker fee
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PaperOrder {
17    pub id: u64,
18    pub pair: String,
19    pub side: String,
20    pub price: f64,
21    pub amount: f64,
22    pub remaining: f64,
23    pub order_type: String,
24    pub status: String,
25    pub created_at: u64,
26    #[serde(default)]
27    pub fees_paid: f64,
28    #[serde(default)]
29    pub filled_price: f64,
30    #[serde(default)]
31    pub total_spent: f64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PaperState {
36    pub balances: HashMap<String, f64>,
37    pub orders: Vec<PaperOrder>,
38    pub next_order_id: u64,
39    pub trade_count: u64,
40    #[serde(default)]
41    pub total_fees_paid: f64,
42    #[serde(default)]
43    pub initial_balances: Option<HashMap<String, f64>>,
44    #[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, &currency.to_lowercase());
331    let current_balance = *state.balances.get(&currency.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            // Priority: fetched price > explicit fill_price > order price
772            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 // Skip if fetch requested but failed
791                }
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, &quote, &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    // Priority: fetched price > explicit fill_price > order price
853    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    // Normalize price keys to match stored order pairs
1119    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
1190// ──────────────────────────────────────────────
1191// Public helpers for MCP tools (Value-returning)
1192// ──────────────────────────────────────────────
1193
1194pub 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
1278// ──────────────────────────────────────────────
1279// Public helpers for MCP tools
1280// ──────────────────────────────────────────────
1281
1282/// Cancel a paper order by ID (public wrapper for MCP tools).
1283pub fn cancel_paper_order(state: &mut PaperState, order_id: u64) -> Result<(), IndodaxError> {
1284    refund_and_cancel(state, order_id)
1285}
1286
1287/// Cancel all paper orders that can be cancelled (public wrapper for MCP tools).
1288/// Returns the number of cancelled orders.
1289pub 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        // Clean up any previous test file
1389        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        // Clean up
1396        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        // Try to buy with insufficient IDR
1476        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        // Try to sell more BTC than we have
1486        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        // Order is open, cancel should succeed and refund balance
1529        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        // First cancel should succeed (order is open)
1549        paper_cancel(&mut state, order_id).unwrap();
1550        // Second cancel should fail (already cancelled)
1551        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        // Orders are open, cancel_all should cancel them
1563        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        // Buy @ 100k, fill at 90k (better price) -> OK
1643        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        // Try to fill already filled order
1649        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        // Buy @ 100k, try to fill at 110k (worse price) -> Error
1660        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        // BTC buy matches (90M <= 100M), ETH buy doesn't (15M > 10M), BTC sell doesn't (90M < 120M)
1829        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        // Empty prices map means no orders match — result should be Ok with no fills
1866        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        // Buy order
1879        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        // IDR deducted
1884        assert!(*state.balances.get("idr").unwrap() < initial_idr);
1885
1886        // Fill the buy
1887        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        // BTC received
1891        assert!(*state.balances.get("btc").unwrap() > initial_btc);
1892
1893        // Already filled, cancel should fail
1894        let result = paper_cancel(&mut state, order_id);
1895        assert!(result.is_err());
1896
1897        // Orders listing now only shows open orders
1898        let output = paper_orders(&state, None, None, None).unwrap();
1899        assert!(!output.render().contains("filled"));
1900        // History should still show all orders
1901        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        // Sell order
1910        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        // Cancel the sell - BTC should be refunded
1916        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        // Topup
1923        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        // Status should show correct counts
1928        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        // Add ETH balance for selling
1939        state.balances.insert("eth".into(), 5.0);
1940
1941        // Place multiple orders
1942        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        // Check fills with market prices - btc buy at 100M matches when market is 95M
1947        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        // First BTC buy (100M) should fill (95M <= 100M)
1952        assert_eq!(state.orders[0].status, "filled");
1953        // ETH sell (10M) should fill (12M >= 10M)
1954        assert_eq!(state.orders[1].status, "filled");
1955        // Second BTC buy (90M) should NOT fill (95M > 90M)
1956        assert_eq!(state.orders[2].status, "open");
1957
1958        // Now fill the remaining one with --all
1959        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}