Skip to main content

qs_core/
engine.rs

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