Skip to main content

trader_bot/strategy/
scalper.rs

1//! Scalping trading strategy implementation.
2//!
3//! The scalping strategy aims to profit from small price movements through
4//! rapid buy-sell cycles. It places buy orders at the best bid price and
5//! sell orders at a price that ensures minimum profit.
6
7use super::settings::TradingSettings;
8use super::traits::{MarketState, Strategy, StrategyAction, StrategyDecision};
9use crate::domain::Order;
10use async_trait::async_trait;
11use rust_decimal::Decimal;
12use std::collections::HashMap;
13use tracing::{debug, info, trace};
14
15/// Tracks lots owned and their purchase prices.
16#[derive(Debug, Clone, Default)]
17struct OwnedLots {
18    /// Maps purchase price to quantity at that price.
19    lots_by_price: HashMap<String, (Decimal, u32)>,
20}
21
22impl OwnedLots {
23    fn new() -> Self {
24        Self::default()
25    }
26
27    fn add(&mut self, price: Decimal, quantity: u32) {
28        let key = price.to_string();
29        let entry = self.lots_by_price.entry(key).or_insert((price, 0));
30        entry.1 += quantity;
31    }
32
33    fn remove(&mut self, quantity: u32) -> Option<Decimal> {
34        // Remove from the lowest priced lots first (FIFO by price)
35        let mut to_remove = quantity;
36        let mut removed_price = None;
37
38        let mut keys_to_update: Vec<(String, u32)> = Vec::new();
39
40        // Sort keys by price to ensure FIFO
41        let mut sorted_entries: Vec<_> = self.lots_by_price.iter().collect();
42        sorted_entries.sort_by_key(|a| a.1 .0);
43
44        for (key, (price, qty)) in sorted_entries {
45            if to_remove == 0 {
46                break;
47            }
48
49            if *qty <= to_remove {
50                to_remove -= qty;
51                keys_to_update.push((key.clone(), 0));
52            } else {
53                keys_to_update.push((key.clone(), qty - to_remove));
54                to_remove = 0;
55            }
56
57            removed_price = Some(*price);
58        }
59
60        for (key, new_qty) in keys_to_update {
61            if new_qty == 0 {
62                self.lots_by_price.remove(&key);
63            } else if let Some(entry) = self.lots_by_price.get_mut(&key) {
64                entry.1 = new_qty;
65            }
66        }
67
68        removed_price
69    }
70
71    fn total_quantity(&self) -> u32 {
72        self.lots_by_price.values().map(|(_, qty)| qty).sum()
73    }
74
75    fn lowest_price(&self) -> Option<Decimal> {
76        self.lots_by_price
77            .values()
78            .min_by(|a, b| a.0.cmp(&b.0))
79            .map(|(price, _)| *price)
80    }
81
82    fn clear(&mut self) {
83        self.lots_by_price.clear();
84    }
85}
86
87/// Scalping trading strategy.
88///
89/// This strategy implements a simple scalping approach:
90/// 1. Place buy orders at the best bid price when conditions are favorable
91/// 2. Track purchased lots and their prices
92/// 3. Place sell orders at a price that ensures minimum profit
93/// 4. Adjust orders as market conditions change
94#[derive(Debug)]
95pub struct ScalpingStrategy {
96    settings: TradingSettings,
97    owned_lots: OwnedLots,
98    active_buy_order: Option<String>,
99    active_sell_orders: HashMap<String, Decimal>,
100}
101
102impl ScalpingStrategy {
103    /// Creates a new scalping strategy with the given settings.
104    #[must_use]
105    pub fn new(settings: TradingSettings) -> Self {
106        Self {
107            settings,
108            owned_lots: OwnedLots::new(),
109            active_buy_order: None,
110            active_sell_orders: HashMap::new(),
111        }
112    }
113
114    /// Returns the settings.
115    #[must_use]
116    pub fn settings(&self) -> &TradingSettings {
117        &self.settings
118    }
119
120    /// Checks if we should place a buy order.
121    fn should_buy(&self, state: &MarketState) -> bool {
122        // Check if trading is enabled
123        if !self.settings.enabled {
124            trace!("Trading disabled");
125            return false;
126        }
127
128        // Check if we're within trading hours
129        let current_time = state.timestamp.time();
130        if !self.settings.is_trading_time(current_time) {
131            trace!("Outside trading hours");
132            return false;
133        }
134
135        // Check if we already have a buy order
136        if self.active_buy_order.is_some() {
137            trace!("Already have active buy order");
138            return false;
139        }
140
141        // Check if we're at max position
142        let current_position = u64::from(self.owned_lots.total_quantity());
143        if current_position >= self.settings.max_position_lots {
144            trace!("At max position: {}", current_position);
145            return false;
146        }
147
148        // Check market liquidity from order book
149        if let Some(ref order_book) = state.order_book {
150            let bid_depth = order_book.bid_depth(self.settings.market_order_book_depth);
151            if bid_depth < self.settings.minimum_market_order_size_to_buy {
152                trace!("Insufficient bid liquidity: {}", bid_depth);
153                return false;
154            }
155        }
156
157        // Check if we have enough cash
158        if let Some(best_ask) = state.best_ask() {
159            let lot_cost = best_ask * Decimal::from(self.settings.lot_size);
160            if state.available_cash < lot_cost {
161                trace!("Insufficient cash: {} < {}", state.available_cash, lot_cost);
162                return false;
163            }
164        }
165
166        true
167    }
168
169    /// Determines the buy price based on order book.
170    fn get_buy_price(&self, state: &MarketState) -> Option<Decimal> {
171        state.best_bid()
172    }
173
174    /// Calculates the sell price for owned lots.
175    fn get_sell_price(&self, buy_price: Decimal) -> Decimal {
176        buy_price + self.settings.minimum_profit()
177    }
178
179    /// Checks if we should place a sell order for owned lots.
180    fn should_sell(&self, state: &MarketState) -> bool {
181        // Check if we have lots to sell
182        if self.owned_lots.total_quantity() == 0 {
183            return false;
184        }
185
186        // Check market liquidity from order book
187        if let Some(ref order_book) = state.order_book {
188            let ask_depth = order_book.ask_depth(self.settings.market_order_book_depth);
189            if ask_depth < self.settings.minimum_market_order_size_to_sell {
190                trace!("Insufficient ask liquidity: {}", ask_depth);
191                return false;
192            }
193        }
194
195        true
196    }
197
198    /// Checks if we should adjust existing buy order price.
199    fn should_adjust_buy_order(&self, state: &MarketState) -> Option<(String, Decimal)> {
200        let order_id = self.active_buy_order.as_ref()?;
201
202        // Find the current order
203        let current_order = state.active_orders.iter().find(|o| o.id() == order_id)?;
204
205        let current_price = current_order.limit_price()?.amount();
206        let best_bid = state.best_bid()?;
207
208        // Check if best bid has changed and has enough liquidity
209        if best_bid != current_price {
210            if let Some(ref order_book) = state.order_book {
211                let bid_depth = order_book.bid_depth(self.settings.market_order_book_depth);
212                if bid_depth >= self.settings.minimum_market_order_size_to_change_buy_price {
213                    return Some((order_id.clone(), best_bid));
214                }
215            }
216        }
217
218        None
219    }
220
221    /// Checks if we should adjust existing sell order price.
222    fn should_adjust_sell_order(&self, state: &MarketState) -> Option<(String, Decimal)> {
223        // Find a sell order that could be improved
224        for (order_id, _target_price) in &self.active_sell_orders {
225            let order = state.active_orders.iter().find(|o| o.id() == order_id)?;
226
227            let current_price = order.limit_price()?.amount();
228            let best_ask = state.best_ask()?;
229
230            // If best ask is lower than our sell price and still profitable
231            if best_ask < current_price {
232                if let Some(lowest_buy) = self.owned_lots.lowest_price() {
233                    let min_sell = self.get_sell_price(lowest_buy);
234                    if best_ask >= min_sell {
235                        if let Some(ref order_book) = state.order_book {
236                            let ask_depth =
237                                order_book.ask_depth(self.settings.market_order_book_depth);
238                            if ask_depth
239                                >= self.settings.minimum_market_order_size_to_change_sell_price
240                            {
241                                return Some((order_id.clone(), best_ask));
242                            }
243                        }
244                    }
245                }
246            }
247        }
248
249        None
250    }
251
252    /// Creates actions for the current market state.
253    fn create_actions(&self, state: &MarketState) -> StrategyDecision {
254        let mut decision = StrategyDecision::new();
255
256        // Check if we should adjust buy order
257        if let Some((order_id, new_price)) = self.should_adjust_buy_order(state) {
258            debug!("Adjusting buy order {} to price {}", order_id, new_price);
259            decision.add_action(StrategyAction::cancel(order_id));
260            decision.add_action(StrategyAction::limit_buy(
261                &self.settings.instrument,
262                self.settings.lot_size as u32,
263                new_price,
264            ));
265            return decision.with_reason("Adjusting buy order to new best bid");
266        }
267
268        // Check if we should adjust sell order
269        if let Some((order_id, new_price)) = self.should_adjust_sell_order(state) {
270            debug!("Adjusting sell order {} to price {}", order_id, new_price);
271            decision.add_action(StrategyAction::cancel(order_id));
272            decision.add_action(StrategyAction::limit_sell(
273                &self.settings.instrument,
274                self.owned_lots.total_quantity(),
275                new_price,
276            ));
277            return decision.with_reason("Adjusting sell order to better price");
278        }
279
280        // Check if we should place a new buy order
281        if self.should_buy(state) {
282            if let Some(buy_price) = self.get_buy_price(state) {
283                debug!("Placing buy order at price {}", buy_price);
284                decision.add_action(StrategyAction::limit_buy(
285                    &self.settings.instrument,
286                    self.settings.lot_size as u32,
287                    buy_price,
288                ));
289                return decision.with_reason("Placing new buy order at best bid");
290            }
291        }
292
293        // Check if we should place a sell order
294        if self.should_sell(state) && self.active_sell_orders.is_empty() {
295            if let Some(lowest_buy) = self.owned_lots.lowest_price() {
296                let sell_price = self.get_sell_price(lowest_buy);
297                debug!("Placing sell order at price {}", sell_price);
298                decision.add_action(StrategyAction::limit_sell(
299                    &self.settings.instrument,
300                    self.owned_lots.total_quantity(),
301                    sell_price,
302                ));
303                return decision.with_reason("Placing new sell order with minimum profit");
304            }
305        }
306
307        StrategyDecision::hold_with_reason("No action needed")
308    }
309}
310
311#[async_trait]
312impl Strategy for ScalpingStrategy {
313    fn name(&self) -> &str {
314        "Scalping Strategy"
315    }
316
317    fn id(&self) -> &str {
318        &self.settings.instrument
319    }
320
321    async fn decide(&self, state: &MarketState) -> StrategyDecision {
322        trace!(
323            "Deciding for {} - position: {} lots, cash: {}",
324            state.symbol,
325            state.position_lots(),
326            state.available_cash
327        );
328
329        self.create_actions(state)
330    }
331
332    async fn on_order_filled(&mut self, order: &Order) {
333        match order.direction() {
334            crate::domain::OrderDirection::Buy => {
335                // Add to owned lots
336                if let Some(price) = order.limit_price() {
337                    self.owned_lots.add(price.amount(), order.filled_lots());
338                    info!(
339                        "Bought {} lots at {} - total owned: {}",
340                        order.filled_lots(),
341                        price.amount(),
342                        self.owned_lots.total_quantity()
343                    );
344                }
345                // Clear active buy order
346                self.active_buy_order = None;
347            }
348            crate::domain::OrderDirection::Sell => {
349                // Remove from owned lots
350                self.owned_lots.remove(order.filled_lots());
351                info!(
352                    "Sold {} lots - remaining owned: {}",
353                    order.filled_lots(),
354                    self.owned_lots.total_quantity()
355                );
356                // Remove from active sell orders
357                self.active_sell_orders.remove(order.id());
358            }
359        }
360    }
361
362    async fn on_order_cancelled(&mut self, order: &Order) {
363        match order.direction() {
364            crate::domain::OrderDirection::Buy => {
365                if self
366                    .active_buy_order
367                    .as_ref()
368                    .is_some_and(|id| id == order.id())
369                {
370                    self.active_buy_order = None;
371                }
372            }
373            crate::domain::OrderDirection::Sell => {
374                self.active_sell_orders.remove(order.id());
375            }
376        }
377    }
378
379    async fn reset(&mut self) {
380        self.owned_lots.clear();
381        self.active_buy_order = None;
382        self.active_sell_orders.clear();
383    }
384
385    fn symbols(&self) -> Vec<String> {
386        vec![self.settings.instrument.clone()]
387    }
388
389    fn is_enabled(&self) -> bool {
390        self.settings.enabled
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use rust_decimal_macros::dec;
398
399    fn create_test_settings() -> TradingSettings {
400        TradingSettings::new("TEST")
401            .with_minimum_profit_steps(2)
402            .with_price_step(Decimal::new(1, 2)) // 0.01
403            .with_lot_size(1)
404            .with_max_position(10)
405            .with_min_order_size_to_buy(5)
406            .with_min_order_size_to_sell(5)
407            .with_order_book_depth(3)
408    }
409
410    #[tokio::test]
411    async fn test_strategy_creation() {
412        let settings = create_test_settings();
413        let strategy = ScalpingStrategy::new(settings);
414        assert_eq!(strategy.name(), "Scalping Strategy");
415    }
416
417    #[tokio::test]
418    async fn test_sell_price_calculation() {
419        let settings = create_test_settings();
420        let strategy = ScalpingStrategy::new(settings);
421
422        let buy_price = dec!(100.00);
423        let sell_price = strategy.get_sell_price(buy_price);
424
425        // With 2 profit steps and 0.01 step size, sell should be 0.02 higher
426        assert_eq!(sell_price, dec!(100.02));
427    }
428
429    #[tokio::test]
430    async fn test_strategy_reset() {
431        let settings = create_test_settings();
432        let mut strategy = ScalpingStrategy::new(settings);
433
434        // Add some state
435        strategy.owned_lots.add(dec!(100.0), 10);
436        strategy.active_buy_order = Some("test-order".to_string());
437
438        // Reset
439        strategy.reset().await;
440
441        assert_eq!(strategy.owned_lots.total_quantity(), 0);
442        assert!(strategy.active_buy_order.is_none());
443        assert!(strategy.active_sell_orders.is_empty());
444    }
445
446    #[test]
447    fn test_owned_lots_fifo() {
448        let mut lots = OwnedLots::new();
449
450        // Add lots at different prices
451        lots.add(dec!(100.0), 5);
452        lots.add(dec!(101.0), 5);
453
454        assert_eq!(lots.total_quantity(), 10);
455
456        // Remove should take from lowest price first
457        lots.remove(3);
458        assert_eq!(lots.total_quantity(), 7);
459
460        // Lowest price should still be 100 (but only 2 lots)
461        let lowest = lots.lowest_price().unwrap();
462        assert_eq!(lowest, dec!(100.0));
463    }
464
465    #[test]
466    fn test_symbols() {
467        let settings = TradingSettings::new("AAPL");
468        let strategy = ScalpingStrategy::new(settings);
469        assert_eq!(strategy.symbols(), vec!["AAPL".to_string()]);
470    }
471}