From b1cb0d0bf9ab96070759801bbde00e2fc0213fc1 Mon Sep 17 00:00:00 2001 From: MacBobby Chibuzor Date: Mon, 23 Mar 2026 16:14:09 +0100 Subject: [PATCH 1/3] [feature] add optional slippage simulation for paper market orders --- src/commands/paper.rs | 39 +++++++++++++++++++++--- src/paper.rs | 71 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/commands/paper.rs b/src/commands/paper.rs index b762376..034d05f 100644 --- a/src/commands/paper.rs +++ b/src/commands/paper.rs @@ -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)] + 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)] + 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,25 @@ 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 +276,17 @@ async fn execute_reset( )); } } + if let Some(s) = slippage_rate { + if !s.is_finite() || !(0.0..=1.0).contains(&s) { + return Err(KrakenError::Validation( + "Slippage rate must be between 0.0 and 1.0 (e.g., 0.001 for 0.1%)".into(), + )) + } + } 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 +298,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 +308,7 @@ async fn execute_reset( "starting_balance": bal, "starting_currency": cur, "fee_rate": state.fee_rate, + "slippage_rate": state.slippage_rate, })), )) } diff --git a/src/paper.rs b/src/paper.rs index 3c3d616..741b197 100644 --- a/src/paper.rs +++ b/src/paper.rs @@ -11,6 +11,8 @@ use crate::errors::{KrakenError, Result}; pub(crate) const DEFAULT_FEE_RATE: f64 = 0.0026; +pub(crate) const DEFAULT_SLIPPAGE_RATE: f64 = 0.0; + const KNOWN_QUOTES: &[&str] = &[ "USDT", "USDC", "USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CHF", "ETH", "BTC", "DAI", ]; @@ -39,6 +41,8 @@ pub(crate) struct PaperState { pub(crate) starting_currency: String, #[serde(default = "default_fee_rate")] pub(crate) fee_rate: f64, + #[serde(default)] + pub(crate) slippage_rate: f64, pub(crate) created_at: String, pub(crate) updated_at: String, #[serde(default = "default_next_order_id")] @@ -131,10 +135,10 @@ 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_fee_rate(balance, currency, DEFAULT_FEE_RATE, DEFAULT_SLIPPAGE_RATE) } - pub(crate) fn with_fee_rate(balance: f64, currency: &str, fee_rate: f64) -> Self { + pub(crate) fn with_fee_rate(balance: f64, currency: &str, fee_rate: f64, slippage_rate: f64) -> Self { let now = Utc::now().to_rfc3339(); let cur = currency.to_uppercase(); let mut balances = HashMap::new(); @@ -147,6 +151,7 @@ impl PaperState { starting_balance: balance, starting_currency: cur, fee_rate, + slippage_rate, created_at: now.clone(), updated_at: now, next_order_id: 1, @@ -159,7 +164,8 @@ impl PaperState { 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); + let slippage_rate = self.slippage_rate; + *self = Self::with_fee_rate(balance, ¤cy, fee_rate, slippage_rate); } pub(crate) fn reset_with( @@ -167,13 +173,15 @@ 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); + let slip = slippage_rate.unwrap_or(self.slippage_rate); + *self = Self::with_fee_rate(bal, &cur, fee, slip); } pub(crate) fn available_balance(&self, asset: &str) -> f64 { @@ -211,7 +219,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 +241,7 @@ impl PaperState { quote, side, volume, - price: ask, + price: fill_price, fee, cost, filled_at: Utc::now().to_rfc3339(), @@ -248,7 +257,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 +273,7 @@ impl PaperState { quote, side, volume, - price: bid, + price: fill_price, fee, cost: proceeds, filled_at: Utc::now().to_rfc3339(), @@ -675,6 +685,51 @@ mod tests { assert!((fee - 130.0).abs() < 1e-10); } + #[test] + fn market_buy_applies_slippage() { + let mut state = PaperState::with_fee_rate(10000.0, "USD", 0.0, 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_fee_rate(0.0, "USD", 0.0, 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_fee_rate(10000.0, "USD", 0.0026, 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_fee_rate(10000.0, "USD", 0.0026, 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"); From 33f1961aeccf2f49cad4efeeeb4d11ba7539c7a2 Mon Sep 17 00:00:00 2001 From: MacBobby Chibuzor Date: Tue, 24 Mar 2026 17:03:12 +0100 Subject: [PATCH 2/3] [fixes] implement PR suggestions --- src/commands/paper.rs | 53 ++++++++++++++++++++++++++++++++----------- src/paper.rs | 24 +++++++++++++++++++- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/commands/paper.rs b/src/commands/paper.rs index 034d05f..34d76a7 100644 --- a/src/commands/paper.rs +++ b/src/commands/paper.rs @@ -28,7 +28,7 @@ pub(crate) enum PaperCommand { #[arg(long)] fee_rate: Option, /// Slippage rate as a decimal (default: 0.0 = no slippage simulation). - #[arg(long)] + #[arg(long, alias = "slippage")] slippage_rate: Option, }, /// Reset paper account. Optionally override balance, currency, or fee rate. @@ -43,7 +43,7 @@ pub(crate) enum PaperCommand { #[arg(long)] fee_rate: Option, /// Slippage rate as a decimal (default: keep previous). - #[arg(long)] + #[arg(long, alias = "slippage")] slippage_rate: Option, }, /// Show paper balances. @@ -208,11 +208,7 @@ fn execute_init(balance: f64, currency: &str, fee_rate: Option, slippage_ra } let slip = slippage_rate.unwrap_or(DEFAULT_SLIPPAGE_RATE); - if !slip.is_finite() || !(0.0..=1.0).contains(&slip) { - return Err(KrakenError::Validation( - "Slippage rate must be between 0.0 and 1.0 (e.g., 0.001 for 0.1%)".into(), - )); - } + validate_slippage_rate(slip)?; let state = PaperState::with_fee_rate(balance, currency, rate, slip); save_state(&state)?; @@ -235,7 +231,7 @@ fn execute_init(balance: f64, currency: &str, fee_rate: Option, slippage_ra "starting_balance": balance, "starting_currency": cur, "fee_rate": rate, - "slipage_rate": slip, + "slippage_rate": slip, })), )) } @@ -277,11 +273,7 @@ async fn execute_reset( } } if let Some(s) = slippage_rate { - if !s.is_finite() || !(0.0..=1.0).contains(&s) { - return Err(KrakenError::Validation( - "Slippage rate must be between 0.0 and 1.0 (e.g., 0.001 for 0.1%)".into(), - )) - } + validate_slippage_rate(s)?; } let mut state = load_state()?; @@ -367,6 +359,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) @@ -959,6 +960,32 @@ mod tests { assert!(!validate_order_type("Market").unwrap()); } + #[test] + fn test_slippage_validation_negative() { + assert!(validate_slippage_rate(-0.001).is_err()); + } + + #[test] + fn test_slippage_validation_above_one() { + assert!(validate_slippage_rate(1.001).is_err()); + } + + #[test] + fn test_slippage_validation_nan() { + assert!(validate_slippage_rate(f64::NAN).is_err()); + } + + #[test] + fn test_slippage_validation_infinity() { + assert!(validate_slippage_rate(f64::INFINITY).is_err()); + } + + #[test] + fn test_slippage_validation_zero_and_one_are_valid() { + assert!(validate_slippage_rate(0.0).is_ok()); + assert!(validate_slippage_rate(1.0).is_ok()); + } + #[test] fn test_status_partial_marker_formatting() { let valuation_complete_false = false; diff --git a/src/paper.rs b/src/paper.rs index 741b197..dd73ee0 100644 --- a/src/paper.rs +++ b/src/paper.rs @@ -41,7 +41,7 @@ pub(crate) struct PaperState { pub(crate) starting_currency: String, #[serde(default = "default_fee_rate")] pub(crate) fee_rate: f64, - #[serde(default)] + #[serde(default = "default_slippage_rate")] pub(crate) slippage_rate: f64, pub(crate) created_at: String, pub(crate) updated_at: String, @@ -59,6 +59,10 @@ fn default_fee_rate() -> f64 { DEFAULT_FEE_RATE } +fn default_slippage_rate() -> f64 { + DEFAULT_SLIPPAGE_RATE +} + fn default_starting_balance() -> f64 { 10_000.0 } @@ -1362,4 +1366,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); + } } From 7b5e21efbf056e4c963bee8e2c22aeeb23afecee Mon Sep 17 00:00:00 2001 From: MacBobby Chibuzor Date: Tue, 24 Mar 2026 17:14:44 +0100 Subject: [PATCH 3/3] [refactor] use PaperConfig to replace with_fee_rate, add fee/slippage rate to execute_status output --- src/commands/paper.rs | 16 ++++++++++-- src/paper.rs | 61 +++++++++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/commands/paper.rs b/src/commands/paper.rs index 34d76a7..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}; @@ -210,7 +210,12 @@ fn execute_init(balance: f64, currency: &str, fee_rate: Option, slippage_ra let slip = slippage_rate.unwrap_or(DEFAULT_SLIPPAGE_RATE); validate_slippage_rate(slip)?; - let state = PaperState::with_fee_rate(balance, currency, rate, slip); + let state = PaperState::with_config(PaperConfig { + balance, + currency: currency.to_string(), + fee_rate: rate, + slippage_rate: slip, + }); save_state(&state)?; let cur = currency.to_uppercase(); @@ -758,6 +763,11 @@ async fn execute_status(client: &SpotClient, verbose: bool) -> Result Result Self { - Self::with_fee_rate(balance, currency, DEFAULT_FEE_RATE, DEFAULT_SLIPPAGE_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, slippage_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, - slippage_rate, + fee_rate: config.fee_rate, + slippage_rate: config.slippage_rate, created_at: now.clone(), updated_at: now, next_order_id: 1, @@ -165,11 +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; - let slippage_rate = self.slippage_rate; - *self = Self::with_fee_rate(balance, ¤cy, fee_rate, slippage_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( @@ -179,13 +193,14 @@ impl PaperState { 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); - let slip = slippage_rate.unwrap_or(self.slippage_rate); - *self = Self::with_fee_rate(bal, &cur, fee, slip); + *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 { @@ -691,7 +706,7 @@ mod tests { #[test] fn market_buy_applies_slippage() { - let mut state = PaperState::with_fee_rate(10000.0, "USD", 0.0, 0.001); + 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(); @@ -701,7 +716,7 @@ mod tests { #[test] fn market_sell_applies_slippage() { - let mut state = PaperState::with_fee_rate(0.0, "USD", 0.0, 0.001); + 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) @@ -722,14 +737,14 @@ mod tests { #[test] fn reset_with_preserves_slippage() { - let mut state = PaperState::with_fee_rate(10000.0, "USD", 0.0026, 0.001); + 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_fee_rate(10000.0, "USD", 0.0026, 0.001); + 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); }