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