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