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    /// Find the current fixed-stoploss price, if any.
815    pub fn current_effective_stop(&self) -> Option<crate::types::EffectiveStop> {
816        self.current_stoploss()
817            .map(|price| crate::types::EffectiveStop {
818                price,
819                origin: self
820                    .data
821                    .stop_origin
822                    .unwrap_or(crate::types::StopOrigin::Initial),
823            })
824    }
825
826    pub fn current_stoploss(&self) -> Option<f64> {
827        for rule in &self.rules {
828            if let Rule::FixedStoploss { price } = rule {
829                return Some(*price);
830            }
831        }
832        None
833    }
834
835    /// Update the fixed-stoploss price.  Returns the old price (if any).
836    pub fn set_stoploss(&mut self, new_price: f64) -> Option<f64> {
837        self.set_stoploss_with_origin(new_price, crate::types::StopOrigin::Modified)
838    }
839
840    pub fn set_stoploss_with_origin(
841        &mut self,
842        new_price: f64,
843        origin: crate::types::StopOrigin,
844    ) -> Option<f64> {
845        self.data.stop_origin = Some(origin);
846        for rule in &mut self.rules {
847            if let Rule::FixedStoploss { price } = rule {
848                let old = *price;
849                *price = new_price;
850                return Some(old);
851            }
852        }
853        // No existing stoploss — add one.
854        self.rules.push(Rule::fixed_stoploss(new_price));
855        None
856    }
857
858    /// Remove a rule by name.  Returns `true` if a rule was removed.
859    pub fn remove_rule(&mut self, name: &str) -> bool {
860        let before = self.rules.len();
861        self.rules.retain(|r| r.name() != name);
862        self.rules.len() < before
863    }
864
865    /// Evaluate only stateful rules (trailing stop, time exit, breakeven-after-targets).
866    /// Used when static rules are handled by the alert register.
867    pub fn evaluate_stateful_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
868        if self.data.status != PositionStatus::Open {
869            return vec![];
870        }
871        let view = self.data.view();
872        let mut effects = Vec::new();
873        for rule in &mut self.rules {
874            if rule.is_stateful() {
875                effects.extend(rule.evaluate(&view, quote, model));
876            }
877        }
878        effects
879    }
880
881    /// Whether this position has any stateful rules requiring tick-by-tick evaluation.
882    pub fn has_stateful_rules(&self) -> bool {
883        self.rules.iter().any(|r| r.is_stateful())
884    }
885}
886
887// ─── Tests ──────────────────────────────────────────────────────────────────
888
889fn stop_transition_effect(
890    position_id: &str,
891    current: Option<(f64, StopOrigin)>,
892    next: Option<(f64, StopOrigin)>,
893) -> Option<FutureIntent> {
894    let (new_price, origin) = next?;
895    if current == next {
896        return None;
897    }
898    Some(FutureIntent {
899        effect: Effect::StoplossModified {
900            id: position_id.to_owned(),
901            old_price: current.map_or(0.0, |stop| stop.0),
902            new_price,
903        },
904        requested_price: Some(new_price),
905        stop_origin: Some(origin),
906    })
907}
908
909fn more_protective_stop(
910    side: Side,
911    current: Option<(f64, StopOrigin)>,
912    candidate: (f64, StopOrigin),
913) -> Option<(f64, StopOrigin)> {
914    match current {
915        None => Some(candidate),
916        Some(existing) => match side {
917            Side::Buy if candidate.0 > existing.0 => Some(candidate),
918            Side::Sell if candidate.0 < existing.0 => Some(candidate),
919            _ => Some(existing),
920        },
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use chrono::NaiveDate;
928
929    fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
930        NaiveDate::from_ymd_opt(2026, 1, 1)
931            .unwrap()
932            .and_hms_opt(h, m, s)
933            .unwrap()
934    }
935
936    fn make_fill(price: f64, size: f64) -> Fill {
937        Fill {
938            price,
939            size,
940            ts: ts(10, 0, 0),
941        }
942    }
943
944    #[test]
945    fn average_entry_single_fill() {
946        let pos = Position::new_market(
947            "p1".into(),
948            "EURUSD".into(),
949            Side::Buy,
950            make_fill(1.0850, 1.0),
951            vec![],
952        );
953        assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
954    }
955
956    #[test]
957    fn average_entry_multiple_fills() {
958        let mut pos = Position::new_market(
959            "p1".into(),
960            "EURUSD".into(),
961            Side::Buy,
962            make_fill(1.0800, 1.0),
963            vec![],
964        );
965        pos.data.add_fill(Fill {
966            price: 1.0900,
967            size: 1.0,
968            ts: ts(10, 5, 0),
969        });
970        // (1.0800 * 1.0 + 1.0900 * 1.0) / 2.0 = 1.0850
971        assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
972    }
973
974    #[test]
975    fn average_entry_weighted() {
976        let mut pos = Position::new_market(
977            "p1".into(),
978            "EURUSD".into(),
979            Side::Buy,
980            make_fill(1.0800, 2.0),
981            vec![],
982        );
983        pos.data.add_fill(Fill {
984            price: 1.0900,
985            size: 1.0,
986            ts: ts(10, 5, 0),
987        });
988        // (1.0800 * 2 + 1.0900 * 1) / 3 = 1.08333...
989        let expected = (1.0800 * 2.0 + 1.0900 * 1.0) / 3.0;
990        assert!((pos.data.average_entry() - expected).abs() < 1e-10);
991    }
992
993    #[test]
994    fn remaining_size_after_partial_close() {
995        let mut pos = Position::new_market(
996            "p1".into(),
997            "EURUSD".into(),
998            Side::Buy,
999            make_fill(1.0850, 2.0),
1000            vec![],
1001        );
1002        assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1003
1004        pos.data
1005            .apply_partial_close(0.5, 1.0900, CloseReason::Target, ts(10, 30, 0));
1006        // remaining_ratio = 0.5, total_filled = 2.0, remaining = 1.0
1007        assert!((pos.data.remaining_size() - 1.0).abs() < f64::EPSILON);
1008        assert_eq!(pos.data.status, PositionStatus::Open);
1009        assert_eq!(pos.data.target_hits, 1);
1010    }
1011
1012    #[test]
1013    fn partial_close_then_scale_in_conserves_absolute_size() {
1014        let mut pos = Position::new_market(
1015            "p1".into(),
1016            "EURUSD".into(),
1017            Side::Buy,
1018            make_fill(1.0850, 2.0),
1019            vec![],
1020        );
1021
1022        pos.data
1023            .apply_partial_close(0.5, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1024        pos.data.add_fill(Fill {
1025            price: 1.0950,
1026            size: 1.0,
1027            ts: ts(10, 35, 0),
1028        });
1029
1030        assert!((pos.data.total_filled_size() - 3.0).abs() < f64::EPSILON);
1031        assert!((pos.data.closed_size - 1.0).abs() < f64::EPSILON);
1032        assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1033        assert!((pos.data.remaining_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
1034    }
1035
1036    #[test]
1037    fn partial_close_then_scale_in_preserves_average_cost_cash_flow() {
1038        let mut pos = Position::new_market(
1039            "p1".into(),
1040            "EURUSD".into(),
1041            Side::Buy,
1042            make_fill(100.0, 2.0),
1043            vec![],
1044        );
1045
1046        let first_basis = pos.data.average_entry();
1047        pos.data
1048            .apply_partial_close(0.5, 110.0, CloseReason::Manual, ts(10, 30, 0));
1049        let first_pnl = (110.0 - first_basis) * 1.0;
1050        assert_eq!(pos.data.open_entry_value, 100.0);
1051        assert_eq!(pos.data.average_entry(), 100.0);
1052
1053        pos.data.add_fill(Fill {
1054            price: 120.0,
1055            size: 1.0,
1056            ts: ts(10, 35, 0),
1057        });
1058        assert_eq!(pos.data.average_entry(), 110.0);
1059        assert_eq!(pos.data.open_entry_value, 220.0);
1060
1061        let final_basis = pos.data.average_entry();
1062        let final_pnl = (130.0 - final_basis) * pos.data.remaining_size();
1063        pos.data
1064            .apply_full_close(CloseReason::Manual, ts(10, 40, 0));
1065
1066        assert_eq!(first_pnl + final_pnl, 50.0);
1067        assert_eq!(pos.data.entries.len(), 2);
1068        assert_eq!(pos.data.historical_average_entry(), 320.0 / 3.0);
1069        assert_eq!(pos.data.open_entry_value, 0.0);
1070    }
1071
1072    #[test]
1073    fn scale_in_then_partial_close_uses_all_entered_size() {
1074        let mut pos = Position::new_market(
1075            "p1".into(),
1076            "EURUSD".into(),
1077            Side::Buy,
1078            make_fill(1.0850, 2.0),
1079            vec![],
1080        );
1081        pos.data.add_fill(Fill {
1082            price: 1.0950,
1083            size: 1.0,
1084            ts: ts(10, 5, 0),
1085        });
1086        pos.data
1087            .apply_partial_close(0.5, 1.1000, CloseReason::Manual, ts(10, 30, 0));
1088
1089        assert!((pos.data.closed_size - 1.5).abs() < f64::EPSILON);
1090        assert!((pos.data.remaining_size() - 1.5).abs() < f64::EPSILON);
1091        assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
1092    }
1093
1094    #[test]
1095    fn repeated_partial_closes_cap_and_reach_exact_zero() {
1096        let mut pos = Position::new_market(
1097            "p1".into(),
1098            "EURUSD".into(),
1099            Side::Buy,
1100            make_fill(1.0850, 1.0),
1101            vec![],
1102        );
1103
1104        for minute in [10, 20, 30] {
1105            pos.data
1106                .apply_partial_close(0.4, 1.0900, CloseReason::Manual, ts(10, minute, 0));
1107        }
1108
1109        assert_eq!(pos.data.closed_size, 1.0);
1110        assert_eq!(pos.data.remaining_size(), 0.0);
1111        assert_eq!(pos.data.remaining_ratio, 0.0);
1112        assert_eq!(pos.data.status, PositionStatus::Closed);
1113        let last_ratio = pos
1114            .data
1115            .records
1116            .iter()
1117            .rev()
1118            .find_map(|(record, _)| match record {
1119                PositionRecord::PartialClose { ratio, .. } => Some(*ratio),
1120                _ => None,
1121            })
1122            .unwrap();
1123        assert!((last_ratio - 0.2).abs() < 1e-12);
1124    }
1125
1126    #[test]
1127    fn serde_migrates_legacy_ratio_to_absolute_closed_size() {
1128        let mut pos = Position::new_market(
1129            "p1".into(),
1130            "EURUSD".into(),
1131            Side::Buy,
1132            make_fill(1.0850, 2.0),
1133            vec![],
1134        );
1135        pos.data
1136            .apply_partial_close(0.25, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1137
1138        let mut legacy = serde_json::to_value(&pos.data).unwrap();
1139        legacy
1140            .as_object_mut()
1141            .unwrap()
1142            .remove("closed_size")
1143            .unwrap();
1144        legacy
1145            .as_object_mut()
1146            .unwrap()
1147            .remove("open_entry_value")
1148            .unwrap();
1149        let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1150        assert!((migrated.closed_size - 0.5).abs() < f64::EPSILON);
1151        assert!((migrated.remaining_size() - 1.5).abs() < f64::EPSILON);
1152        assert!((migrated.remaining_ratio - 0.75).abs() < f64::EPSILON);
1153        assert!((migrated.open_entry_value - 1.6275).abs() < f64::EPSILON);
1154        assert!((migrated.average_entry() - 1.0850).abs() < f64::EPSILON);
1155
1156        let mut current = serde_json::to_value(&migrated).unwrap();
1157        current["remaining_ratio"] = serde_json::json!(0.99);
1158        let round_trip: PositionData = serde_json::from_value(current).unwrap();
1159        assert!((round_trip.closed_size - 0.5).abs() < f64::EPSILON);
1160        assert!((round_trip.remaining_ratio - 0.75).abs() < f64::EPSILON);
1161    }
1162
1163    #[test]
1164    fn serde_defaults_legacy_unclosed_position_to_zero_closed_size() {
1165        let pos = Position::new_market(
1166            "p1".into(),
1167            "EURUSD".into(),
1168            Side::Buy,
1169            make_fill(1.0850, 2.0),
1170            vec![],
1171        );
1172        let mut legacy = serde_json::to_value(&pos.data).unwrap();
1173        legacy.as_object_mut().unwrap().remove("closed_size");
1174        legacy.as_object_mut().unwrap().remove("open_entry_value");
1175
1176        let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1177        assert_eq!(migrated.closed_size, 0.0);
1178        assert_eq!(migrated.remaining_size(), 2.0);
1179        assert_eq!(migrated.remaining_ratio, 1.0);
1180    }
1181
1182    #[test]
1183    fn full_close_via_partial() {
1184        let mut pos = Position::new_market(
1185            "p1".into(),
1186            "EURUSD".into(),
1187            Side::Buy,
1188            make_fill(1.0850, 1.0),
1189            vec![],
1190        );
1191        pos.data
1192            .apply_partial_close(1.0, 1.0900, CloseReason::Target, ts(10, 30, 0));
1193        assert_eq!(pos.data.status, PositionStatus::Closed);
1194        assert!(pos.data.close_ts.is_some());
1195        assert_eq!(pos.data.closed_size, 1.0);
1196        assert_eq!(pos.data.remaining_size(), 0.0);
1197        assert_eq!(pos.data.remaining_ratio, 0.0);
1198    }
1199
1200    #[test]
1201    fn full_close() {
1202        let mut pos = Position::new_market(
1203            "p1".into(),
1204            "EURUSD".into(),
1205            Side::Sell,
1206            make_fill(1.0850, 1.0),
1207            vec![],
1208        );
1209        pos.data
1210            .apply_full_close(CloseReason::Stoploss, ts(10, 30, 0));
1211        assert_eq!(pos.data.status, PositionStatus::Closed);
1212        assert_eq!(pos.data.closed_size, 1.0);
1213        assert_eq!(pos.data.remaining_size(), 0.0);
1214        assert_eq!(pos.data.remaining_ratio, 0.0);
1215    }
1216
1217    #[test]
1218    fn unrealized_pnl_buy() {
1219        let pos = Position::new_market(
1220            "p1".into(),
1221            "EURUSD".into(),
1222            Side::Buy,
1223            make_fill(1.0850, 1.0),
1224            vec![],
1225        );
1226        let pnl = pos.data.unrealized_pnl(1.0900);
1227        assert!((pnl - 0.0050).abs() < 1e-10);
1228    }
1229
1230    #[test]
1231    fn unrealized_pnl_sell() {
1232        let pos = Position::new_market(
1233            "p1".into(),
1234            "EURUSD".into(),
1235            Side::Sell,
1236            make_fill(1.0850, 1.0),
1237            vec![],
1238        );
1239        let pnl = pos.data.unrealized_pnl(1.0800);
1240        assert!((pnl - 0.0050).abs() < 1e-10);
1241    }
1242
1243    #[test]
1244    fn try_fill_limit_buy() {
1245        let mut pos = Position::new_pending(
1246            "p1".into(),
1247            "EURUSD".into(),
1248            Side::Buy,
1249            OrderType::Limit,
1250            1.0800,
1251            1.0,
1252            ts(9, 0, 0),
1253            vec![],
1254        );
1255        assert_eq!(pos.data.status, PositionStatus::Pending);
1256
1257        // Ask still above limit → no fill
1258        let q1 = PriceQuote {
1259            symbol: "EURUSD".into(),
1260            ts: ts(10, 0, 0),
1261            bid: 1.0808,
1262            ask: 1.0810,
1263        };
1264        assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1265        assert_eq!(pos.data.status, PositionStatus::Pending);
1266
1267        // Ask at or below limit → fill
1268        let q2 = PriceQuote {
1269            symbol: "EURUSD".into(),
1270            ts: ts(10, 5, 0),
1271            bid: 1.0798,
1272            ask: 1.0800,
1273        };
1274        assert!(pos.try_fill(&q2, FillModel::BidAsk));
1275        assert_eq!(pos.data.status, PositionStatus::Open);
1276        assert_eq!(pos.data.entries.len(), 1);
1277        assert!((pos.data.entries[0].price - 1.0800).abs() < f64::EPSILON);
1278    }
1279
1280    #[test]
1281    fn try_fill_stop_sell() {
1282        let mut pos = Position::new_pending(
1283            "p1".into(),
1284            "EURUSD".into(),
1285            Side::Sell,
1286            OrderType::Stop,
1287            1.0800,
1288            1.0,
1289            ts(9, 0, 0),
1290            vec![],
1291        );
1292
1293        // Ask still above stop → no fill (BidAsk mode: sell checks bid)
1294        let q1 = PriceQuote {
1295            symbol: "EURUSD".into(),
1296            ts: ts(10, 0, 0),
1297            bid: 1.0810,
1298            ask: 1.0812,
1299        };
1300        assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1301
1302        // Bid at or below stop → fill
1303        let q2 = PriceQuote {
1304            symbol: "EURUSD".into(),
1305            ts: ts(10, 5, 0),
1306            bid: 1.0800,
1307            ask: 1.0802,
1308        };
1309        assert!(pos.try_fill(&q2, FillModel::BidAsk));
1310        assert_eq!(pos.data.status, PositionStatus::Open);
1311    }
1312
1313    #[test]
1314    fn set_stoploss_updates_existing() {
1315        let mut pos = Position::new_market(
1316            "p1".into(),
1317            "EURUSD".into(),
1318            Side::Buy,
1319            make_fill(1.0850, 1.0),
1320            vec![Rule::fixed_stoploss(1.0800)],
1321        );
1322        assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1323
1324        let old = pos.set_stoploss(1.0820);
1325        assert!((old.unwrap() - 1.0800).abs() < f64::EPSILON);
1326        assert!((pos.current_stoploss().unwrap() - 1.0820).abs() < f64::EPSILON);
1327    }
1328
1329    #[test]
1330    fn set_stoploss_adds_when_missing() {
1331        let mut pos = Position::new_market(
1332            "p1".into(),
1333            "EURUSD".into(),
1334            Side::Buy,
1335            make_fill(1.0850, 1.0),
1336            vec![],
1337        );
1338        assert!(pos.current_stoploss().is_none());
1339
1340        let old = pos.set_stoploss(1.0800);
1341        assert!(old.is_none());
1342        assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1343    }
1344
1345    #[test]
1346    fn remove_rule_by_name() {
1347        let mut pos = Position::new_market(
1348            "p1".into(),
1349            "EURUSD".into(),
1350            Side::Buy,
1351            make_fill(1.0850, 1.0),
1352            vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1353        );
1354        assert_eq!(pos.rules.len(), 2);
1355        assert!(pos.remove_rule("TakeProfit"));
1356        assert_eq!(pos.rules.len(), 1);
1357        assert_eq!(pos.rules[0].name(), "FixedStoploss");
1358    }
1359
1360    #[test]
1361    fn evaluate_rules_produces_effects() {
1362        let mut pos = Position::new_market(
1363            "p1".into(),
1364            "EURUSD".into(),
1365            Side::Buy,
1366            make_fill(1.0850, 1.0),
1367            vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1368        );
1369
1370        // Price between SL and TP → no effects
1371        let q = PriceQuote {
1372            symbol: "EURUSD".into(),
1373            ts: ts(10, 5, 0),
1374            bid: 1.0860,
1375            ask: 1.0862,
1376        };
1377        let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1378        assert!(effects.is_empty());
1379
1380        // Price hits SL → close effect
1381        let q_sl = PriceQuote {
1382            symbol: "EURUSD".into(),
1383            ts: ts(10, 10, 0),
1384            bid: 1.0799,
1385            ask: 1.0801,
1386        };
1387        let effects = pos.evaluate_rules(&q_sl, FillModel::BidAsk);
1388        assert!(!effects.is_empty());
1389        assert!(matches!(
1390            &effects[0],
1391            Effect::PositionClosed {
1392                reason: CloseReason::Stoploss,
1393                ..
1394            }
1395        ));
1396    }
1397
1398    #[test]
1399    fn pending_position_skips_rule_evaluation() {
1400        let mut pos = Position::new_pending(
1401            "p1".into(),
1402            "EURUSD".into(),
1403            Side::Buy,
1404            OrderType::Limit,
1405            1.0800,
1406            1.0,
1407            ts(9, 0, 0),
1408            vec![Rule::fixed_stoploss(1.0750)],
1409        );
1410
1411        // Even though bid is below SL, position is pending → no effects
1412        let q = PriceQuote {
1413            symbol: "EURUSD".into(),
1414            ts: ts(10, 0, 0),
1415            bid: 1.0740,
1416            ask: 1.0742,
1417        };
1418        let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1419        assert!(effects.is_empty());
1420    }
1421}