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