1use std::collections::HashMap;
16
17use chrono::NaiveDateTime;
18use nanoid::nanoid;
19use thiserror::Error;
20
21use crate::alert_register::{
22 AlertKind, PriceAlertRegister, PriceAlertRegisterQuoteCheckpoint, TriggeredAlert,
23};
24use crate::error::{CoreError, Result};
25use crate::execution::{ExecutionError, ExecutionPricer};
26use crate::position::Position;
27use crate::position_manager::{
28 PositionManager, PositionManagerCheckpoint, PositionManagerError,
29 PositionManagerQuoteCheckpoint,
30};
31use crate::rules::Rule;
32use crate::types::{
33 Action, CloseReason, Effect, ExecutionFill, ExecutionModel, Fill, FillModel, FillPurpose,
34 FutureEffect, FutureFill, FutureIntent, OrderType, PositionId, PositionRecord, PositionStatus,
35 PreparedPendingFill, PriceQuote, RuleConfig, Side, TargetSpec, position_size_tolerance,
36};
37
38#[derive(Debug, Error)]
40pub enum FutureApplyError {
41 #[error(transparent)]
42 Core(#[from] CoreError),
43 #[error(transparent)]
44 Pricing(#[from] ExecutionError),
45 #[error("invalid prepared fill for {position_id}: {reason}")]
46 InvalidPreparedFill {
47 position_id: PositionId,
48 reason: String,
49 },
50}
51
52pub type FutureApplyResult<T> = std::result::Result<T, FutureApplyError>;
53
54fn gen_id() -> PositionId {
56 nanoid!(12)
57}
58
59struct OpenActionParams {
60 symbol: String,
61 side: Side,
62 order_type: OrderType,
63 price: Option<f64>,
64 size: f64,
65 stoploss: Option<f64>,
66 targets: Vec<TargetSpec>,
67 rules: Vec<crate::types::RuleConfig>,
68 group: Option<String>,
69 trade_id: Option<crate::types::TradeId>,
70}
71
72#[derive(Debug)]
73enum TradeEngineCheckpoint {
74 Action {
75 manager: PositionManagerCheckpoint,
76 alert_register: Option<PriceAlertRegister>,
77 next_position_sequence: u64,
78 },
79 Quote {
80 manager: PositionManagerQuoteCheckpoint,
81 symbol: String,
82 last_quote: Option<PriceQuote>,
83 alert_register: Option<PriceAlertRegisterQuoteCheckpoint>,
84 },
85}
86
87#[derive(Debug)]
89pub struct FutureEngineTransaction {
90 effects: Vec<FutureEffect>,
91 checkpoint: TradeEngineCheckpoint,
92}
93
94impl FutureEngineTransaction {
95 pub fn effects(&self) -> &[FutureEffect] {
97 &self.effects
98 }
99
100 pub fn has_effects(&self) -> bool {
102 !self.effects.is_empty()
103 }
104
105 pub fn commit(self) -> Vec<FutureEffect> {
107 self.effects
108 }
109
110 pub fn rollback(self, engine: &mut TradeEngine) {
112 engine.restore_checkpoint(self.checkpoint);
113 }
114}
115
116#[derive(Debug, Clone)]
120pub struct TradeEngine {
121 pub manager: PositionManager,
122 last_quotes: HashMap<String, PriceQuote>,
123 pub fill_model: FillModel,
127 alert_register: Option<PriceAlertRegister>,
130 deterministic_ids: bool,
131 next_position_sequence: u64,
132}
133
134impl Default for TradeEngine {
135 fn default() -> Self {
136 Self::new()
137 }
138}
139
140impl TradeEngine {
141 pub fn new() -> Self {
143 Self {
144 manager: PositionManager::new(),
145 last_quotes: HashMap::new(),
146 fill_model: FillModel::default(),
147 alert_register: None,
148 deterministic_ids: false,
149 next_position_sequence: 0,
150 }
151 }
152
153 pub fn with_fill_model(fill_model: FillModel) -> Self {
155 Self {
156 manager: PositionManager::new(),
157 last_quotes: HashMap::new(),
158 fill_model,
159 alert_register: None,
160 deterministic_ids: false,
161 next_position_sequence: 0,
162 }
163 }
164
165 pub fn with_fill_model_and_deterministic_ids(fill_model: FillModel) -> Self {
167 Self {
168 manager: PositionManager::new(),
169 last_quotes: HashMap::new(),
170 fill_model,
171 alert_register: None,
172 deterministic_ids: true,
173 next_position_sequence: 0,
174 }
175 }
176
177 pub fn with_alert_register() -> Self {
179 Self {
180 manager: PositionManager::new(),
181 last_quotes: HashMap::new(),
182 fill_model: FillModel::default(),
183 alert_register: Some(PriceAlertRegister::new()),
184 deterministic_ids: false,
185 next_position_sequence: 0,
186 }
187 }
188
189 pub fn with_alert_register_and_fill_model(fill_model: FillModel) -> Self {
191 Self {
192 manager: PositionManager::new(),
193 last_quotes: HashMap::new(),
194 fill_model,
195 alert_register: Some(PriceAlertRegister::new()),
196 deterministic_ids: false,
197 next_position_sequence: 0,
198 }
199 }
200
201 pub fn last_quote(&self, symbol: &str) -> Option<&PriceQuote> {
205 self.last_quotes.get(symbol)
206 }
207
208 pub fn get_position(&self, id: &str) -> Option<&Position> {
210 self.manager.get(id)
211 }
212
213 pub fn open_positions(&self) -> Vec<&Position> {
215 self.manager.open_positions()
216 }
217
218 pub fn pending_positions(&self) -> Vec<&Position> {
220 self.manager.pending_positions()
221 }
222
223 pub fn closed_positions(&self) -> Vec<&Position> {
225 self.manager.closed_positions()
226 }
227
228 fn checkpoint_for_action(&self, action: &Action) -> TradeEngineCheckpoint {
229 self.checkpoint_for_positions(self.position_ids_for_action(action))
230 }
231
232 fn checkpoint_for_quote(&self, quote: &PriceQuote) -> TradeEngineCheckpoint {
233 let symbol = quote.symbol.clone();
234 TradeEngineCheckpoint::Quote {
235 manager: self.manager.checkpoint_for_quote(&symbol),
236 last_quote: self.last_quotes.get(&symbol).cloned(),
237 alert_register: self
238 .alert_register
239 .as_ref()
240 .map(|register| register.checkpoint_for_quote(&symbol)),
241 symbol,
242 }
243 }
244
245 fn checkpoint_for_positions(&self, position_ids: Vec<PositionId>) -> TradeEngineCheckpoint {
246 TradeEngineCheckpoint::Action {
247 manager: self.manager.checkpoint(position_ids),
248 alert_register: self.alert_register.clone(),
249 next_position_sequence: self.next_position_sequence,
250 }
251 }
252
253 fn restore_checkpoint(&mut self, checkpoint: TradeEngineCheckpoint) {
254 match checkpoint {
255 TradeEngineCheckpoint::Action {
256 manager,
257 alert_register,
258 next_position_sequence,
259 } => {
260 self.manager.restore(manager);
261 self.alert_register = alert_register;
262 self.next_position_sequence = next_position_sequence;
263 }
264 TradeEngineCheckpoint::Quote {
265 manager,
266 symbol,
267 last_quote,
268 alert_register,
269 } => {
270 self.manager.restore_quote(manager);
271 match last_quote {
272 Some(quote) => {
273 self.last_quotes.insert(symbol.clone(), quote);
274 }
275 None => {
276 self.last_quotes.remove(&symbol);
277 }
278 }
279 if let Some(checkpoint) = alert_register {
280 self.alert_register
281 .as_mut()
282 .expect("quote transaction alert register must remain enabled")
283 .restore_quote(checkpoint);
284 }
285 }
286 }
287 }
288
289 fn position_ids_for_action(&self, action: &Action) -> Vec<PositionId> {
290 match action {
291 Action::Open { .. } if self.deterministic_ids => {
292 vec![format!("position:{:08}", self.next_position_sequence)]
293 }
294 Action::Open { .. } => Vec::new(),
295 Action::ScaleIn { position_id, .. }
296 | Action::ClosePosition { position_id }
297 | Action::ClosePartial { position_id, .. }
298 | Action::CancelPending { position_id }
299 | Action::ModifyStoploss { position_id, .. }
300 | Action::MoveStoplossToEntry { position_id }
301 | Action::AddTarget { position_id, .. }
302 | Action::RemoveTarget { position_id, .. }
303 | Action::ModifyTarget { position_id, .. }
304 | Action::AddRule { position_id, .. }
305 | Action::RemoveRule { position_id, .. } => vec![position_id.clone()],
306 Action::CloseAllOf { symbol } | Action::ModifyAllStoploss { symbol, .. } => {
307 self.manager.open_ids_by_symbol_sorted(symbol)
308 }
309 Action::CloseAll => self.manager.all_open_ids_sorted(),
310 Action::CancelAllPending => self.manager.all_pending_ids_sorted(),
311 Action::CloseAllInGroup { group_id }
312 | Action::ModifyAllStoplossInGroup { group_id, .. } => {
313 let mut ids = self.manager.open_ids_by_group(group_id);
314 ids.sort();
315 ids
316 }
317 }
318 }
319
320 pub fn on_price(&mut self, quote: &PriceQuote) -> Vec<Effect> {
330 self.last_quotes.insert(quote.symbol.clone(), quote.clone());
331
332 let mut all_effects = Vec::new();
333 let fill_model = self.fill_model;
334
335 if self.alert_register.is_some() {
337 } else {
340 let pending_ids = self.manager.pending_ids_by_symbol("e.symbol);
341 for id in pending_ids {
342 if let Some(pos) = self.manager.get_mut(&id)
343 && pos.try_fill(quote, fill_model)
344 {
345 all_effects.push(Effect::PositionOpened { id: id.clone() });
346 }
347 }
348 }
349
350 if let Some(ref mut register) = self.alert_register {
352 let triggered = register.check(quote, fill_model);
353 let triggered_alerts: Vec<TriggeredAlert> = triggered;
355
356 for alert in triggered_alerts {
357 let effects = self.apply_triggered_alert(&alert, quote);
358 all_effects.extend(effects);
359 }
360 }
361
362 if self.alert_register.is_some() {
364 let tick_ids = self
366 .alert_register
367 .as_ref()
368 .unwrap()
369 .tick_eval_ids("e.symbol);
370
371 for id in tick_ids {
372 let effects = {
373 let pos = match self.manager.get_mut(&id) {
374 Some(p) if p.data.status == PositionStatus::Open => p,
375 _ => continue,
376 };
377 pos.evaluate_stateful_rules(quote, fill_model)
378 };
379 for effect in &effects {
380 self.apply_effect(effect, quote);
381 }
382 all_effects.extend(effects);
383 }
384 } else {
385 let open_ids = self.manager.open_ids_by_symbol("e.symbol);
387 for id in open_ids {
388 let effects = {
389 let pos = match self.manager.get_mut(&id) {
390 Some(p) => p,
391 None => continue,
392 };
393 pos.evaluate_rules(quote, fill_model)
394 };
395 for effect in &effects {
396 self.apply_effect(effect, quote);
397 }
398 all_effects.extend(effects);
399 }
400 }
401
402 all_effects
403 }
404
405 pub fn on_price_future_quote(&mut self, quote: &PriceQuote) -> Vec<Effect> {
410 self.on_price_future_effects(quote)
411 .into_iter()
412 .map(FutureEffect::into_effect)
413 .collect()
414 }
415
416 pub fn on_price_future_effects(&mut self, quote: &PriceQuote) -> Vec<FutureEffect> {
418 let pricer = ExecutionPricer::new(ExecutionModel::future_quote_v1(self.fill_model));
419 let prepared = match self.prepare_pending_fills(quote, &pricer, 1.0) {
420 Ok(prepared) => prepared,
421 Err(_) => return Vec::new(),
422 };
423 self.on_price_future_effects_priced(quote, &prepared, &pricer, 1.0)
424 .unwrap_or_default()
425 }
426
427 pub fn begin_on_price_future_effects_priced(
429 &mut self,
430 quote: &PriceQuote,
431 prepared_pending: &[PreparedPendingFill],
432 pricer: &ExecutionPricer,
433 pip_size: f64,
434 ) -> FutureApplyResult<FutureEngineTransaction> {
435 let checkpoint = self.checkpoint_for_quote(quote);
436 match self.on_price_future_effects_in_place(quote, prepared_pending, pricer, pip_size) {
437 Ok(effects) => Ok(FutureEngineTransaction {
438 effects,
439 checkpoint,
440 }),
441 Err(error) => {
442 self.restore_checkpoint(checkpoint);
443 Err(error)
444 }
445 }
446 }
447
448 pub fn stage_on_price_future_effects_priced(
451 &self,
452 quote: &PriceQuote,
453 prepared_pending: &[PreparedPendingFill],
454 pricer: &ExecutionPricer,
455 pip_size: f64,
456 ) -> FutureApplyResult<(Self, Vec<FutureEffect>)> {
457 let mut staged = self.clone();
458 let transaction = staged.begin_on_price_future_effects_priced(
459 quote,
460 prepared_pending,
461 pricer,
462 pip_size,
463 )?;
464 let effects = transaction.commit();
465 Ok((staged, effects))
466 }
467
468 pub fn on_price_future_effects_priced(
471 &mut self,
472 quote: &PriceQuote,
473 prepared_pending: &[PreparedPendingFill],
474 pricer: &ExecutionPricer,
475 pip_size: f64,
476 ) -> FutureApplyResult<Vec<FutureEffect>> {
477 Ok(self
478 .begin_on_price_future_effects_priced(quote, prepared_pending, pricer, pip_size)?
479 .commit())
480 }
481
482 fn prepare_pending_fills(
483 &self,
484 quote: &PriceQuote,
485 pricer: &ExecutionPricer,
486 pip_size: f64,
487 ) -> std::result::Result<Vec<PreparedPendingFill>, ExecutionError> {
488 let mut prepared = Vec::new();
489 for id in self.manager.pending_ids_by_symbol_sorted("e.symbol) {
490 let Some(position) = self.manager.get(&id) else {
491 continue;
492 };
493 let Some(purpose) = position.pending_fill_purpose(quote, self.fill_model) else {
494 continue;
495 };
496 let execution = pricer.price(
497 purpose,
498 position.data.side,
499 quote,
500 position.data.pending_price,
501 pip_size,
502 )?;
503 prepared.push(PreparedPendingFill {
504 position_id: id,
505 execution,
506 size: position.data.size,
507 });
508 }
509 Ok(prepared)
510 }
511
512 fn on_price_future_effects_in_place(
513 &mut self,
514 quote: &PriceQuote,
515 prepared_pending: &[PreparedPendingFill],
516 pricer: &ExecutionPricer,
517 pip_size: f64,
518 ) -> FutureApplyResult<Vec<FutureEffect>> {
519 self.last_quotes.insert(quote.symbol.clone(), quote.clone());
520
521 let fill_model = self.fill_model;
522 let existing_open_ids = self.manager.open_ids_by_symbol_sorted("e.symbol);
523 let pending_ids = self.manager.pending_ids_by_symbol_sorted("e.symbol);
524 let mut all_effects = Vec::new();
525
526 for id in pending_ids {
527 let Some(position) = self.manager.get(&id) else {
528 continue;
529 };
530 let Some(expected_purpose) = position.pending_fill_purpose(quote, fill_model) else {
531 continue;
532 };
533 let prepared = prepared_pending
534 .iter()
535 .find(|prepared| prepared.position_id == id)
536 .ok_or_else(|| FutureApplyError::InvalidPreparedFill {
537 position_id: id.clone(),
538 reason: "triggered pending order has no prepared execution".into(),
539 })?;
540 validate_future_execution(
541 &prepared.execution,
542 expected_purpose,
543 position.data.side,
544 &id,
545 )?;
546 if prepared.execution.requested_price != position.data.pending_price {
547 return Err(FutureApplyError::InvalidPreparedFill {
548 position_id: id,
549 reason: "requested price does not match pending order".into(),
550 });
551 }
552 if !valid_position_size(prepared.size) {
553 return Err(FutureApplyError::InvalidPreparedFill {
554 position_id: id,
555 reason: format!(
556 "size must be finite and greater than the accounting tolerance, got {}",
557 prepared.size
558 ),
559 });
560 }
561
562 let fill = FutureFill {
563 execution: prepared.execution,
564 size: prepared.size,
565 ts: quote.ts,
566 source_quote_ts: Some(quote.ts),
567 };
568 let position = self
569 .manager
570 .get_mut(&prepared.position_id)
571 .ok_or_else(|| CoreError::PositionNotFound(prepared.position_id.clone()))?;
572 position.data.size = prepared.size;
573 if !position.apply_pending_fill(fill.as_fill()) {
574 return Err(FutureApplyError::InvalidPreparedFill {
575 position_id: prepared.position_id.clone(),
576 reason: "position is no longer pending".into(),
577 });
578 }
579 all_effects.push(FutureEffect::filled(
580 Effect::PositionOpened {
581 id: prepared.position_id.clone(),
582 },
583 fill,
584 None,
585 ));
586 }
587
588 for id in existing_open_ids {
589 let intents = {
590 let Some(position) = self.manager.get_mut(&id) else {
591 continue;
592 };
593 if position.data.status != PositionStatus::Open {
594 continue;
595 }
596 position.evaluate_rules_future(quote, fill_model)
597 };
598 for intent in intents {
599 all_effects.push(self.apply_future_intent(intent, quote, pricer, pip_size)?);
600 }
601 }
602
603 Ok(all_effects)
604 }
605
606 pub fn set_pending_size(&mut self, position_id: &str, size: f64) -> Result<()> {
608 let pos = self
609 .manager
610 .get_mut(position_id)
611 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
612 if pos.data.status != PositionStatus::Pending {
613 return Err(CoreError::InvalidState {
614 id: position_id.to_owned(),
615 expected: "Pending".into(),
616 actual: pos.data.status.to_string(),
617 });
618 }
619 validate_position_size("pending size", size)?;
620 pos.data.size = size;
621 Ok(())
622 }
623
624 pub fn synchronize_latest_fill(&mut self, position_id: &str, fill: Fill) -> Result<()> {
626 let pos = self
627 .manager
628 .get_mut(position_id)
629 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
630 if !pos.data.synchronize_latest_fill(fill) {
631 return Err(CoreError::InvalidState {
632 id: position_id.to_owned(),
633 expected: "position with an entry fill".into(),
634 actual: pos.data.status.to_string(),
635 });
636 }
637 Ok(())
638 }
639
640 pub fn close_position_with_reason(
646 &mut self,
647 position_id: &str,
648 reason: CloseReason,
649 ts: NaiveDateTime,
650 ) -> Result<Vec<Effect>> {
651 let pos = self
652 .manager
653 .get_mut(position_id)
654 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
655 if pos.data.status != PositionStatus::Open {
656 return Err(CoreError::InvalidState {
657 id: position_id.to_owned(),
658 expected: "Open".into(),
659 actual: pos.data.status.to_string(),
660 });
661 }
662 pos.data.apply_full_close(reason, ts);
663 if let Some(ref mut register) = self.alert_register {
664 register.deregister_position(position_id);
665 }
666 Ok(vec![Effect::PositionClosed {
667 id: position_id.to_owned(),
668 reason,
669 }])
670 }
671
672 pub fn apply_action(&mut self, action: Action, ts: NaiveDateTime) -> Result<Vec<Effect>> {
677 match action {
678 Action::Open {
680 symbol,
681 side,
682 order_type,
683 price,
684 size,
685 stoploss,
686 targets,
687 rules,
688 group,
689 trade_id,
690 } => self.action_open(
691 OpenActionParams {
692 symbol,
693 side,
694 order_type,
695 price,
696 size,
697 stoploss,
698 targets,
699 rules,
700 group,
701 trade_id,
702 },
703 ts,
704 ),
705
706 Action::ScaleIn {
708 position_id,
709 price,
710 size,
711 trade_id,
712 } => self.action_scale_in(&position_id, price, size, trade_id, ts),
713
714 Action::ClosePosition { position_id } => self.action_close_position(&position_id, ts),
716
717 Action::ClosePartial { position_id, ratio } => {
719 self.action_close_partial(&position_id, ratio, ts)
720 }
721
722 Action::CancelPending { position_id } => self.action_cancel_pending(&position_id, ts),
724
725 Action::ModifyStoploss { position_id, price } => {
727 self.action_modify_stoploss(&position_id, price, ts)
728 }
729
730 Action::MoveStoplossToEntry { position_id } => {
732 self.action_move_sl_to_entry(&position_id, ts)
733 }
734
735 Action::AddTarget {
737 position_id,
738 price,
739 close_ratio,
740 } => self.action_add_target(&position_id, price, close_ratio, ts),
741
742 Action::RemoveTarget { position_id, price } => {
744 self.action_remove_target(&position_id, price, ts)
745 }
746
747 Action::ModifyTarget {
749 position_id,
750 old_price,
751 new_price,
752 } => self.action_modify_target(&position_id, old_price, new_price, ts),
753
754 Action::AddRule { position_id, rule } => self.action_add_rule(&position_id, rule, ts),
756
757 Action::RemoveRule {
759 position_id,
760 rule_name,
761 } => self.action_remove_rule(&position_id, &rule_name, ts),
762
763 Action::CloseAllOf { symbol } => self.action_close_all_of(&symbol, ts),
765
766 Action::CloseAll => self.action_close_all(ts),
768
769 Action::CancelAllPending => self.action_cancel_all_pending(ts),
771
772 Action::ModifyAllStoploss { symbol, price } => {
774 self.action_modify_all_stoploss(&symbol, price, ts)
775 }
776
777 Action::CloseAllInGroup { group_id } => self.action_close_all_in_group(&group_id, ts),
779
780 Action::ModifyAllStoplossInGroup { group_id, price } => {
782 self.action_modify_all_stoploss_in_group(&group_id, price, ts)
783 }
784 }
785 }
786
787 pub fn begin_future_action(
789 &mut self,
790 action: Action,
791 effective_ts: NaiveDateTime,
792 ) -> FutureApplyResult<FutureEngineTransaction> {
793 let checkpoint = self.checkpoint_for_action(&action);
794 match self.apply_future_action(action, effective_ts) {
795 Ok(effects) => Ok(FutureEngineTransaction {
796 effects,
797 checkpoint,
798 }),
799 Err(error) => {
800 self.restore_checkpoint(checkpoint);
801 Err(error)
802 }
803 }
804 }
805
806 pub fn apply_future_action(
809 &mut self,
810 action: Action,
811 effective_ts: NaiveDateTime,
812 ) -> FutureApplyResult<Vec<FutureEffect>> {
813 if matches!(
814 action,
815 Action::Open {
816 order_type: OrderType::Market,
817 ..
818 } | Action::ScaleIn { .. }
819 | Action::ClosePosition { .. }
820 | Action::ClosePartial { .. }
821 | Action::CloseAllOf { .. }
822 | Action::CloseAll
823 | Action::CloseAllInGroup { .. }
824 ) {
825 return Err(FutureApplyError::InvalidPreparedFill {
826 position_id: String::new(),
827 reason: "fill-bearing action requires a priced execution".into(),
828 });
829 }
830 let effects = self.apply_action(action, effective_ts)?;
831 Ok(effects
832 .into_iter()
833 .map(|effect| self.plain_future_effect(effect))
834 .collect())
835 }
836
837 pub fn begin_priced_future_action(
839 &mut self,
840 action: Action,
841 quote: &PriceQuote,
842 execution: ExecutionFill,
843 ) -> FutureApplyResult<FutureEngineTransaction> {
844 let checkpoint = self.checkpoint_for_action(&action);
845 match self.apply_priced_future_action(action, quote, execution) {
846 Ok(effects) => Ok(FutureEngineTransaction {
847 effects,
848 checkpoint,
849 }),
850 Err(error) => {
851 self.restore_checkpoint(checkpoint);
852 Err(error)
853 }
854 }
855 }
856
857 pub fn apply_priced_future_action(
860 &mut self,
861 action: Action,
862 quote: &PriceQuote,
863 execution: ExecutionFill,
864 ) -> FutureApplyResult<Vec<FutureEffect>> {
865 match action {
866 Action::Open {
867 symbol,
868 side,
869 order_type,
870 price,
871 size,
872 stoploss,
873 targets,
874 rules,
875 group,
876 trade_id,
877 } => {
878 if order_type != OrderType::Market {
879 return Err(FutureApplyError::InvalidPreparedFill {
880 position_id: String::new(),
881 reason: "only market opens are fill-bearing actions".into(),
882 });
883 }
884 if symbol != quote.symbol {
885 return Err(FutureApplyError::InvalidPreparedFill {
886 position_id: String::new(),
887 reason: format!(
888 "action symbol {symbol} does not match quote symbol {}",
889 quote.symbol
890 ),
891 });
892 }
893 validate_position_size("position size", size)?;
894 if let Some(price) = price {
895 validate_positive_price("supplied entry price", price)?;
896 }
897 validate_future_execution(
898 &execution,
899 FillPurpose::MarketEntry,
900 side,
901 "<new-position>",
902 )?;
903 let effect = self
904 .action_open(
905 OpenActionParams {
906 symbol,
907 side,
908 order_type,
909 price: Some(execution.price),
910 size,
911 stoploss,
912 targets,
913 rules,
914 group,
915 trade_id,
916 },
917 quote.ts,
918 )?
919 .into_iter()
920 .next()
921 .expect("market open produces one effect");
922 Ok(vec![FutureEffect::filled(
923 effect,
924 FutureFill {
925 execution,
926 size,
927 ts: quote.ts,
928 source_quote_ts: Some(quote.ts),
929 },
930 None,
931 )])
932 }
933 Action::ScaleIn {
934 position_id,
935 price,
936 size,
937 trade_id,
938 } => {
939 validate_position_size("scale-in size", size)?;
940 if let Some(price) = price {
941 validate_positive_price("supplied scale-in price", price)?;
942 }
943 let position = self
944 .manager
945 .get(&position_id)
946 .ok_or_else(|| CoreError::PositionNotFound(position_id.clone()))?;
947 if position.data.symbol != quote.symbol {
948 return Err(FutureApplyError::InvalidPreparedFill {
949 position_id,
950 reason: "position symbol does not match quote symbol".into(),
951 });
952 }
953 validate_future_execution(
954 &execution,
955 FillPurpose::MarketEntry,
956 position.data.side,
957 &position_id,
958 )?;
959 let effect = self
960 .action_scale_in(
961 &position_id,
962 Some(execution.price),
963 size,
964 trade_id,
965 quote.ts,
966 )?
967 .into_iter()
968 .next()
969 .expect("scale-in produces one effect");
970 Ok(vec![FutureEffect::filled(
971 effect,
972 FutureFill {
973 execution,
974 size,
975 ts: quote.ts,
976 source_quote_ts: Some(quote.ts),
977 },
978 None,
979 )])
980 }
981 Action::ClosePosition { position_id } => self.close_position_with_reason_future(
982 &position_id,
983 CloseReason::Manual,
984 quote,
985 execution,
986 ),
987 Action::ClosePartial { position_id, ratio } => {
988 let position = self
989 .manager
990 .get(&position_id)
991 .ok_or_else(|| CoreError::PositionNotFound(position_id.clone()))?;
992 if position.data.symbol != quote.symbol {
993 return Err(FutureApplyError::InvalidPreparedFill {
994 position_id,
995 reason: "position symbol does not match quote symbol".into(),
996 });
997 }
998 validate_future_execution(
999 &execution,
1000 FillPurpose::MarketExit,
1001 position.data.side,
1002 &position_id,
1003 )?;
1004 if !ratio.is_finite() || ratio <= 0.0 || ratio > 1.0 {
1005 return Err(CoreError::InvalidAction(format!(
1006 "partial-close ratio must be finite and in (0, 1], got {ratio}"
1007 ))
1008 .into());
1009 }
1010 if position.data.status != PositionStatus::Open {
1011 return Err(CoreError::InvalidState {
1012 id: position_id,
1013 expected: "Open".into(),
1014 actual: position.data.status.to_string(),
1015 }
1016 .into());
1017 }
1018 let actual_ratio = position.data.capped_close_ratio(ratio);
1019 let close_size = position.data.close_size_for_ratio(actual_ratio);
1020 let effect = self
1021 .action_close_partial_at(&position_id, ratio, execution.price, quote.ts)?
1022 .into_iter()
1023 .next()
1024 .expect("partial close produces one effect");
1025 Ok(vec![FutureEffect::filled(
1026 effect,
1027 FutureFill {
1028 execution,
1029 size: close_size,
1030 ts: quote.ts,
1031 source_quote_ts: Some(quote.ts),
1032 },
1033 None,
1034 )])
1035 }
1036 _ => Err(FutureApplyError::InvalidPreparedFill {
1037 position_id: String::new(),
1038 reason: "non-fill action passed to priced FutureQuote API".into(),
1039 }),
1040 }
1041 }
1042
1043 pub fn close_position_with_reason_future(
1045 &mut self,
1046 position_id: &str,
1047 reason: CloseReason,
1048 quote: &PriceQuote,
1049 execution: ExecutionFill,
1050 ) -> FutureApplyResult<Vec<FutureEffect>> {
1051 self.close_position_with_reason_future_at(position_id, reason, quote, execution, quote.ts)
1052 }
1053
1054 pub fn begin_close_position_with_reason_future_at(
1056 &mut self,
1057 position_id: &str,
1058 reason: CloseReason,
1059 quote: &PriceQuote,
1060 execution: ExecutionFill,
1061 execution_ts: NaiveDateTime,
1062 ) -> FutureApplyResult<FutureEngineTransaction> {
1063 let checkpoint = self.checkpoint_for_positions(vec![position_id.to_owned()]);
1064 match self.close_position_with_reason_future_at(
1065 position_id,
1066 reason,
1067 quote,
1068 execution,
1069 execution_ts,
1070 ) {
1071 Ok(effects) => Ok(FutureEngineTransaction {
1072 effects,
1073 checkpoint,
1074 }),
1075 Err(error) => {
1076 self.restore_checkpoint(checkpoint);
1077 Err(error)
1078 }
1079 }
1080 }
1081
1082 pub fn close_position_with_reason_future_at(
1084 &mut self,
1085 position_id: &str,
1086 reason: CloseReason,
1087 quote: &PriceQuote,
1088 execution: ExecutionFill,
1089 execution_ts: NaiveDateTime,
1090 ) -> FutureApplyResult<Vec<FutureEffect>> {
1091 if execution_ts < quote.ts {
1092 return Err(FutureApplyError::InvalidPreparedFill {
1093 position_id: position_id.to_owned(),
1094 reason: "execution timestamp precedes source quote timestamp".into(),
1095 });
1096 }
1097 let position = self
1098 .manager
1099 .get(position_id)
1100 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1101 if position.data.symbol != quote.symbol {
1102 return Err(FutureApplyError::InvalidPreparedFill {
1103 position_id: position_id.to_owned(),
1104 reason: "position symbol does not match quote symbol".into(),
1105 });
1106 }
1107 validate_future_execution(
1108 &execution,
1109 FillPurpose::MarketExit,
1110 position.data.side,
1111 position_id,
1112 )?;
1113 if position.data.status != PositionStatus::Open {
1114 return Err(CoreError::InvalidState {
1115 id: position_id.to_owned(),
1116 expected: "Open".into(),
1117 actual: position.data.status.to_string(),
1118 }
1119 .into());
1120 }
1121 let close_size = position.data.remaining_size();
1122 let effect = self
1123 .close_position_with_reason(position_id, reason, execution_ts)?
1124 .into_iter()
1125 .next()
1126 .expect("full close produces one effect");
1127 Ok(vec![FutureEffect::filled(
1128 effect,
1129 FutureFill {
1130 execution,
1131 size: close_size,
1132 ts: execution_ts,
1133 source_quote_ts: Some(quote.ts),
1134 },
1135 None,
1136 )])
1137 }
1138
1139 fn plain_future_effect(&self, effect: Effect) -> FutureEffect {
1140 let stop_origin = match &effect {
1141 Effect::StoplossModified { id, .. } => self
1142 .manager
1143 .get(id)
1144 .and_then(|position| position.data.stop_origin),
1145 _ => None,
1146 };
1147 FutureEffect::plain_with_metadata(effect, None, stop_origin)
1148 }
1149
1150 fn action_open(&mut self, params: OpenActionParams, ts: NaiveDateTime) -> Result<Vec<Effect>> {
1153 let OpenActionParams {
1154 symbol,
1155 side,
1156 order_type,
1157 price,
1158 size,
1159 stoploss,
1160 targets,
1161 rules,
1162 group,
1163 trade_id,
1164 } = params;
1165
1166 validate_position_size("position size", size)?;
1167 let entry_price = match (order_type, price) {
1168 (OrderType::Market, Some(price)) => price,
1169 (OrderType::Market, None) => self
1170 .last_quotes
1171 .get(&symbol)
1172 .ok_or_else(|| CoreError::NoPriceAvailable(symbol.clone()))?
1173 .open_price(side),
1174 (OrderType::Limit | OrderType::Stop, Some(price)) => price,
1175 (OrderType::Limit | OrderType::Stop, None) => {
1176 return Err(CoreError::InvalidAction(format!(
1177 "{order_type} order requires a price"
1178 )));
1179 }
1180 };
1181 validate_positive_price("entry price", entry_price)?;
1182
1183 let mut target_price_keys = Vec::new();
1184 if let Some(stoploss) = stoploss {
1185 validate_stop_price("open stoploss", side, entry_price, stoploss)?;
1186 }
1187 for target in &targets {
1188 validate_target_ratio("open target", target.close_ratio)?;
1189 validate_target_price("open target", side, entry_price, target.price)?;
1190 register_unique_target_price("open target", target.price, &mut target_price_keys)?;
1191 }
1192 for rule in &rules {
1193 validate_rule_config(
1194 "open rule",
1195 rule,
1196 side,
1197 Some(entry_price),
1198 &mut target_price_keys,
1199 )?;
1200 }
1201
1202 if let Some(ref trade_id) = trade_id {
1203 self.manager
1204 .ensure_trade_id_available(trade_id, None)
1205 .map_err(core_error_from_manager)?;
1206 }
1207
1208 let id = if self.deterministic_ids {
1209 let id = format!("position:{:08}", self.next_position_sequence);
1210 self.next_position_sequence += 1;
1211 id
1212 } else {
1213 gen_id()
1214 };
1215
1216 let mut live_rules: Vec<Rule> = Vec::new();
1218 if let Some(sl) = stoploss {
1219 live_rules.push(Rule::fixed_stoploss(sl));
1220 }
1221 for t in &targets {
1222 live_rules.push(Rule::take_profit(t.price, t.close_ratio));
1223 }
1224 for rc in rules {
1225 live_rules.push(Rule::from_config(rc));
1226 }
1227
1228 match order_type {
1229 OrderType::Market => {
1230 let fill = Fill {
1231 price: entry_price,
1232 size,
1233 ts,
1234 };
1235 let mut pos =
1236 Position::new_market(id.clone(), symbol.clone(), side, fill, live_rules);
1237 if stoploss.is_some() {
1238 pos.data.stop_origin = Some(crate::types::StopOrigin::Initial);
1239 }
1240 if let Some(ref gid) = group {
1242 pos.data.group = Some(gid.clone());
1243 pos.data.records.push((
1244 PositionRecord::GroupAssigned {
1245 group_id: gid.clone(),
1246 },
1247 ts,
1248 ));
1249 }
1250 if let Some(ref tid) = trade_id {
1251 pos.set_trade_id(Some(tid.clone()));
1252 }
1253 self.manager
1254 .add_checked(pos)
1255 .map_err(core_error_from_manager)?;
1256 if let Some(gid) = group.as_deref() {
1257 self.manager.add_to_group(gid, id.clone());
1258 }
1259 self.register_alerts_for_position(&id, &symbol, side);
1261 Ok(vec![Effect::PositionOpened { id }])
1262 }
1263 OrderType::Limit | OrderType::Stop => {
1264 let pending_price = entry_price;
1265 let mut pos = Position::new_pending(
1266 id.clone(),
1267 symbol.clone(),
1268 side,
1269 order_type,
1270 pending_price,
1271 size,
1272 ts,
1273 live_rules,
1274 );
1275 if stoploss.is_some() {
1276 pos.data.stop_origin = Some(crate::types::StopOrigin::Initial);
1277 }
1278 if let Some(ref gid) = group {
1280 pos.data.group = Some(gid.clone());
1281 pos.data.records.push((
1282 PositionRecord::GroupAssigned {
1283 group_id: gid.clone(),
1284 },
1285 ts,
1286 ));
1287 }
1288 if let Some(ref tid) = trade_id {
1289 pos.set_trade_id(Some(tid.clone()));
1290 }
1291 self.manager
1292 .add_checked(pos)
1293 .map_err(core_error_from_manager)?;
1294 if let Some(gid) = group.as_deref() {
1295 self.manager.add_to_group(gid, id.clone());
1296 }
1297 if let Some(register) = self.alert_register.as_mut() {
1299 register.register(
1300 &symbol,
1301 pending_price,
1302 id.clone(),
1303 side,
1304 AlertKind::PendingFill { order_type, side },
1305 );
1306 }
1307 Ok(vec![Effect::OrderPlaced { id }])
1308 }
1309 }
1310 }
1311
1312 fn action_scale_in(
1313 &mut self,
1314 position_id: &str,
1315 price: Option<f64>,
1316 size: f64,
1317 trade_id: Option<crate::types::TradeId>,
1318 ts: NaiveDateTime,
1319 ) -> Result<Vec<Effect>> {
1320 validate_position_size("scale-in size", size)?;
1321 if let Some(price) = price {
1322 validate_positive_price("scale-in price", price)?;
1323 }
1324
1325 let (symbol, side, status, has_trade_id) = {
1326 let pos = self
1327 .manager
1328 .get(position_id)
1329 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1330 (
1331 pos.data.symbol.clone(),
1332 pos.data.side,
1333 pos.data.status,
1334 pos.data.trade_id.is_some(),
1335 )
1336 };
1337
1338 if status != PositionStatus::Open {
1339 return Err(CoreError::InvalidState {
1340 id: position_id.to_owned(),
1341 expected: "Open".into(),
1342 actual: status.to_string(),
1343 });
1344 }
1345
1346 if let Some(ref trade_id) = trade_id {
1347 self.manager
1348 .ensure_trade_id_available(trade_id, Some(position_id))
1349 .map_err(core_error_from_manager)?;
1350 }
1351
1352 let fill_price = match price {
1353 Some(price) => price,
1354 None => self
1355 .last_quotes
1356 .get(&symbol)
1357 .ok_or_else(|| CoreError::NoPriceAvailable(symbol.clone()))?
1358 .open_price(side),
1359 };
1360 validate_positive_price("scale-in fill price", fill_price)?;
1361 let fill = Fill {
1362 price: fill_price,
1363 size,
1364 ts,
1365 };
1366
1367 if !has_trade_id && let Some(trade_id) = trade_id {
1370 self.manager
1371 .set_trade_id_checked(position_id, trade_id)
1372 .map_err(core_error_from_manager)?;
1373 }
1374
1375 let pos = self
1376 .manager
1377 .get_mut(position_id)
1378 .expect("position was validated above");
1379 pos.data.add_fill(fill.clone());
1380 pos.data
1381 .records
1382 .push((PositionRecord::Filled { fill: fill.clone() }, ts));
1383
1384 Ok(vec![Effect::ScaledIn {
1385 id: position_id.to_owned(),
1386 fill,
1387 }])
1388 }
1389
1390 fn action_close_position(
1391 &mut self,
1392 position_id: &str,
1393 ts: NaiveDateTime,
1394 ) -> Result<Vec<Effect>> {
1395 self.close_position_with_reason(position_id, CloseReason::Manual, ts)
1396 }
1397
1398 fn action_close_partial(
1399 &mut self,
1400 position_id: &str,
1401 ratio: f64,
1402 ts: NaiveDateTime,
1403 ) -> Result<Vec<Effect>> {
1404 let pos = self
1405 .manager
1406 .get(position_id)
1407 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1408 let close_price = self
1409 .last_quotes
1410 .get(&pos.data.symbol)
1411 .map(|quote| quote.close_price(pos.data.side))
1412 .unwrap_or(pos.data.average_entry());
1413 self.action_close_partial_at(position_id, ratio, close_price, ts)
1414 }
1415
1416 fn action_close_partial_at(
1417 &mut self,
1418 position_id: &str,
1419 ratio: f64,
1420 close_price: f64,
1421 ts: NaiveDateTime,
1422 ) -> Result<Vec<Effect>> {
1423 if !ratio.is_finite() || ratio <= 0.0 || ratio > 1.0 {
1424 return Err(CoreError::InvalidAction(format!(
1425 "partial-close ratio must be finite and in (0, 1], got {ratio}"
1426 )));
1427 }
1428 let pos = self
1429 .manager
1430 .get_mut(position_id)
1431 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1432
1433 if pos.data.status != PositionStatus::Open {
1434 return Err(CoreError::InvalidState {
1435 id: position_id.to_owned(),
1436 expected: "Open".into(),
1437 actual: pos.data.status.to_string(),
1438 });
1439 }
1440
1441 let actual_ratio = pos.data.capped_close_ratio(ratio);
1442 pos.data
1443 .apply_partial_close(actual_ratio, close_price, CloseReason::Manual, ts);
1444
1445 if pos.data.status == PositionStatus::Closed {
1446 Ok(vec![Effect::PositionClosed {
1447 id: position_id.to_owned(),
1448 reason: CloseReason::Manual,
1449 }])
1450 } else {
1451 Ok(vec![Effect::PartialClose {
1452 id: position_id.to_owned(),
1453 ratio: actual_ratio,
1454 reason: CloseReason::Manual,
1455 }])
1456 }
1457 }
1458
1459 fn action_cancel_pending(
1460 &mut self,
1461 position_id: &str,
1462 ts: NaiveDateTime,
1463 ) -> Result<Vec<Effect>> {
1464 let pos = self
1465 .manager
1466 .get_mut(position_id)
1467 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1468
1469 if pos.data.status != PositionStatus::Pending {
1470 return Err(CoreError::InvalidState {
1471 id: position_id.to_owned(),
1472 expected: "Pending".into(),
1473 actual: pos.data.status.to_string(),
1474 });
1475 }
1476
1477 pos.data.status = PositionStatus::Cancelled;
1478 pos.data.close_ts = Some(ts);
1479 pos.data.records.push((PositionRecord::Cancelled, ts));
1480
1481 if let Some(ref mut register) = self.alert_register {
1483 register.deregister_position(position_id);
1484 }
1485
1486 Ok(vec![Effect::OrderCancelled {
1487 id: position_id.to_owned(),
1488 }])
1489 }
1490
1491 fn action_modify_stoploss(
1492 &mut self,
1493 position_id: &str,
1494 new_price: f64,
1495 ts: NaiveDateTime,
1496 ) -> Result<Vec<Effect>> {
1497 let (symbol, side, status, old) = {
1498 let pos = self
1499 .manager
1500 .get(position_id)
1501 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1502 ensure_management_status(pos, true)?;
1503 let entry = position_entry_basis(pos)?;
1504 validate_stop_price("modified stoploss", pos.data.side, entry, new_price)?;
1505 (
1506 pos.data.symbol.clone(),
1507 pos.data.side,
1508 pos.data.status,
1509 pos.current_stoploss(),
1510 )
1511 };
1512
1513 let pos = self
1514 .manager
1515 .get_mut(position_id)
1516 .expect("position was validated above");
1517 pos.set_stoploss(new_price);
1518 pos.data.records.push((
1519 PositionRecord::StoplossModified {
1520 from: old,
1521 to: new_price,
1522 },
1523 ts,
1524 ));
1525 let old_price = old.unwrap_or(0.0);
1526
1527 if status == PositionStatus::Open
1528 && let Some(register) = self.alert_register.as_mut()
1529 {
1530 replace_stoploss_alert(register, &symbol, side, position_id, old, new_price);
1531 }
1532
1533 Ok(vec![Effect::StoplossModified {
1534 id: position_id.to_owned(),
1535 old_price,
1536 new_price,
1537 }])
1538 }
1539
1540 fn action_move_sl_to_entry(
1541 &mut self,
1542 position_id: &str,
1543 ts: NaiveDateTime,
1544 ) -> Result<Vec<Effect>> {
1545 let (entry, old, symbol, side) = {
1546 let pos = self
1547 .manager
1548 .get(position_id)
1549 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1550 ensure_management_status(pos, false)?;
1551 let entry = position_entry_basis(pos)?;
1552 validate_positive_price("average entry price", entry)?;
1553 (
1554 entry,
1555 pos.current_stoploss(),
1556 pos.data.symbol.clone(),
1557 pos.data.side,
1558 )
1559 };
1560
1561 let pos = self
1562 .manager
1563 .get_mut(position_id)
1564 .expect("position was validated above");
1565 pos.set_stoploss_with_origin(entry, crate::types::StopOrigin::Breakeven);
1566 pos.data.records.push((
1567 PositionRecord::StoplossModified {
1568 from: old,
1569 to: entry,
1570 },
1571 ts,
1572 ));
1573
1574 if let Some(register) = self.alert_register.as_mut() {
1575 replace_stoploss_alert(register, &symbol, side, position_id, old, entry);
1576 }
1577
1578 Ok(vec![Effect::StoplossModified {
1579 id: position_id.to_owned(),
1580 old_price: old.unwrap_or(0.0),
1581 new_price: entry,
1582 }])
1583 }
1584
1585 fn action_add_target(
1586 &mut self,
1587 position_id: &str,
1588 price: f64,
1589 close_ratio: f64,
1590 ts: NaiveDateTime,
1591 ) -> Result<Vec<Effect>> {
1592 validate_target_ratio("added target", close_ratio)?;
1593 let (symbol, side, status) = {
1594 let pos = self
1595 .manager
1596 .get(position_id)
1597 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1598 ensure_management_status(pos, true)?;
1599 let entry = position_entry_basis(pos)?;
1600 validate_target_price("added target", pos.data.side, entry, price)?;
1601 ensure_target_price_available(pos, price, None)?;
1602 (pos.data.symbol.clone(), pos.data.side, pos.data.status)
1603 };
1604
1605 let pos = self
1606 .manager
1607 .get_mut(position_id)
1608 .expect("position was validated above");
1609 pos.rules.push(Rule::take_profit(price, close_ratio));
1610 pos.data
1611 .records
1612 .push((PositionRecord::TargetAdded { price, close_ratio }, ts));
1613
1614 if status == PositionStatus::Open
1615 && let Some(register) = self.alert_register.as_mut()
1616 {
1617 register.register(
1618 &symbol,
1619 price,
1620 position_id.to_owned(),
1621 side,
1622 AlertKind::TakeProfit { close_ratio },
1623 );
1624 }
1625
1626 Ok(vec![])
1627 }
1628
1629 fn action_remove_target(
1630 &mut self,
1631 position_id: &str,
1632 price: f64,
1633 ts: NaiveDateTime,
1634 ) -> Result<Vec<Effect>> {
1635 validate_positive_price("removed target price", price)?;
1636 let (symbol, side, status, removed_ratio) = {
1637 let pos = self
1638 .manager
1639 .get(position_id)
1640 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1641 ensure_management_status(pos, true)?;
1642 let removed_ratio = pos.rules.iter().find_map(|rule| match rule {
1643 Rule::TakeProfit {
1644 price: target_price,
1645 close_ratio,
1646 ..
1647 } if same_alert_price(*target_price, price) => Some(*close_ratio),
1648 _ => None,
1649 });
1650 (
1651 pos.data.symbol.clone(),
1652 pos.data.side,
1653 pos.data.status,
1654 removed_ratio,
1655 )
1656 };
1657
1658 if let Some(close_ratio) = removed_ratio {
1659 let pos = self
1660 .manager
1661 .get_mut(position_id)
1662 .expect("position was validated above");
1663 pos.rules.retain(|rule| {
1664 !matches!(rule, Rule::TakeProfit { price: target_price, .. } if same_alert_price(*target_price, price))
1665 });
1666 pos.data
1667 .records
1668 .push((PositionRecord::TargetRemoved { price }, ts));
1669
1670 if status == PositionStatus::Open
1671 && let Some(register) = self.alert_register.as_mut()
1672 {
1673 register.deregister_alert(
1674 &symbol,
1675 price,
1676 position_id,
1677 side,
1678 &AlertKind::TakeProfit { close_ratio },
1679 );
1680 }
1681 }
1682
1683 Ok(vec![])
1684 }
1685
1686 fn action_modify_target(
1687 &mut self,
1688 position_id: &str,
1689 old_price: f64,
1690 new_price: f64,
1691 ts: NaiveDateTime,
1692 ) -> Result<Vec<Effect>> {
1693 validate_positive_price("existing target price", old_price)?;
1694 validate_positive_price("replacement target price", new_price)?;
1695
1696 let (target_index, symbol, side, status, close_ratio) = {
1697 let pos = self
1698 .manager
1699 .get(position_id)
1700 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1701 ensure_management_status(pos, true)?;
1702 let entry = position_entry_basis(pos)?;
1703 validate_target_price("replacement target", pos.data.side, entry, new_price)?;
1704 let target_index = pos
1705 .rules
1706 .iter()
1707 .position(|rule| {
1708 matches!(rule, Rule::TakeProfit { price, .. } if same_alert_price(*price, old_price))
1709 })
1710 .ok_or_else(|| CoreError::TargetNotFound {
1711 position_id: position_id.to_owned(),
1712 price: old_price,
1713 })?;
1714 let Rule::TakeProfit {
1715 close_ratio,
1716 triggered,
1717 ..
1718 } = &pos.rules[target_index]
1719 else {
1720 unreachable!("target lookup only returns take-profit rules");
1721 };
1722 if *triggered {
1723 return Err(CoreError::TargetAlreadyTriggered {
1724 position_id: position_id.to_owned(),
1725 price: old_price,
1726 });
1727 }
1728 validate_target_ratio("modified target", *close_ratio)?;
1729 ensure_target_price_available(pos, new_price, Some(target_index))?;
1730 (
1731 target_index,
1732 pos.data.symbol.clone(),
1733 pos.data.side,
1734 pos.data.status,
1735 *close_ratio,
1736 )
1737 };
1738
1739 let pos = self
1740 .manager
1741 .get_mut(position_id)
1742 .expect("position was validated above");
1743 let Rule::TakeProfit { price, .. } = &mut pos.rules[target_index] else {
1744 unreachable!("validated target index changed without mutation");
1745 };
1746 *price = new_price;
1747 pos.data.records.push((
1748 PositionRecord::TargetModified {
1749 from: old_price,
1750 to: new_price,
1751 close_ratio,
1752 },
1753 ts,
1754 ));
1755
1756 if status == PositionStatus::Open
1757 && let Some(register) = self.alert_register.as_mut()
1758 {
1759 register.deregister_alert(
1760 &symbol,
1761 old_price,
1762 position_id,
1763 side,
1764 &AlertKind::TakeProfit { close_ratio },
1765 );
1766 register.register(
1767 &symbol,
1768 new_price,
1769 position_id.to_owned(),
1770 side,
1771 AlertKind::TakeProfit { close_ratio },
1772 );
1773 }
1774
1775 Ok(vec![])
1776 }
1777
1778 fn action_add_rule(
1779 &mut self,
1780 position_id: &str,
1781 rule_config: crate::types::RuleConfig,
1782 ts: NaiveDateTime,
1783 ) -> Result<Vec<Effect>> {
1784 let (symbol, side, status) = {
1785 let pos = self
1786 .manager
1787 .get(position_id)
1788 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1789 ensure_management_status(pos, true)?;
1790 let entry = position_entry_basis(pos)?;
1791 let mut target_keys = target_price_keys(pos);
1792 validate_rule_config(
1793 "added rule",
1794 &rule_config,
1795 pos.data.side,
1796 Some(entry),
1797 &mut target_keys,
1798 )?;
1799 (pos.data.symbol.clone(), pos.data.side, pos.data.status)
1800 };
1801
1802 let rule = Rule::from_config(rule_config);
1803 let is_stateful = rule.is_stateful();
1804 let name = rule.name().to_owned();
1805 let fixed_price = match &rule {
1806 Rule::FixedStoploss { price } => Some(*price),
1807 _ => None,
1808 };
1809 let pos = self
1810 .manager
1811 .get_mut(position_id)
1812 .expect("position was validated above");
1813 let old_stop = if let Some(price) = fixed_price {
1814 pos.set_stoploss_with_origin(price, crate::types::StopOrigin::Modified)
1815 } else {
1816 pos.rules.push(rule.clone());
1817 None
1818 };
1819 pos.data.records.push((
1820 PositionRecord::RuleAdded {
1821 rule_name: name.clone(),
1822 },
1823 ts,
1824 ));
1825
1826 if status == PositionStatus::Open
1827 && let Some(register) = self.alert_register.as_mut()
1828 {
1829 if is_stateful {
1830 register.register_tick_eval(&symbol, position_id.to_owned());
1831 } else {
1832 match &rule {
1833 Rule::FixedStoploss { price } => replace_stoploss_alert(
1834 register,
1835 &symbol,
1836 side,
1837 position_id,
1838 old_stop,
1839 *price,
1840 ),
1841 Rule::TakeProfit {
1842 price, close_ratio, ..
1843 } => register.register(
1844 &symbol,
1845 *price,
1846 position_id.to_owned(),
1847 side,
1848 AlertKind::TakeProfit {
1849 close_ratio: *close_ratio,
1850 },
1851 ),
1852 Rule::BreakevenWhen { trigger_price, .. } => register.register(
1853 &symbol,
1854 *trigger_price,
1855 position_id.to_owned(),
1856 side,
1857 AlertKind::BreakevenTrigger,
1858 ),
1859 _ => {}
1860 }
1861 }
1862 }
1863
1864 Ok(fixed_price
1865 .map(|new_price| Effect::StoplossModified {
1866 id: position_id.to_owned(),
1867 old_price: old_stop.unwrap_or(0.0),
1868 new_price,
1869 })
1870 .into_iter()
1871 .collect())
1872 }
1873
1874 fn action_remove_rule(
1875 &mut self,
1876 position_id: &str,
1877 rule_name: &str,
1878 ts: NaiveDateTime,
1879 ) -> Result<Vec<Effect>> {
1880 let status = {
1881 let pos = self
1882 .manager
1883 .get(position_id)
1884 .ok_or_else(|| CoreError::PositionNotFound(position_id.to_owned()))?;
1885 ensure_management_status(pos, true)?;
1886 pos.data.status
1887 };
1888 let (symbol, side, removed, has_stateful_rules) = {
1889 let pos = self
1890 .manager
1891 .get_mut(position_id)
1892 .expect("position was validated above");
1893 let removed: Vec<Rule> = pos
1894 .rules
1895 .iter()
1896 .filter(|rule| rule.name() == rule_name)
1897 .cloned()
1898 .collect();
1899 if removed.is_empty() {
1900 return Ok(Vec::new());
1901 }
1902 pos.rules.retain(|rule| rule.name() != rule_name);
1903 if removed
1904 .iter()
1905 .any(|rule| matches!(rule, Rule::FixedStoploss { .. }))
1906 {
1907 pos.data.stop_origin = None;
1908 }
1909 pos.data.records.push((
1910 PositionRecord::RuleRemoved {
1911 rule_name: rule_name.to_owned(),
1912 },
1913 ts,
1914 ));
1915 (
1916 pos.data.symbol.clone(),
1917 pos.data.side,
1918 removed,
1919 pos.has_stateful_rules(),
1920 )
1921 };
1922
1923 if status == PositionStatus::Open
1924 && let Some(register) = self.alert_register.as_mut()
1925 {
1926 for rule in &removed {
1927 match rule {
1928 Rule::FixedStoploss { price } => register.deregister_alert(
1929 &symbol,
1930 *price,
1931 position_id,
1932 side,
1933 &AlertKind::Stoploss,
1934 ),
1935 Rule::TakeProfit {
1936 price, close_ratio, ..
1937 } => register.deregister_alert(
1938 &symbol,
1939 *price,
1940 position_id,
1941 side,
1942 &AlertKind::TakeProfit {
1943 close_ratio: *close_ratio,
1944 },
1945 ),
1946 Rule::BreakevenWhen { trigger_price, .. } => register.deregister_alert(
1947 &symbol,
1948 *trigger_price,
1949 position_id,
1950 side,
1951 &AlertKind::BreakevenTrigger,
1952 ),
1953 _ => {}
1954 }
1955 }
1956 if !has_stateful_rules {
1957 register.unregister_tick_eval(&symbol, position_id);
1958 }
1959 }
1960
1961 Ok(removed
1962 .iter()
1963 .find_map(|rule| match rule {
1964 Rule::FixedStoploss { price } => Some(Effect::StoplossRemoved {
1965 id: position_id.to_owned(),
1966 old_price: *price,
1967 }),
1968 _ => None,
1969 })
1970 .into_iter()
1971 .collect())
1972 }
1973
1974 fn action_close_all_of(&mut self, symbol: &str, ts: NaiveDateTime) -> Result<Vec<Effect>> {
1977 let ids = self.manager.open_ids_by_symbol(symbol);
1978 let mut effects = Vec::new();
1979 for id in &ids {
1980 if let Some(pos) = self.manager.get_mut(id) {
1981 pos.data.apply_full_close(CloseReason::Manual, ts);
1982 effects.push(Effect::PositionClosed {
1983 id: id.clone(),
1984 reason: CloseReason::Manual,
1985 });
1986 }
1987 }
1988 if let Some(ref mut register) = self.alert_register {
1990 for id in &ids {
1991 register.deregister_position(id);
1992 }
1993 }
1994 Ok(effects)
1995 }
1996
1997 fn action_close_all(&mut self, ts: NaiveDateTime) -> Result<Vec<Effect>> {
1998 let ids = self.manager.all_open_ids();
1999 let mut effects = Vec::new();
2000 for id in ids {
2001 if let Some(pos) = self.manager.get_mut(&id) {
2002 pos.data.apply_full_close(CloseReason::Manual, ts);
2003 effects.push(Effect::PositionClosed {
2004 id,
2005 reason: CloseReason::Manual,
2006 });
2007 }
2008 }
2009 if let Some(ref mut register) = self.alert_register {
2011 register.clear_all();
2012 }
2013 Ok(effects)
2014 }
2015
2016 fn action_cancel_all_pending(&mut self, ts: NaiveDateTime) -> Result<Vec<Effect>> {
2017 let ids = self.manager.all_pending_ids();
2018 let mut effects = Vec::new();
2019 for id in &ids {
2020 if let Some(pos) = self.manager.get_mut(id) {
2021 pos.data.status = PositionStatus::Cancelled;
2022 pos.data.close_ts = Some(ts);
2023 pos.data.records.push((PositionRecord::Cancelled, ts));
2024 effects.push(Effect::OrderCancelled { id: id.clone() });
2025 }
2026 }
2027 if let Some(ref mut register) = self.alert_register {
2029 for id in &ids {
2030 register.deregister_position(id);
2031 }
2032 }
2033 Ok(effects)
2034 }
2035
2036 fn action_modify_all_stoploss(
2037 &mut self,
2038 symbol: &str,
2039 price: f64,
2040 ts: NaiveDateTime,
2041 ) -> Result<Vec<Effect>> {
2042 validate_positive_price("bulk stoploss price", price)?;
2043 let ids = self.manager.open_ids_by_symbol_sorted(symbol);
2044 let mut preflight = Vec::with_capacity(ids.len());
2045 for id in &ids {
2046 let pos = self
2047 .manager
2048 .get(id)
2049 .expect("open position id came from the manager");
2050 let entry = position_entry_basis(pos)?;
2051 validate_stop_price("bulk stoploss", pos.data.side, entry, price)?;
2052 preflight.push((
2053 id.clone(),
2054 pos.data.symbol.clone(),
2055 pos.data.side,
2056 pos.current_stoploss(),
2057 ));
2058 }
2059
2060 let mut effects = Vec::with_capacity(preflight.len());
2061 for (id, position_symbol, side, old) in preflight {
2062 let pos = self
2063 .manager
2064 .get_mut(&id)
2065 .expect("position was validated above");
2066 pos.set_stoploss(price);
2067 pos.data.records.push((
2068 PositionRecord::StoplossModified {
2069 from: old,
2070 to: price,
2071 },
2072 ts,
2073 ));
2074 if let Some(register) = self.alert_register.as_mut() {
2075 replace_stoploss_alert(register, &position_symbol, side, &id, old, price);
2076 }
2077 effects.push(Effect::StoplossModified {
2078 id,
2079 old_price: old.unwrap_or(0.0),
2080 new_price: price,
2081 });
2082 }
2083 Ok(effects)
2084 }
2085
2086 fn action_close_all_in_group(
2088 &mut self,
2089 group_id: &str,
2090 ts: NaiveDateTime,
2091 ) -> Result<Vec<Effect>> {
2092 let ids = self.manager.open_ids_by_group(group_id);
2093 let mut effects = Vec::new();
2094 for id in &ids {
2095 if let Some(pos) = self.manager.get_mut(id)
2096 && pos.data.status == PositionStatus::Open
2097 {
2098 pos.data.apply_full_close(CloseReason::GroupRule, ts);
2099 effects.push(Effect::PositionClosed {
2100 id: id.clone(),
2101 reason: CloseReason::GroupRule,
2102 });
2103 }
2104 }
2105 if let Some(ref mut register) = self.alert_register {
2107 for id in &ids {
2108 register.deregister_position(id);
2109 }
2110 }
2111 Ok(effects)
2112 }
2113
2114 fn action_modify_all_stoploss_in_group(
2116 &mut self,
2117 group_id: &str,
2118 price: f64,
2119 ts: NaiveDateTime,
2120 ) -> Result<Vec<Effect>> {
2121 validate_positive_price("group bulk stoploss price", price)?;
2122 let mut ids = self.manager.open_ids_by_group(group_id);
2123 ids.sort();
2124 let mut preflight = Vec::with_capacity(ids.len());
2125 for id in &ids {
2126 let pos = self
2127 .manager
2128 .get(id)
2129 .expect("open group position id came from the manager");
2130 let entry = position_entry_basis(pos)?;
2131 validate_stop_price("group bulk stoploss", pos.data.side, entry, price)?;
2132 preflight.push((
2133 id.clone(),
2134 pos.data.symbol.clone(),
2135 pos.data.side,
2136 pos.current_stoploss(),
2137 ));
2138 }
2139
2140 let mut effects = Vec::with_capacity(preflight.len());
2141 for (id, symbol, side, old) in preflight {
2142 let pos = self
2143 .manager
2144 .get_mut(&id)
2145 .expect("position was validated above");
2146 pos.set_stoploss(price);
2147 pos.data.records.push((
2148 PositionRecord::StoplossModified {
2149 from: old,
2150 to: price,
2151 },
2152 ts,
2153 ));
2154 if let Some(register) = self.alert_register.as_mut() {
2155 replace_stoploss_alert(register, &symbol, side, &id, old, price);
2156 }
2157 effects.push(Effect::StoplossModified {
2158 id,
2159 old_price: old.unwrap_or(0.0),
2160 new_price: price,
2161 });
2162 }
2163 Ok(effects)
2164 }
2165
2166 fn apply_effect(&mut self, effect: &Effect, quote: &PriceQuote) {
2173 match effect {
2174 Effect::PositionClosed { id, reason } => {
2175 if let Some(pos) = self.manager.get_mut(id) {
2176 if pos.data.status != PositionStatus::Open {
2177 return; }
2179 pos.data.apply_full_close(*reason, quote.ts);
2180 }
2181 if let Some(ref mut register) = self.alert_register {
2183 register.deregister_position(id);
2184 }
2185 }
2186 Effect::PartialClose { id, ratio, reason } => {
2187 if let Some(pos) = self.manager.get_mut(id) {
2188 if pos.data.status != PositionStatus::Open {
2189 return;
2190 }
2191 let close_price = quote.close_price(pos.data.side);
2192 pos.data
2193 .apply_partial_close(*ratio, close_price, *reason, quote.ts);
2194 }
2195 }
2196 Effect::StoplossModified { id, new_price, .. } => {
2197 if !new_price.is_finite() || *new_price <= 0.0 {
2198 return;
2199 }
2200 let old_and_info = if let Some(pos) = self.manager.get_mut(id) {
2201 if pos.data.status != PositionStatus::Open {
2202 return;
2203 }
2204 let old = pos.set_stoploss(*new_price);
2205 pos.data.records.push((
2206 PositionRecord::StoplossModified {
2207 from: old,
2208 to: *new_price,
2209 },
2210 quote.ts,
2211 ));
2212 Some((old, pos.data.symbol.clone(), pos.data.side))
2213 } else {
2214 None
2215 };
2216
2217 if let Some((old, symbol, side)) = old_and_info
2219 && let Some(ref mut register) = self.alert_register
2220 {
2221 if let Some(old_price) = old {
2222 register.deregister_alert(
2223 &symbol,
2224 old_price,
2225 id,
2226 side,
2227 &AlertKind::Stoploss,
2228 );
2229 }
2230 register.register(&symbol, *new_price, id.clone(), side, AlertKind::Stoploss);
2231 }
2232 }
2233 _ => {}
2235 }
2236 }
2237
2238 fn apply_future_intent(
2239 &mut self,
2240 intent: FutureIntent,
2241 quote: &PriceQuote,
2242 pricer: &ExecutionPricer,
2243 pip_size: f64,
2244 ) -> FutureApplyResult<FutureEffect> {
2245 match intent.effect {
2246 Effect::PositionClosed { id, reason } => {
2247 let position = self
2248 .manager
2249 .get(&id)
2250 .ok_or_else(|| CoreError::PositionNotFound(id.clone()))?;
2251 let side = position.data.side;
2252 let close_size = position.data.remaining_size();
2253 let purpose = fill_purpose_for_close(reason);
2254 let execution =
2255 pricer.price(purpose, side, quote, intent.requested_price, pip_size)?;
2256 validate_future_execution(&execution, purpose, side, &id)?;
2257
2258 let position = self
2259 .manager
2260 .get_mut(&id)
2261 .ok_or_else(|| CoreError::PositionNotFound(id.clone()))?;
2262 if position.data.status != PositionStatus::Open {
2263 return Err(CoreError::InvalidState {
2264 id,
2265 expected: "Open".into(),
2266 actual: position.data.status.to_string(),
2267 }
2268 .into());
2269 }
2270 position.data.apply_full_close(reason, quote.ts);
2271 if let Some(ref mut register) = self.alert_register {
2272 register.deregister_position(&id);
2273 }
2274 Ok(FutureEffect::filled(
2275 Effect::PositionClosed { id, reason },
2276 FutureFill {
2277 execution,
2278 size: close_size,
2279 ts: quote.ts,
2280 source_quote_ts: Some(quote.ts),
2281 },
2282 intent.stop_origin,
2283 ))
2284 }
2285 Effect::PartialClose { id, ratio, reason } => {
2286 let position = self
2287 .manager
2288 .get(&id)
2289 .ok_or_else(|| CoreError::PositionNotFound(id.clone()))?;
2290 let side = position.data.side;
2291 let purpose = fill_purpose_for_close(reason);
2292 let execution =
2293 pricer.price(purpose, side, quote, intent.requested_price, pip_size)?;
2294 validate_future_execution(&execution, purpose, side, &id)?;
2295
2296 let position = self
2297 .manager
2298 .get_mut(&id)
2299 .ok_or_else(|| CoreError::PositionNotFound(id.clone()))?;
2300 if position.data.status != PositionStatus::Open {
2301 return Err(CoreError::InvalidState {
2302 id,
2303 expected: "Open".into(),
2304 actual: position.data.status.to_string(),
2305 }
2306 .into());
2307 }
2308 let actual_ratio = position.data.capped_close_ratio(ratio);
2309 let close_size = position.data.close_size_for_ratio(actual_ratio);
2310 position
2311 .data
2312 .apply_partial_close(actual_ratio, execution.price, reason, quote.ts);
2313 Ok(FutureEffect::filled(
2314 Effect::PartialClose {
2315 id,
2316 ratio: actual_ratio,
2317 reason,
2318 },
2319 FutureFill {
2320 execution,
2321 size: close_size,
2322 ts: quote.ts,
2323 source_quote_ts: Some(quote.ts),
2324 },
2325 intent.stop_origin,
2326 ))
2327 }
2328 Effect::StoplossModified {
2329 id,
2330 old_price,
2331 new_price,
2332 } => {
2333 let effect = Effect::StoplossModified {
2334 id: id.clone(),
2335 old_price,
2336 new_price,
2337 };
2338 self.apply_effect(&effect, quote);
2339 if let Some(origin) = intent.stop_origin
2340 && let Some(position) = self.manager.get_mut(&id)
2341 && position.data.status == PositionStatus::Open
2342 {
2343 position.set_stoploss_with_origin(new_price, origin);
2344 }
2345 Ok(FutureEffect::plain_with_metadata(
2346 effect,
2347 intent.requested_price,
2348 intent.stop_origin,
2349 ))
2350 }
2351 effect => Ok(FutureEffect::plain_with_metadata(
2352 effect,
2353 intent.requested_price,
2354 intent.stop_origin,
2355 )),
2356 }
2357 }
2358
2359 fn register_alerts_for_position(&mut self, position_id: &str, symbol: &str, side: Side) {
2363 if self.alert_register.is_none() {
2364 return;
2365 }
2366
2367 let rules_snapshot: Vec<Rule> = {
2368 let pos = match self.manager.get(position_id) {
2369 Some(p) => p,
2370 None => return,
2371 };
2372 pos.rules.clone()
2373 };
2374
2375 let register = self.alert_register.as_mut().unwrap();
2376 let mut has_stateful = false;
2377
2378 for rule in &rules_snapshot {
2379 match rule {
2380 Rule::FixedStoploss { price } => {
2381 register.register(
2382 symbol,
2383 *price,
2384 position_id.to_owned(),
2385 side,
2386 AlertKind::Stoploss,
2387 );
2388 }
2389 Rule::TakeProfit {
2390 price,
2391 close_ratio,
2392 triggered,
2393 } => {
2394 if !triggered {
2395 register.register(
2396 symbol,
2397 *price,
2398 position_id.to_owned(),
2399 side,
2400 AlertKind::TakeProfit {
2401 close_ratio: *close_ratio,
2402 },
2403 );
2404 }
2405 }
2406 Rule::BreakevenWhen {
2407 trigger_price,
2408 triggered,
2409 } => {
2410 if !triggered {
2411 register.register(
2412 symbol,
2413 *trigger_price,
2414 position_id.to_owned(),
2415 side,
2416 AlertKind::BreakevenTrigger,
2417 );
2418 }
2419 }
2420 Rule::TrailingStop { .. }
2421 | Rule::TimeExit { .. }
2422 | Rule::BreakevenAfterTargets { .. } => {
2423 has_stateful = true;
2424 }
2425 }
2426 }
2427
2428 if has_stateful {
2429 register.register_tick_eval(symbol, position_id.to_owned());
2430 }
2431 }
2432
2433 fn apply_triggered_alert(&mut self, alert: &TriggeredAlert, quote: &PriceQuote) -> Vec<Effect> {
2435 match &alert.kind {
2436 AlertKind::Stoploss => {
2437 let pos = match self.manager.get_mut(&alert.position_id) {
2438 Some(p) if p.data.status == PositionStatus::Open => p,
2439 _ => return vec![],
2440 };
2441 pos.data.apply_full_close(CloseReason::Stoploss, quote.ts);
2442 if let Some(ref mut register) = self.alert_register {
2444 register.deregister_position(&alert.position_id);
2445 }
2446 vec![Effect::PositionClosed {
2447 id: alert.position_id.clone(),
2448 reason: CloseReason::Stoploss,
2449 }]
2450 }
2451 AlertKind::TakeProfit { close_ratio } => {
2452 let pos = match self.manager.get_mut(&alert.position_id) {
2453 Some(p) if p.data.status == PositionStatus::Open => p,
2454 _ => return vec![],
2455 };
2456
2457 let remaining = pos.data.open_ratio();
2458 let actual_ratio = pos.data.capped_close_ratio(*close_ratio);
2459
2460 for rule in &mut pos.rules {
2462 if let Rule::TakeProfit {
2463 price, triggered, ..
2464 } = rule
2465 && !*triggered
2466 && (price_to_micros_static(*price)
2467 == price_to_micros_static(alert.trigger_price))
2468 {
2469 *triggered = true;
2470 break;
2471 }
2472 }
2473
2474 if remaining - actual_ratio <= position_size_tolerance(1.0) {
2475 let close_price = quote.close_price(alert.side);
2477 pos.data.apply_partial_close(
2478 actual_ratio,
2479 close_price,
2480 CloseReason::Target,
2481 quote.ts,
2482 );
2483 if let Some(ref mut register) = self.alert_register {
2484 register.deregister_position(&alert.position_id);
2485 }
2486 vec![Effect::PositionClosed {
2487 id: alert.position_id.clone(),
2488 reason: CloseReason::Target,
2489 }]
2490 } else {
2491 let close_price = quote.close_price(alert.side);
2493 pos.data.apply_partial_close(
2494 actual_ratio,
2495 close_price,
2496 CloseReason::Target,
2497 quote.ts,
2498 );
2499 vec![Effect::PartialClose {
2500 id: alert.position_id.clone(),
2501 ratio: actual_ratio,
2502 reason: CloseReason::Target,
2503 }]
2504 }
2505 }
2506 AlertKind::BreakevenTrigger => {
2507 let (entry_price, _symbol, _side) = {
2508 let pos = match self.manager.get_mut(&alert.position_id) {
2509 Some(p) if p.data.status == PositionStatus::Open => p,
2510 _ => return vec![],
2511 };
2512
2513 for rule in &mut pos.rules {
2515 if let Rule::BreakevenWhen { triggered, .. } = rule {
2516 *triggered = true;
2517 break;
2518 }
2519 }
2520
2521 (
2522 pos.data.average_entry(),
2523 pos.data.symbol.clone(),
2524 pos.data.side,
2525 )
2526 };
2527
2528 let effect = Effect::StoplossModified {
2530 id: alert.position_id.clone(),
2531 old_price: 0.0,
2532 new_price: entry_price,
2533 };
2534 self.apply_effect(&effect, quote);
2535 vec![effect]
2536 }
2537 AlertKind::PendingFill { .. } => {
2538 let pos = match self.manager.get_mut(&alert.position_id) {
2540 Some(p) if p.data.status == PositionStatus::Pending => p,
2541 _ => return vec![],
2542 };
2543
2544 let fill_model = self.fill_model;
2545 if pos.try_fill(quote, fill_model) {
2546 let symbol = pos.data.symbol.clone();
2547 let side = pos.data.side;
2548 let id = alert.position_id.clone();
2549
2550 self.register_alerts_for_position(&id, &symbol, side);
2552
2553 vec![Effect::PositionOpened { id }]
2554 } else {
2555 vec![]
2556 }
2557 }
2558 }
2559 }
2560}
2561
2562fn core_error_from_manager(error: PositionManagerError) -> CoreError {
2563 match error {
2564 PositionManagerError::PositionNotFound(id) => CoreError::PositionNotFound(id),
2565 duplicate @ PositionManagerError::DuplicateTradeId { .. } => {
2566 CoreError::InvalidAction(duplicate.to_string())
2567 }
2568 }
2569}
2570
2571fn validate_target_ratio(context: &str, ratio: f64) -> Result<()> {
2572 if ratio.is_finite() && ratio > 0.0 && ratio <= 1.0 {
2573 Ok(())
2574 } else {
2575 Err(CoreError::InvalidAction(format!(
2576 "{context} close ratio must be finite and in (0, 1], got {ratio}"
2577 )))
2578 }
2579}
2580
2581fn valid_position_size(size: f64) -> bool {
2582 size.is_finite() && size > position_size_tolerance(size)
2583}
2584
2585fn validate_position_size(context: &str, size: f64) -> Result<()> {
2586 if valid_position_size(size) {
2587 Ok(())
2588 } else {
2589 Err(CoreError::InvalidAction(format!(
2590 "{context} must be finite and greater than the accounting tolerance, got {size}"
2591 )))
2592 }
2593}
2594
2595fn validate_positive_price(context: &str, price: f64) -> Result<()> {
2596 if price.is_finite() && price > 0.0 {
2597 Ok(())
2598 } else {
2599 Err(CoreError::InvalidAction(format!(
2600 "{context} must be finite and positive, got {price}"
2601 )))
2602 }
2603}
2604
2605fn validate_stop_price(context: &str, side: Side, entry: f64, price: f64) -> Result<()> {
2606 validate_positive_price(context, price)?;
2607 let valid = match side {
2608 Side::Buy => price < entry,
2609 Side::Sell => price > entry,
2610 };
2611 if valid {
2612 Ok(())
2613 } else {
2614 Err(CoreError::InvalidAction(format!(
2615 "{context} has invalid {side} geometry: entry {entry}, stop {price}"
2616 )))
2617 }
2618}
2619
2620fn validate_target_price(context: &str, side: Side, entry: f64, price: f64) -> Result<()> {
2621 validate_positive_price(context, price)?;
2622 let valid = match side {
2623 Side::Buy => price > entry,
2624 Side::Sell => price < entry,
2625 };
2626 if valid {
2627 Ok(())
2628 } else {
2629 Err(CoreError::InvalidAction(format!(
2630 "{context} has invalid {side} geometry: entry {entry}, target {price}"
2631 )))
2632 }
2633}
2634
2635fn register_unique_target_price(
2636 context: &str,
2637 price: f64,
2638 target_price_keys: &mut Vec<i64>,
2639) -> Result<()> {
2640 let key = price_to_micros_static(price);
2641 if target_price_keys.contains(&key) {
2642 return Err(CoreError::InvalidAction(format!(
2643 "{context} duplicates take-profit price {price}"
2644 )));
2645 }
2646 target_price_keys.push(key);
2647 Ok(())
2648}
2649
2650fn validate_rule_config(
2651 context: &str,
2652 rule: &RuleConfig,
2653 side: Side,
2654 entry: Option<f64>,
2655 target_price_keys: &mut Vec<i64>,
2656) -> Result<()> {
2657 match rule {
2658 RuleConfig::FixedStoploss { price } => match entry {
2659 Some(entry) => validate_stop_price(context, side, entry, *price),
2660 None => validate_positive_price(context, *price),
2661 },
2662 RuleConfig::TrailingStop { distance } => {
2663 validate_positive_price(&format!("{context} trailing distance"), *distance)?;
2664 if let Some(entry) = entry {
2665 let initial_stop = match side {
2666 Side::Buy => entry - distance,
2667 Side::Sell => entry + distance,
2668 };
2669 validate_stop_price(
2670 &format!("{context} initial trailing stop"),
2671 side,
2672 entry,
2673 initial_stop,
2674 )?;
2675 }
2676 Ok(())
2677 }
2678 RuleConfig::TakeProfit { price, close_ratio } => {
2679 validate_target_ratio(&format!("{context} take-profit"), *close_ratio)?;
2680 match entry {
2681 Some(entry) => validate_target_price(context, side, entry, *price)?,
2682 None => validate_positive_price(context, *price)?,
2683 }
2684 register_unique_target_price(context, *price, target_price_keys)
2685 }
2686 RuleConfig::BreakevenWhen { trigger_price } => match entry {
2687 Some(entry) => validate_target_price(context, side, entry, *trigger_price),
2688 None => validate_positive_price(context, *trigger_price),
2689 },
2690 RuleConfig::BreakevenAfterTargets { after_n } => {
2691 if *after_n == 0 {
2692 Err(CoreError::InvalidAction(format!(
2693 "{context} target count must be greater than zero"
2694 )))
2695 } else {
2696 Ok(())
2697 }
2698 }
2699 RuleConfig::TimeExit { max_seconds } => {
2700 if *max_seconds == 0 {
2701 Err(CoreError::InvalidAction(format!(
2702 "{context} maximum seconds must be greater than zero"
2703 )))
2704 } else {
2705 Ok(())
2706 }
2707 }
2708 }
2709}
2710
2711fn ensure_management_status(position: &Position, allow_pending: bool) -> Result<()> {
2712 let valid = position.data.status == PositionStatus::Open
2713 || (allow_pending && position.data.status == PositionStatus::Pending);
2714 if valid {
2715 Ok(())
2716 } else {
2717 Err(CoreError::InvalidState {
2718 id: position.data.id.clone(),
2719 expected: if allow_pending {
2720 "Open or Pending".into()
2721 } else {
2722 "Open".into()
2723 },
2724 actual: position.data.status.to_string(),
2725 })
2726 }
2727}
2728
2729fn position_entry_basis(position: &Position) -> Result<f64> {
2730 let entry = match position.data.status {
2731 PositionStatus::Open => position.data.average_entry(),
2732 PositionStatus::Pending => position.data.pending_price.ok_or_else(|| {
2733 CoreError::InvalidAction(format!(
2734 "pending position {} has no entry price",
2735 position.data.id
2736 ))
2737 })?,
2738 PositionStatus::Closed | PositionStatus::Cancelled => {
2739 return Err(CoreError::InvalidState {
2740 id: position.data.id.clone(),
2741 expected: "Open or Pending".into(),
2742 actual: position.data.status.to_string(),
2743 });
2744 }
2745 };
2746 validate_positive_price("position entry basis", entry)?;
2747 Ok(entry)
2748}
2749
2750fn target_price_keys(position: &Position) -> Vec<i64> {
2751 position
2752 .rules
2753 .iter()
2754 .filter_map(|rule| match rule {
2755 Rule::TakeProfit { price, .. } => Some(price_to_micros_static(*price)),
2756 _ => None,
2757 })
2758 .collect()
2759}
2760
2761fn ensure_target_price_available(
2762 position: &Position,
2763 price: f64,
2764 except_index: Option<usize>,
2765) -> Result<()> {
2766 let duplicate = position.rules.iter().enumerate().any(|(index, rule)| {
2767 except_index != Some(index)
2768 && matches!(rule, Rule::TakeProfit { price: existing, .. } if same_alert_price(*existing, price))
2769 });
2770 if duplicate {
2771 Err(CoreError::InvalidAction(format!(
2772 "take-profit price {price} is already present for position {}",
2773 position.data.id
2774 )))
2775 } else {
2776 Ok(())
2777 }
2778}
2779
2780fn same_alert_price(left: f64, right: f64) -> bool {
2781 price_to_micros_static(left) == price_to_micros_static(right)
2782}
2783
2784fn replace_stoploss_alert(
2785 register: &mut PriceAlertRegister,
2786 symbol: &str,
2787 side: Side,
2788 position_id: &str,
2789 old_price: Option<f64>,
2790 new_price: f64,
2791) {
2792 if let Some(old_price) = old_price {
2793 register.deregister_alert(symbol, old_price, position_id, side, &AlertKind::Stoploss);
2794 }
2795 register.register(
2796 symbol,
2797 new_price,
2798 position_id.to_owned(),
2799 side,
2800 AlertKind::Stoploss,
2801 );
2802}
2803
2804fn fill_purpose_for_close(reason: CloseReason) -> FillPurpose {
2805 match reason {
2806 CloseReason::Target => FillPurpose::TakeProfit,
2807 CloseReason::Stoploss | CloseReason::TrailingStop | CloseReason::BreakevenStop => {
2808 FillPurpose::StopLoss
2809 }
2810 _ => FillPurpose::MarketExit,
2811 }
2812}
2813
2814fn validate_future_execution(
2815 execution: &ExecutionFill,
2816 expected_purpose: FillPurpose,
2817 expected_side: Side,
2818 position_id: &str,
2819) -> FutureApplyResult<()> {
2820 if execution.purpose != expected_purpose {
2821 return Err(FutureApplyError::InvalidPreparedFill {
2822 position_id: position_id.to_owned(),
2823 reason: format!("expected {expected_purpose:?}, got {:?}", execution.purpose),
2824 });
2825 }
2826 if execution.side != expected_side {
2827 return Err(FutureApplyError::InvalidPreparedFill {
2828 position_id: position_id.to_owned(),
2829 reason: format!("expected side {expected_side:?}, got {:?}", execution.side),
2830 });
2831 }
2832 if !execution.price.is_finite() || execution.price <= 0.0 {
2833 return Err(FutureApplyError::InvalidPreparedFill {
2834 position_id: position_id.to_owned(),
2835 reason: format!(
2836 "execution price must be finite and positive, got {}",
2837 execution.price
2838 ),
2839 });
2840 }
2841 Ok(())
2842}
2843
2844fn price_to_micros_static(price: f64) -> i64 {
2846 (price * 1_000_000.0).round() as i64
2847}
2848
2849#[cfg(test)]
2852mod tests {
2853 use super::*;
2854 use crate::types::{
2855 Action, ExecutionConvention, OrderType, RuleConfig, SlippageModel, StopOrigin, TargetSpec,
2856 };
2857 use chrono::NaiveDate;
2858
2859 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
2860 NaiveDate::from_ymd_opt(2026, 1, 1)
2861 .unwrap()
2862 .and_hms_opt(h, m, s)
2863 .unwrap()
2864 }
2865
2866 fn quote(symbol: &str, bid: f64, ask: f64, time: NaiveDateTime) -> PriceQuote {
2867 PriceQuote {
2868 symbol: symbol.into(),
2869 ts: time,
2870 bid,
2871 ask,
2872 }
2873 }
2874
2875 fn execution(purpose: FillPurpose, side: Side, price: f64) -> ExecutionFill {
2876 ExecutionFill {
2877 purpose,
2878 side,
2879 price,
2880 quote_price: price,
2881 requested_price: None,
2882 slippage_pips: 0.0,
2883 }
2884 }
2885
2886 fn effect_position_id(effect: &Effect) -> &str {
2887 match effect {
2888 Effect::OrderPlaced { id }
2889 | Effect::OrderCancelled { id }
2890 | Effect::PositionOpened { id }
2891 | Effect::PositionClosed { id, .. }
2892 | Effect::PartialClose { id, .. }
2893 | Effect::StoplossModified { id, .. }
2894 | Effect::StoplossRemoved { id, .. }
2895 | Effect::ScaledIn { id, .. }
2896 | Effect::RuleTriggered { id, .. } => id,
2897 }
2898 }
2899
2900 fn open_future_position(
2901 engine: &mut TradeEngine,
2902 side: Side,
2903 stoploss: Option<f64>,
2904 rules: Vec<RuleConfig>,
2905 ) -> PositionId {
2906 let effects = engine
2907 .apply_action(
2908 Action::Open {
2909 symbol: "EURUSD".into(),
2910 side,
2911 order_type: OrderType::Market,
2912 price: Some(100.0),
2913 size: 1.0,
2914 stoploss,
2915 targets: vec![],
2916 rules,
2917 group: None,
2918 trade_id: None,
2919 },
2920 ts(10, 0, 0),
2921 )
2922 .unwrap();
2923 match &effects[0] {
2924 Effect::PositionOpened { id } => id.clone(),
2925 effect => panic!("expected position-opened effect, got {effect:?}"),
2926 }
2927 }
2928
2929 #[test]
2930 fn future_priced_actions_carry_the_authoritative_fill_into_state() {
2931 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
2932 let open_quote = quote("EURUSD", 99.0, 100.0, ts(10, 0, 0));
2933 let open_execution = execution(FillPurpose::MarketEntry, Side::Buy, 100.25);
2934 let effects = engine
2935 .apply_priced_future_action(
2936 Action::Open {
2937 symbol: "EURUSD".into(),
2938 side: Side::Buy,
2939 order_type: OrderType::Market,
2940 price: Some(1.0),
2941 size: 2.0,
2942 stoploss: None,
2943 targets: vec![],
2944 rules: vec![],
2945 group: None,
2946 trade_id: None,
2947 },
2948 &open_quote,
2949 open_execution,
2950 )
2951 .unwrap();
2952 let id = match effects.as_slice() {
2953 [
2954 FutureEffect::Filled {
2955 effect: Effect::PositionOpened { id },
2956 fill,
2957 ..
2958 },
2959 ] => {
2960 assert_eq!(fill.execution, open_execution);
2961 assert_eq!(fill.size, 2.0);
2962 assert_eq!(fill.ts, open_quote.ts);
2963 id.clone()
2964 }
2965 other => panic!("unexpected open effects: {other:?}"),
2966 };
2967 assert_eq!(
2968 engine.get_position(&id).unwrap().data.entries[0].price,
2969 100.25
2970 );
2971
2972 let scale_quote = quote("EURUSD", 100.0, 101.0, ts(10, 1, 0));
2973 let scale_execution = execution(FillPurpose::MarketEntry, Side::Buy, 101.5);
2974 let effects = engine
2975 .apply_priced_future_action(
2976 Action::ScaleIn {
2977 position_id: id.clone(),
2978 price: Some(2.0),
2979 size: 1.0,
2980 trade_id: None,
2981 },
2982 &scale_quote,
2983 scale_execution,
2984 )
2985 .unwrap();
2986 assert!(matches!(
2987 effects.as_slice(),
2988 [FutureEffect::Filled { fill, .. }]
2989 if fill.execution == scale_execution && fill.size == 1.0
2990 ));
2991 assert_eq!(
2992 engine.get_position(&id).unwrap().data.entries[1].price,
2993 101.5
2994 );
2995
2996 let partial_quote = quote("EURUSD", 98.0, 99.0, ts(10, 2, 0));
2997 let partial_execution = execution(FillPurpose::MarketExit, Side::Buy, 97.75);
2998 let effects = engine
2999 .apply_priced_future_action(
3000 Action::ClosePartial {
3001 position_id: id.clone(),
3002 ratio: 0.25,
3003 },
3004 &partial_quote,
3005 partial_execution,
3006 )
3007 .unwrap();
3008 assert!(matches!(
3009 effects.as_slice(),
3010 [FutureEffect::Filled {
3011 effect: Effect::PartialClose { ratio, .. },
3012 fill,
3013 ..
3014 }] if (*ratio - 0.25).abs() < f64::EPSILON
3015 && fill.execution == partial_execution
3016 && (fill.size - 0.75).abs() < f64::EPSILON
3017 ));
3018 let position = engine.get_position(&id).unwrap();
3019 assert!(position.data.records.iter().any(|(record, recorded_ts)| {
3020 matches!(
3021 record,
3022 PositionRecord::PartialClose { price, .. }
3023 if (*price - partial_execution.price).abs() < f64::EPSILON
3024 && *recorded_ts == partial_quote.ts
3025 )
3026 }));
3027
3028 let close_quote = quote("EURUSD", 97.0, 98.0, ts(10, 3, 0));
3029 let close_execution = execution(FillPurpose::MarketExit, Side::Buy, 96.5);
3030 let effects = engine
3031 .apply_priced_future_action(
3032 Action::ClosePosition {
3033 position_id: id.clone(),
3034 },
3035 &close_quote,
3036 close_execution,
3037 )
3038 .unwrap();
3039 assert!(matches!(
3040 effects.as_slice(),
3041 [FutureEffect::Filled {
3042 effect: Effect::PositionClosed { .. },
3043 fill,
3044 ..
3045 }] if fill.execution == close_execution
3046 && (fill.size - 2.25).abs() < f64::EPSILON
3047 ));
3048 let position = engine.get_position(&id).unwrap();
3049 assert_eq!(position.data.status, PositionStatus::Closed);
3050 assert_eq!(position.data.close_ts, Some(close_quote.ts));
3051 }
3052
3053 #[test]
3054 fn future_pending_gap_fill_is_priced_once_and_carried() {
3055 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3056 let effects = engine
3057 .apply_future_action(
3058 Action::Open {
3059 symbol: "EURUSD".into(),
3060 side: Side::Buy,
3061 order_type: OrderType::Stop,
3062 price: Some(101.0),
3063 size: 1.0,
3064 stoploss: None,
3065 targets: vec![],
3066 rules: vec![],
3067 group: None,
3068 trade_id: None,
3069 },
3070 ts(9, 59, 0),
3071 )
3072 .unwrap();
3073 let id = match effects[0].effect() {
3074 Effect::OrderPlaced { id } => id.clone(),
3075 effect => panic!("expected order placement, got {effect:?}"),
3076 };
3077 let gap_quote = quote("EURUSD", 102.0, 102.25, ts(10, 0, 0));
3078 let pricer = ExecutionPricer::new(ExecutionModel::new(
3079 ExecutionConvention::FutureQuoteV1,
3080 FillModel::BidAsk,
3081 SlippageModel::FixedPips { pips: 0.5 },
3082 ));
3083 let priced = pricer
3084 .stop_entry(Side::Buy, &gap_quote, 101.0, 0.1)
3085 .unwrap();
3086 let effects = engine
3087 .on_price_future_effects_priced(
3088 &gap_quote,
3089 &[PreparedPendingFill {
3090 position_id: id.clone(),
3091 execution: priced,
3092 size: 1.0,
3093 }],
3094 &pricer,
3095 0.1,
3096 )
3097 .unwrap();
3098 assert!(matches!(
3099 effects.as_slice(),
3100 [FutureEffect::Filled {
3101 effect: Effect::PositionOpened { id: effect_id },
3102 fill,
3103 ..
3104 }] if effect_id == &id && fill.execution == priced && fill.size == 1.0
3105 ));
3106 let position = engine.get_position(&id).unwrap();
3107 assert_eq!(position.data.status, PositionStatus::Open);
3108 assert_eq!(position.data.entries[0].price, priced.price);
3109 assert_eq!(position.data.entries[0].ts, gap_quote.ts);
3110 }
3111
3112 #[test]
3113 fn future_open_transaction_rollback_restores_indexes_and_id_sequence() {
3114 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3115 let open_quote = quote("EURUSD", 99.0, 100.0, ts(10, 0, 0));
3116 let action = Action::Open {
3117 symbol: "EURUSD".into(),
3118 side: Side::Buy,
3119 order_type: OrderType::Market,
3120 price: None,
3121 size: 1.0,
3122 stoploss: None,
3123 targets: vec![],
3124 rules: vec![],
3125 group: Some("group-a".into()),
3126 trade_id: Some("trade-a".into()),
3127 };
3128 let transaction = engine
3129 .begin_priced_future_action(
3130 action.clone(),
3131 &open_quote,
3132 execution(FillPurpose::MarketEntry, Side::Buy, 100.0),
3133 )
3134 .unwrap();
3135 let first_id = effect_position_id(transaction.effects()[0].effect()).to_owned();
3136 assert_eq!(first_id, "position:00000000");
3137 assert_eq!(
3138 engine.manager.id_by_trade_id("trade-a"),
3139 Some(first_id.clone())
3140 );
3141 assert_eq!(engine.manager.group_position_ids("group-a"), vec![first_id]);
3142
3143 transaction.rollback(&mut engine);
3144
3145 assert!(engine.manager.is_empty());
3146 assert_eq!(engine.manager.id_by_trade_id("trade-a"), None);
3147 assert!(engine.manager.group_position_ids("group-a").is_empty());
3148 let transaction = engine
3149 .begin_priced_future_action(
3150 action,
3151 &open_quote,
3152 execution(FillPurpose::MarketEntry, Side::Buy, 100.0),
3153 )
3154 .unwrap();
3155 assert_eq!(
3156 effect_position_id(transaction.effects()[0].effect()),
3157 "position:00000000"
3158 );
3159 transaction.commit();
3160 }
3161
3162 #[test]
3163 fn future_pending_quote_transaction_rollback_restores_quote_and_position() {
3164 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3165 let pricer = ExecutionPricer::new(ExecutionModel::future_quote_v1(FillModel::BidAsk));
3166 let previous_quote = quote("EURUSD", 100.0, 100.5, ts(9, 59, 0));
3167 engine
3168 .on_price_future_effects_priced(&previous_quote, &[], &pricer, 0.1)
3169 .unwrap();
3170 let effects = engine
3171 .apply_future_action(
3172 Action::Open {
3173 symbol: "EURUSD".into(),
3174 side: Side::Buy,
3175 order_type: OrderType::Limit,
3176 price: Some(99.0),
3177 size: 1.0,
3178 stoploss: None,
3179 targets: vec![],
3180 rules: vec![],
3181 group: None,
3182 trade_id: Some("pending-a".into()),
3183 },
3184 ts(9, 59, 30),
3185 )
3186 .unwrap();
3187 let id = effect_position_id(effects[0].effect()).to_owned();
3188 let trigger_quote = quote("EURUSD", 98.0, 98.5, ts(10, 0, 0));
3189 let transaction = engine
3190 .begin_on_price_future_effects_priced(
3191 &trigger_quote,
3192 &[PreparedPendingFill {
3193 position_id: id.clone(),
3194 execution: ExecutionFill {
3195 purpose: FillPurpose::LimitEntry,
3196 side: Side::Buy,
3197 price: 98.5,
3198 quote_price: 98.5,
3199 requested_price: Some(99.0),
3200 slippage_pips: 0.0,
3201 },
3202 size: 1.0,
3203 }],
3204 &pricer,
3205 0.1,
3206 )
3207 .unwrap();
3208 assert_eq!(
3209 engine.get_position(&id).unwrap().data.status,
3210 PositionStatus::Open
3211 );
3212 let current_quote = engine.last_quote("EURUSD").unwrap();
3213 assert_eq!(current_quote.ts, trigger_quote.ts);
3214 assert_eq!(current_quote.bid, trigger_quote.bid);
3215 assert_eq!(current_quote.ask, trigger_quote.ask);
3216
3217 transaction.rollback(&mut engine);
3218
3219 assert_eq!(
3220 engine.get_position(&id).unwrap().data.status,
3221 PositionStatus::Pending
3222 );
3223 assert_eq!(engine.manager.id_by_trade_id("pending-a"), Some(id));
3224 let restored_quote = engine.last_quote("EURUSD").unwrap();
3225 assert_eq!(restored_quote.ts, previous_quote.ts);
3226 assert_eq!(restored_quote.bid, previous_quote.bid);
3227 assert_eq!(restored_quote.ask, previous_quote.ask);
3228 }
3229
3230 #[test]
3231 fn future_quote_accounting_rollback_restores_rule_and_alert_state() {
3232 let mut engine = TradeEngine::with_alert_register_and_fill_model(FillModel::BidAsk);
3233 let pricer = ExecutionPricer::new(ExecutionModel::future_quote_v1(FillModel::BidAsk));
3234 let id = open_future_position(
3235 &mut engine,
3236 Side::Buy,
3237 None,
3238 vec![RuleConfig::TakeProfit {
3239 price: 105.0,
3240 close_ratio: 1.0,
3241 }],
3242 );
3243 let trigger_quote = quote("EURUSD", 106.0, 106.5, ts(10, 1, 0));
3244
3245 let transaction = engine
3246 .begin_on_price_future_effects_priced(&trigger_quote, &[], &pricer, 0.1)
3247 .unwrap();
3248
3249 assert!(matches!(
3250 transaction.effects(),
3251 [FutureEffect::Filled {
3252 effect: Effect::PositionClosed { id: effect_id, .. },
3253 ..
3254 }] if effect_id == &id
3255 ));
3256 assert_eq!(
3257 engine.get_position(&id).unwrap().data.status,
3258 PositionStatus::Closed
3259 );
3260 assert!(!engine.alert_register.as_ref().unwrap().has_alerts(&id));
3261
3262 transaction.rollback(&mut engine);
3263
3264 let position = engine.get_position(&id).unwrap();
3265 assert_eq!(position.data.status, PositionStatus::Open);
3266 assert!(matches!(
3267 position.rules.as_slice(),
3268 [Rule::TakeProfit {
3269 triggered: false,
3270 ..
3271 }]
3272 ));
3273 assert!(engine.alert_register.as_ref().unwrap().has_alerts(&id));
3274 assert!(engine.last_quote("EURUSD").is_none());
3275 }
3276
3277 #[test]
3278 fn future_transaction_reports_empty_effects_without_losing_rollback() {
3279 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3280 let id = open_future_position(&mut engine, Side::Buy, None, vec![]);
3281 let rules_before = engine.get_position(&id).unwrap().rules.len();
3282 let transaction = engine
3283 .begin_future_action(
3284 Action::AddTarget {
3285 position_id: id.clone(),
3286 price: 105.0,
3287 close_ratio: 0.5,
3288 },
3289 ts(10, 1, 0),
3290 )
3291 .unwrap();
3292 assert!(!transaction.has_effects());
3293 assert_eq!(
3294 engine.get_position(&id).unwrap().rules.len(),
3295 rules_before + 1
3296 );
3297
3298 transaction.rollback(&mut engine);
3299
3300 assert_eq!(engine.get_position(&id).unwrap().rules.len(), rules_before);
3301 }
3302
3303 #[test]
3304 fn future_rule_pricing_failure_is_atomic() {
3305 let mut engine = TradeEngine::new();
3306 let id = open_future_position(
3307 &mut engine,
3308 Side::Buy,
3309 None,
3310 vec![RuleConfig::TakeProfit {
3311 price: 105.0,
3312 close_ratio: 0.5,
3313 }],
3314 );
3315 let before = engine.get_position(&id).unwrap();
3316 let before_status = before.data.status;
3317 let before_remaining = before.data.remaining_ratio;
3318 let before_records = before.data.records.len();
3319 let before_triggered = matches!(
3320 before.rules.as_slice(),
3321 [Rule::TakeProfit {
3322 triggered: false,
3323 ..
3324 }]
3325 );
3326 assert!(before_triggered);
3327
3328 let pricer = ExecutionPricer::new(ExecutionModel::new(
3329 ExecutionConvention::FutureQuoteV1,
3330 FillModel::BidAsk,
3331 SlippageModel::FixedPips { pips: f64::NAN },
3332 ));
3333 let result = engine.on_price_future_effects_priced(
3334 "e("EURUSD", 106.0, 106.1, ts(10, 1, 0)),
3335 &[],
3336 &pricer,
3337 0.1,
3338 );
3339 assert!(matches!(
3340 result,
3341 Err(FutureApplyError::Pricing(ExecutionError::InvalidSlippage(value)))
3342 if value.is_nan()
3343 ));
3344
3345 let after = engine.get_position(&id).unwrap();
3346 assert_eq!(after.data.status, before_status);
3347 assert_eq!(after.data.remaining_ratio, before_remaining);
3348 assert_eq!(after.data.records.len(), before_records);
3349 assert!(matches!(
3350 after.rules.as_slice(),
3351 [Rule::TakeProfit {
3352 triggered: false,
3353 ..
3354 }]
3355 ));
3356 }
3357
3358 #[test]
3359 fn open_market_order() {
3360 let mut engine = TradeEngine::new();
3361 let effects = engine
3362 .apply_action(
3363 Action::Open {
3364 symbol: "EURUSD".into(),
3365 side: Side::Buy,
3366 order_type: OrderType::Market,
3367 price: Some(1.0850),
3368 size: 1.0,
3369 stoploss: Some(1.0800),
3370 targets: vec![TargetSpec {
3371 price: 1.0900,
3372 close_ratio: 1.0,
3373 }],
3374 rules: vec![],
3375 group: None,
3376 trade_id: None,
3377 },
3378 ts(10, 0, 0),
3379 )
3380 .unwrap();
3381
3382 assert_eq!(effects.len(), 1);
3383 assert!(matches!(&effects[0], Effect::PositionOpened { .. }));
3384
3385 let id = match &effects[0] {
3386 Effect::PositionOpened { id } => id.clone(),
3387 _ => panic!(),
3388 };
3389
3390 let pos = engine.get_position(&id).unwrap();
3391 assert_eq!(pos.data.status, PositionStatus::Open);
3392 assert_eq!(pos.data.side, Side::Buy);
3393 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
3394 assert_eq!(pos.rules.len(), 2);
3396 }
3397
3398 #[test]
3399 fn open_market_order_uses_last_quote() {
3400 let mut engine = TradeEngine::new();
3401 engine.on_price("e("EURUSD", 1.0848, 1.0850, ts(9, 59, 0)));
3403
3404 let effects = engine
3405 .apply_action(
3406 Action::Open {
3407 symbol: "EURUSD".into(),
3408 side: Side::Buy,
3409 order_type: OrderType::Market,
3410 price: None, size: 1.0,
3412 stoploss: None,
3413 targets: vec![],
3414 rules: vec![],
3415 group: None,
3416 trade_id: None,
3417 },
3418 ts(10, 0, 0),
3419 )
3420 .unwrap();
3421
3422 let id = match &effects[0] {
3423 Effect::PositionOpened { id } => id.clone(),
3424 _ => panic!(),
3425 };
3426 let pos = engine.get_position(&id).unwrap();
3427 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
3428 }
3429
3430 #[test]
3431 fn open_limit_order_and_fill() {
3432 let mut engine = TradeEngine::new();
3433 let effects = engine
3434 .apply_action(
3435 Action::Open {
3436 symbol: "EURUSD".into(),
3437 side: Side::Buy,
3438 order_type: OrderType::Limit,
3439 price: Some(1.0800),
3440 size: 1.0,
3441 stoploss: Some(1.0750),
3442 targets: vec![],
3443 rules: vec![],
3444 group: None,
3445 trade_id: None,
3446 },
3447 ts(9, 0, 0),
3448 )
3449 .unwrap();
3450
3451 assert!(matches!(&effects[0], Effect::OrderPlaced { .. }));
3452 let id = match &effects[0] {
3453 Effect::OrderPlaced { id } => id.clone(),
3454 _ => panic!(),
3455 };
3456
3457 let effects = engine.on_price("e("EURUSD", 1.0810, 1.0812, ts(10, 0, 0)));
3459 assert!(effects.is_empty());
3460 assert_eq!(
3461 engine.get_position(&id).unwrap().data.status,
3462 PositionStatus::Pending
3463 );
3464
3465 let effects = engine.on_price("e("EURUSD", 1.0798, 1.0800, ts(10, 5, 0)));
3467 assert_eq!(effects.len(), 1);
3468 assert!(matches!(&effects[0], Effect::PositionOpened { .. }));
3469 assert_eq!(
3470 engine.get_position(&id).unwrap().data.status,
3471 PositionStatus::Open
3472 );
3473 }
3474
3475 #[test]
3476 fn stoploss_triggers_on_price() {
3477 let mut engine = TradeEngine::new();
3478 let effects = engine
3479 .apply_action(
3480 Action::Open {
3481 symbol: "EURUSD".into(),
3482 side: Side::Buy,
3483 order_type: OrderType::Market,
3484 price: Some(1.0850),
3485 size: 1.0,
3486 stoploss: Some(1.0800),
3487 targets: vec![],
3488 rules: vec![],
3489 group: None,
3490 trade_id: None,
3491 },
3492 ts(10, 0, 0),
3493 )
3494 .unwrap();
3495
3496 let id = match &effects[0] {
3497 Effect::PositionOpened { id } => id.clone(),
3498 _ => panic!(),
3499 };
3500
3501 let effects = engine.on_price("e("EURUSD", 1.0840, 1.0842, ts(10, 1, 0)));
3503 assert!(effects.is_empty());
3504
3505 let effects = engine.on_price("e("EURUSD", 1.0799, 1.0801, ts(10, 2, 0)));
3507 assert_eq!(effects.len(), 1);
3508 assert!(matches!(
3509 &effects[0],
3510 Effect::PositionClosed {
3511 reason: CloseReason::Stoploss,
3512 ..
3513 }
3514 ));
3515
3516 let pos = engine.get_position(&id).unwrap();
3517 assert_eq!(pos.data.status, PositionStatus::Closed);
3518 }
3519
3520 #[test]
3521 fn take_profit_partial_then_stoploss() {
3522 let mut engine = TradeEngine::new();
3523 let effects = engine
3524 .apply_action(
3525 Action::Open {
3526 symbol: "EURUSD".into(),
3527 side: Side::Buy,
3528 order_type: OrderType::Market,
3529 price: Some(1.0850),
3530 size: 2.0,
3531 stoploss: Some(1.0800),
3532 targets: vec![TargetSpec {
3533 price: 1.0900,
3534 close_ratio: 0.5,
3535 }],
3536 rules: vec![],
3537 group: None,
3538 trade_id: None,
3539 },
3540 ts(10, 0, 0),
3541 )
3542 .unwrap();
3543 let id = match &effects[0] {
3544 Effect::PositionOpened { id } => id.clone(),
3545 _ => panic!(),
3546 };
3547
3548 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 5, 0)));
3550 assert!(effects
3551 .iter()
3552 .any(|e| matches!(e, Effect::PartialClose { ratio, .. } if (*ratio - 0.5).abs() < f64::EPSILON)));
3553
3554 let pos = engine.get_position(&id).unwrap();
3555 assert_eq!(pos.data.status, PositionStatus::Open);
3556 assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
3557 assert_eq!(pos.data.target_hits, 1);
3558
3559 let effects = engine.on_price("e("EURUSD", 1.0799, 1.0801, ts(10, 10, 0)));
3561 assert!(effects.iter().any(|e| matches!(
3562 e,
3563 Effect::PositionClosed {
3564 reason: CloseReason::Stoploss,
3565 ..
3566 }
3567 )));
3568
3569 let pos = engine.get_position(&id).unwrap();
3570 assert_eq!(pos.data.status, PositionStatus::Closed);
3571 }
3572
3573 #[test]
3574 fn scale_in() {
3575 let mut engine = TradeEngine::new();
3576 let effects = engine
3577 .apply_action(
3578 Action::Open {
3579 symbol: "EURUSD".into(),
3580 side: Side::Buy,
3581 order_type: OrderType::Market,
3582 price: Some(1.0800),
3583 size: 1.0,
3584 stoploss: None,
3585 targets: vec![],
3586 rules: vec![],
3587 group: None,
3588 trade_id: None,
3589 },
3590 ts(10, 0, 0),
3591 )
3592 .unwrap();
3593 let id = match &effects[0] {
3594 Effect::PositionOpened { id } => id.clone(),
3595 _ => panic!(),
3596 };
3597
3598 let effects = engine
3599 .apply_action(
3600 Action::ScaleIn {
3601 position_id: id.clone(),
3602 price: Some(1.0900),
3603 size: 1.0,
3604 trade_id: None,
3605 },
3606 ts(10, 5, 0),
3607 )
3608 .unwrap();
3609
3610 assert!(matches!(&effects[0], Effect::ScaledIn { .. }));
3611
3612 let pos = engine.get_position(&id).unwrap();
3613 assert_eq!(pos.data.entries.len(), 2);
3614 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
3616 }
3617
3618 #[test]
3619 fn partial_close_then_scale_in_conserves_lots_through_engine() {
3620 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3621 let effects = engine
3622 .apply_action(
3623 Action::Open {
3624 symbol: "EURUSD".into(),
3625 side: Side::Buy,
3626 order_type: OrderType::Market,
3627 price: Some(1.0800),
3628 size: 2.0,
3629 stoploss: None,
3630 targets: vec![],
3631 rules: vec![],
3632 group: None,
3633 trade_id: None,
3634 },
3635 ts(10, 0, 0),
3636 )
3637 .unwrap();
3638 let id = match &effects[0] {
3639 Effect::PositionOpened { id } => id.clone(),
3640 effect => panic!("expected open, got {effect:?}"),
3641 };
3642
3643 engine
3644 .apply_action(
3645 Action::ClosePartial {
3646 position_id: id.clone(),
3647 ratio: 0.5,
3648 },
3649 ts(10, 1, 0),
3650 )
3651 .unwrap();
3652 engine
3653 .apply_action(
3654 Action::ScaleIn {
3655 position_id: id.clone(),
3656 price: Some(1.0900),
3657 size: 1.0,
3658 trade_id: None,
3659 },
3660 ts(10, 2, 0),
3661 )
3662 .unwrap();
3663
3664 let position = engine.get_position(&id).unwrap();
3665 assert_eq!(position.data.total_filled_size(), 3.0);
3666 assert_eq!(position.data.closed_size, 1.0);
3667 assert_eq!(position.data.remaining_size(), 2.0);
3668 assert!((position.data.remaining_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
3669 }
3670
3671 #[test]
3672 fn target_ratios_are_validated_atomically_at_core_boundaries() {
3673 for ratio in [0.0, -0.1, 1.1, f64::NAN, f64::INFINITY] {
3674 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3675 let result = engine.apply_action(
3676 Action::Open {
3677 symbol: "EURUSD".into(),
3678 side: Side::Buy,
3679 order_type: OrderType::Market,
3680 price: Some(100.0),
3681 size: 1.0,
3682 stoploss: None,
3683 targets: vec![TargetSpec {
3684 price: 101.0,
3685 close_ratio: ratio,
3686 }],
3687 rules: vec![],
3688 group: None,
3689 trade_id: None,
3690 },
3691 ts(10, 0, 0),
3692 );
3693 assert!(matches!(result, Err(CoreError::InvalidAction(_))));
3694 assert!(engine.open_positions().is_empty());
3695 }
3696
3697 let mut invalid_rule_engine = TradeEngine::new();
3698 assert!(matches!(
3699 invalid_rule_engine.apply_action(
3700 Action::Open {
3701 symbol: "EURUSD".into(),
3702 side: Side::Buy,
3703 order_type: OrderType::Market,
3704 price: Some(100.0),
3705 size: 1.0,
3706 stoploss: None,
3707 targets: vec![],
3708 rules: vec![RuleConfig::TakeProfit {
3709 price: 101.0,
3710 close_ratio: f64::NEG_INFINITY,
3711 }],
3712 group: None,
3713 trade_id: None,
3714 },
3715 ts(10, 0, 0),
3716 ),
3717 Err(CoreError::InvalidAction(_))
3718 ));
3719 assert!(invalid_rule_engine.open_positions().is_empty());
3720
3721 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3722 let id = open_future_position(&mut engine, Side::Buy, None, vec![]);
3723 let before_rules = engine.get_position(&id).unwrap().rules.len();
3724 let before_records = engine.get_position(&id).unwrap().data.records.len();
3725 for action in [
3726 Action::AddTarget {
3727 position_id: id.clone(),
3728 price: 101.0,
3729 close_ratio: f64::NAN,
3730 },
3731 Action::AddRule {
3732 position_id: id.clone(),
3733 rule: RuleConfig::TakeProfit {
3734 price: 102.0,
3735 close_ratio: 0.0,
3736 },
3737 },
3738 ] {
3739 assert!(matches!(
3740 engine.apply_action(action, ts(10, 1, 0)),
3741 Err(CoreError::InvalidAction(_))
3742 ));
3743 assert_eq!(engine.get_position(&id).unwrap().rules.len(), before_rules);
3744 assert_eq!(
3745 engine.get_position(&id).unwrap().data.records.len(),
3746 before_records
3747 );
3748 }
3749
3750 engine
3751 .apply_action(
3752 Action::AddTarget {
3753 position_id: id.clone(),
3754 price: 103.0,
3755 close_ratio: 0.5,
3756 },
3757 ts(10, 2, 0),
3758 )
3759 .unwrap();
3760 if let Some(Rule::TakeProfit { close_ratio, .. }) = engine
3761 .manager
3762 .get_mut(&id)
3763 .unwrap()
3764 .rules
3765 .iter_mut()
3766 .find(|rule| matches!(rule, Rule::TakeProfit { price, .. } if *price == 103.0))
3767 {
3768 *close_ratio = f64::NAN;
3769 }
3770 assert!(matches!(
3771 engine.apply_action(
3772 Action::ModifyTarget {
3773 position_id: id.clone(),
3774 old_price: 103.0,
3775 new_price: 104.0,
3776 },
3777 ts(10, 3, 0),
3778 ),
3779 Err(CoreError::InvalidAction(_))
3780 ));
3781 assert!(
3782 engine
3783 .get_position(&id)
3784 .unwrap()
3785 .rules
3786 .iter()
3787 .any(|rule| matches!(rule, Rule::TakeProfit { price, .. } if *price == 103.0))
3788 );
3789 }
3790
3791 #[test]
3792 fn fixed_stop_rule_add_and_remove_emit_synchronization_effects() {
3793 let mut engine = TradeEngine::new();
3794 let id = open_future_position(&mut engine, Side::Buy, None, vec![]);
3795
3796 let added = engine
3797 .apply_future_action(
3798 Action::AddRule {
3799 position_id: id.clone(),
3800 rule: RuleConfig::FixedStoploss { price: 95.0 },
3801 },
3802 ts(10, 1, 0),
3803 )
3804 .unwrap();
3805 assert!(matches!(
3806 added.as_slice(),
3807 [FutureEffect::Plain {
3808 effect: Effect::StoplossModified {
3809 old_price: 0.0,
3810 new_price: 95.0,
3811 ..
3812 },
3813 stop_origin: Some(StopOrigin::Modified),
3814 ..
3815 }]
3816 ));
3817 assert_eq!(
3818 engine.get_position(&id).unwrap().current_effective_stop(),
3819 Some(crate::types::EffectiveStop::new(95.0, StopOrigin::Modified))
3820 );
3821
3822 let removed = engine
3823 .apply_future_action(
3824 Action::RemoveRule {
3825 position_id: id.clone(),
3826 rule_name: "FixedStoploss".into(),
3827 },
3828 ts(10, 2, 0),
3829 )
3830 .unwrap();
3831 assert!(matches!(
3832 removed.as_slice(),
3833 [FutureEffect::Plain {
3834 effect: Effect::StoplossRemoved {
3835 old_price: 95.0,
3836 ..
3837 },
3838 ..
3839 }]
3840 ));
3841 assert!(
3842 engine
3843 .get_position(&id)
3844 .unwrap()
3845 .current_effective_stop()
3846 .is_none()
3847 );
3848 }
3849
3850 #[test]
3851 fn near_full_partial_close_emits_full_close_and_leaves_no_residual() {
3852 let mut engine = TradeEngine::new();
3853 let id = open_future_position(&mut engine, Side::Buy, None, vec![]);
3854 let effects = engine
3855 .apply_action(
3856 Action::ClosePartial {
3857 position_id: id.clone(),
3858 ratio: 1.0 - 5.0e-13,
3859 },
3860 ts(10, 1, 0),
3861 )
3862 .unwrap();
3863 assert!(matches!(
3864 effects.as_slice(),
3865 [Effect::PositionClosed { .. }]
3866 ));
3867 let position = engine.get_position(&id).unwrap();
3868 assert_eq!(position.data.status, PositionStatus::Closed);
3869 assert_eq!(position.data.remaining_size(), 0.0);
3870 assert_eq!(position.data.open_entry_value, 0.0);
3871 }
3872
3873 #[test]
3874 fn engine_rejects_duplicate_trade_ids_without_partial_mutation() {
3875 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
3876 let first = engine
3877 .apply_action(
3878 Action::Open {
3879 symbol: "EURUSD".into(),
3880 side: Side::Buy,
3881 order_type: OrderType::Market,
3882 price: Some(1.0800),
3883 size: 1.0,
3884 stoploss: None,
3885 targets: vec![],
3886 rules: vec![],
3887 group: None,
3888 trade_id: Some("trade-7".into()),
3889 },
3890 ts(10, 0, 0),
3891 )
3892 .unwrap();
3893 let first_id = match &first[0] {
3894 Effect::PositionOpened { id } => id.clone(),
3895 effect => panic!("expected open, got {effect:?}"),
3896 };
3897
3898 let duplicate = engine.apply_action(
3899 Action::Open {
3900 symbol: "XAUUSD".into(),
3901 side: Side::Sell,
3902 order_type: OrderType::Market,
3903 price: Some(2000.0),
3904 size: 1.0,
3905 stoploss: None,
3906 targets: vec![],
3907 rules: vec![],
3908 group: None,
3909 trade_id: Some("trade-7".into()),
3910 },
3911 ts(10, 1, 0),
3912 );
3913 assert!(matches!(
3914 duplicate,
3915 Err(CoreError::InvalidAction(message)) if message.contains("trade-7")
3916 ));
3917 assert_eq!(engine.manager.len(), 1);
3918 assert_eq!(
3919 engine.manager.id_by_trade_id("trade-7"),
3920 Some(first_id.clone())
3921 );
3922 assert!(engine.manager.ids_for_symbol("XAUUSD").is_empty());
3923
3924 let second = engine
3925 .apply_action(
3926 Action::Open {
3927 symbol: "EURUSD".into(),
3928 side: Side::Sell,
3929 order_type: OrderType::Market,
3930 price: Some(1.0800),
3931 size: 1.0,
3932 stoploss: None,
3933 targets: vec![],
3934 rules: vec![],
3935 group: None,
3936 trade_id: None,
3937 },
3938 ts(10, 2, 0),
3939 )
3940 .unwrap();
3941 let second_id = match &second[0] {
3942 Effect::PositionOpened { id } => id.clone(),
3943 effect => panic!("expected open, got {effect:?}"),
3944 };
3945 let duplicate_scale = engine.apply_action(
3946 Action::ScaleIn {
3947 position_id: second_id.clone(),
3948 price: Some(1.0750),
3949 size: 2.0,
3950 trade_id: Some("trade-7".into()),
3951 },
3952 ts(10, 3, 0),
3953 );
3954 assert!(matches!(
3955 duplicate_scale,
3956 Err(CoreError::InvalidAction(message)) if message.contains("trade-7")
3957 ));
3958 let second = engine.get_position(&second_id).unwrap();
3959 assert_eq!(second.data.entries.len(), 1);
3960 assert_eq!(second.data.total_filled_size(), 1.0);
3961 assert_eq!(second.data.trade_id, None);
3962 assert_eq!(engine.manager.id_by_trade_id("trade-7"), Some(first_id));
3963 }
3964
3965 #[test]
3966 fn close_position_manually() {
3967 let mut engine = TradeEngine::new();
3968 let effects = engine
3969 .apply_action(
3970 Action::Open {
3971 symbol: "EURUSD".into(),
3972 side: Side::Sell,
3973 order_type: OrderType::Market,
3974 price: Some(1.0850),
3975 size: 1.0,
3976 stoploss: None,
3977 targets: vec![],
3978 rules: vec![],
3979 group: None,
3980 trade_id: None,
3981 },
3982 ts(10, 0, 0),
3983 )
3984 .unwrap();
3985 let id = match &effects[0] {
3986 Effect::PositionOpened { id } => id.clone(),
3987 _ => panic!(),
3988 };
3989
3990 let effects = engine
3991 .apply_action(
3992 Action::ClosePosition {
3993 position_id: id.clone(),
3994 },
3995 ts(10, 5, 0),
3996 )
3997 .unwrap();
3998
3999 assert!(matches!(
4000 &effects[0],
4001 Effect::PositionClosed {
4002 reason: CloseReason::Manual,
4003 ..
4004 }
4005 ));
4006 assert_eq!(
4007 engine.get_position(&id).unwrap().data.status,
4008 PositionStatus::Closed
4009 );
4010 }
4011
4012 #[test]
4013 fn cancel_pending() {
4014 let mut engine = TradeEngine::new();
4015 let effects = engine
4016 .apply_action(
4017 Action::Open {
4018 symbol: "EURUSD".into(),
4019 side: Side::Buy,
4020 order_type: OrderType::Limit,
4021 price: Some(1.0800),
4022 size: 1.0,
4023 stoploss: None,
4024 targets: vec![],
4025 rules: vec![],
4026 group: None,
4027 trade_id: None,
4028 },
4029 ts(9, 0, 0),
4030 )
4031 .unwrap();
4032 let id = match &effects[0] {
4033 Effect::OrderPlaced { id } => id.clone(),
4034 _ => panic!(),
4035 };
4036
4037 let effects = engine
4038 .apply_action(
4039 Action::CancelPending {
4040 position_id: id.clone(),
4041 },
4042 ts(9, 30, 0),
4043 )
4044 .unwrap();
4045
4046 assert!(matches!(&effects[0], Effect::OrderCancelled { .. }));
4047 assert_eq!(
4048 engine.get_position(&id).unwrap().data.status,
4049 PositionStatus::Cancelled
4050 );
4051 }
4052
4053 #[test]
4054 fn modify_stoploss() {
4055 let mut engine = TradeEngine::new();
4056 let effects = engine
4057 .apply_action(
4058 Action::Open {
4059 symbol: "EURUSD".into(),
4060 side: Side::Buy,
4061 order_type: OrderType::Market,
4062 price: Some(1.0850),
4063 size: 1.0,
4064 stoploss: Some(1.0800),
4065 targets: vec![],
4066 rules: vec![],
4067 group: None,
4068 trade_id: None,
4069 },
4070 ts(10, 0, 0),
4071 )
4072 .unwrap();
4073 let id = match &effects[0] {
4074 Effect::PositionOpened { id } => id.clone(),
4075 _ => panic!(),
4076 };
4077
4078 let effects = engine
4079 .apply_action(
4080 Action::ModifyStoploss {
4081 position_id: id.clone(),
4082 price: 1.0820,
4083 },
4084 ts(10, 5, 0),
4085 )
4086 .unwrap();
4087
4088 assert!(matches!(
4089 &effects[0],
4090 Effect::StoplossModified {
4091 old_price,
4092 new_price,
4093 ..
4094 } if (*old_price - 1.0800).abs() < f64::EPSILON && (*new_price - 1.0820).abs() < f64::EPSILON
4095 ));
4096
4097 let pos = engine.get_position(&id).unwrap();
4098 assert!((pos.current_stoploss().unwrap() - 1.0820).abs() < f64::EPSILON);
4099 }
4100
4101 #[test]
4102 fn move_stoploss_to_entry() {
4103 let mut engine = TradeEngine::new();
4104 let effects = engine
4105 .apply_action(
4106 Action::Open {
4107 symbol: "EURUSD".into(),
4108 side: Side::Buy,
4109 order_type: OrderType::Market,
4110 price: Some(1.0850),
4111 size: 1.0,
4112 stoploss: Some(1.0800),
4113 targets: vec![],
4114 rules: vec![],
4115 group: None,
4116 trade_id: None,
4117 },
4118 ts(10, 0, 0),
4119 )
4120 .unwrap();
4121 let id = match &effects[0] {
4122 Effect::PositionOpened { id } => id.clone(),
4123 _ => panic!(),
4124 };
4125
4126 engine
4127 .apply_action(
4128 Action::MoveStoplossToEntry {
4129 position_id: id.clone(),
4130 },
4131 ts(10, 5, 0),
4132 )
4133 .unwrap();
4134
4135 let pos = engine.get_position(&id).unwrap();
4136 assert!((pos.current_stoploss().unwrap() - 1.0850).abs() < f64::EPSILON);
4137 assert_eq!(
4138 pos.current_effective_stop().unwrap().origin,
4139 StopOrigin::Breakeven
4140 );
4141 }
4142
4143 #[test]
4144 fn future_quote_breakeven_close_retains_reason_and_provenance() {
4145 let mut engine = TradeEngine::new();
4146 let id = open_future_position(
4147 &mut engine,
4148 Side::Buy,
4149 Some(95.0),
4150 vec![RuleConfig::BreakevenWhen {
4151 trigger_price: 105.0,
4152 }],
4153 );
4154
4155 let effects = engine.on_price_future_effects("e("EURUSD", 105.0, 105.1, ts(10, 1, 0)));
4156 assert!(matches!(
4157 effects.as_slice(),
4158 [FutureEffect::Plain {
4159 effect: Effect::StoplossModified { new_price, .. },
4160 requested_price: Some(requested_price),
4161 stop_origin: Some(StopOrigin::Breakeven),
4162 }] if (*new_price - 100.0).abs() < f64::EPSILON
4163 && (*requested_price - 100.0).abs() < f64::EPSILON
4164 ));
4165 assert_eq!(
4166 engine
4167 .get_position(&id)
4168 .unwrap()
4169 .current_effective_stop()
4170 .unwrap()
4171 .origin,
4172 StopOrigin::Breakeven
4173 );
4174
4175 let effects = engine.on_price_future_effects("e("EURUSD", 99.9, 100.0, ts(10, 2, 0)));
4176 assert!(matches!(
4177 effects.as_slice(),
4178 [FutureEffect::Filled {
4179 effect: Effect::PositionClosed {
4180 reason: CloseReason::BreakevenStop,
4181 ..
4182 },
4183 fill,
4184 stop_origin: Some(StopOrigin::Breakeven),
4185 }] if fill.execution.requested_price == Some(100.0)
4186 ));
4187 }
4188
4189 #[test]
4190 fn manual_stops_on_the_target_side_are_rejected_atomically() {
4191 for (side, initial_stop, invalid_stop) in
4192 [(Side::Buy, 95.0, 102.0), (Side::Sell, 105.0, 98.0)]
4193 {
4194 let mut engine = TradeEngine::new();
4195 let id = open_future_position(&mut engine, side, Some(initial_stop), vec![]);
4196 let before = engine.get_position(&id).unwrap();
4197 let before_rules = format!("{:?}", before.rules);
4198 let before_records = before.data.records.len();
4199 let before_origin = before.data.stop_origin;
4200
4201 assert!(matches!(
4202 engine.apply_action(
4203 Action::ModifyStoploss {
4204 position_id: id.clone(),
4205 price: invalid_stop,
4206 },
4207 ts(10, 0, 30),
4208 ),
4209 Err(CoreError::InvalidAction(_))
4210 ));
4211
4212 let position = engine.get_position(&id).unwrap();
4213 assert_eq!(format!("{:?}", position.rules), before_rules);
4214 assert_eq!(position.data.records.len(), before_records);
4215 assert_eq!(position.data.stop_origin, before_origin);
4216 assert_eq!(position.current_stoploss(), Some(initial_stop));
4217 }
4218 }
4219
4220 #[test]
4221 fn future_quote_trailing_stop_wins_breakeven_and_retains_provenance() {
4222 let mut engine = TradeEngine::new();
4223 let id = open_future_position(
4224 &mut engine,
4225 Side::Buy,
4226 None,
4227 vec![
4228 RuleConfig::TrailingStop { distance: 2.0 },
4229 RuleConfig::BreakevenWhen {
4230 trigger_price: 105.0,
4231 },
4232 ],
4233 );
4234
4235 let effects = engine.on_price_future_effects("e("EURUSD", 105.0, 105.1, ts(10, 1, 0)));
4236 assert!(matches!(
4237 effects.as_slice(),
4238 [FutureEffect::Plain {
4239 effect: Effect::StoplossModified { new_price, .. },
4240 requested_price: Some(requested_price),
4241 stop_origin: Some(StopOrigin::Trailing),
4242 }] if (*new_price - 103.0).abs() < f64::EPSILON
4243 && (*requested_price - 103.0).abs() < f64::EPSILON
4244 ));
4245 let stop = engine
4246 .get_position(&id)
4247 .unwrap()
4248 .current_effective_stop()
4249 .unwrap();
4250 assert!((stop.price - 103.0).abs() < f64::EPSILON);
4251 assert_eq!(stop.origin, StopOrigin::Trailing);
4252
4253 let effects = engine.on_price_future_effects("e("EURUSD", 102.9, 103.0, ts(10, 2, 0)));
4254 assert!(matches!(
4255 effects.as_slice(),
4256 [FutureEffect::Filled {
4257 effect: Effect::PositionClosed {
4258 reason: CloseReason::TrailingStop,
4259 ..
4260 },
4261 fill,
4262 stop_origin: Some(StopOrigin::Trailing),
4263 }] if fill.execution.requested_price == Some(103.0)
4264 ));
4265 assert_eq!(
4266 engine
4267 .get_position(&id)
4268 .unwrap()
4269 .current_effective_stop()
4270 .unwrap()
4271 .origin,
4272 StopOrigin::Trailing
4273 );
4274 }
4275
4276 #[test]
4277 fn bulk_close_all_of_symbol() {
4278 let mut engine = TradeEngine::new();
4279
4280 engine
4282 .apply_action(
4283 Action::Open {
4284 symbol: "EURUSD".into(),
4285 side: Side::Buy,
4286 order_type: OrderType::Market,
4287 price: Some(1.0850),
4288 size: 1.0,
4289 stoploss: None,
4290 targets: vec![],
4291 rules: vec![],
4292 group: None,
4293 trade_id: None,
4294 },
4295 ts(10, 0, 0),
4296 )
4297 .unwrap();
4298 engine
4299 .apply_action(
4300 Action::Open {
4301 symbol: "EURUSD".into(),
4302 side: Side::Sell,
4303 order_type: OrderType::Market,
4304 price: Some(1.0850),
4305 size: 1.0,
4306 stoploss: None,
4307 targets: vec![],
4308 rules: vec![],
4309 group: None,
4310 trade_id: None,
4311 },
4312 ts(10, 0, 0),
4313 )
4314 .unwrap();
4315 engine
4316 .apply_action(
4317 Action::Open {
4318 symbol: "XAUUSD".into(),
4319 side: Side::Buy,
4320 order_type: OrderType::Market,
4321 price: Some(2000.0),
4322 size: 1.0,
4323 stoploss: None,
4324 targets: vec![],
4325 rules: vec![],
4326 group: None,
4327 trade_id: None,
4328 },
4329 ts(10, 0, 0),
4330 )
4331 .unwrap();
4332
4333 assert_eq!(engine.open_positions().len(), 3);
4334
4335 let effects = engine
4336 .apply_action(
4337 Action::CloseAllOf {
4338 symbol: "EURUSD".into(),
4339 },
4340 ts(10, 5, 0),
4341 )
4342 .unwrap();
4343
4344 assert_eq!(effects.len(), 2);
4345 assert_eq!(engine.open_positions().len(), 1);
4346 assert_eq!(engine.open_positions()[0].data.symbol, "XAUUSD");
4347 }
4348
4349 #[test]
4350 fn bulk_cancel_all_pending() {
4351 let mut engine = TradeEngine::new();
4352 engine
4353 .apply_action(
4354 Action::Open {
4355 symbol: "EURUSD".into(),
4356 side: Side::Buy,
4357 order_type: OrderType::Limit,
4358 price: Some(1.0800),
4359 size: 1.0,
4360 stoploss: None,
4361 targets: vec![],
4362 rules: vec![],
4363 group: None,
4364 trade_id: None,
4365 },
4366 ts(9, 0, 0),
4367 )
4368 .unwrap();
4369 engine
4370 .apply_action(
4371 Action::Open {
4372 symbol: "XAUUSD".into(),
4373 side: Side::Sell,
4374 order_type: OrderType::Stop,
4375 price: Some(1990.0),
4376 size: 1.0,
4377 stoploss: None,
4378 targets: vec![],
4379 rules: vec![],
4380 group: None,
4381 trade_id: None,
4382 },
4383 ts(9, 0, 0),
4384 )
4385 .unwrap();
4386
4387 assert_eq!(engine.pending_positions().len(), 2);
4388
4389 let effects = engine
4390 .apply_action(Action::CancelAllPending, ts(9, 30, 0))
4391 .unwrap();
4392
4393 assert_eq!(effects.len(), 2);
4394 assert_eq!(engine.pending_positions().len(), 0);
4395 }
4396
4397 #[test]
4398 fn breakeven_when_triggers_via_on_price() {
4399 let mut engine = TradeEngine::new();
4400 let effects = engine
4401 .apply_action(
4402 Action::Open {
4403 symbol: "EURUSD".into(),
4404 side: Side::Buy,
4405 order_type: OrderType::Market,
4406 price: Some(1.0850),
4407 size: 1.0,
4408 stoploss: Some(1.0800),
4409 targets: vec![],
4410 rules: vec![RuleConfig::BreakevenWhen {
4411 trigger_price: 1.0900,
4412 }],
4413 group: None,
4414 trade_id: None,
4415 },
4416 ts(10, 0, 0),
4417 )
4418 .unwrap();
4419 let id = match &effects[0] {
4420 Effect::PositionOpened { id } => id.clone(),
4421 _ => panic!(),
4422 };
4423
4424 engine.on_price("e("EURUSD", 1.0880, 1.0882, ts(10, 1, 0)));
4426 let pos = engine.get_position(&id).unwrap();
4427 assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
4428
4429 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 2, 0)));
4431 assert!(
4432 effects
4433 .iter()
4434 .any(|e| matches!(e, Effect::StoplossModified { .. }))
4435 );
4436
4437 let pos = engine.get_position(&id).unwrap();
4438 assert!((pos.current_stoploss().unwrap() - 1.0850).abs() < f64::EPSILON);
4440 }
4441
4442 #[test]
4443 fn trailing_stop_via_on_price() {
4444 let mut engine = TradeEngine::new();
4445 let effects = engine
4446 .apply_action(
4447 Action::Open {
4448 symbol: "EURUSD".into(),
4449 side: Side::Buy,
4450 order_type: OrderType::Market,
4451 price: Some(1.0850),
4452 size: 1.0,
4453 stoploss: None,
4454 targets: vec![],
4455 rules: vec![RuleConfig::TrailingStop { distance: 0.0020 }],
4456 group: None,
4457 trade_id: None,
4458 },
4459 ts(10, 0, 0),
4460 )
4461 .unwrap();
4462 let id = match &effects[0] {
4463 Effect::PositionOpened { id } => id.clone(),
4464 _ => panic!(),
4465 };
4466
4467 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 1, 0)));
4469 assert!(effects.is_empty());
4470
4471 let effects = engine.on_price("e("EURUSD", 1.0882, 1.0884, ts(10, 2, 0)));
4473 assert!(effects.is_empty());
4474
4475 let effects = engine.on_price("e("EURUSD", 1.0879, 1.0881, ts(10, 3, 0)));
4477 assert!(effects.iter().any(|e| matches!(
4478 e,
4479 Effect::PositionClosed {
4480 reason: CloseReason::TrailingStop,
4481 ..
4482 }
4483 )));
4484
4485 assert_eq!(
4486 engine.get_position(&id).unwrap().data.status,
4487 PositionStatus::Closed
4488 );
4489 }
4490
4491 #[test]
4492 fn add_and_remove_target_via_action() {
4493 let mut engine = TradeEngine::new();
4494 let effects = engine
4495 .apply_action(
4496 Action::Open {
4497 symbol: "EURUSD".into(),
4498 side: Side::Buy,
4499 order_type: OrderType::Market,
4500 price: Some(1.0850),
4501 size: 1.0,
4502 stoploss: None,
4503 targets: vec![],
4504 rules: vec![],
4505 group: None,
4506 trade_id: None,
4507 },
4508 ts(10, 0, 0),
4509 )
4510 .unwrap();
4511 let id = match &effects[0] {
4512 Effect::PositionOpened { id } => id.clone(),
4513 _ => panic!(),
4514 };
4515
4516 engine
4517 .apply_action(
4518 Action::AddTarget {
4519 position_id: id.clone(),
4520 price: 1.0900,
4521 close_ratio: 0.5,
4522 },
4523 ts(10, 1, 0),
4524 )
4525 .unwrap();
4526
4527 let pos = engine.get_position(&id).unwrap();
4528 assert_eq!(pos.rules.len(), 1);
4529 assert_eq!(pos.rules[0].name(), "TakeProfit");
4530
4531 engine
4532 .apply_action(
4533 Action::RemoveTarget {
4534 position_id: id.clone(),
4535 price: 1.0900,
4536 },
4537 ts(10, 2, 0),
4538 )
4539 .unwrap();
4540
4541 let pos = engine.get_position(&id).unwrap();
4542 assert_eq!(pos.rules.len(), 0);
4543 }
4544
4545 #[test]
4546 fn modify_target_preserves_colocated_alerts_with_register_parity() {
4547 let open = Action::Open {
4548 symbol: "EURUSD".into(),
4549 side: Side::Buy,
4550 order_type: OrderType::Market,
4551 price: Some(1.0850),
4552 size: 1.0,
4553 stoploss: Some(1.0800),
4554 targets: vec![TargetSpec {
4555 price: 1.0900,
4556 close_ratio: 0.5,
4557 }],
4558 rules: vec![RuleConfig::BreakevenWhen {
4559 trigger_price: 1.0900,
4560 }],
4561 group: None,
4562 trade_id: None,
4563 };
4564 let mut tick_engine = TradeEngine::new();
4565 let mut register_engine = TradeEngine::with_alert_register();
4566
4567 let tick_id = match &tick_engine
4568 .apply_action(open.clone(), ts(10, 0, 0))
4569 .unwrap()[0]
4570 {
4571 Effect::PositionOpened { id } => id.clone(),
4572 effect => panic!("expected position open, got {effect:?}"),
4573 };
4574 let register_id = match ®ister_engine.apply_action(open, ts(10, 0, 0)).unwrap()[0] {
4575 Effect::PositionOpened { id } => id.clone(),
4576 effect => panic!("expected position open, got {effect:?}"),
4577 };
4578
4579 tick_engine
4580 .apply_action(
4581 Action::ModifyTarget {
4582 position_id: tick_id.clone(),
4583 old_price: 1.0900,
4584 new_price: 1.0950,
4585 },
4586 ts(10, 1, 0),
4587 )
4588 .unwrap();
4589 register_engine
4590 .apply_action(
4591 Action::ModifyTarget {
4592 position_id: register_id.clone(),
4593 old_price: 1.0900,
4594 new_price: 1.0950,
4595 },
4596 ts(10, 1, 0),
4597 )
4598 .unwrap();
4599
4600 assert_eq!(
4601 register_engine
4602 .alert_register
4603 .as_ref()
4604 .unwrap()
4605 .alert_count(),
4606 3
4607 );
4608
4609 let old_target_quote = quote("EURUSD", 1.0900, 1.0902, ts(10, 2, 0));
4610 let tick_effects = tick_engine.on_price(&old_target_quote);
4611 let register_effects = register_engine.on_price(&old_target_quote);
4612 assert!(matches!(
4613 tick_effects.as_slice(),
4614 [Effect::StoplossModified { new_price, .. }]
4615 if (*new_price - 1.0850).abs() < f64::EPSILON
4616 ));
4617 assert!(matches!(
4618 register_effects.as_slice(),
4619 [Effect::StoplossModified { new_price, .. }]
4620 if (*new_price - 1.0850).abs() < f64::EPSILON
4621 ));
4622
4623 let new_target_quote = quote("EURUSD", 1.0950, 1.0952, ts(10, 3, 0));
4624 let tick_effects = tick_engine.on_price(&new_target_quote);
4625 let register_effects = register_engine.on_price(&new_target_quote);
4626 assert!(matches!(
4627 tick_effects.as_slice(),
4628 [Effect::PartialClose { ratio, .. }]
4629 if (*ratio - 0.5).abs() < f64::EPSILON
4630 ));
4631 assert!(matches!(
4632 register_effects.as_slice(),
4633 [Effect::PartialClose { ratio, .. }]
4634 if (*ratio - 0.5).abs() < f64::EPSILON
4635 ));
4636
4637 let stop_quote = quote("EURUSD", 1.0850, 1.0852, ts(10, 4, 0));
4638 let tick_effects = tick_engine.on_price(&stop_quote);
4639 let register_effects = register_engine.on_price(&stop_quote);
4640 assert!(matches!(
4641 tick_effects.as_slice(),
4642 [Effect::PositionClosed {
4643 reason: CloseReason::Stoploss,
4644 ..
4645 }]
4646 ));
4647 assert!(matches!(
4648 register_effects.as_slice(),
4649 [Effect::PositionClosed {
4650 reason: CloseReason::Stoploss,
4651 ..
4652 }]
4653 ));
4654 }
4655
4656 #[test]
4657 fn modify_target_preserves_ratio_rekeys_alert_and_rejects_invalid_state() {
4658 let mut engine = TradeEngine::with_alert_register();
4659 let effects = engine
4660 .apply_action(
4661 Action::Open {
4662 symbol: "EURUSD".into(),
4663 side: Side::Buy,
4664 order_type: OrderType::Market,
4665 price: Some(1.0850),
4666 size: 1.0,
4667 stoploss: None,
4668 targets: vec![
4669 TargetSpec {
4670 price: 1.0900,
4671 close_ratio: 0.3,
4672 },
4673 TargetSpec {
4674 price: 1.1000,
4675 close_ratio: 0.7,
4676 },
4677 ],
4678 rules: vec![],
4679 group: None,
4680 trade_id: None,
4681 },
4682 ts(10, 0, 0),
4683 )
4684 .unwrap();
4685 let id = match &effects[0] {
4686 Effect::PositionOpened { id } => id.clone(),
4687 other => panic!("expected position open, got {other:?}"),
4688 };
4689
4690 engine
4691 .apply_action(
4692 Action::ModifyTarget {
4693 position_id: id.clone(),
4694 old_price: 1.0900,
4695 new_price: 1.0950,
4696 },
4697 ts(10, 1, 0),
4698 )
4699 .unwrap();
4700
4701 let position = engine.get_position(&id).unwrap();
4702 assert!(matches!(
4703 &position.rules[0],
4704 Rule::TakeProfit {
4705 price,
4706 close_ratio,
4707 triggered: false,
4708 } if (*price - 1.0950).abs() < f64::EPSILON
4709 && (*close_ratio - 0.3).abs() < f64::EPSILON
4710 ));
4711 assert!(matches!(
4712 position.data.records.last(),
4713 Some((
4714 PositionRecord::TargetModified {
4715 from,
4716 to,
4717 close_ratio,
4718 },
4719 _
4720 )) if (*from - 1.0900).abs() < f64::EPSILON
4721 && (*to - 1.0950).abs() < f64::EPSILON
4722 && (*close_ratio - 0.3).abs() < f64::EPSILON
4723 ));
4724
4725 assert!(
4726 engine
4727 .on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 2, 0)))
4728 .is_empty()
4729 );
4730 let effects = engine.on_price("e("EURUSD", 1.0950, 1.0952, ts(10, 3, 0)));
4731 assert!(matches!(
4732 effects.as_slice(),
4733 [Effect::PartialClose { ratio, .. }] if (*ratio - 0.3).abs() < f64::EPSILON
4734 ));
4735
4736 assert!(matches!(
4737 engine.apply_action(
4738 Action::ModifyTarget {
4739 position_id: id.clone(),
4740 old_price: 1.0950,
4741 new_price: 1.0960,
4742 },
4743 ts(10, 4, 0),
4744 ),
4745 Err(CoreError::TargetAlreadyTriggered { .. })
4746 ));
4747 assert!(matches!(
4748 engine.apply_action(
4749 Action::ModifyTarget {
4750 position_id: id,
4751 old_price: 1.0910,
4752 new_price: 1.0960,
4753 },
4754 ts(10, 5, 0),
4755 ),
4756 Err(CoreError::TargetNotFound { .. })
4757 ));
4758 }
4759
4760 #[test]
4761 fn error_on_missing_position() {
4762 let mut engine = TradeEngine::new();
4763 let result = engine.apply_action(
4764 Action::ClosePosition {
4765 position_id: "nonexistent".into(),
4766 },
4767 ts(10, 0, 0),
4768 );
4769 assert!(result.is_err());
4770 assert!(matches!(
4771 result.unwrap_err(),
4772 CoreError::PositionNotFound(_)
4773 ));
4774 }
4775
4776 #[test]
4777 fn error_on_market_order_no_price_no_quote() {
4778 let mut engine = TradeEngine::new();
4779 let result = engine.apply_action(
4780 Action::Open {
4781 symbol: "EURUSD".into(),
4782 side: Side::Buy,
4783 order_type: OrderType::Market,
4784 price: None,
4785 size: 1.0,
4786 stoploss: None,
4787 targets: vec![],
4788 rules: vec![],
4789 group: None,
4790 trade_id: None,
4791 },
4792 ts(10, 0, 0),
4793 );
4794 assert!(result.is_err());
4795 assert!(matches!(
4796 result.unwrap_err(),
4797 CoreError::NoPriceAvailable(_)
4798 ));
4799 }
4800
4801 #[test]
4804 fn fill_model_ask_only_sl_triggers_on_ask() {
4805 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
4809 let effects = engine
4810 .apply_action(
4811 Action::Open {
4812 symbol: "EURUSD".into(),
4813 side: Side::Buy,
4814 order_type: OrderType::Market,
4815 price: Some(1.0850),
4816 size: 1.0,
4817 stoploss: Some(1.0800),
4818 targets: vec![],
4819 rules: vec![],
4820 group: None,
4821 trade_id: None,
4822 },
4823 ts(10, 0, 0),
4824 )
4825 .unwrap();
4826 let id = match &effects[0] {
4827 Effect::PositionOpened { id } => id.clone(),
4828 _ => panic!(),
4829 };
4830
4831 let effects = engine.on_price("e("EURUSD", 1.0790, 1.0810, ts(10, 1, 0)));
4833 assert!(effects.is_empty());
4834 assert_eq!(
4835 engine.get_position(&id).unwrap().data.status,
4836 PositionStatus::Open
4837 );
4838
4839 let effects = engine.on_price("e("EURUSD", 1.0790, 1.0800, ts(10, 2, 0)));
4841 assert!(effects.iter().any(|e| matches!(
4842 e,
4843 Effect::PositionClosed {
4844 reason: CloseReason::Stoploss,
4845 ..
4846 }
4847 )));
4848 }
4849
4850 #[test]
4851 fn fill_model_ask_only_tp_triggers_on_ask() {
4852 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
4854 let _effects = engine
4855 .apply_action(
4856 Action::Open {
4857 symbol: "EURUSD".into(),
4858 side: Side::Buy,
4859 order_type: OrderType::Market,
4860 price: Some(1.0850),
4861 size: 1.0,
4862 stoploss: None,
4863 targets: vec![TargetSpec {
4864 price: 1.0900,
4865 close_ratio: 1.0,
4866 }],
4867 rules: vec![],
4868 group: None,
4869 trade_id: None,
4870 },
4871 ts(10, 0, 0),
4872 )
4873 .unwrap();
4874
4875 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0895, ts(10, 1, 0)));
4877 assert!(effects.is_empty());
4878
4879 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0900, ts(10, 2, 0)));
4881 assert!(effects.iter().any(|e| matches!(
4882 e,
4883 Effect::PositionClosed {
4884 reason: CloseReason::Target,
4885 ..
4886 }
4887 )));
4888 }
4889
4890 #[test]
4891 fn fill_model_ask_only_sell_sl_triggers_on_ask() {
4892 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
4895 engine
4896 .apply_action(
4897 Action::Open {
4898 symbol: "EURUSD".into(),
4899 side: Side::Sell,
4900 order_type: OrderType::Market,
4901 price: Some(1.0850),
4902 size: 1.0,
4903 stoploss: Some(1.0900),
4904 targets: vec![],
4905 rules: vec![],
4906 group: None,
4907 trade_id: None,
4908 },
4909 ts(10, 0, 0),
4910 )
4911 .unwrap();
4912
4913 let effects = engine.on_price("e("EURUSD", 1.0880, 1.0890, ts(10, 1, 0)));
4915 assert!(effects.is_empty());
4916
4917 let effects = engine.on_price("e("EURUSD", 1.0895, 1.0900, ts(10, 2, 0)));
4919 assert!(effects.iter().any(|e| matches!(
4920 e,
4921 Effect::PositionClosed {
4922 reason: CloseReason::Stoploss,
4923 ..
4924 }
4925 )));
4926 }
4927
4928 #[test]
4929 fn fill_model_mid_price_uses_midpoint() {
4930 let mut engine = TradeEngine::with_fill_model(FillModel::MidPrice);
4933 let effects = engine
4934 .apply_action(
4935 Action::Open {
4936 symbol: "EURUSD".into(),
4937 side: Side::Buy,
4938 order_type: OrderType::Market,
4939 price: Some(1.0850),
4940 size: 1.0,
4941 stoploss: Some(1.0800),
4942 targets: vec![],
4943 rules: vec![],
4944 group: None,
4945 trade_id: None,
4946 },
4947 ts(10, 0, 0),
4948 )
4949 .unwrap();
4950 let id = match &effects[0] {
4951 Effect::PositionOpened { id } => id.clone(),
4952 _ => panic!(),
4953 };
4954
4955 let effects = engine.on_price("e("EURUSD", 1.0790, 1.0820, ts(10, 1, 0)));
4957 assert!(effects.is_empty());
4958 assert_eq!(
4959 engine.get_position(&id).unwrap().data.status,
4960 PositionStatus::Open
4961 );
4962
4963 let effects = engine.on_price("e("EURUSD", 1.0790, 1.0810, ts(10, 2, 0)));
4965 assert!(effects.iter().any(|e| matches!(
4966 e,
4967 Effect::PositionClosed {
4968 reason: CloseReason::Stoploss,
4969 ..
4970 }
4971 )));
4972 }
4973
4974 #[test]
4975 fn fill_model_ask_only_limit_sell_fills_on_ask() {
4976 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
4979 let effects = engine
4980 .apply_action(
4981 Action::Open {
4982 symbol: "EURUSD".into(),
4983 side: Side::Sell,
4984 order_type: OrderType::Limit,
4985 price: Some(1.0900),
4986 size: 1.0,
4987 stoploss: None,
4988 targets: vec![],
4989 rules: vec![],
4990 group: None,
4991 trade_id: None,
4992 },
4993 ts(9, 0, 0),
4994 )
4995 .unwrap();
4996 let id = match &effects[0] {
4997 Effect::OrderPlaced { id } => id.clone(),
4998 _ => panic!(),
4999 };
5000
5001 let effects = engine.on_price("e("EURUSD", 1.0905, 1.0895, ts(10, 0, 0)));
5003 assert!(effects.is_empty());
5004 assert_eq!(
5005 engine.get_position(&id).unwrap().data.status,
5006 PositionStatus::Pending
5007 );
5008
5009 let effects = engine.on_price("e("EURUSD", 1.0898, 1.0900, ts(10, 1, 0)));
5011 assert!(
5012 effects
5013 .iter()
5014 .any(|e| matches!(e, Effect::PositionOpened { .. }))
5015 );
5016 assert_eq!(
5017 engine.get_position(&id).unwrap().data.status,
5018 PositionStatus::Open
5019 );
5020 }
5021
5022 #[test]
5023 fn fill_model_mid_price_limit_buy_fills_on_mid() {
5024 let mut engine = TradeEngine::with_fill_model(FillModel::MidPrice);
5026 engine
5027 .apply_action(
5028 Action::Open {
5029 symbol: "EURUSD".into(),
5030 side: Side::Buy,
5031 order_type: OrderType::Limit,
5032 price: Some(1.0800),
5033 size: 1.0,
5034 stoploss: None,
5035 targets: vec![],
5036 rules: vec![],
5037 group: None,
5038 trade_id: None,
5039 },
5040 ts(9, 0, 0),
5041 )
5042 .unwrap();
5043
5044 let effects = engine.on_price("e("EURUSD", 1.0798, 1.0810, ts(10, 0, 0)));
5046 assert!(effects.is_empty());
5047
5048 let effects = engine.on_price("e("EURUSD", 1.0790, 1.0810, ts(10, 1, 0)));
5050 assert!(
5051 effects
5052 .iter()
5053 .any(|e| matches!(e, Effect::PositionOpened { .. }))
5054 );
5055 }
5056
5057 #[test]
5058 fn fill_model_ask_only_trailing_stop_tracks_ask() {
5059 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5062 engine
5063 .apply_action(
5064 Action::Open {
5065 symbol: "EURUSD".into(),
5066 side: Side::Buy,
5067 order_type: OrderType::Market,
5068 price: Some(1.0850),
5069 size: 1.0,
5070 stoploss: None,
5071 targets: vec![],
5072 rules: vec![RuleConfig::TrailingStop { distance: 0.0020 }],
5073 group: None,
5074 trade_id: None,
5075 },
5076 ts(10, 0, 0),
5077 )
5078 .unwrap();
5079
5080 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0910, ts(10, 1, 0)));
5082 assert!(effects.is_empty());
5083
5084 let effects = engine.on_price("e("EURUSD", 1.0890, 1.0895, ts(10, 2, 0)));
5087 assert!(effects.is_empty());
5088
5089 let effects = engine.on_price("e("EURUSD", 1.0880, 1.0889, ts(10, 3, 0)));
5091 assert!(effects.iter().any(|e| matches!(
5092 e,
5093 Effect::PositionClosed {
5094 reason: CloseReason::TrailingStop,
5095 ..
5096 }
5097 )));
5098 }
5099
5100 #[test]
5101 fn fill_model_default_is_bidask() {
5102 let engine = TradeEngine::new();
5103 assert_eq!(engine.fill_model, FillModel::BidAsk);
5104 }
5105
5106 #[test]
5107 fn fill_model_with_fill_model_constructor() {
5108 let engine = TradeEngine::with_fill_model(FillModel::MidPrice);
5109 assert_eq!(engine.fill_model, FillModel::MidPrice);
5110 }
5111
5112 #[test]
5113 fn askonly_sell_market_order_fills_at_bid() {
5114 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5117 engine.on_price("e("EURUSD", 1.0848, 1.0850, ts(9, 59, 0)));
5118
5119 let effects = engine
5120 .apply_action(
5121 Action::Open {
5122 symbol: "EURUSD".into(),
5123 side: Side::Sell,
5124 order_type: OrderType::Market,
5125 price: None,
5126 size: 1.0,
5127 stoploss: None,
5128 targets: vec![],
5129 rules: vec![],
5130 group: None,
5131 trade_id: None,
5132 },
5133 ts(10, 0, 0),
5134 )
5135 .unwrap();
5136
5137 let id = match &effects[0] {
5138 Effect::PositionOpened { id } => id.clone(),
5139 _ => panic!("expected PositionOpened"),
5140 };
5141
5142 let pos = engine.get_position(&id).unwrap();
5143 assert!(
5145 (pos.data.average_entry() - 1.0848).abs() < 1e-10,
5146 "Sell market order should fill at bid=1.0848, got {}",
5147 pos.data.average_entry()
5148 );
5149 }
5150
5151 #[test]
5152 fn askonly_partial_close_records_realistic_price() {
5153 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5157 let effects = engine
5158 .apply_action(
5159 Action::Open {
5160 symbol: "EURUSD".into(),
5161 side: Side::Buy,
5162 order_type: OrderType::Market,
5163 price: Some(1.0850),
5164 size: 1.0,
5165 stoploss: None,
5166 targets: vec![TargetSpec {
5167 price: 1.0900,
5168 close_ratio: 0.5,
5169 }],
5170 rules: vec![],
5171 group: None,
5172 trade_id: None,
5173 },
5174 ts(10, 0, 0),
5175 )
5176 .unwrap();
5177
5178 let id = match &effects[0] {
5179 Effect::PositionOpened { id } => id.clone(),
5180 _ => panic!("expected PositionOpened"),
5181 };
5182
5183 let _effects = engine.on_price("e("EURUSD", 1.0905, 1.0910, ts(10, 5, 0)));
5185
5186 let pos = engine.get_position(&id).unwrap();
5187 let partial_record = pos
5189 .data
5190 .records
5191 .iter()
5192 .find_map(|(rec, _ts)| match rec {
5193 PositionRecord::PartialClose { price, .. } => Some(*price),
5194 _ => None,
5195 })
5196 .expect("should have a PartialClose record");
5197
5198 assert!(
5200 (partial_record - 1.0905).abs() < 1e-10,
5201 "PartialClose should record bid=1.0905, got {partial_record}"
5202 );
5203 }
5204
5205 #[test]
5206 fn askonly_manual_partial_close_records_bid() {
5207 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5210 let effects = engine
5211 .apply_action(
5212 Action::Open {
5213 symbol: "EURUSD".into(),
5214 side: Side::Buy,
5215 order_type: OrderType::Market,
5216 price: Some(1.0850),
5217 size: 2.0,
5218 stoploss: None,
5219 targets: vec![],
5220 rules: vec![],
5221 group: None,
5222 trade_id: None,
5223 },
5224 ts(10, 0, 0),
5225 )
5226 .unwrap();
5227
5228 let id = match &effects[0] {
5229 Effect::PositionOpened { id } => id.clone(),
5230 _ => panic!("expected PositionOpened"),
5231 };
5232
5233 engine.on_price("e("EURUSD", 1.0870, 1.0880, ts(10, 5, 0)));
5235 let _effects = engine
5236 .apply_action(
5237 Action::ClosePartial {
5238 position_id: id.clone(),
5239 ratio: 0.5,
5240 },
5241 ts(10, 5, 0),
5242 )
5243 .unwrap();
5244
5245 let pos = engine.get_position(&id).unwrap();
5246 let partial_record = pos
5247 .data
5248 .records
5249 .iter()
5250 .find_map(|(rec, _ts)| match rec {
5251 PositionRecord::PartialClose { price, .. } => Some(*price),
5252 _ => None,
5253 })
5254 .expect("should have a PartialClose record");
5255
5256 assert!(
5258 (partial_record - 1.0870).abs() < 1e-10,
5259 "Manual partial close should record bid=1.0870, got {partial_record}"
5260 );
5261 }
5262
5263 #[test]
5264 fn midprice_sell_fills_at_bid() {
5265 let mut engine = TradeEngine::with_fill_model(FillModel::MidPrice);
5268 engine.on_price("e("EURUSD", 1.0848, 1.0852, ts(9, 59, 0)));
5269
5270 let effects = engine
5271 .apply_action(
5272 Action::Open {
5273 symbol: "EURUSD".into(),
5274 side: Side::Sell,
5275 order_type: OrderType::Market,
5276 price: None,
5277 size: 1.0,
5278 stoploss: None,
5279 targets: vec![],
5280 rules: vec![],
5281 group: None,
5282 trade_id: None,
5283 },
5284 ts(10, 0, 0),
5285 )
5286 .unwrap();
5287
5288 let id = match &effects[0] {
5289 Effect::PositionOpened { id } => id.clone(),
5290 _ => panic!("expected PositionOpened"),
5291 };
5292
5293 let pos = engine.get_position(&id).unwrap();
5294 assert!(
5296 (pos.data.average_entry() - 1.0848).abs() < 1e-10,
5297 "Sell market order in MidPrice should fill at bid=1.0848, got {}",
5298 pos.data.average_entry()
5299 );
5300 }
5301
5302 #[test]
5303 fn askonly_scale_in_sell_records_bid() {
5304 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5307 let effects = engine
5308 .apply_action(
5309 Action::Open {
5310 symbol: "EURUSD".into(),
5311 side: Side::Sell,
5312 order_type: OrderType::Market,
5313 price: Some(1.0850),
5314 size: 1.0,
5315 stoploss: None,
5316 targets: vec![],
5317 rules: vec![],
5318 group: None,
5319 trade_id: None,
5320 },
5321 ts(10, 0, 0),
5322 )
5323 .unwrap();
5324
5325 let id = match &effects[0] {
5326 Effect::PositionOpened { id } => id.clone(),
5327 _ => panic!("expected PositionOpened"),
5328 };
5329
5330 engine.on_price("e("EURUSD", 1.0830, 1.0840, ts(10, 5, 0)));
5332 let _effects = engine
5333 .apply_action(
5334 Action::ScaleIn {
5335 position_id: id.clone(),
5336 price: None,
5337 size: 1.0,
5338 trade_id: None,
5339 },
5340 ts(10, 5, 0),
5341 )
5342 .unwrap();
5343
5344 let pos = engine.get_position(&id).unwrap();
5345 let second_fill = &pos.data.entries[1];
5347 assert!(
5348 (second_fill.price - 1.0830).abs() < 1e-10,
5349 "Scale-in Sell should fill at bid=1.0830, got {}",
5350 second_fill.price
5351 );
5352 assert!(
5354 (pos.data.average_entry() - 1.0840).abs() < 1e-10,
5355 "average_entry should be 1.0840, got {}",
5356 pos.data.average_entry()
5357 );
5358 }
5359
5360 #[test]
5361 fn askonly_breakeven_uses_correct_average_entry() {
5362 let mut engine = TradeEngine::with_fill_model(FillModel::AskOnly);
5366 engine.on_price("e("EURUSD", 1.0848, 1.0850, ts(9, 59, 0)));
5367
5368 let effects = engine
5369 .apply_action(
5370 Action::Open {
5371 symbol: "EURUSD".into(),
5372 side: Side::Sell,
5373 order_type: OrderType::Market,
5374 price: None,
5375 size: 1.0,
5376 stoploss: Some(1.0900),
5377 targets: vec![],
5378 rules: vec![RuleConfig::BreakevenWhen {
5379 trigger_price: 1.0800,
5380 }],
5381 group: None,
5382 trade_id: None,
5383 },
5384 ts(10, 0, 0),
5385 )
5386 .unwrap();
5387
5388 let id = match &effects[0] {
5389 Effect::PositionOpened { id } => id.clone(),
5390 _ => panic!("expected PositionOpened"),
5391 };
5392
5393 let entry = engine.get_position(&id).unwrap().data.average_entry();
5395 assert!(
5396 (entry - 1.0848).abs() < 1e-10,
5397 "Entry should be bid=1.0848, got {entry}"
5398 );
5399
5400 let effects = engine.on_price("e("EURUSD", 1.0795, 1.0800, ts(10, 5, 0)));
5404
5405 assert!(
5407 effects.iter().any(|e| matches!(
5408 e,
5409 Effect::StoplossModified { new_price, .. } if (*new_price - 1.0848).abs() < 1e-10
5410 )),
5411 "Breakeven should move SL to average_entry=1.0848, effects: {effects:?}"
5412 );
5413
5414 let sl = engine.get_position(&id).unwrap().current_stoploss();
5415 assert!(
5416 (sl.unwrap() - 1.0848).abs() < 1e-10,
5417 "SL should be at average_entry=1.0848, got {sl:?}"
5418 );
5419 }
5420
5421 #[test]
5424 fn engine_with_register_open_registers_alerts() {
5425 let mut engine = TradeEngine::with_alert_register();
5426 let effects = engine
5427 .apply_action(
5428 Action::Open {
5429 symbol: "EURUSD".into(),
5430 side: Side::Buy,
5431 order_type: OrderType::Market,
5432 price: Some(1.0850),
5433 size: 1.0,
5434 stoploss: Some(1.0800),
5435 targets: vec![TargetSpec {
5436 price: 1.0900,
5437 close_ratio: 1.0,
5438 }],
5439 rules: vec![],
5440 group: None,
5441 trade_id: None,
5442 },
5443 ts(10, 0, 0),
5444 )
5445 .unwrap();
5446 assert_eq!(effects.len(), 1);
5447 assert!(matches!(effects[0], Effect::PositionOpened { .. }));
5448
5449 let id = match &effects[0] {
5451 Effect::PositionOpened { id } => id.clone(),
5452 _ => panic!(),
5453 };
5454
5455 let effects = engine.on_price("e("EURUSD", 1.0800, 1.0802, ts(10, 1, 0)));
5456 assert!(effects.iter().any(|e| matches!(
5457 e,
5458 Effect::PositionClosed {
5459 reason: CloseReason::Stoploss,
5460 ..
5461 }
5462 )));
5463 assert_eq!(
5464 engine.get_position(&id).unwrap().data.status,
5465 PositionStatus::Closed
5466 );
5467 }
5468
5469 #[test]
5470 fn engine_with_register_sl_triggers_via_register() {
5471 let mut engine = TradeEngine::with_alert_register();
5472 let effects = engine
5473 .apply_action(
5474 Action::Open {
5475 symbol: "EURUSD".into(),
5476 side: Side::Buy,
5477 order_type: OrderType::Market,
5478 price: Some(1.0850),
5479 size: 1.0,
5480 stoploss: Some(1.0800),
5481 targets: vec![],
5482 rules: vec![],
5483 group: None,
5484 trade_id: None,
5485 },
5486 ts(10, 0, 0),
5487 )
5488 .unwrap();
5489 let id = match &effects[0] {
5490 Effect::PositionOpened { id } => id.clone(),
5491 _ => panic!(),
5492 };
5493
5494 let effects = engine.on_price("e("EURUSD", 1.0840, 1.0842, ts(10, 0, 1)));
5496 assert!(effects.is_empty());
5497
5498 let effects = engine.on_price("e("EURUSD", 1.0800, 1.0802, ts(10, 0, 2)));
5500 assert_eq!(effects.len(), 1);
5501 assert!(matches!(
5502 effects[0],
5503 Effect::PositionClosed {
5504 reason: CloseReason::Stoploss,
5505 ..
5506 }
5507 ));
5508 assert_eq!(
5509 engine.get_position(&id).unwrap().data.status,
5510 PositionStatus::Closed
5511 );
5512 }
5513
5514 #[test]
5515 fn engine_with_register_tp_triggers_via_register() {
5516 let mut engine = TradeEngine::with_alert_register();
5517 let effects = engine
5518 .apply_action(
5519 Action::Open {
5520 symbol: "EURUSD".into(),
5521 side: Side::Buy,
5522 order_type: OrderType::Market,
5523 price: Some(1.0850),
5524 size: 1.0,
5525 stoploss: Some(1.0800),
5526 targets: vec![
5527 TargetSpec {
5528 price: 1.0900,
5529 close_ratio: 0.5,
5530 },
5531 TargetSpec {
5532 price: 1.0950,
5533 close_ratio: 0.5,
5534 },
5535 ],
5536 rules: vec![],
5537 group: None,
5538 trade_id: None,
5539 },
5540 ts(10, 0, 0),
5541 )
5542 .unwrap();
5543 let id = match &effects[0] {
5544 Effect::PositionOpened { id } => id.clone(),
5545 _ => panic!(),
5546 };
5547
5548 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 1, 0)));
5550 assert!(effects.iter().any(|e| matches!(
5551 e,
5552 Effect::PartialClose {
5553 reason: CloseReason::Target,
5554 ..
5555 }
5556 )));
5557 let pos = engine.get_position(&id).unwrap();
5558 assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
5559
5560 let effects = engine.on_price("e("EURUSD", 1.0950, 1.0952, ts(10, 2, 0)));
5562 assert!(effects.iter().any(|e| matches!(
5563 e,
5564 Effect::PositionClosed {
5565 reason: CloseReason::Target,
5566 ..
5567 }
5568 )));
5569 assert_eq!(
5570 engine.get_position(&id).unwrap().data.status,
5571 PositionStatus::Closed
5572 );
5573 }
5574
5575 #[test]
5576 fn engine_with_register_trailing_stop_works() {
5577 let mut engine = TradeEngine::with_alert_register();
5578 let effects = engine
5579 .apply_action(
5580 Action::Open {
5581 symbol: "EURUSD".into(),
5582 side: Side::Buy,
5583 order_type: OrderType::Market,
5584 price: Some(1.0850),
5585 size: 1.0,
5586 stoploss: None,
5587 targets: vec![],
5588 rules: vec![RuleConfig::TrailingStop { distance: 0.0020 }],
5589 group: None,
5590 trade_id: None,
5591 },
5592 ts(10, 0, 0),
5593 )
5594 .unwrap();
5595 let id = match &effects[0] {
5596 Effect::PositionOpened { id } => id.clone(),
5597 _ => panic!(),
5598 };
5599
5600 let effects = engine.on_price("e("EURUSD", 1.0870, 1.0872, ts(10, 0, 1)));
5602 assert!(effects.is_empty());
5603 let effects = engine.on_price("e("EURUSD", 1.0890, 1.0892, ts(10, 0, 2)));
5604 assert!(effects.is_empty());
5605
5606 let effects = engine.on_price("e("EURUSD", 1.0875, 1.0877, ts(10, 0, 3)));
5608 assert!(effects.is_empty());
5609
5610 let effects = engine.on_price("e("EURUSD", 1.0870, 1.0872, ts(10, 0, 4)));
5612 assert!(effects.iter().any(|e| matches!(
5613 e,
5614 Effect::PositionClosed {
5615 reason: CloseReason::TrailingStop,
5616 ..
5617 }
5618 )));
5619 assert_eq!(
5620 engine.get_position(&id).unwrap().data.status,
5621 PositionStatus::Closed
5622 );
5623 }
5624
5625 #[test]
5626 fn engine_with_register_modify_sl_reregisters() {
5627 let mut engine = TradeEngine::with_alert_register();
5628 let effects = engine
5629 .apply_action(
5630 Action::Open {
5631 symbol: "EURUSD".into(),
5632 side: Side::Buy,
5633 order_type: OrderType::Market,
5634 price: Some(1.0850),
5635 size: 1.0,
5636 stoploss: Some(1.0800),
5637 targets: vec![],
5638 rules: vec![],
5639 group: None,
5640 trade_id: None,
5641 },
5642 ts(10, 0, 0),
5643 )
5644 .unwrap();
5645 let id = match &effects[0] {
5646 Effect::PositionOpened { id } => id.clone(),
5647 _ => panic!(),
5648 };
5649
5650 engine
5652 .apply_action(
5653 Action::ModifyStoploss {
5654 position_id: id.clone(),
5655 price: 1.0820,
5656 },
5657 ts(10, 0, 1),
5658 )
5659 .unwrap();
5660
5661 let effects = engine.on_price("e("EURUSD", 1.0830, 1.0832, ts(10, 0, 2)));
5663 assert!(effects.is_empty());
5664
5665 let effects = engine.on_price("e("EURUSD", 1.0820, 1.0822, ts(10, 0, 3)));
5667 assert!(effects.iter().any(|e| matches!(
5668 e,
5669 Effect::PositionClosed {
5670 reason: CloseReason::Stoploss,
5671 ..
5672 }
5673 )));
5674 }
5675
5676 #[test]
5677 fn engine_with_register_close_deregisters() {
5678 let mut engine = TradeEngine::with_alert_register();
5679 let effects = engine
5680 .apply_action(
5681 Action::Open {
5682 symbol: "EURUSD".into(),
5683 side: Side::Buy,
5684 order_type: OrderType::Market,
5685 price: Some(1.0850),
5686 size: 1.0,
5687 stoploss: Some(1.0800),
5688 targets: vec![TargetSpec {
5689 price: 1.0900,
5690 close_ratio: 1.0,
5691 }],
5692 rules: vec![],
5693 group: None,
5694 trade_id: None,
5695 },
5696 ts(10, 0, 0),
5697 )
5698 .unwrap();
5699 let id = match &effects[0] {
5700 Effect::PositionOpened { id } => id.clone(),
5701 _ => panic!(),
5702 };
5703
5704 engine
5706 .apply_action(
5707 Action::ClosePosition {
5708 position_id: id.clone(),
5709 },
5710 ts(10, 0, 1),
5711 )
5712 .unwrap();
5713
5714 let effects = engine.on_price("e("EURUSD", 1.0750, 1.0752, ts(10, 0, 2)));
5716 assert!(effects.is_empty());
5717 let effects = engine.on_price("e("EURUSD", 1.0950, 1.0952, ts(10, 0, 3)));
5718 assert!(effects.is_empty());
5719 }
5720
5721 #[test]
5722 fn engine_with_register_pending_fill() {
5723 let mut engine = TradeEngine::with_alert_register();
5724 let effects = engine
5726 .apply_action(
5727 Action::Open {
5728 symbol: "EURUSD".into(),
5729 side: Side::Buy,
5730 order_type: OrderType::Limit,
5731 price: Some(1.0800),
5732 size: 1.0,
5733 stoploss: Some(1.0750),
5734 targets: vec![TargetSpec {
5735 price: 1.0900,
5736 close_ratio: 1.0,
5737 }],
5738 rules: vec![],
5739 group: None,
5740 trade_id: None,
5741 },
5742 ts(10, 0, 0),
5743 )
5744 .unwrap();
5745 assert!(matches!(effects[0], Effect::OrderPlaced { .. }));
5746 let id = match &effects[0] {
5747 Effect::OrderPlaced { id } => id.clone(),
5748 _ => panic!(),
5749 };
5750
5751 let effects = engine.on_price("e("EURUSD", 1.0848, 1.0850, ts(10, 0, 1)));
5753 assert!(effects.is_empty());
5754
5755 let effects = engine.on_price("e("EURUSD", 1.0798, 1.0800, ts(10, 0, 2)));
5757 assert!(
5758 effects
5759 .iter()
5760 .any(|e| matches!(e, Effect::PositionOpened { .. }))
5761 );
5762 assert_eq!(
5763 engine.get_position(&id).unwrap().data.status,
5764 PositionStatus::Open
5765 );
5766
5767 let effects = engine.on_price("e("EURUSD", 1.0750, 1.0752, ts(10, 0, 3)));
5769 assert!(effects.iter().any(|e| matches!(
5770 e,
5771 Effect::PositionClosed {
5772 reason: CloseReason::Stoploss,
5773 ..
5774 }
5775 )));
5776 }
5777
5778 #[test]
5779 fn engine_with_register_close_all_deregisters() {
5780 let mut engine = TradeEngine::with_alert_register();
5781 for i in 0..3 {
5783 engine
5784 .apply_action(
5785 Action::Open {
5786 symbol: "EURUSD".into(),
5787 side: Side::Buy,
5788 order_type: OrderType::Market,
5789 price: Some(1.0850),
5790 size: 1.0,
5791 stoploss: Some(1.0800),
5792 targets: vec![],
5793 rules: vec![],
5794 group: None,
5795 trade_id: None,
5796 },
5797 ts(10, 0, i),
5798 )
5799 .unwrap();
5800 }
5801
5802 engine.apply_action(Action::CloseAll, ts(10, 1, 0)).unwrap();
5804
5805 let effects = engine.on_price("e("EURUSD", 1.0750, 1.0752, ts(10, 2, 0)));
5807 assert!(effects.is_empty());
5808 }
5809
5810 #[test]
5811 fn engine_with_register_breakeven_reregisters_sl() {
5812 let mut engine = TradeEngine::with_alert_register();
5813 let effects = engine
5814 .apply_action(
5815 Action::Open {
5816 symbol: "EURUSD".into(),
5817 side: Side::Buy,
5818 order_type: OrderType::Market,
5819 price: Some(1.0850),
5820 size: 1.0,
5821 stoploss: Some(1.0800),
5822 targets: vec![],
5823 rules: vec![RuleConfig::BreakevenWhen {
5824 trigger_price: 1.0900,
5825 }],
5826 group: None,
5827 trade_id: None,
5828 },
5829 ts(10, 0, 0),
5830 )
5831 .unwrap();
5832 let id = match &effects[0] {
5833 Effect::PositionOpened { id } => id.clone(),
5834 _ => panic!(),
5835 };
5836
5837 let effects = engine.on_price("e("EURUSD", 1.0900, 1.0902, ts(10, 1, 0)));
5839 assert!(effects.iter().any(|e| matches!(
5840 e,
5841 Effect::StoplossModified { new_price, .. } if (*new_price - 1.0850).abs() < 1e-10
5842 )));
5843
5844 let effects = engine.on_price("e("EURUSD", 1.0860, 1.0862, ts(10, 2, 0)));
5846 assert!(effects.is_empty());
5847
5848 let effects = engine.on_price("e("EURUSD", 1.0850, 1.0852, ts(10, 3, 0)));
5850 assert!(effects.iter().any(|e| matches!(
5851 e,
5852 Effect::PositionClosed {
5853 reason: CloseReason::Stoploss,
5854 ..
5855 }
5856 )));
5857 assert_eq!(
5858 engine.get_position(&id).unwrap().data.status,
5859 PositionStatus::Closed
5860 );
5861 }
5862
5863 #[test]
5864 fn engine_with_register_matches_tickbytick_results() {
5865 let actions_and_prices: Vec<(Option<Action>, Option<PriceQuote>)> = vec![
5867 (
5869 Some(Action::Open {
5870 symbol: "EURUSD".into(),
5871 side: Side::Buy,
5872 order_type: OrderType::Market,
5873 price: Some(1.0850),
5874 size: 1.0,
5875 stoploss: Some(1.0800),
5876 targets: vec![
5877 TargetSpec {
5878 price: 1.0900,
5879 close_ratio: 0.5,
5880 },
5881 TargetSpec {
5882 price: 1.0950,
5883 close_ratio: 0.5,
5884 },
5885 ],
5886 rules: vec![],
5887 group: None,
5888 trade_id: None,
5889 }),
5890 None,
5891 ),
5892 (None, Some(quote("EURUSD", 1.0860, 1.0862, ts(10, 0, 1)))),
5894 (None, Some(quote("EURUSD", 1.0870, 1.0872, ts(10, 0, 2)))),
5895 (None, Some(quote("EURUSD", 1.0900, 1.0902, ts(10, 0, 3)))),
5897 (None, Some(quote("EURUSD", 1.0920, 1.0922, ts(10, 0, 4)))),
5899 (None, Some(quote("EURUSD", 1.0950, 1.0952, ts(10, 0, 5)))),
5901 ];
5902
5903 let mut engine_tick = TradeEngine::new();
5904 let mut engine_reg = TradeEngine::with_alert_register();
5905
5906 let mut effects_tick_all = Vec::new();
5907 let mut effects_reg_all = Vec::new();
5908
5909 for (action, price) in &actions_and_prices {
5910 if let Some(a) = action {
5911 let e1 = engine_tick.apply_action(a.clone(), ts(10, 0, 0)).unwrap();
5912 let e2 = engine_reg.apply_action(a.clone(), ts(10, 0, 0)).unwrap();
5913 effects_tick_all.extend(e1);
5914 effects_reg_all.extend(e2);
5915 }
5916 if let Some(q) = price {
5917 let e1 = engine_tick.on_price(q);
5918 let e2 = engine_reg.on_price(q);
5919 effects_tick_all.extend(e1);
5920 effects_reg_all.extend(e2);
5921 }
5922 }
5923
5924 assert_eq!(
5926 effects_tick_all.len(),
5927 effects_reg_all.len(),
5928 "Effect count mismatch: tick={}, reg={}\ntick: {:?}\nreg: {:?}",
5929 effects_tick_all.len(),
5930 effects_reg_all.len(),
5931 effects_tick_all,
5932 effects_reg_all,
5933 );
5934
5935 let tick_positions: Vec<_> = engine_tick.closed_positions();
5937 let reg_positions: Vec<_> = engine_reg.closed_positions();
5938 assert_eq!(tick_positions.len(), reg_positions.len());
5939 }
5940
5941 #[test]
5942 fn invalid_open_prices_rules_duplicates_and_tiny_sizes_are_atomic() {
5943 let invalid_actions = vec![
5944 Action::Open {
5945 symbol: "EURUSD".into(),
5946 side: Side::Buy,
5947 order_type: OrderType::Market,
5948 price: Some(f64::NAN),
5949 size: 1.0,
5950 stoploss: None,
5951 targets: vec![],
5952 rules: vec![],
5953 group: None,
5954 trade_id: None,
5955 },
5956 Action::Open {
5957 symbol: "EURUSD".into(),
5958 side: Side::Buy,
5959 order_type: OrderType::Market,
5960 price: Some(100.0),
5961 size: position_size_tolerance(1.0),
5962 stoploss: None,
5963 targets: vec![],
5964 rules: vec![],
5965 group: None,
5966 trade_id: None,
5967 },
5968 Action::Open {
5969 symbol: "EURUSD".into(),
5970 side: Side::Buy,
5971 order_type: OrderType::Market,
5972 price: Some(100.0),
5973 size: 1.0,
5974 stoploss: Some(100.0),
5975 targets: vec![],
5976 rules: vec![],
5977 group: None,
5978 trade_id: None,
5979 },
5980 Action::Open {
5981 symbol: "EURUSD".into(),
5982 side: Side::Buy,
5983 order_type: OrderType::Market,
5984 price: Some(100.0),
5985 size: 1.0,
5986 stoploss: None,
5987 targets: vec![TargetSpec {
5988 price: 99.0,
5989 close_ratio: 1.0,
5990 }],
5991 rules: vec![],
5992 group: None,
5993 trade_id: None,
5994 },
5995 Action::Open {
5996 symbol: "EURUSD".into(),
5997 side: Side::Buy,
5998 order_type: OrderType::Market,
5999 price: Some(100.0),
6000 size: 1.0,
6001 stoploss: None,
6002 targets: vec![TargetSpec {
6003 price: 101.0,
6004 close_ratio: 0.5,
6005 }],
6006 rules: vec![RuleConfig::TakeProfit {
6007 price: 101.0,
6008 close_ratio: 0.5,
6009 }],
6010 group: None,
6011 trade_id: None,
6012 },
6013 Action::Open {
6014 symbol: "EURUSD".into(),
6015 side: Side::Buy,
6016 order_type: OrderType::Market,
6017 price: Some(100.0),
6018 size: 1.0,
6019 stoploss: None,
6020 targets: vec![],
6021 rules: vec![RuleConfig::TrailingStop { distance: 100.0 }],
6022 group: None,
6023 trade_id: None,
6024 },
6025 Action::Open {
6026 symbol: "EURUSD".into(),
6027 side: Side::Buy,
6028 order_type: OrderType::Market,
6029 price: Some(100.0),
6030 size: 1.0,
6031 stoploss: None,
6032 targets: vec![],
6033 rules: vec![RuleConfig::TimeExit { max_seconds: 0 }],
6034 group: None,
6035 trade_id: None,
6036 },
6037 ];
6038
6039 let mut engine = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
6040 for action in invalid_actions {
6041 assert!(matches!(
6042 engine.apply_action(action, ts(10, 0, 0)),
6043 Err(CoreError::InvalidAction(_))
6044 ));
6045 assert!(engine.manager.is_empty());
6046 }
6047
6048 let effects = engine
6049 .apply_action(
6050 Action::Open {
6051 symbol: "EURUSD".into(),
6052 side: Side::Buy,
6053 order_type: OrderType::Market,
6054 price: Some(100.0),
6055 size: 1.0,
6056 stoploss: Some(99.0),
6057 targets: vec![TargetSpec {
6058 price: 101.0,
6059 close_ratio: 1.0,
6060 }],
6061 rules: vec![],
6062 group: None,
6063 trade_id: None,
6064 },
6065 ts(10, 1, 0),
6066 )
6067 .unwrap();
6068 assert!(matches!(
6069 effects.as_slice(),
6070 [Effect::PositionOpened { id }] if id == "position:00000000"
6071 ));
6072 }
6073
6074 #[test]
6075 fn legacy_and_future_scale_in_validation_is_atomic() {
6076 let mut legacy = TradeEngine::with_fill_model_and_deterministic_ids(FillModel::BidAsk);
6077 let id = open_future_position(&mut legacy, Side::Buy, Some(95.0), vec![]);
6078 let before = legacy.get_position(&id).unwrap();
6079 let before_entries = before.data.entries.len();
6080 let before_records = before.data.records.len();
6081 let before_trade_id = before.data.trade_id.clone();
6082
6083 for (price, size) in [
6084 (Some(f64::NAN), 1.0),
6085 (Some(101.0), f64::NAN),
6086 (Some(101.0), 0.0),
6087 (Some(101.0), position_size_tolerance(1.0)),
6088 ] {
6089 assert!(matches!(
6090 legacy.apply_action(
6091 Action::ScaleIn {
6092 position_id: id.clone(),
6093 price,
6094 size,
6095 trade_id: Some("scale-trade".into()),
6096 },
6097 ts(10, 1, 0),
6098 ),
6099 Err(CoreError::InvalidAction(_))
6100 ));
6101 let position = legacy.get_position(&id).unwrap();
6102 assert_eq!(position.data.entries.len(), before_entries);
6103 assert_eq!(position.data.records.len(), before_records);
6104 assert_eq!(position.data.trade_id, before_trade_id);
6105 }
6106
6107 let mut future = legacy.clone();
6108 let result = future.apply_priced_future_action(
6109 Action::ScaleIn {
6110 position_id: id.clone(),
6111 price: Some(f64::INFINITY),
6112 size: 1.0,
6113 trade_id: Some("future-scale".into()),
6114 },
6115 "e("EURUSD", 100.0, 100.1, ts(10, 2, 0)),
6116 execution(FillPurpose::MarketEntry, Side::Buy, 100.1),
6117 );
6118 assert!(matches!(
6119 result,
6120 Err(FutureApplyError::Core(CoreError::InvalidAction(_)))
6121 ));
6122 let position = future.get_position(&id).unwrap();
6123 assert_eq!(position.data.entries.len(), before_entries);
6124 assert_eq!(position.data.records.len(), before_records);
6125 assert_eq!(position.data.trade_id, before_trade_id);
6126 }
6127
6128 #[test]
6129 fn target_and_rule_mutations_reject_duplicates_and_terminal_positions_atomically() {
6130 let mut engine = TradeEngine::with_alert_register();
6131 let effects = engine
6132 .apply_action(
6133 Action::Open {
6134 symbol: "EURUSD".into(),
6135 side: Side::Buy,
6136 order_type: OrderType::Market,
6137 price: Some(100.0),
6138 size: 1.0,
6139 stoploss: Some(95.0),
6140 targets: vec![
6141 TargetSpec {
6142 price: 105.0,
6143 close_ratio: 0.5,
6144 },
6145 TargetSpec {
6146 price: 110.0,
6147 close_ratio: 0.5,
6148 },
6149 ],
6150 rules: vec![],
6151 group: None,
6152 trade_id: None,
6153 },
6154 ts(10, 0, 0),
6155 )
6156 .unwrap();
6157 let id = match &effects[0] {
6158 Effect::PositionOpened { id } => id.clone(),
6159 effect => panic!("unexpected effect: {effect:?}"),
6160 };
6161 let before_rules = format!("{:?}", engine.get_position(&id).unwrap().rules);
6162 let before_records = engine.get_position(&id).unwrap().data.records.len();
6163
6164 for action in [
6165 Action::AddTarget {
6166 position_id: id.clone(),
6167 price: 105.0,
6168 close_ratio: 0.25,
6169 },
6170 Action::AddRule {
6171 position_id: id.clone(),
6172 rule: RuleConfig::TakeProfit {
6173 price: 110.0,
6174 close_ratio: 0.25,
6175 },
6176 },
6177 Action::ModifyTarget {
6178 position_id: id.clone(),
6179 old_price: 105.0,
6180 new_price: 110.0,
6181 },
6182 ] {
6183 assert!(matches!(
6184 engine.apply_action(action, ts(10, 1, 0)),
6185 Err(CoreError::InvalidAction(_))
6186 ));
6187 let position = engine.get_position(&id).unwrap();
6188 assert_eq!(format!("{:?}", position.rules), before_rules);
6189 assert_eq!(position.data.records.len(), before_records);
6190 }
6191
6192 engine
6193 .apply_action(
6194 Action::ClosePosition {
6195 position_id: id.clone(),
6196 },
6197 ts(10, 2, 0),
6198 )
6199 .unwrap();
6200 let closed_rules = format!("{:?}", engine.get_position(&id).unwrap().rules);
6201 let closed_records = engine.get_position(&id).unwrap().data.records.len();
6202 let closed_origin = engine.get_position(&id).unwrap().data.stop_origin;
6203 for action in [
6204 Action::ModifyStoploss {
6205 position_id: id.clone(),
6206 price: 94.0,
6207 },
6208 Action::MoveStoplossToEntry {
6209 position_id: id.clone(),
6210 },
6211 Action::AddTarget {
6212 position_id: id.clone(),
6213 price: 120.0,
6214 close_ratio: 1.0,
6215 },
6216 Action::RemoveTarget {
6217 position_id: id.clone(),
6218 price: 105.0,
6219 },
6220 Action::AddRule {
6221 position_id: id.clone(),
6222 rule: RuleConfig::TrailingStop { distance: 1.0 },
6223 },
6224 Action::RemoveRule {
6225 position_id: id.clone(),
6226 rule_name: "FixedStoploss".into(),
6227 },
6228 ] {
6229 assert!(matches!(
6230 engine.apply_action(action, ts(10, 3, 0)),
6231 Err(CoreError::InvalidState { .. })
6232 ));
6233 let position = engine.get_position(&id).unwrap();
6234 assert_eq!(format!("{:?}", position.rules), closed_rules);
6235 assert_eq!(position.data.records.len(), closed_records);
6236 assert_eq!(position.data.stop_origin, closed_origin);
6237 }
6238 }
6239
6240 #[test]
6241 fn move_and_bulk_stop_changes_match_tick_and_indexed_evaluation() {
6242 let open = Action::Open {
6243 symbol: "EURUSD".into(),
6244 side: Side::Buy,
6245 order_type: OrderType::Market,
6246 price: Some(100.0),
6247 size: 1.0,
6248 stoploss: Some(95.0),
6249 targets: vec![],
6250 rules: vec![],
6251 group: Some("g".into()),
6252 trade_id: None,
6253 };
6254
6255 for mutation in [
6256 Action::MoveStoplossToEntry {
6257 position_id: String::new(),
6258 },
6259 Action::ModifyAllStoploss {
6260 symbol: "EURUSD".into(),
6261 price: 98.0,
6262 },
6263 Action::ModifyAllStoplossInGroup {
6264 group_id: "g".into(),
6265 price: 97.0,
6266 },
6267 ] {
6268 let mut tick_engine = TradeEngine::new();
6269 let mut indexed_engine = TradeEngine::with_alert_register();
6270 let tick_id = match tick_engine
6271 .apply_action(open.clone(), ts(10, 0, 0))
6272 .unwrap()
6273 .remove(0)
6274 {
6275 Effect::PositionOpened { id } => id,
6276 effect => panic!("unexpected effect: {effect:?}"),
6277 };
6278 let indexed_id = match indexed_engine
6279 .apply_action(open.clone(), ts(10, 0, 0))
6280 .unwrap()
6281 .remove(0)
6282 {
6283 Effect::PositionOpened { id } => id,
6284 effect => panic!("unexpected effect: {effect:?}"),
6285 };
6286
6287 let tick_mutation = match &mutation {
6288 Action::MoveStoplossToEntry { .. } => Action::MoveStoplossToEntry {
6289 position_id: tick_id.clone(),
6290 },
6291 action => action.clone(),
6292 };
6293 let indexed_mutation = match &mutation {
6294 Action::MoveStoplossToEntry { .. } => Action::MoveStoplossToEntry {
6295 position_id: indexed_id.clone(),
6296 },
6297 action => action.clone(),
6298 };
6299 tick_engine
6300 .apply_action(tick_mutation, ts(10, 1, 0))
6301 .unwrap();
6302 indexed_engine
6303 .apply_action(indexed_mutation, ts(10, 1, 0))
6304 .unwrap();
6305
6306 let trigger = match mutation {
6307 Action::MoveStoplossToEntry { .. } => 99.5,
6308 Action::ModifyAllStoploss { .. } => 97.5,
6309 Action::ModifyAllStoplossInGroup { .. } => 96.5,
6310 _ => unreachable!(),
6311 };
6312 let quote = quote("EURUSD", trigger, trigger, ts(10, 2, 0));
6313 let tick_effects = tick_engine.on_price("e);
6314 let indexed_effects = indexed_engine.on_price("e);
6315 assert_eq!(tick_effects.len(), indexed_effects.len());
6316 assert_eq!(
6317 tick_engine.get_position(&tick_id).unwrap().data.status,
6318 PositionStatus::Closed
6319 );
6320 assert_eq!(
6321 indexed_engine
6322 .get_position(&indexed_id)
6323 .unwrap()
6324 .data
6325 .status,
6326 PositionStatus::Closed
6327 );
6328 }
6329 }
6330
6331 #[test]
6332 fn mixed_side_bulk_stop_rejection_is_atomic() {
6333 let mut engine = TradeEngine::with_alert_register();
6334 let buy_id = open_future_position(&mut engine, Side::Buy, Some(95.0), vec![]);
6335 let sell_id = open_future_position(&mut engine, Side::Sell, Some(105.0), vec![]);
6336 let buy_records = engine.get_position(&buy_id).unwrap().data.records.len();
6337 let sell_records = engine.get_position(&sell_id).unwrap().data.records.len();
6338
6339 assert!(matches!(
6340 engine.apply_action(
6341 Action::ModifyAllStoploss {
6342 symbol: "EURUSD".into(),
6343 price: 99.0,
6344 },
6345 ts(10, 1, 0),
6346 ),
6347 Err(CoreError::InvalidAction(_))
6348 ));
6349 assert_eq!(
6350 engine.get_position(&buy_id).unwrap().current_stoploss(),
6351 Some(95.0)
6352 );
6353 assert_eq!(
6354 engine.get_position(&sell_id).unwrap().current_stoploss(),
6355 Some(105.0)
6356 );
6357 assert_eq!(
6358 engine.get_position(&buy_id).unwrap().data.records.len(),
6359 buy_records
6360 );
6361 assert_eq!(
6362 engine.get_position(&sell_id).unwrap().data.records.len(),
6363 sell_records
6364 );
6365 }
6366
6367 #[test]
6368 fn engine_with_register_sell_sl_triggers() {
6369 let mut engine = TradeEngine::with_alert_register();
6370 let effects = engine
6371 .apply_action(
6372 Action::Open {
6373 symbol: "EURUSD".into(),
6374 side: Side::Sell,
6375 order_type: OrderType::Market,
6376 price: Some(1.0850),
6377 size: 1.0,
6378 stoploss: Some(1.0900),
6379 targets: vec![],
6380 rules: vec![],
6381 group: None,
6382 trade_id: None,
6383 },
6384 ts(10, 0, 0),
6385 )
6386 .unwrap();
6387 let id = match &effects[0] {
6388 Effect::PositionOpened { id } => id.clone(),
6389 _ => panic!(),
6390 };
6391
6392 let effects = engine.on_price("e("EURUSD", 1.0898, 1.0900, ts(10, 0, 1)));
6394 assert!(effects.iter().any(|e| matches!(
6395 e,
6396 Effect::PositionClosed {
6397 reason: CloseReason::Stoploss,
6398 ..
6399 }
6400 )));
6401 assert_eq!(
6402 engine.get_position(&id).unwrap().data.status,
6403 PositionStatus::Closed
6404 );
6405 }
6406}