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 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; // Mark as dirty so it gets saved to the new file on first use
109            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, &currency.to_lowercase());
332    let current_balance = *state.balances.get(&currency.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            // Priority: fetched price > explicit fill_price > order price
773            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 // Skip if fetch requested but failed
792                }
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, &quote, &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    // Priority: fetched price > explicit fill_price > order price
854    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    // Normalize price keys to match stored order pairs
1120    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
1191// ──────────────────────────────────────────────
1192// Public helpers for MCP tools (Value-returning)
1193// ──────────────────────────────────────────────
1194
1195pub 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
1279// ──────────────────────────────────────────────
1280// Public helpers for MCP tools
1281// ──────────────────────────────────────────────
1282
1283/// Cancel a paper order by ID (public wrapper for MCP tools).
1284pub fn cancel_paper_order(state: &mut PaperState, order_id: u64) -> Result<(), IndodaxError> {
1285    refund_and_cancel(state, order_id)
1286}
1287
1288/// Cancel all paper orders that can be cancelled (public wrapper for MCP tools).
1289/// Returns the number of cancelled orders.
1290pub 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        // Clean up any previous test file
1390        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        // Clean up
1397        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        // Try to buy with insufficient IDR
1477        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        // Try to sell more BTC than we have
1487        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        // Order is open, cancel should succeed and refund balance
1530        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        // First cancel should succeed (order is open)
1550        paper_cancel(&mut state, order_id).unwrap();
1551        // Second cancel should fail (already cancelled)
1552        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        // Orders are open, cancel_all should cancel them
1564        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        // Buy @ 100k, fill at 90k (better price) -> OK
1644        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        // Try to fill already filled order
1650        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        // Buy @ 100k, try to fill at 110k (worse price) -> Error
1661        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        // BTC buy matches (90M <= 100M), ETH buy doesn't (15M > 10M), BTC sell doesn't (90M < 120M)
1830        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        // Empty prices map means no orders match — result should be Ok with no fills
1867        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        // Buy order
1880        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        // IDR deducted
1885        assert!(*state.balances.get("idr").unwrap() < initial_idr);
1886
1887        // Fill the buy
1888        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        // BTC received
1892        assert!(*state.balances.get("btc").unwrap() > initial_btc);
1893
1894        // Already filled, cancel should fail
1895        let result = paper_cancel(&mut state, order_id);
1896        assert!(result.is_err());
1897
1898        // Orders listing now only shows open orders
1899        let output = paper_orders(&state, None, None, None).unwrap();
1900        assert!(!output.render().contains("filled"));
1901        // History should still show all orders
1902        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        // Sell order
1911        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        // Cancel the sell - BTC should be refunded
1917        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        // Topup
1924        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        // Status should show correct counts
1929        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        // Add ETH balance for selling
1940        state.balances.insert("eth".into(), 5.0);
1941
1942        // Place multiple orders
1943        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        // Check fills with market prices - btc buy at 100M matches when market is 95M
1948        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        // First BTC buy (100M) should fill (95M <= 100M)
1953        assert_eq!(state.orders[0].status, "filled");
1954        // ETH sell (10M) should fill (12M >= 10M)
1955        assert_eq!(state.orders[1].status, "filled");
1956        // Second BTC buy (90M) should NOT fill (95M > 90M)
1957        assert_eq!(state.orders[2].status, "open");
1958
1959        // Now fill the remaining one with --all
1960        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}