1use 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#[derive(Debug, Clone, Default)]
17struct OwnedLots {
18 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 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 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#[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 #[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 #[must_use]
116 pub fn settings(&self) -> &TradingSettings {
117 &self.settings
118 }
119
120 fn should_buy(&self, state: &MarketState) -> bool {
122 if !self.settings.enabled {
124 trace!("Trading disabled");
125 return false;
126 }
127
128 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 if self.active_buy_order.is_some() {
137 trace!("Already have active buy order");
138 return false;
139 }
140
141 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 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 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 fn get_buy_price(&self, state: &MarketState) -> Option<Decimal> {
171 state.best_bid()
172 }
173
174 fn get_sell_price(&self, buy_price: Decimal) -> Decimal {
176 buy_price + self.settings.minimum_profit()
177 }
178
179 fn should_sell(&self, state: &MarketState) -> bool {
181 if self.owned_lots.total_quantity() == 0 {
183 return false;
184 }
185
186 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 fn should_adjust_buy_order(&self, state: &MarketState) -> Option<(String, Decimal)> {
200 let order_id = self.active_buy_order.as_ref()?;
201
202 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 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 fn should_adjust_sell_order(&self, state: &MarketState) -> Option<(String, Decimal)> {
223 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 < 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 fn create_actions(&self, state: &MarketState) -> StrategyDecision {
254 let mut decision = StrategyDecision::new();
255
256 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 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 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 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 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 self.active_buy_order = None;
347 }
348 crate::domain::OrderDirection::Sell => {
349 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 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)) .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 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 strategy.owned_lots.add(dec!(100.0), 10);
436 strategy.active_buy_order = Some("test-order".to_string());
437
438 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 lots.add(dec!(100.0), 5);
452 lots.add(dec!(101.0), 5);
453
454 assert_eq!(lots.total_quantity(), 10);
455
456 lots.remove(3);
458 assert_eq!(lots.total_quantity(), 7);
459
460 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}