diff --git a/src/commands/paper.rs b/src/commands/paper.rs index b762376..22379b9 100644 --- a/src/commands/paper.rs +++ b/src/commands/paper.rs @@ -8,7 +8,7 @@ use crate::errors::{KrakenError, Result}; use crate::output::{self, CommandOutput}; use crate::paper::{ load_state, migrate_legacy_state, paper_state_path, parse_pair, save_state, OrderSide, - PaperState, PaperTrade, + PaperConfig, PaperState, PaperTrade, }; use crate::{build_spot_client, AppContext}; @@ -27,6 +27,9 @@ pub(crate) enum PaperCommand { /// Fee rate as a decimal (default: 0.0026 = 0.26% Kraken Starter tier). #[arg(long)] fee_rate: Option, + /// Slippage rate as a decimal (default: 0.0 = no slippage simulation). + #[arg(long, alias = "slippage")] + slippage_rate: Option, }, /// Reset paper account. Optionally override balance, currency, or fee rate. Reset { @@ -39,6 +42,9 @@ pub(crate) enum PaperCommand { /// New fee rate (default: keep previous). #[arg(long)] fee_rate: Option, + /// Slippage rate as a decimal (default: keep previous). + #[arg(long, alias = "slippage")] + slippage_rate: Option, }, /// Show paper balances. Balance, @@ -93,17 +99,20 @@ pub(crate) async fn execute( balance, currency, fee_rate, - } => execute_init(*balance, currency, *fee_rate), + slippage_rate, + } => execute_init(*balance, currency, *fee_rate, *slippage_rate), PaperCommand::Reset { balance, currency, fee_rate, + slippage_rate, } => { let client = build_spot_client(ctx)?; execute_reset( balance.as_ref().copied(), currency.as_deref(), fee_rate.as_ref().copied(), + slippage_rate.as_ref().copied(), &client, verbose, ) @@ -172,8 +181,8 @@ pub(crate) async fn execute( } } -fn execute_init(balance: f64, currency: &str, fee_rate: Option) -> Result { - use crate::paper::DEFAULT_FEE_RATE; +fn execute_init(balance: f64, currency: &str, fee_rate: Option, slippage_rate: Option) -> Result { + use crate::paper::{DEFAULT_FEE_RATE, DEFAULT_SLIPPAGE_RATE}; let migrated = migrate_legacy_state()?; let path = paper_state_path()?; @@ -198,16 +207,26 @@ fn execute_init(balance: f64, currency: &str, fee_rate: Option) -> Result) -> Result, currency: Option<&str>, fee_rate: Option, + slippage_rate: Option, client: &SpotClient, verbose: bool, ) -> Result { @@ -256,10 +277,13 @@ async fn execute_reset( )); } } + if let Some(s) = slippage_rate { + validate_slippage_rate(s)?; + } let mut state = load_state()?; reconcile_best_effort(&mut state, client, verbose).await; - state.reset_with(balance, currency, fee_rate); + state.reset_with(balance, currency, fee_rate, slippage_rate); save_state(&state)?; let bal = state.starting_balance; @@ -271,6 +295,7 @@ async fn execute_reset( ("Action".into(), "Account reset".into()), ("Starting Balance".into(), format!("{bal:.2} {cur}")), ("Fee Rate".into(), fee_pct), + ("Slippage Rate".into(), format!("{:.2}%", state.slippage_rate * 100.0)) ]; Ok(CommandOutput::key_value( @@ -280,6 +305,7 @@ async fn execute_reset( "starting_balance": bal, "starting_currency": cur, "fee_rate": state.fee_rate, + "slippage_rate": state.slippage_rate, })), )) } @@ -338,6 +364,15 @@ async fn execute_balance(client: &SpotClient, verbose: bool) -> Result Result<()> { + if !rate.is_finite() || !(0.0..=1.0).contains(&rate) { + return Err(KrakenError::Validation( + "Slippage rate must be between 0.0 and 1.0 (e.g., 0.001 for 0.1%)".into(), + )); + } + Ok(()) +} + fn validate_order_type(order_type_str: &str) -> Result { if order_type_str.eq_ignore_ascii_case("limit") { Ok(true) @@ -728,6 +763,11 @@ async fn execute_status(client: &SpotClient, verbose: bool) -> Result Result f64 { DEFAULT_FEE_RATE } +fn default_slippage_rate() -> f64 { + DEFAULT_SLIPPAGE_RATE +} + fn default_starting_balance() -> f64 { 10_000.0 } @@ -131,22 +147,28 @@ impl Default for PaperState { impl PaperState { pub(crate) fn new(balance: f64, currency: &str) -> Self { - Self::with_fee_rate(balance, currency, DEFAULT_FEE_RATE) + Self::with_config(PaperConfig { + balance, + currency: currency.to_string(), + fee_rate: DEFAULT_FEE_RATE, + slippage_rate: DEFAULT_SLIPPAGE_RATE, + }) } - pub(crate) fn with_fee_rate(balance: f64, currency: &str, fee_rate: f64) -> Self { + pub(crate) fn with_config(config: PaperConfig) -> Self { let now = Utc::now().to_rfc3339(); - let cur = currency.to_uppercase(); + let cur = config.currency.to_uppercase(); let mut balances = HashMap::new(); - balances.insert(cur.clone(), balance); + balances.insert(cur.clone(), config.balance); Self { balances, reserved: HashMap::new(), open_orders: Vec::new(), filled_trades: Vec::new(), - starting_balance: balance, + starting_balance: config.balance, starting_currency: cur, - fee_rate, + fee_rate: config.fee_rate, + slippage_rate: config.slippage_rate, created_at: now.clone(), updated_at: now, next_order_id: 1, @@ -156,10 +178,12 @@ impl PaperState { #[cfg(test)] pub(crate) fn reset(&mut self) { - let balance = self.starting_balance; - let currency = self.starting_currency.clone(); - let fee_rate = self.fee_rate; - *self = Self::with_fee_rate(balance, ¤cy, fee_rate); + *self = Self::with_config(PaperConfig { + balance: self.starting_balance, + currency: self.starting_currency.clone(), + fee_rate: self.fee_rate, + slippage_rate: self.slippage_rate, + }); } pub(crate) fn reset_with( @@ -167,13 +191,16 @@ impl PaperState { balance: Option, currency: Option<&str>, fee_rate: Option, + slippage_rate: Option, ) { - let bal = balance.unwrap_or(self.starting_balance); - let cur = currency - .map(|c| c.to_uppercase()) - .unwrap_or_else(|| self.starting_currency.clone()); - let fee = fee_rate.unwrap_or(self.fee_rate); - *self = Self::with_fee_rate(bal, &cur, fee); + *self = Self::with_config(PaperConfig { + balance: balance.unwrap_or(self.starting_balance), + currency: currency + .map(|c| c.to_uppercase()) + .unwrap_or_else(|| self.starting_currency.clone()), + fee_rate: fee_rate.unwrap_or(self.fee_rate), + slippage_rate: slippage_rate.unwrap_or(self.slippage_rate), + }); } pub(crate) fn available_balance(&self, asset: &str) -> f64 { @@ -211,7 +238,8 @@ impl PaperState { match side { OrderSide::Buy => { - let cost = volume * ask; + let fill_price = ask * (1.0 + self.slippage_rate); + let cost = volume * fill_price; let fee = cost * self.fee_rate; let total_cost = cost + fee; let available = self.available_balance("e); @@ -232,7 +260,7 @@ impl PaperState { quote, side, volume, - price: ask, + price: fill_price, fee, cost, filled_at: Utc::now().to_rfc3339(), @@ -248,7 +276,8 @@ impl PaperState { "Insufficient {base} balance. Available: {available:.8}, Required: {volume:.8}" ))); } - let proceeds = volume * bid; + let fill_price = bid * (1.0 - self.slippage_rate); + let proceeds = volume * fill_price; let fee = proceeds * self.fee_rate; let net_proceeds = proceeds - fee; *self.balances.entry(base.clone()).or_insert(0.0) -= volume; @@ -263,7 +292,7 @@ impl PaperState { quote, side, volume, - price: bid, + price: fill_price, fee, cost: proceeds, filled_at: Utc::now().to_rfc3339(), @@ -675,6 +704,51 @@ mod tests { assert!((fee - 130.0).abs() < 1e-10); } + #[test] + fn market_buy_applies_slippage() { + let mut state = PaperState::with_config(PaperConfig { balance: 10000.0, currency: "USD".into(), fee_rate: 0.0, slippage_rate: 0.001 }); + let trade = state + .place_market_order(OrderSide::Buy, "BTCUSD", 0.1, 50000.0, 49900.0) + .unwrap(); + // 50000 * 1.001 = 50050.0, volume 0.1 => cost 5005.0 + assert!((trade.price - 50050.0).abs() < 1e-6); + } + + #[test] + fn market_sell_applies_slippage() { + let mut state = PaperState::with_config(PaperConfig { balance: 0.0, currency: "USD".into(), fee_rate: 0.0, slippage_rate: 0.001 }); + state.balances.insert("BTC".into(), 1.0); + let trade = state + .place_market_order(OrderSide::Sell, "BTCUSD", 1.0, 50000.0, 50000.0) + .unwrap(); + // 50000 * 0.999 = 49950.0 — clean number, no precision issue + assert!((trade.price - 49950.0).abs() < 1e-6); + } + + #[test] + fn zero_slippage_is_backward_compatible() { + let mut state = PaperState::new(10000.0, "USD"); + let trade = state + .place_market_order(OrderSide::Buy, "BTCUSD", 0.1, 50000.0, 49900.0) + .unwrap(); + // no slippage, fill at exact ask + assert!((trade.price - 50000.0).abs() < 1e-6); + } + + #[test] + fn reset_with_preserves_slippage() { + let mut state = PaperState::with_config(PaperConfig { balance: 10000.0, currency: "USD".into(), fee_rate: 0.0026, slippage_rate: 0.001 }); + state.reset_with(Some(5000.0), None, None, None); + assert!((state.slippage_rate - 0.001).abs() < 1e-10); + } + + #[test] + fn reset_with_updates_slippage() { + let mut state = PaperState::with_config(PaperConfig { balance: 10000.0, currency: "USD".into(), fee_rate: 0.0026, slippage_rate: 0.001 }); + state.reset_with(None, None, None, Some(0.002)); + assert!((state.slippage_rate - 0.002).abs() < 1e-10); + } + #[test] fn test_limit_buy_reserves_quote() { let mut state = PaperState::new(10000.0, "USD"); @@ -1307,4 +1381,22 @@ mod tests { serde_json::from_str(json).expect("must deserialize without next_order_id"); assert_eq!(state.next_order_id, 1); } + + #[test] + fn deserialize_state_missing_slippage_rate_defaults_to_zero() { + let json = r#"{ + "balances": {"USD": 10000.0}, + "reserved": {}, + "open_orders": [], + "filled_trades": [], + "fee_rate": 0.0026, + "starting_balance": 10000.0, + "starting_currency": "USD", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#; + let state: PaperState = + serde_json::from_str(json).expect("must deserialize without slippage_rate"); + assert_eq!(state.slippage_rate, 0.0); + } }