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