Skip to main content

qs_core/
position.rs

1//! Position — the atomic unit of market exposure.
2//!
3//! A `Position` represents a single directional exposure on a single symbol.
4//! It can be filled in one shot or scaled into over time (multiple [`Fill`]s).
5//! Management rules are stored alongside the position data and evaluated on
6//! every price tick by the engine.
7
8use chrono::NaiveDateTime;
9use serde::{Deserialize, Serialize};
10
11use crate::rules::{PositionView, Rule};
12use crate::types::{
13    CloseReason, Effect, Fill, FillModel, FillPurpose, FutureIntent, GroupId, OrderType,
14    PositionId, PositionRecord, PositionStatus, PriceQuote, Side, StopOrigin, TradeId,
15    position_size_tolerance,
16};
17
18/// Core position data — the pure state without rules.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(from = "PositionDataSerde")]
21pub struct PositionData {
22    /// Unique identifier.
23    pub id: PositionId,
24
25    /// Instrument symbol (e.g. "EURUSD", "XAUUSD").
26    pub symbol: String,
27
28    /// Trade direction.
29    pub side: Side,
30
31    /// How the order was placed.
32    pub order_type: OrderType,
33
34    /// Current lifecycle status.
35    pub status: PositionStatus,
36
37    /// For Limit/Stop orders: the price at which the order should fill.
38    pub pending_price: Option<f64>,
39
40    /// Intended order size (lots / units).
41    pub size: f64,
42
43    /// Actual execution fills (one for market, potentially many for scale-in).
44    pub entries: Vec<Fill>,
45
46    /// Fraction of all entered size still open (1.0 = full, 0.0 = closed).
47    ///
48    /// Retained for wire compatibility and rule views. Absolute sizes are the
49    /// source of truth and every core mutation keeps this value synchronized.
50    pub remaining_ratio: f64,
51
52    /// Absolute size closed across all partial and full exits.
53    ///
54    /// Entry size is the sum of `entries`; open size is derived as entered size
55    /// minus this value. Older serialized positions infer this from
56    /// `remaining_ratio` during deserialization.
57    pub closed_size: f64,
58
59    /// Cost basis assigned to the inventory that is still open.
60    ///
61    /// Positions use average-cost accounting: every close releases
62    /// `average_entry * close_size` from this value, while a scale-in adds only
63    /// the new fill's value. Historical `entries` remain unchanged for audit.
64    /// Older serialized positions infer this from their historical weighted
65    /// average and remaining size.
66    pub open_entry_value: f64,
67
68    /// Number of take-profit levels that have been hit.  Used by
69    /// `BreakevenAfterTargets` rule.
70    pub target_hits: u32,
71
72    /// When the position first filled.
73    pub open_ts: Option<NaiveDateTime>,
74
75    /// When the position was fully closed.
76    pub close_ts: Option<NaiveDateTime>,
77
78    /// Optional group for per-signal-source tracking and group-level actions.
79    #[serde(default)]
80    pub group: Option<GroupId>,
81
82    /// Optional application-defined trade identity.
83    ///
84    /// Parsers mint a stable `TradeId` (for example, `chat_id:msg_id`) and
85    /// reference it from later management signals via `PositionRef::ByTradeId`.
86    /// When `None`, management signals must use bulk references or the
87    /// engine's `PositionId`.
88    #[serde(default)]
89    pub trade_id: Option<TradeId>,
90
91    /// Provenance of the current fixed protective stop.
92    #[serde(default)]
93    pub stop_origin: Option<crate::types::StopOrigin>,
94
95    /// Immutable audit trail.
96    pub records: Vec<(PositionRecord, NaiveDateTime)>,
97}
98
99/// A position: data + composable management rules.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Position {
102    pub data: PositionData,
103    pub rules: Vec<Rule>,
104}
105
106#[derive(Deserialize)]
107struct PositionDataSerde {
108    id: PositionId,
109    symbol: String,
110    side: Side,
111    order_type: OrderType,
112    status: PositionStatus,
113    pending_price: Option<f64>,
114    size: f64,
115    entries: Vec<Fill>,
116    remaining_ratio: f64,
117    #[serde(default)]
118    closed_size: Option<f64>,
119    #[serde(default)]
120    open_entry_value: Option<f64>,
121    target_hits: u32,
122    open_ts: Option<NaiveDateTime>,
123    close_ts: Option<NaiveDateTime>,
124    #[serde(default)]
125    group: Option<GroupId>,
126    #[serde(default)]
127    trade_id: Option<TradeId>,
128    #[serde(default)]
129    stop_origin: Option<StopOrigin>,
130    records: Vec<(PositionRecord, NaiveDateTime)>,
131}
132
133impl From<PositionDataSerde> for PositionData {
134    fn from(value: PositionDataSerde) -> Self {
135        let entered_size: f64 = value.entries.iter().map(|fill| fill.size).sum();
136        let inferred_closed_size = entered_size * (1.0 - value.remaining_ratio.clamp(0.0, 1.0));
137        let closed_size = value
138            .closed_size
139            .unwrap_or(inferred_closed_size)
140            .max(0.0)
141            .min(entered_size.max(0.0));
142        let remaining_size = (entered_size - closed_size).max(0.0);
143        let remaining_ratio = if entered_size > 0.0 {
144            remaining_size / entered_size
145        } else {
146            value.remaining_ratio
147        };
148        let historical_entry_value: f64 = value
149            .entries
150            .iter()
151            .map(|fill| fill.price * fill.size)
152            .sum();
153        let inferred_open_entry_value = if entered_size > 0.0 {
154            historical_entry_value * (remaining_size / entered_size)
155        } else {
156            0.0
157        };
158        let open_entry_value = if remaining_size <= position_size_tolerance(entered_size) {
159            0.0
160        } else {
161            value
162                .open_entry_value
163                .filter(|basis| basis.is_finite() && *basis >= 0.0)
164                .unwrap_or(inferred_open_entry_value)
165        };
166
167        Self {
168            id: value.id,
169            symbol: value.symbol,
170            side: value.side,
171            order_type: value.order_type,
172            status: value.status,
173            pending_price: value.pending_price,
174            size: value.size,
175            entries: value.entries,
176            remaining_ratio,
177            closed_size,
178            open_entry_value,
179            target_hits: value.target_hits,
180            open_ts: value.open_ts,
181            close_ts: value.close_ts,
182            group: value.group,
183            trade_id: value.trade_id,
184            stop_origin: value.stop_origin,
185            records: value.records,
186        }
187    }
188}
189
190// ─── PositionData helpers ───────────────────────────────────────────────────
191
192impl PositionData {
193    /// Average-cost entry price of the inventory that is still open.
194    ///
195    /// Historical fills are intentionally not re-averaged here: after a
196    /// partial close, only the remaining inventory basis participates in a
197    /// later scale-in and subsequent close.
198    pub fn average_entry(&self) -> f64 {
199        let remaining_size = self.remaining_size();
200        if remaining_size == 0.0 {
201            0.0
202        } else {
203            self.open_entry_value / remaining_size
204        }
205    }
206
207    /// Volume-weighted average across all historical entry fills.
208    pub fn historical_average_entry(&self) -> f64 {
209        let total_size = self.total_filled_size();
210        if total_size == 0.0 {
211            0.0
212        } else {
213            self.entries
214                .iter()
215                .map(|fill| fill.price * fill.size)
216                .sum::<f64>()
217                / total_size
218        }
219    }
220
221    /// Total filled size (sum of all fills).
222    pub fn total_filled_size(&self) -> f64 {
223        self.entries.iter().map(|f| f.size).sum()
224    }
225
226    /// Size still active in the market, derived from absolute quantities.
227    pub fn remaining_size(&self) -> f64 {
228        let entered_size = self.total_filled_size();
229        let remaining = (entered_size - self.closed_size).max(0.0);
230        if remaining <= position_size_tolerance(entered_size) {
231            0.0
232        } else {
233            remaining
234        }
235    }
236
237    /// Fraction of all entered size that is still open.
238    pub fn open_ratio(&self) -> f64 {
239        let entered_size = self.total_filled_size();
240        if entered_size <= 0.0 {
241            return 0.0;
242        }
243        self.remaining_size() / entered_size
244    }
245
246    /// Cap an original-entered-size close ratio to the exposure still open.
247    pub fn capped_close_ratio(&self, ratio: f64) -> f64 {
248        if !ratio.is_finite() || ratio <= 0.0 {
249            return 0.0;
250        }
251        ratio.min(self.open_ratio())
252    }
253
254    /// Absolute size represented by a close ratio, capped to open exposure.
255    pub fn close_size_for_ratio(&self, ratio: f64) -> f64 {
256        let actual_ratio = self.capped_close_ratio(ratio);
257        (self.total_filled_size() * actual_ratio).min(self.remaining_size())
258    }
259
260    fn sync_remaining_ratio(&mut self) {
261        let entered_size = self.total_filled_size().max(0.0);
262        self.closed_size = self.closed_size.max(0.0).min(entered_size);
263        self.remaining_ratio = if entered_size > 0.0 {
264            self.remaining_size() / entered_size
265        } else if self.status == PositionStatus::Pending {
266            1.0
267        } else {
268            0.0
269        };
270        if self.remaining_size() == 0.0 {
271            self.open_entry_value = 0.0;
272        }
273    }
274
275    /// Unrealised P&L at the given price.
276    pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
277        let entry = self.average_entry();
278        let size = self.remaining_size();
279        match self.side {
280            Side::Buy => (current_price - entry) * size,
281            Side::Sell => (entry - current_price) * size,
282        }
283    }
284
285    /// Whether the position is live (Open) and has remaining size.
286    pub fn is_active(&self) -> bool {
287        self.status == PositionStatus::Open && self.remaining_size() > 0.0
288    }
289
290    /// Add a fill (scale-in), preserving previously closed absolute size and
291    /// adding the fill only to active inventory cost basis.
292    pub fn add_fill(&mut self, fill: Fill) {
293        self.open_entry_value += fill.price * fill.size;
294        self.entries.push(fill);
295        self.sync_remaining_ratio();
296    }
297
298    /// Replace the most recent entry fill price and timestamp.
299    ///
300    /// Future-quote backtests use this after a pending order triggers so the
301    /// engine's average entry matches the authoritative gap-aware execution
302    /// fill produced by the execution pricer. Returns `false` when no fill
303    /// exists or the replacement is invalid.
304    pub fn replace_latest_fill_execution(&mut self, price: f64, ts: NaiveDateTime) -> bool {
305        if !price.is_finite() || price <= 0.0 {
306            return false;
307        }
308        let Some(fill) = self.entries.last_mut() else {
309            return false;
310        };
311        self.open_entry_value += (price - fill.price) * fill.size;
312        fill.price = price;
313        fill.ts = ts;
314        true
315    }
316
317    /// Replace the latest entry fill and its audit record.
318    ///
319    /// Future-quote executors use this after the engine transitions a pending
320    /// order to `Open`, keeping core position state synchronized with the
321    /// externally calculated gap/improvement execution price.
322    pub fn synchronize_latest_fill(&mut self, fill: Fill) -> bool {
323        let Some(latest) = self.entries.last_mut() else {
324            return false;
325        };
326        self.open_entry_value += fill.price * fill.size - latest.price * latest.size;
327        *latest = fill.clone();
328        self.open_ts = Some(fill.ts);
329        self.sync_remaining_ratio();
330        if let Some((PositionRecord::Filled { fill: recorded }, _)) = self
331            .records
332            .iter_mut()
333            .rev()
334            .find(|(record, _)| matches!(record, PositionRecord::Filled { .. }))
335        {
336            *recorded = fill;
337        }
338        true
339    }
340
341    /// Record a partial close using an original-entered-size ratio.
342    ///
343    /// The close is capped to the absolute size still open. If no exposure
344    /// remains, the status is flipped to `Closed` and both absolute and ratio
345    /// accounting reach exact zero.
346    pub fn apply_partial_close(
347        &mut self,
348        ratio: f64,
349        price: f64,
350        reason: CloseReason,
351        ts: NaiveDateTime,
352    ) {
353        let actual_ratio = self.capped_close_ratio(ratio);
354        let entered_size = self.total_filled_size();
355        let open_size = self.remaining_size();
356        let close_size = self.close_size_for_ratio(actual_ratio);
357        let released_entry_value = self.average_entry() * close_size;
358        if open_size - close_size <= position_size_tolerance(entered_size) {
359            self.closed_size = entered_size;
360            self.open_entry_value = 0.0;
361        } else {
362            self.closed_size = (self.closed_size + close_size).min(entered_size);
363            self.open_entry_value = (self.open_entry_value - released_entry_value).max(0.0);
364        }
365        self.sync_remaining_ratio();
366        if reason == CloseReason::Target {
367            self.target_hits += 1;
368        }
369        self.records.push((
370            PositionRecord::PartialClose {
371                ratio: actual_ratio,
372                price,
373                reason,
374            },
375            ts,
376        ));
377        if self.remaining_size() == 0.0 {
378            self.closed_size = entered_size;
379            self.open_entry_value = 0.0;
380            self.remaining_ratio = 0.0;
381            self.status = PositionStatus::Closed;
382            self.close_ts = Some(ts);
383            self.records.push((PositionRecord::Closed { reason }, ts));
384        }
385    }
386
387    /// Mark the position as fully closed.
388    pub fn apply_full_close(&mut self, reason: CloseReason, ts: NaiveDateTime) {
389        self.closed_size = self.total_filled_size();
390        self.open_entry_value = 0.0;
391        self.remaining_ratio = 0.0;
392        self.status = PositionStatus::Closed;
393        self.close_ts = Some(ts);
394        if reason == CloseReason::Target {
395            self.target_hits += 1;
396        }
397        self.records.push((PositionRecord::Closed { reason }, ts));
398    }
399
400    /// Create a read-only view for rule evaluation.
401    pub fn view(&self) -> PositionView<'_> {
402        PositionView {
403            id: &self.id,
404            symbol: &self.symbol,
405            side: self.side,
406            status: self.status,
407            average_entry: self.average_entry(),
408            remaining_ratio: self.open_ratio(),
409            target_hits: self.target_hits,
410            open_ts: self.open_ts,
411        }
412    }
413}
414
415// ─── Position constructors & methods ────────────────────────────────────────
416
417impl Position {
418    /// Create a new position that is immediately filled (Market order).
419    pub fn new_market(
420        id: PositionId,
421        symbol: String,
422        side: Side,
423        fill: Fill,
424        rules: Vec<Rule>,
425    ) -> Self {
426        let open_ts = fill.ts;
427        let size = fill.size;
428        let open_entry_value = fill.price * fill.size;
429        Self {
430            data: PositionData {
431                id,
432                symbol: symbol.clone(),
433                side,
434                order_type: OrderType::Market,
435                status: PositionStatus::Open,
436                pending_price: None,
437                size,
438                entries: vec![fill],
439                remaining_ratio: 1.0,
440                closed_size: 0.0,
441                open_entry_value,
442                target_hits: 0,
443                open_ts: Some(open_ts),
444                close_ts: None,
445                group: None,
446                trade_id: None,
447                stop_origin: None,
448                records: vec![(
449                    PositionRecord::Created {
450                        symbol,
451                        side,
452                        order_type: OrderType::Market,
453                    },
454                    open_ts,
455                )],
456            },
457            rules,
458        }
459    }
460
461    /// Create a pending position (Limit or Stop order).
462    // Preserve the established public constructor shape for API compatibility.
463    #[allow(clippy::too_many_arguments)]
464    pub fn new_pending(
465        id: PositionId,
466        symbol: String,
467        side: Side,
468        order_type: OrderType,
469        pending_price: f64,
470        size: f64,
471        ts: NaiveDateTime,
472        rules: Vec<Rule>,
473    ) -> Self {
474        debug_assert!(
475            order_type == OrderType::Limit || order_type == OrderType::Stop,
476            "new_pending requires Limit or Stop order type"
477        );
478        Self {
479            data: PositionData {
480                id,
481                symbol: symbol.clone(),
482                side,
483                order_type,
484                status: PositionStatus::Pending,
485                pending_price: Some(pending_price),
486                size,
487                entries: Vec::new(),
488                remaining_ratio: 1.0,
489                closed_size: 0.0,
490                open_entry_value: 0.0,
491                target_hits: 0,
492                open_ts: None,
493                close_ts: None,
494                group: None,
495                trade_id: None,
496                stop_origin: None,
497                records: vec![(
498                    PositionRecord::Created {
499                        symbol,
500                        side,
501                        order_type,
502                    },
503                    ts,
504                )],
505            },
506            rules,
507        }
508    }
509
510    /// Attach or replace a `trade_id` on this position.
511    pub fn set_trade_id(&mut self, trade_id: Option<TradeId>) {
512        self.data.trade_id = trade_id;
513    }
514
515    /// Return the execution purpose when this pending order is triggered by
516    /// `quote`. This check is pure and is shared by Legacy and FutureQuote paths.
517    pub fn pending_fill_purpose(
518        &self,
519        quote: &PriceQuote,
520        model: FillModel,
521    ) -> Option<FillPurpose> {
522        if self.data.status != PositionStatus::Pending {
523            return None;
524        }
525        let pending_price = self.data.pending_price?;
526        let check = quote.fill_price(self.data.side, model);
527        let triggered = match (self.data.order_type, self.data.side) {
528            (OrderType::Limit, Side::Buy) => check <= pending_price,
529            (OrderType::Limit, Side::Sell) => check >= pending_price,
530            (OrderType::Stop, Side::Buy) => check >= pending_price,
531            (OrderType::Stop, Side::Sell) => check <= pending_price,
532            (OrderType::Market, _) => false,
533        };
534        if !triggered {
535            return None;
536        }
537        match self.data.order_type {
538            OrderType::Limit => Some(FillPurpose::LimitEntry),
539            OrderType::Stop => Some(FillPurpose::StopEntry),
540            OrderType::Market => None,
541        }
542    }
543
544    /// Commit a previously priced pending fill.
545    pub(crate) fn apply_pending_fill(&mut self, fill: Fill) -> bool {
546        if self.data.status != PositionStatus::Pending {
547            return false;
548        }
549        let ts = fill.ts;
550        self.data.status = PositionStatus::Open;
551        self.data.add_fill(fill.clone());
552        self.data.open_ts = Some(ts);
553        self.data
554            .records
555            .push((PositionRecord::Filled { fill }, ts));
556        true
557    }
558
559    /// Check if a pending order should fill at the given quote.
560    ///
561    /// Returns `true` (and transitions the position to Open) if the fill
562    /// condition is met. Legacy semantics retain the requested-price fill.
563    pub fn try_fill(&mut self, quote: &PriceQuote, model: FillModel) -> bool {
564        if self.pending_fill_purpose(quote, model).is_none() {
565            return false;
566        }
567        let Some(pending_price) = self.data.pending_price else {
568            return false;
569        };
570        self.apply_pending_fill(Fill {
571            price: pending_price,
572            size: self.data.size,
573            ts: quote.ts,
574        })
575    }
576
577    /// Evaluate all management rules against the current quote.
578    ///
579    /// Rules may mutate their own internal state (e.g. mark themselves as
580    /// triggered), but the position data is only read, not written.
581    /// The engine applies the returned effects to the position afterwards.
582    pub fn evaluate_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
583        if self.data.status != PositionStatus::Open {
584            return vec![];
585        }
586
587        let view = self.data.view();
588        let mut effects = Vec::new();
589
590        for rule in &mut self.rules {
591            let rule_effects = rule.evaluate(&view, quote, model);
592            effects.extend(rule_effects);
593        }
594
595        effects
596    }
597
598    /// Deterministic FutureQuote rule arbitration.
599    ///
600    /// One authoritative protective stop is evaluated first, crossed targets
601    /// are processed in economic order, terminal time exits follow, and
602    /// breakeven transitions are emitted only for surviving exposure.
603    pub(crate) fn evaluate_rules_future(
604        &mut self,
605        quote: &PriceQuote,
606        model: FillModel,
607    ) -> Vec<FutureIntent> {
608        if self.data.status != PositionStatus::Open {
609            return Vec::new();
610        }
611        let side = self.data.side;
612        let check = quote.eval_price(side, model);
613        let average_entry = self.data.average_entry();
614        let current_stop = self
615            .current_effective_stop()
616            .map(|stop| (stop.price, stop.origin));
617        let mut effective_stop = None;
618
619        for rule in &mut self.rules {
620            match rule {
621                Rule::FixedStoploss { price } => {
622                    let origin = self.data.stop_origin.unwrap_or(StopOrigin::Initial);
623                    effective_stop = more_protective_stop(side, effective_stop, (*price, origin));
624                }
625                Rule::TrailingStop {
626                    distance,
627                    peak_price,
628                    initialized,
629                } => {
630                    if !*initialized {
631                        *peak_price = average_entry;
632                        *initialized = true;
633                    }
634                    match side {
635                        Side::Buy => *peak_price = peak_price.max(check),
636                        Side::Sell => {
637                            *peak_price = if *peak_price == 0.0 {
638                                check
639                            } else {
640                                peak_price.min(check)
641                            }
642                        }
643                    }
644                    let candidate = match side {
645                        Side::Buy => *peak_price - *distance,
646                        Side::Sell => *peak_price + *distance,
647                    };
648                    effective_stop = more_protective_stop(
649                        side,
650                        effective_stop,
651                        (candidate, StopOrigin::Trailing),
652                    );
653                }
654                _ => {}
655            }
656        }
657
658        if let Some((price, origin)) = effective_stop {
659            let hit = match side {
660                Side::Buy => check <= price,
661                Side::Sell => check >= price,
662            };
663            if hit {
664                let mut effects = Vec::new();
665                if let Some(effect) =
666                    stop_transition_effect(&self.data.id, current_stop, effective_stop)
667                {
668                    effects.push(effect);
669                }
670                let reason = match origin {
671                    StopOrigin::Breakeven => CloseReason::BreakevenStop,
672                    StopOrigin::Trailing => CloseReason::TrailingStop,
673                    _ => CloseReason::Stoploss,
674                };
675                effects.push(FutureIntent {
676                    effect: Effect::PositionClosed {
677                        id: self.data.id.clone(),
678                        reason,
679                    },
680                    requested_price: Some(price),
681                    stop_origin: Some(origin),
682                });
683                return effects;
684            }
685        }
686
687        let mut target_indices: Vec<(usize, f64, f64)> = self
688            .rules
689            .iter()
690            .enumerate()
691            .filter_map(|(index, rule)| match rule {
692                Rule::TakeProfit {
693                    price,
694                    close_ratio,
695                    triggered: false,
696                } if match side {
697                    Side::Buy => check >= *price,
698                    Side::Sell => check <= *price,
699                } =>
700                {
701                    Some((index, *price, *close_ratio))
702                }
703                _ => None,
704            })
705            .collect();
706        target_indices.sort_by(|left, right| match side {
707            Side::Buy => left.1.total_cmp(&right.1),
708            Side::Sell => right.1.total_cmp(&left.1),
709        });
710
711        let mut effects = Vec::new();
712        let mut remaining = self.data.open_ratio();
713        let mut target_hits = self.data.target_hits;
714        for (index, price, ratio) in target_indices {
715            if remaining <= position_size_tolerance(1.0) {
716                break;
717            }
718            if let Rule::TakeProfit { triggered, .. } = &mut self.rules[index] {
719                *triggered = true;
720            }
721            let actual = ratio.min(remaining).max(0.0);
722            if actual <= position_size_tolerance(1.0) {
723                continue;
724            }
725            target_hits += 1;
726            remaining = (remaining - actual).max(0.0);
727            let effect = if remaining <= position_size_tolerance(1.0) {
728                Effect::PositionClosed {
729                    id: self.data.id.clone(),
730                    reason: CloseReason::Target,
731                }
732            } else {
733                Effect::PartialClose {
734                    id: self.data.id.clone(),
735                    ratio: actual,
736                    reason: CloseReason::Target,
737                }
738            };
739            effects.push(FutureIntent {
740                effect,
741                requested_price: Some(price),
742                stop_origin: None,
743            });
744            if remaining <= position_size_tolerance(1.0) {
745                if let Some(effect) =
746                    stop_transition_effect(&self.data.id, current_stop, effective_stop)
747                {
748                    effects.insert(effects.len() - 1, effect);
749                }
750                return effects;
751            }
752        }
753
754        for rule in &self.rules {
755            if let Rule::TimeExit { max_seconds } = rule
756                && self
757                    .data
758                    .open_ts
759                    .is_some_and(|open| (quote.ts - open).num_seconds() >= *max_seconds as i64)
760            {
761                if let Some(effect) =
762                    stop_transition_effect(&self.data.id, current_stop, effective_stop)
763                {
764                    effects.push(effect);
765                }
766                effects.push(FutureIntent::plain(Effect::PositionClosed {
767                    id: self.data.id.clone(),
768                    reason: CloseReason::TimeExit,
769                }));
770                return effects;
771            }
772        }
773
774        let mut breakeven_triggered = false;
775        for rule in &mut self.rules {
776            let trigger = match rule {
777                Rule::BreakevenWhen {
778                    trigger_price,
779                    triggered,
780                } if !*triggered => {
781                    let hit = match side {
782                        Side::Buy => check >= *trigger_price,
783                        Side::Sell => check <= *trigger_price,
784                    };
785                    if hit {
786                        *triggered = true;
787                    }
788                    hit
789                }
790                Rule::BreakevenAfterTargets { after_n, triggered } if !*triggered => {
791                    let hit = target_hits >= *after_n;
792                    if hit {
793                        *triggered = true;
794                    }
795                    hit
796                }
797                _ => false,
798            };
799            if trigger {
800                breakeven_triggered = true;
801                break;
802            }
803        }
804        if breakeven_triggered {
805            effective_stop =
806                more_protective_stop(side, effective_stop, (average_entry, StopOrigin::Breakeven));
807        }
808        if let Some(effect) = stop_transition_effect(&self.data.id, current_stop, effective_stop) {
809            effects.push(effect);
810        }
811        effects
812    }
813
814    /// Every price at which a quote could change this position under FutureQuote evaluation: a pending order's requested price, or an open position's fixed stop, current trailing-stop level, untriggered targets, and untriggered breakeven triggers.
815    ///
816    /// Pending levels are compared against the quote's fill price and open levels against its evaluation price, so a caller that walks a price path can place one quote exactly at each level.
817    pub fn future_trigger_levels(&self) -> Vec<f64> {
818        match self.data.status {
819            PositionStatus::Pending => self.data.pending_price.into_iter().collect(),
820            PositionStatus::Open => {
821                let average_entry = self.data.average_entry();
822                self.rules
823                    .iter()
824                    .filter_map(|rule| match rule {
825                        Rule::FixedStoploss { price } => Some(*price),
826                        Rule::TrailingStop {
827                            distance,
828                            peak_price,
829                            initialized,
830                        } => {
831                            let peak = if *initialized {
832                                *peak_price
833                            } else {
834                                average_entry
835                            };
836                            Some(match self.data.side {
837                                Side::Buy => peak - *distance,
838                                Side::Sell => peak + *distance,
839                            })
840                        }
841                        Rule::TakeProfit {
842                            price,
843                            triggered: false,
844                            ..
845                        } => Some(*price),
846                        Rule::BreakevenWhen {
847                            trigger_price,
848                            triggered: false,
849                        } => Some(*trigger_price),
850                        _ => None,
851                    })
852                    .filter(|level| level.is_finite() && *level > 0.0)
853                    .collect()
854            }
855            PositionStatus::Closed | PositionStatus::Cancelled => Vec::new(),
856        }
857    }
858
859    /// Find the current fixed-stoploss price, if any.
860    pub fn current_effective_stop(&self) -> Option<crate::types::EffectiveStop> {
861        self.current_stoploss()
862            .map(|price| crate::types::EffectiveStop {
863                price,
864                origin: self
865                    .data
866                    .stop_origin
867                    .unwrap_or(crate::types::StopOrigin::Initial),
868            })
869    }
870
871    pub fn current_stoploss(&self) -> Option<f64> {
872        for rule in &self.rules {
873            if let Rule::FixedStoploss { price } = rule {
874                return Some(*price);
875            }
876        }
877        None
878    }
879
880    /// Update the fixed-stoploss price.  Returns the old price (if any).
881    pub fn set_stoploss(&mut self, new_price: f64) -> Option<f64> {
882        self.set_stoploss_with_origin(new_price, crate::types::StopOrigin::Modified)
883    }
884
885    pub fn set_stoploss_with_origin(
886        &mut self,
887        new_price: f64,
888        origin: crate::types::StopOrigin,
889    ) -> Option<f64> {
890        self.data.stop_origin = Some(origin);
891        for rule in &mut self.rules {
892            if let Rule::FixedStoploss { price } = rule {
893                let old = *price;
894                *price = new_price;
895                return Some(old);
896            }
897        }
898        // No existing stoploss — add one.
899        self.rules.push(Rule::fixed_stoploss(new_price));
900        None
901    }
902
903    /// Remove a rule by name.  Returns `true` if a rule was removed.
904    pub fn remove_rule(&mut self, name: &str) -> bool {
905        let before = self.rules.len();
906        self.rules.retain(|r| r.name() != name);
907        self.rules.len() < before
908    }
909
910    /// Evaluate only stateful rules (trailing stop, time exit, breakeven-after-targets).
911    /// Used when static rules are handled by the alert register.
912    pub fn evaluate_stateful_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
913        if self.data.status != PositionStatus::Open {
914            return vec![];
915        }
916        let view = self.data.view();
917        let mut effects = Vec::new();
918        for rule in &mut self.rules {
919            if rule.is_stateful() {
920                effects.extend(rule.evaluate(&view, quote, model));
921            }
922        }
923        effects
924    }
925
926    /// Whether this position has any stateful rules requiring tick-by-tick evaluation.
927    pub fn has_stateful_rules(&self) -> bool {
928        self.rules.iter().any(|r| r.is_stateful())
929    }
930}
931
932// ─── Tests ──────────────────────────────────────────────────────────────────
933
934fn stop_transition_effect(
935    position_id: &str,
936    current: Option<(f64, StopOrigin)>,
937    next: Option<(f64, StopOrigin)>,
938) -> Option<FutureIntent> {
939    let (new_price, origin) = next?;
940    if current == next {
941        return None;
942    }
943    Some(FutureIntent {
944        effect: Effect::StoplossModified {
945            id: position_id.to_owned(),
946            old_price: current.map_or(0.0, |stop| stop.0),
947            new_price,
948        },
949        requested_price: Some(new_price),
950        stop_origin: Some(origin),
951    })
952}
953
954fn more_protective_stop(
955    side: Side,
956    current: Option<(f64, StopOrigin)>,
957    candidate: (f64, StopOrigin),
958) -> Option<(f64, StopOrigin)> {
959    match current {
960        None => Some(candidate),
961        Some(existing) => match side {
962            Side::Buy if candidate.0 > existing.0 => Some(candidate),
963            Side::Sell if candidate.0 < existing.0 => Some(candidate),
964            _ => Some(existing),
965        },
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    use chrono::NaiveDate;
973
974    fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
975        NaiveDate::from_ymd_opt(2026, 1, 1)
976            .unwrap()
977            .and_hms_opt(h, m, s)
978            .unwrap()
979    }
980
981    fn make_fill(price: f64, size: f64) -> Fill {
982        Fill {
983            price,
984            size,
985            ts: ts(10, 0, 0),
986        }
987    }
988
989    #[test]
990    fn average_entry_single_fill() {
991        let pos = Position::new_market(
992            "p1".into(),
993            "EURUSD".into(),
994            Side::Buy,
995            make_fill(1.0850, 1.0),
996            vec![],
997        );
998        assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
999    }
1000
1001    #[test]
1002    fn average_entry_multiple_fills() {
1003        let mut pos = Position::new_market(
1004            "p1".into(),
1005            "EURUSD".into(),
1006            Side::Buy,
1007            make_fill(1.0800, 1.0),
1008            vec![],
1009        );
1010        pos.data.add_fill(Fill {
1011            price: 1.0900,
1012            size: 1.0,
1013            ts: ts(10, 5, 0),
1014        });
1015        // (1.0800 * 1.0 + 1.0900 * 1.0) / 2.0 = 1.0850
1016        assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
1017    }
1018
1019    #[test]
1020    fn average_entry_weighted() {
1021        let mut pos = Position::new_market(
1022            "p1".into(),
1023            "EURUSD".into(),
1024            Side::Buy,
1025            make_fill(1.0800, 2.0),
1026            vec![],
1027        );
1028        pos.data.add_fill(Fill {
1029            price: 1.0900,
1030            size: 1.0,
1031            ts: ts(10, 5, 0),
1032        });
1033        // (1.0800 * 2 + 1.0900 * 1) / 3 = 1.08333...
1034        let expected = (1.0800 * 2.0 + 1.0900 * 1.0) / 3.0;
1035        assert!((pos.data.average_entry() - expected).abs() < 1e-10);
1036    }
1037
1038    #[test]
1039    fn remaining_size_after_partial_close() {
1040        let mut pos = Position::new_market(
1041            "p1".into(),
1042            "EURUSD".into(),
1043            Side::Buy,
1044            make_fill(1.0850, 2.0),
1045            vec![],
1046        );
1047        assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1048
1049        pos.data
1050            .apply_partial_close(0.5, 1.0900, CloseReason::Target, ts(10, 30, 0));
1051        // remaining_ratio = 0.5, total_filled = 2.0, remaining = 1.0
1052        assert!((pos.data.remaining_size() - 1.0).abs() < f64::EPSILON);
1053        assert_eq!(pos.data.status, PositionStatus::Open);
1054        assert_eq!(pos.data.target_hits, 1);
1055    }
1056
1057    #[test]
1058    fn partial_close_then_scale_in_conserves_absolute_size() {
1059        let mut pos = Position::new_market(
1060            "p1".into(),
1061            "EURUSD".into(),
1062            Side::Buy,
1063            make_fill(1.0850, 2.0),
1064            vec![],
1065        );
1066
1067        pos.data
1068            .apply_partial_close(0.5, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1069        pos.data.add_fill(Fill {
1070            price: 1.0950,
1071            size: 1.0,
1072            ts: ts(10, 35, 0),
1073        });
1074
1075        assert!((pos.data.total_filled_size() - 3.0).abs() < f64::EPSILON);
1076        assert!((pos.data.closed_size - 1.0).abs() < f64::EPSILON);
1077        assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1078        assert!((pos.data.remaining_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
1079    }
1080
1081    #[test]
1082    fn partial_close_then_scale_in_preserves_average_cost_cash_flow() {
1083        let mut pos = Position::new_market(
1084            "p1".into(),
1085            "EURUSD".into(),
1086            Side::Buy,
1087            make_fill(100.0, 2.0),
1088            vec![],
1089        );
1090
1091        let first_basis = pos.data.average_entry();
1092        pos.data
1093            .apply_partial_close(0.5, 110.0, CloseReason::Manual, ts(10, 30, 0));
1094        let first_pnl = (110.0 - first_basis) * 1.0;
1095        assert_eq!(pos.data.open_entry_value, 100.0);
1096        assert_eq!(pos.data.average_entry(), 100.0);
1097
1098        pos.data.add_fill(Fill {
1099            price: 120.0,
1100            size: 1.0,
1101            ts: ts(10, 35, 0),
1102        });
1103        assert_eq!(pos.data.average_entry(), 110.0);
1104        assert_eq!(pos.data.open_entry_value, 220.0);
1105
1106        let final_basis = pos.data.average_entry();
1107        let final_pnl = (130.0 - final_basis) * pos.data.remaining_size();
1108        pos.data
1109            .apply_full_close(CloseReason::Manual, ts(10, 40, 0));
1110
1111        assert_eq!(first_pnl + final_pnl, 50.0);
1112        assert_eq!(pos.data.entries.len(), 2);
1113        assert_eq!(pos.data.historical_average_entry(), 320.0 / 3.0);
1114        assert_eq!(pos.data.open_entry_value, 0.0);
1115    }
1116
1117    #[test]
1118    fn scale_in_then_partial_close_uses_all_entered_size() {
1119        let mut pos = Position::new_market(
1120            "p1".into(),
1121            "EURUSD".into(),
1122            Side::Buy,
1123            make_fill(1.0850, 2.0),
1124            vec![],
1125        );
1126        pos.data.add_fill(Fill {
1127            price: 1.0950,
1128            size: 1.0,
1129            ts: ts(10, 5, 0),
1130        });
1131        pos.data
1132            .apply_partial_close(0.5, 1.1000, CloseReason::Manual, ts(10, 30, 0));
1133
1134        assert!((pos.data.closed_size - 1.5).abs() < f64::EPSILON);
1135        assert!((pos.data.remaining_size() - 1.5).abs() < f64::EPSILON);
1136        assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
1137    }
1138
1139    #[test]
1140    fn repeated_partial_closes_cap_and_reach_exact_zero() {
1141        let mut pos = Position::new_market(
1142            "p1".into(),
1143            "EURUSD".into(),
1144            Side::Buy,
1145            make_fill(1.0850, 1.0),
1146            vec![],
1147        );
1148
1149        for minute in [10, 20, 30] {
1150            pos.data
1151                .apply_partial_close(0.4, 1.0900, CloseReason::Manual, ts(10, minute, 0));
1152        }
1153
1154        assert_eq!(pos.data.closed_size, 1.0);
1155        assert_eq!(pos.data.remaining_size(), 0.0);
1156        assert_eq!(pos.data.remaining_ratio, 0.0);
1157        assert_eq!(pos.data.status, PositionStatus::Closed);
1158        let last_ratio = pos
1159            .data
1160            .records
1161            .iter()
1162            .rev()
1163            .find_map(|(record, _)| match record {
1164                PositionRecord::PartialClose { ratio, .. } => Some(*ratio),
1165                _ => None,
1166            })
1167            .unwrap();
1168        assert!((last_ratio - 0.2).abs() < 1e-12);
1169    }
1170
1171    #[test]
1172    fn serde_migrates_legacy_ratio_to_absolute_closed_size() {
1173        let mut pos = Position::new_market(
1174            "p1".into(),
1175            "EURUSD".into(),
1176            Side::Buy,
1177            make_fill(1.0850, 2.0),
1178            vec![],
1179        );
1180        pos.data
1181            .apply_partial_close(0.25, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1182
1183        let mut legacy = serde_json::to_value(&pos.data).unwrap();
1184        legacy
1185            .as_object_mut()
1186            .unwrap()
1187            .remove("closed_size")
1188            .unwrap();
1189        legacy
1190            .as_object_mut()
1191            .unwrap()
1192            .remove("open_entry_value")
1193            .unwrap();
1194        let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1195        assert!((migrated.closed_size - 0.5).abs() < f64::EPSILON);
1196        assert!((migrated.remaining_size() - 1.5).abs() < f64::EPSILON);
1197        assert!((migrated.remaining_ratio - 0.75).abs() < f64::EPSILON);
1198        assert!((migrated.open_entry_value - 1.6275).abs() < f64::EPSILON);
1199        assert!((migrated.average_entry() - 1.0850).abs() < f64::EPSILON);
1200
1201        let mut current = serde_json::to_value(&migrated).unwrap();
1202        current["remaining_ratio"] = serde_json::json!(0.99);
1203        let round_trip: PositionData = serde_json::from_value(current).unwrap();
1204        assert!((round_trip.closed_size - 0.5).abs() < f64::EPSILON);
1205        assert!((round_trip.remaining_ratio - 0.75).abs() < f64::EPSILON);
1206    }
1207
1208    #[test]
1209    fn serde_defaults_legacy_unclosed_position_to_zero_closed_size() {
1210        let pos = Position::new_market(
1211            "p1".into(),
1212            "EURUSD".into(),
1213            Side::Buy,
1214            make_fill(1.0850, 2.0),
1215            vec![],
1216        );
1217        let mut legacy = serde_json::to_value(&pos.data).unwrap();
1218        legacy.as_object_mut().unwrap().remove("closed_size");
1219        legacy.as_object_mut().unwrap().remove("open_entry_value");
1220
1221        let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1222        assert_eq!(migrated.closed_size, 0.0);
1223        assert_eq!(migrated.remaining_size(), 2.0);
1224        assert_eq!(migrated.remaining_ratio, 1.0);
1225    }
1226
1227    #[test]
1228    fn full_close_via_partial() {
1229        let mut pos = Position::new_market(
1230            "p1".into(),
1231            "EURUSD".into(),
1232            Side::Buy,
1233            make_fill(1.0850, 1.0),
1234            vec![],
1235        );
1236        pos.data
1237            .apply_partial_close(1.0, 1.0900, CloseReason::Target, ts(10, 30, 0));
1238        assert_eq!(pos.data.status, PositionStatus::Closed);
1239        assert!(pos.data.close_ts.is_some());
1240        assert_eq!(pos.data.closed_size, 1.0);
1241        assert_eq!(pos.data.remaining_size(), 0.0);
1242        assert_eq!(pos.data.remaining_ratio, 0.0);
1243    }
1244
1245    #[test]
1246    fn full_close() {
1247        let mut pos = Position::new_market(
1248            "p1".into(),
1249            "EURUSD".into(),
1250            Side::Sell,
1251            make_fill(1.0850, 1.0),
1252            vec![],
1253        );
1254        pos.data
1255            .apply_full_close(CloseReason::Stoploss, ts(10, 30, 0));
1256        assert_eq!(pos.data.status, PositionStatus::Closed);
1257        assert_eq!(pos.data.closed_size, 1.0);
1258        assert_eq!(pos.data.remaining_size(), 0.0);
1259        assert_eq!(pos.data.remaining_ratio, 0.0);
1260    }
1261
1262    #[test]
1263    fn unrealized_pnl_buy() {
1264        let pos = Position::new_market(
1265            "p1".into(),
1266            "EURUSD".into(),
1267            Side::Buy,
1268            make_fill(1.0850, 1.0),
1269            vec![],
1270        );
1271        let pnl = pos.data.unrealized_pnl(1.0900);
1272        assert!((pnl - 0.0050).abs() < 1e-10);
1273    }
1274
1275    #[test]
1276    fn unrealized_pnl_sell() {
1277        let pos = Position::new_market(
1278            "p1".into(),
1279            "EURUSD".into(),
1280            Side::Sell,
1281            make_fill(1.0850, 1.0),
1282            vec![],
1283        );
1284        let pnl = pos.data.unrealized_pnl(1.0800);
1285        assert!((pnl - 0.0050).abs() < 1e-10);
1286    }
1287
1288    #[test]
1289    fn try_fill_limit_buy() {
1290        let mut pos = Position::new_pending(
1291            "p1".into(),
1292            "EURUSD".into(),
1293            Side::Buy,
1294            OrderType::Limit,
1295            1.0800,
1296            1.0,
1297            ts(9, 0, 0),
1298            vec![],
1299        );
1300        assert_eq!(pos.data.status, PositionStatus::Pending);
1301
1302        // Ask still above limit → no fill
1303        let q1 = PriceQuote {
1304            symbol: "EURUSD".into(),
1305            ts: ts(10, 0, 0),
1306            bid: 1.0808,
1307            ask: 1.0810,
1308        };
1309        assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1310        assert_eq!(pos.data.status, PositionStatus::Pending);
1311
1312        // Ask at or below limit → fill
1313        let q2 = PriceQuote {
1314            symbol: "EURUSD".into(),
1315            ts: ts(10, 5, 0),
1316            bid: 1.0798,
1317            ask: 1.0800,
1318        };
1319        assert!(pos.try_fill(&q2, FillModel::BidAsk));
1320        assert_eq!(pos.data.status, PositionStatus::Open);
1321        assert_eq!(pos.data.entries.len(), 1);
1322        assert!((pos.data.entries[0].price - 1.0800).abs() < f64::EPSILON);
1323    }
1324
1325    #[test]
1326    fn try_fill_stop_sell() {
1327        let mut pos = Position::new_pending(
1328            "p1".into(),
1329            "EURUSD".into(),
1330            Side::Sell,
1331            OrderType::Stop,
1332            1.0800,
1333            1.0,
1334            ts(9, 0, 0),
1335            vec![],
1336        );
1337
1338        // Ask still above stop → no fill (BidAsk mode: sell checks bid)
1339        let q1 = PriceQuote {
1340            symbol: "EURUSD".into(),
1341            ts: ts(10, 0, 0),
1342            bid: 1.0810,
1343            ask: 1.0812,
1344        };
1345        assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1346
1347        // Bid at or below stop → fill
1348        let q2 = PriceQuote {
1349            symbol: "EURUSD".into(),
1350            ts: ts(10, 5, 0),
1351            bid: 1.0800,
1352            ask: 1.0802,
1353        };
1354        assert!(pos.try_fill(&q2, FillModel::BidAsk));
1355        assert_eq!(pos.data.status, PositionStatus::Open);
1356    }
1357
1358    #[test]
1359    fn set_stoploss_updates_existing() {
1360        let mut pos = Position::new_market(
1361            "p1".into(),
1362            "EURUSD".into(),
1363            Side::Buy,
1364            make_fill(1.0850, 1.0),
1365            vec![Rule::fixed_stoploss(1.0800)],
1366        );
1367        assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1368
1369        let old = pos.set_stoploss(1.0820);
1370        assert!((old.unwrap() - 1.0800).abs() < f64::EPSILON);
1371        assert!((pos.current_stoploss().unwrap() - 1.0820).abs() < f64::EPSILON);
1372    }
1373
1374    #[test]
1375    fn set_stoploss_adds_when_missing() {
1376        let mut pos = Position::new_market(
1377            "p1".into(),
1378            "EURUSD".into(),
1379            Side::Buy,
1380            make_fill(1.0850, 1.0),
1381            vec![],
1382        );
1383        assert!(pos.current_stoploss().is_none());
1384
1385        let old = pos.set_stoploss(1.0800);
1386        assert!(old.is_none());
1387        assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1388    }
1389
1390    #[test]
1391    fn remove_rule_by_name() {
1392        let mut pos = Position::new_market(
1393            "p1".into(),
1394            "EURUSD".into(),
1395            Side::Buy,
1396            make_fill(1.0850, 1.0),
1397            vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1398        );
1399        assert_eq!(pos.rules.len(), 2);
1400        assert!(pos.remove_rule("TakeProfit"));
1401        assert_eq!(pos.rules.len(), 1);
1402        assert_eq!(pos.rules[0].name(), "FixedStoploss");
1403    }
1404
1405    #[test]
1406    fn evaluate_rules_produces_effects() {
1407        let mut pos = Position::new_market(
1408            "p1".into(),
1409            "EURUSD".into(),
1410            Side::Buy,
1411            make_fill(1.0850, 1.0),
1412            vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1413        );
1414
1415        // Price between SL and TP → no effects
1416        let q = PriceQuote {
1417            symbol: "EURUSD".into(),
1418            ts: ts(10, 5, 0),
1419            bid: 1.0860,
1420            ask: 1.0862,
1421        };
1422        let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1423        assert!(effects.is_empty());
1424
1425        // Price hits SL → close effect
1426        let q_sl = PriceQuote {
1427            symbol: "EURUSD".into(),
1428            ts: ts(10, 10, 0),
1429            bid: 1.0799,
1430            ask: 1.0801,
1431        };
1432        let effects = pos.evaluate_rules(&q_sl, FillModel::BidAsk);
1433        assert!(!effects.is_empty());
1434        assert!(matches!(
1435            &effects[0],
1436            Effect::PositionClosed {
1437                reason: CloseReason::Stoploss,
1438                ..
1439            }
1440        ));
1441    }
1442
1443    #[test]
1444    fn pending_position_skips_rule_evaluation() {
1445        let mut pos = Position::new_pending(
1446            "p1".into(),
1447            "EURUSD".into(),
1448            Side::Buy,
1449            OrderType::Limit,
1450            1.0800,
1451            1.0,
1452            ts(9, 0, 0),
1453            vec![Rule::fixed_stoploss(1.0750)],
1454        );
1455
1456        // Even though bid is below SL, position is pending → no effects
1457        let q = PriceQuote {
1458            symbol: "EURUSD".into(),
1459            ts: ts(10, 0, 0),
1460            bid: 1.0740,
1461            ask: 1.0742,
1462        };
1463        let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1464        assert!(effects.is_empty());
1465    }
1466}