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