Skip to main content

qs_backtest/
future_executor.rs

1//! Fill-authoritative accounting for the FutureQuoteV1 replay path.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use chrono::NaiveDateTime;
6
7use qs_core::TradeEngine;
8use qs_core::costs::{CostBasis, CostCharge, CostKind, InstrumentCosts};
9use qs_core::types::{
10    CloseReason, Effect, EffectiveStop, FillPurpose, FutureEffect, FutureFill, PriceQuote, Side,
11    StopOrigin, position_size_tolerance,
12};
13use thiserror::Error;
14
15use crate::artifacts::{
16    CloseEvent, CompletedPosition, CostEvent, OpenPositionSnapshot, PendingOrderLifecycleEvent,
17    PendingOrderLifecycleState, RecordedFill, RiskBasisStatus, RiskTranche, deterministic_event_id,
18};
19use crate::currency::{
20    ConversionError, ConversionQuoteBook, ConversionResult, ConversionRoute, RunCurrencyPlan,
21};
22use crate::portfolio::PortfolioRecorder;
23
24#[derive(Debug, Clone)]
25struct PendingOrigin {
26    position_id: String,
27    placement_action_id: Option<String>,
28    signal_ts: Option<NaiveDateTime>,
29    effective_ts: NaiveDateTime,
30    placed_ts: NaiveDateTime,
31    symbol: String,
32    side: Side,
33    order_type: qs_core::types::OrderType,
34    requested_size: f64,
35    requested_price: Option<f64>,
36    placement_sequence: u64,
37    initial_stop: Option<f64>,
38}
39
40#[derive(Debug, Error)]
41pub(crate) enum FutureExecutorError {
42    #[error("position not found while processing FutureQuote effect: {0}")]
43    PositionNotFound(String),
44    #[error("account not found while processing FutureQuote effect: {0}")]
45    AccountNotFound(String),
46    #[error("fill-bearing effect was emitted without a fill: {0}")]
47    MissingFill(String),
48    #[error("non-fill effect unexpectedly carried a fill: {0}")]
49    UnexpectedFill(String),
50    #[error("invalid carried fill for {position_id}: {reason}")]
51    InvalidFill { position_id: String, reason: String },
52    #[error("portfolio rejected realized P&L {pnl} for {position_id}")]
53    PortfolioRejectedRealizedPnl { position_id: String, pnl: f64 },
54    #[error("currency plan has no P&L currency or route for primary symbol {0}")]
55    MissingCurrencyRoute(String),
56
57    #[error("account conversion failed for {symbol} at {operation_ts}: {source}")]
58    Conversion {
59        symbol: String,
60        operation_ts: NaiveDateTime,
61        #[source]
62        source: ConversionError,
63    },
64    #[error("account conversion for {symbol} produced invalid {kind} amount {amount}")]
65    InvalidConvertedAmount {
66        symbol: String,
67        kind: &'static str,
68        amount: f64,
69    },
70}
71
72#[derive(Debug, Clone)]
73struct PositionAccount {
74    position_id: String,
75    symbol: String,
76    side: Side,
77    group: Option<String>,
78    trade_id: Option<String>,
79    open_ts: NaiveDateTime,
80    entry_size: f64,
81    remaining_size: f64,
82    /// Value of every historical entry, used only for campaign-level audit.
83    entry_value: f64,
84    /// Average-cost basis assigned only to inventory that is still open.
85    open_entry_value: f64,
86    initial_stop: Option<f64>,
87    effective_stop: Option<EffectiveStop>,
88    risk_tranches: Vec<RiskTranche>,
89    close_events: Vec<CloseEvent>,
90    realized_pnl: f64,
91    native_realized_pnl: f64,
92    native_currency: Option<String>,
93    account_currency: Option<String>,
94    /// Account-currency commission charged on entry and scale-in fills.
95    entry_commission_total: f64,
96    /// Account-currency swap charged while this position was open.
97    swap_total: f64,
98}
99
100/// One trading cost after optional account conversion.
101#[derive(Debug, Clone, Default)]
102struct ResolvedCost {
103    /// Signed account-currency amount, where positive reduces the balance.
104    amount: f64,
105    native_amount: Option<f64>,
106    native_currency: Option<String>,
107    conversion: Option<ConversionResult>,
108}
109
110#[derive(Debug, Clone)]
111struct AccountedAmount {
112    amount: f64,
113    native_currency: Option<String>,
114    conversion: Option<ConversionResult>,
115}
116
117impl PositionAccount {
118    fn average_entry(&self) -> f64 {
119        if self.remaining_size <= position_size_tolerance(self.entry_size) {
120            0.0
121        } else {
122            self.open_entry_value / self.remaining_size
123        }
124    }
125
126    fn historical_average_entry(&self) -> f64 {
127        if self.entry_size <= position_size_tolerance(self.entry_size) {
128            0.0
129        } else {
130            self.entry_value / self.entry_size
131        }
132    }
133
134    fn snapshot(&self) -> OpenPositionSnapshot {
135        let mut snapshot = OpenPositionSnapshot::new(
136            self.position_id.clone(),
137            self.symbol.clone(),
138            self.side,
139            self.average_entry(),
140            self.remaining_size,
141        );
142        snapshot.group = self.group.clone();
143        snapshot.trade_id = self.trade_id.clone();
144        snapshot.open_ts = Some(self.open_ts);
145        snapshot.initial_stop = self.initial_stop;
146        snapshot.effective_stop = self.effective_stop;
147        snapshot.realized_pnl = self.realized_pnl;
148        snapshot.native_realized_pnl = Some(self.native_realized_pnl);
149        snapshot.native_currency = self.native_currency.clone();
150        snapshot.account_currency = self.account_currency.clone();
151        snapshot
152    }
153}
154
155/// Accounting adapter for quote-authoritative FutureQuoteV1 execution.
156#[derive(Debug, Clone)]
157pub struct FutureExecutor {
158    initial_balance: f64,
159    balance: f64,
160    contract_sizes: HashMap<String, f64>,
161    costs: HashMap<String, InstrumentCosts>,
162    point_sizes: HashMap<String, f64>,
163    currency_plan: Option<RunCurrencyPlan>,
164    accounts: BTreeMap<String, PositionAccount>,
165    pending_origins: BTreeMap<String, PendingOrigin>,
166    terminal_pending_orders: BTreeSet<String>,
167
168    pub fills: Vec<RecordedFill>,
169    pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
170    pub close_events: Vec<CloseEvent>,
171    pub cost_events: Vec<CostEvent>,
172    pub completed_positions: Vec<CompletedPosition>,
173    fill_sequence: u64,
174    close_sequence: u64,
175    cost_sequence: u64,
176    pending_lifecycle_sequence: u64,
177    pnl_epsilon: f64,
178    last_rollover_ts: Option<NaiveDateTime>,
179}
180
181#[derive(Debug)]
182struct FutureExecutorCheckpoint {
183    accounts: BTreeMap<String, Option<PositionAccount>>,
184    pending_origins: BTreeMap<String, Option<PendingOrigin>>,
185    terminal_pending_orders: BTreeMap<String, bool>,
186    balance: f64,
187    fill_sequence: u64,
188    close_sequence: u64,
189    cost_sequence: u64,
190    pending_lifecycle_sequence: u64,
191    fills_len: usize,
192    pending_order_lifecycle_len: usize,
193    close_events_len: usize,
194    cost_events_len: usize,
195    completed_positions_len: usize,
196}
197
198#[derive(Debug)]
199struct PendingCampaignCompletion {
200    position_id: String,
201    final_net_pnl: f64,
202    completed_position_index: usize,
203}
204
205#[derive(Debug)]
206struct PortfolioBatch {
207    realized_pnl: f64,
208    realized_pnl_changed: bool,
209    campaign_completions: Vec<PendingCampaignCompletion>,
210}
211
212impl PortfolioBatch {
213    fn new(portfolio: &PortfolioRecorder) -> Self {
214        Self {
215            realized_pnl: portfolio.realized_pnl(),
216            realized_pnl_changed: false,
217            campaign_completions: Vec::new(),
218        }
219    }
220
221    fn add_realized_pnl(&mut self, pnl: f64) -> bool {
222        let next = self.realized_pnl + pnl;
223        if !pnl.is_finite() || !next.is_finite() {
224            return false;
225        }
226        self.realized_pnl = next;
227        self.realized_pnl_changed = true;
228        true
229    }
230
231    fn finish_campaign(
232        &mut self,
233        position_id: String,
234        final_net_pnl: f64,
235        completed_position_index: usize,
236    ) {
237        self.campaign_completions.push(PendingCampaignCompletion {
238            position_id,
239            final_net_pnl,
240            completed_position_index,
241        });
242    }
243
244    fn commit(self, portfolio: &mut PortfolioRecorder, completed: &mut [CompletedPosition]) {
245        if self.realized_pnl_changed {
246            let updated = portfolio.set_realized_pnl(self.realized_pnl);
247            debug_assert!(updated, "staged realized P&L was prevalidated");
248        }
249        for completion in self.campaign_completions {
250            let excursion =
251                portfolio.finish_campaign(&completion.position_id, completion.final_net_pnl);
252            if let Some(position) = completed.get_mut(completion.completed_position_index) {
253                position.mae = excursion.map(|value| value.mae);
254                position.mfe = excursion.map(|value| value.mfe);
255            }
256        }
257    }
258}
259
260impl FutureExecutorCheckpoint {
261    fn capture(executor: &FutureExecutor, effects: &[FutureEffect]) -> Self {
262        let affected_ids: BTreeSet<_> = effects
263            .iter()
264            .map(|effect| effect_position_id(effect.effect()).to_owned())
265            .collect();
266        let accounts = affected_ids
267            .iter()
268            .map(|id| (id.clone(), executor.accounts.get(id).cloned()))
269            .collect();
270        let pending_origins = affected_ids
271            .iter()
272            .map(|id| (id.clone(), executor.pending_origins.get(id).cloned()))
273            .collect();
274        let terminal_pending_orders = affected_ids
275            .into_iter()
276            .map(|id| {
277                let present = executor.terminal_pending_orders.contains(&id);
278                (id, present)
279            })
280            .collect();
281        Self {
282            accounts,
283            pending_origins,
284            terminal_pending_orders,
285            balance: executor.balance,
286            fill_sequence: executor.fill_sequence,
287            close_sequence: executor.close_sequence,
288            cost_sequence: executor.cost_sequence,
289            pending_lifecycle_sequence: executor.pending_lifecycle_sequence,
290            fills_len: executor.fills.len(),
291            pending_order_lifecycle_len: executor.pending_order_lifecycle.len(),
292            close_events_len: executor.close_events.len(),
293            cost_events_len: executor.cost_events.len(),
294            completed_positions_len: executor.completed_positions.len(),
295        }
296    }
297
298    fn restore(self, executor: &mut FutureExecutor) {
299        restore_entries(&mut executor.accounts, self.accounts);
300        restore_entries(&mut executor.pending_origins, self.pending_origins);
301        for (id, present) in self.terminal_pending_orders {
302            if present {
303                executor.terminal_pending_orders.insert(id);
304            } else {
305                executor.terminal_pending_orders.remove(&id);
306            }
307        }
308        executor.balance = self.balance;
309        executor.fill_sequence = self.fill_sequence;
310        executor.close_sequence = self.close_sequence;
311        executor.cost_sequence = self.cost_sequence;
312        executor.pending_lifecycle_sequence = self.pending_lifecycle_sequence;
313        executor.fills.truncate(self.fills_len);
314        executor
315            .pending_order_lifecycle
316            .truncate(self.pending_order_lifecycle_len);
317        executor.close_events.truncate(self.close_events_len);
318        executor.cost_events.truncate(self.cost_events_len);
319        executor
320            .completed_positions
321            .truncate(self.completed_positions_len);
322    }
323}
324
325fn restore_entries<T>(entries: &mut BTreeMap<String, T>, checkpoint: BTreeMap<String, Option<T>>) {
326    for (id, value) in checkpoint {
327        match value {
328            Some(value) => {
329                entries.insert(id, value);
330            }
331            None => {
332                entries.remove(&id);
333            }
334        }
335    }
336}
337
338fn effect_position_id(effect: &Effect) -> &str {
339    match effect {
340        Effect::OrderPlaced { id }
341        | Effect::OrderCancelled { id }
342        | Effect::PositionOpened { id }
343        | Effect::PositionClosed { id, .. }
344        | Effect::PartialClose { id, .. }
345        | Effect::StoplossModified { id, .. }
346        | Effect::StoplossRemoved { id, .. }
347        | Effect::ScaledIn { id, .. }
348        | Effect::RuleTriggered { id, .. } => id,
349    }
350}
351
352impl FutureExecutor {
353    pub fn new(
354        initial_balance: f64,
355        contract_sizes: HashMap<String, f64>,
356        pnl_epsilon: f64,
357    ) -> Self {
358        Self {
359            initial_balance,
360            balance: initial_balance,
361            contract_sizes,
362            costs: HashMap::new(),
363            point_sizes: HashMap::new(),
364            currency_plan: None,
365            accounts: BTreeMap::new(),
366            pending_origins: BTreeMap::new(),
367            terminal_pending_orders: BTreeSet::new(),
368
369            fills: Vec::new(),
370            pending_order_lifecycle: Vec::new(),
371            close_events: Vec::new(),
372            cost_events: Vec::new(),
373            completed_positions: Vec::new(),
374            fill_sequence: 0,
375            close_sequence: 0,
376            cost_sequence: 0,
377            pending_lifecycle_sequence: 0,
378            pnl_epsilon: if pnl_epsilon.is_finite() {
379                pnl_epsilon.abs()
380            } else {
381                1.0e-9
382            },
383            last_rollover_ts: None,
384        }
385    }
386
387    pub fn with_currency_plan(mut self, currency_plan: Option<RunCurrencyPlan>) -> Self {
388        self.currency_plan = currency_plan;
389        self
390    }
391
392    /// Attach per-symbol trading costs and the price point size used by point-denominated swap.
393    pub fn with_costs(
394        mut self,
395        costs: HashMap<String, InstrumentCosts>,
396        point_sizes: HashMap<String, f64>,
397    ) -> Self {
398        self.costs = costs;
399        self.point_sizes = point_sizes;
400        self
401    }
402
403    pub fn balance(&self) -> f64 {
404        self.balance
405    }
406
407    pub fn realized_pnl(&self) -> f64 {
408        self.balance - self.initial_balance
409    }
410
411    pub(crate) fn requires_processing(effects: &[FutureEffect]) -> bool {
412        effects.iter().any(|effect| {
413            !matches!(
414                effect,
415                FutureEffect::Plain {
416                    effect: Effect::RuleTriggered { .. },
417                    ..
418                }
419            )
420        })
421    }
422
423    pub fn has_close(&self, position_id: &str) -> bool {
424        self.accounts
425            .get(position_id)
426            .is_some_and(|account| !account.close_events.is_empty())
427            || self
428                .completed_positions
429                .iter()
430                .any(|position| position.position_id == position_id)
431    }
432
433    pub fn pending_metadata(
434        &self,
435        position_id: &str,
436    ) -> Option<(String, NaiveDateTime, NaiveDateTime)> {
437        self.pending_origins.get(position_id).and_then(|origin| {
438            Some((
439                origin.placement_action_id.clone()?,
440                origin.signal_ts?,
441                origin.effective_ts,
442            ))
443        })
444    }
445
446    /// Positive initial risk of an open position on the same basis a completed position reports for R normalization, or `None` when that basis is unavailable.
447    pub(crate) fn open_initial_risk(&self, position_id: &str) -> Option<f64> {
448        let account = self.accounts.get(position_id)?;
449        crate::artifacts::summarize_risk(&account.risk_tranches, self.pnl_epsilon)
450            .1
451            .filter(|risk| risk.is_finite() && *risk > 0.0)
452    }
453
454    pub fn open_snapshots(&self) -> Vec<OpenPositionSnapshot> {
455        self.accounts
456            .values()
457            .filter(|account| account.remaining_size > position_size_tolerance(account.entry_size))
458            .map(PositionAccount::snapshot)
459            .collect()
460    }
461
462    /// Mark every still-pending order as unfilled at the deterministic end of
463    /// the accepted quote stream. Pending snapshots remain available separately.
464    pub fn finalize_pending_orders_at_end(&mut self, terminal_ts: NaiveDateTime) {
465        let mut pending: Vec<_> = self.pending_origins.values().cloned().collect();
466        pending.sort_by_key(|origin| origin.placement_sequence);
467        for origin in pending {
468            self.record_pending_terminal(
469                &origin,
470                PendingOrderLifecycleState::UnfilledAtEnd,
471                None,
472                0.0,
473                None,
474                terminal_ts,
475            );
476        }
477    }
478
479    #[allow(dead_code, clippy::too_many_arguments)]
480    pub(crate) fn process_future_effects(
481        &mut self,
482        effects: &[FutureEffect],
483        engine: &TradeEngine,
484        quote: &PriceQuote,
485        action_id: Option<&str>,
486        signal_ts: Option<NaiveDateTime>,
487        effective_ts: NaiveDateTime,
488        portfolio: &mut PortfolioRecorder,
489    ) -> Result<Vec<String>, FutureExecutorError> {
490        self.process_future_effects_with_currency(
491            effects,
492            engine,
493            quote,
494            action_id,
495            signal_ts,
496            effective_ts,
497            portfolio,
498            None,
499        )
500    }
501
502    #[allow(clippy::too_many_arguments)]
503    pub(crate) fn process_future_effects_with_currency(
504        &mut self,
505        effects: &[FutureEffect],
506        engine: &TradeEngine,
507        quote: &PriceQuote,
508        action_id: Option<&str>,
509        signal_ts: Option<NaiveDateTime>,
510        effective_ts: NaiveDateTime,
511        portfolio: &mut PortfolioRecorder,
512        conversion_quotes: Option<&ConversionQuoteBook>,
513    ) -> Result<Vec<String>, FutureExecutorError> {
514        if !Self::requires_processing(effects) {
515            return Ok(Vec::new());
516        }
517
518        let checkpoint = FutureExecutorCheckpoint::capture(self, effects);
519        let mut portfolio_batch = PortfolioBatch::new(portfolio);
520        let result = (|| -> Result<Vec<String>, FutureExecutorError> {
521            let mut affected = Vec::new();
522            for future_effect in effects {
523                match future_effect {
524                    FutureEffect::Plain {
525                        effect,
526                        stop_origin,
527                        ..
528                    } => match effect {
529                        Effect::PositionOpened { .. }
530                        | Effect::PositionClosed { .. }
531                        | Effect::PartialClose { .. }
532                        | Effect::ScaledIn { .. } => {
533                            return Err(FutureExecutorError::MissingFill(format!("{effect:?}")));
534                        }
535                        Effect::OrderPlaced { id } => {
536                            let position = engine
537                                .get_position(id)
538                                .ok_or_else(|| FutureExecutorError::PositionNotFound(id.clone()))?;
539                            let origin = PendingOrigin {
540                                position_id: id.clone(),
541                                placement_action_id: action_id.map(str::to_owned),
542                                signal_ts,
543                                effective_ts,
544                                placed_ts: quote.ts,
545                                symbol: position.data.symbol.clone(),
546                                side: position.data.side,
547                                order_type: position.data.order_type,
548                                requested_size: position.data.size,
549                                requested_price: position.data.pending_price,
550                                placement_sequence: self.pending_lifecycle_sequence,
551                                initial_stop: position.current_stoploss(),
552                            };
553                            self.record_pending_placed(&origin);
554                            self.pending_origins.insert(id.clone(), origin);
555                        }
556                        Effect::OrderCancelled { id } => {
557                            if let Some(origin) = self.pending_origins.remove(id) {
558                                self.record_pending_terminal(
559                                    &origin,
560                                    PendingOrderLifecycleState::Cancelled,
561                                    action_id.map(str::to_owned),
562                                    0.0,
563                                    None,
564                                    quote.ts,
565                                );
566                            }
567                        }
568                        Effect::StoplossModified { id, new_price, .. } => {
569                            if !new_price.is_finite() || *new_price <= 0.0 {
570                                return Err(FutureExecutorError::InvalidFill {
571                                    position_id: id.clone(),
572                                    reason: format!(
573                                        "stoploss must be finite and positive, got {new_price}"
574                                    ),
575                                });
576                            }
577                            if let Some(account) = self.accounts.get_mut(id) {
578                                account.effective_stop = Some(EffectiveStop::new(
579                                    *new_price,
580                                    stop_origin.unwrap_or(StopOrigin::Modified),
581                                ));
582                            } else if self.pending_origins.contains_key(id) {
583                                // The immutable initial stop stays on the pending origin;
584                                // the engine supplies the effective stop when it fills.
585                            } else {
586                                return Err(FutureExecutorError::AccountNotFound(id.clone()));
587                            }
588                            affected.push(id.clone());
589                        }
590                        Effect::StoplossRemoved { id, .. } => {
591                            if let Some(account) = self.accounts.get_mut(id) {
592                                account.effective_stop = None;
593                            } else if self.pending_origins.contains_key(id) {
594                                // Removing a pending stop changes engine state only; the
595                                // placement-time initial stop remains an audit field.
596                            } else {
597                                return Err(FutureExecutorError::AccountNotFound(id.clone()));
598                            }
599                            affected.push(id.clone());
600                        }
601                        Effect::RuleTriggered { .. } => {}
602                    },
603                    FutureEffect::Filled { effect, fill, .. } => {
604                        self.validate_carried_fill(effect, fill, quote)?;
605                        match effect {
606                            Effect::PositionOpened { id } => {
607                                self.record_open(
608                                    id,
609                                    fill,
610                                    engine,
611                                    quote,
612                                    action_id,
613                                    signal_ts,
614                                    effective_ts,
615                                    &mut portfolio_batch,
616                                    conversion_quotes,
617                                )?;
618                                affected.push(id.clone());
619                            }
620                            Effect::ScaledIn { id, .. } => {
621                                self.record_scale_in(
622                                    id,
623                                    fill,
624                                    engine,
625                                    quote,
626                                    action_id,
627                                    signal_ts,
628                                    effective_ts,
629                                    &mut portfolio_batch,
630                                    conversion_quotes,
631                                )?;
632                                affected.push(id.clone());
633                            }
634                            Effect::PositionClosed { id, reason } => {
635                                self.record_close(
636                                    id,
637                                    *reason,
638                                    fill,
639                                    quote,
640                                    action_id,
641                                    signal_ts,
642                                    effective_ts,
643                                    &mut portfolio_batch,
644                                    true,
645                                    conversion_quotes,
646                                )?;
647                                if self.accounts.contains_key(id) {
648                                    return Err(FutureExecutorError::InvalidFill {
649                                        position_id: id.clone(),
650                                        reason: "full-close effect left an open account".into(),
651                                    });
652                                }
653                                affected.push(id.clone());
654                            }
655                            Effect::PartialClose { id, reason, .. } => {
656                                self.record_close(
657                                    id,
658                                    *reason,
659                                    fill,
660                                    quote,
661                                    action_id,
662                                    signal_ts,
663                                    effective_ts,
664                                    &mut portfolio_batch,
665                                    false,
666                                    conversion_quotes,
667                                )?;
668                                affected.push(id.clone());
669                            }
670                            _ => {
671                                return Err(FutureExecutorError::UnexpectedFill(format!(
672                                    "{effect:?}"
673                                )));
674                            }
675                        }
676                    }
677                }
678            }
679            affected.sort();
680            affected.dedup();
681            Ok(affected)
682        })();
683
684        match result {
685            Ok(affected) => {
686                portfolio_batch.commit(portfolio, &mut self.completed_positions);
687                Ok(affected)
688            }
689            Err(error) => {
690                checkpoint.restore(self);
691                Err(error)
692            }
693        }
694    }
695
696    fn record_pending_placed(&mut self, origin: &PendingOrigin) {
697        let event = self.pending_lifecycle_event(
698            origin,
699            PendingOrderLifecycleState::Placed,
700            None,
701            None,
702            None,
703            None,
704        );
705        self.pending_order_lifecycle.push(event);
706    }
707
708    fn record_pending_terminal(
709        &mut self,
710        origin: &PendingOrigin,
711        state: PendingOrderLifecycleState,
712        terminal_action_id: Option<String>,
713        filled_size: f64,
714        fill_price: Option<f64>,
715        terminal_ts: NaiveDateTime,
716    ) {
717        debug_assert!(state.is_terminal());
718        if !self
719            .terminal_pending_orders
720            .insert(origin.position_id.clone())
721        {
722            return;
723        }
724        let event = self.pending_lifecycle_event(
725            origin,
726            state,
727            terminal_action_id,
728            Some(filled_size),
729            fill_price,
730            Some(terminal_ts),
731        );
732        self.pending_order_lifecycle.push(event);
733    }
734
735    fn pending_lifecycle_event(
736        &mut self,
737        origin: &PendingOrigin,
738        state: PendingOrderLifecycleState,
739        terminal_action_id: Option<String>,
740        filled_size: Option<f64>,
741        fill_price: Option<f64>,
742        terminal_ts: Option<NaiveDateTime>,
743    ) -> PendingOrderLifecycleEvent {
744        let sequence = self.pending_lifecycle_sequence;
745        self.pending_lifecycle_sequence += 1;
746        let kind = match state {
747            PendingOrderLifecycleState::Placed => "pending_placed",
748            PendingOrderLifecycleState::Filled => "pending_filled",
749            PendingOrderLifecycleState::Cancelled => "pending_cancelled",
750            PendingOrderLifecycleState::UnfilledAtEnd => "pending_unfilled_at_end",
751        };
752        let wait_latency_ms = terminal_ts
753            .map(|terminal_ts| (terminal_ts - origin.placed_ts).num_milliseconds().max(0));
754        let fill_ratio = filled_size.and_then(|filled_size| {
755            (origin.requested_size.is_finite() && origin.requested_size > 0.0)
756                .then_some(filled_size / origin.requested_size)
757        });
758
759        PendingOrderLifecycleEvent {
760            id: deterministic_event_id(&origin.position_id, kind, sequence),
761            sequence,
762            position_id: origin.position_id.clone(),
763            placement_action_id: origin.placement_action_id.clone(),
764            terminal_action_id,
765            state,
766            symbol: origin.symbol.clone(),
767            side: origin.side,
768            order_type: origin.order_type,
769            requested_size: origin.requested_size,
770            filled_size,
771            requested_price: origin.requested_price,
772            fill_price,
773            signal_ts: origin.signal_ts,
774            placed_ts: Some(origin.placed_ts),
775            effective_ts: Some(origin.effective_ts),
776            terminal_ts,
777            wait_latency_ms,
778            fill_ratio,
779        }
780    }
781
782    fn validate_carried_fill(
783        &self,
784        effect: &Effect,
785        fill: &FutureFill,
786        quote: &PriceQuote,
787    ) -> Result<(), FutureExecutorError> {
788        let position_id = match effect {
789            Effect::PositionOpened { id }
790            | Effect::PositionClosed { id, .. }
791            | Effect::PartialClose { id, .. }
792            | Effect::ScaledIn { id, .. } => id.clone(),
793            _ => "<non-fill-effect>".into(),
794        };
795        if fill.source_quote_ts() != quote.ts {
796            return Err(FutureExecutorError::InvalidFill {
797                position_id,
798                reason: format!(
799                    "fill source quote timestamp {} does not match quote {}",
800                    fill.source_quote_ts(),
801                    quote.ts
802                ),
803            });
804        }
805        if fill.ts < quote.ts {
806            return Err(FutureExecutorError::InvalidFill {
807                position_id,
808                reason: format!(
809                    "fill execution timestamp {} precedes source quote {}",
810                    fill.ts, quote.ts
811                ),
812            });
813        }
814        if !fill.size.is_finite() || fill.size <= position_size_tolerance(fill.size) {
815            return Err(FutureExecutorError::InvalidFill {
816                position_id,
817                reason: format!(
818                    "size must be finite and greater than the accounting tolerance, got {}",
819                    fill.size
820                ),
821            });
822        }
823        if !fill.execution.price.is_finite() || fill.execution.price <= 0.0 {
824            return Err(FutureExecutorError::InvalidFill {
825                position_id,
826                reason: format!(
827                    "price must be finite and positive, got {}",
828                    fill.execution.price
829                ),
830            });
831        }
832        let purpose_matches = match effect {
833            Effect::PositionOpened { .. } => matches!(
834                fill.execution.purpose,
835                FillPurpose::MarketEntry | FillPurpose::LimitEntry | FillPurpose::StopEntry
836            ),
837            Effect::ScaledIn { .. } => fill.execution.purpose == FillPurpose::MarketEntry,
838            Effect::PositionClosed { reason, .. } | Effect::PartialClose { reason, .. } => {
839                fill.execution.purpose
840                    == match reason {
841                        CloseReason::Target => FillPurpose::TakeProfit,
842                        CloseReason::Stoploss
843                        | CloseReason::TrailingStop
844                        | CloseReason::BreakevenStop => FillPurpose::StopLoss,
845                        _ => FillPurpose::MarketExit,
846                    }
847            }
848            _ => false,
849        };
850        if !purpose_matches {
851            return Err(FutureExecutorError::InvalidFill {
852                position_id,
853                reason: format!(
854                    "execution purpose {:?} does not match effect",
855                    fill.execution.purpose
856                ),
857            });
858        }
859        Ok(())
860    }
861
862    #[allow(clippy::too_many_arguments)]
863    fn record_open(
864        &mut self,
865        id: &str,
866        fill: &FutureFill,
867        engine: &TradeEngine,
868        quote: &PriceQuote,
869        action_id: Option<&str>,
870        signal_ts: Option<NaiveDateTime>,
871        effective_ts: NaiveDateTime,
872        portfolio_batch: &mut PortfolioBatch,
873        conversion_quotes: Option<&ConversionQuoteBook>,
874    ) -> Result<(), FutureExecutorError> {
875        if self.accounts.contains_key(id) {
876            return Err(FutureExecutorError::InvalidFill {
877                position_id: id.to_owned(),
878                reason: "position already has an open accounting record".into(),
879            });
880        }
881        let position = engine
882            .get_position(id)
883            .ok_or_else(|| FutureExecutorError::PositionNotFound(id.to_owned()))?;
884        if position.data.status != qs_core::types::PositionStatus::Open {
885            return Err(FutureExecutorError::InvalidFill {
886                position_id: id.to_owned(),
887                reason: format!("position is not open: {}", position.data.status),
888            });
889        }
890        if position.data.side != fill.execution.side {
891            return Err(FutureExecutorError::InvalidFill {
892                position_id: id.to_owned(),
893                reason: "execution side does not match position".into(),
894            });
895        }
896        let execution = fill.execution;
897        let size = fill.size;
898        let current_stop = position.current_effective_stop();
899        let group = position.data.group.clone();
900        let trade_id = position.data.trade_id.clone();
901        let side = position.data.side;
902        let symbol = position.data.symbol.clone();
903        let open_ts = fill.ts;
904
905        let origin = self.pending_origins.get(id).cloned();
906        let initial_stop = origin.as_ref().map_or_else(
907            || current_stop.map(|stop| stop.price),
908            |value| value.initial_stop,
909        );
910        let recorded_action_id = action_id.map(str::to_owned).or_else(|| {
911            origin
912                .as_ref()
913                .and_then(|value| value.placement_action_id.clone())
914        });
915        let recorded_signal_ts =
916            signal_ts.or_else(|| origin.as_ref().and_then(|value| value.signal_ts));
917        let recorded_effective_ts = origin
918            .as_ref()
919            .map(|value| value.effective_ts)
920            .unwrap_or(effective_ts);
921        let recorded = RecordedFill::from_quote_at(
922            id.to_owned(),
923            recorded_action_id,
924            self.fill_sequence,
925            recorded_signal_ts,
926            recorded_effective_ts,
927            fill.ts,
928            size,
929            quote,
930            execution,
931        );
932        let contract_size = self.contract_size(&symbol);
933        let risk = self.account_risk_tranche(
934            &symbol,
935            fill.ts,
936            RiskTranche::calculate(
937                Some(recorded.id.clone()),
938                side,
939                size,
940                execution.price,
941                current_stop.map(|stop| stop.price),
942                contract_size,
943                self.pnl_epsilon,
944            ),
945            conversion_quotes,
946        )?;
947        let symbol_for_cost = symbol.clone();
948        let commission = self.resolve_commission(
949            &symbol,
950            side,
951            size,
952            execution.price,
953            fill.ts,
954            conversion_quotes,
955        )?;
956        let next_balance = self.balance - commission.amount;
957        if !next_balance.is_finite()
958            || (commission.amount != 0.0 && !portfolio_batch.add_realized_pnl(-commission.amount))
959        {
960            return Err(FutureExecutorError::PortfolioRejectedRealizedPnl {
961                position_id: id.to_owned(),
962                pnl: -commission.amount,
963            });
964        }
965        self.balance = next_balance;
966        self.fill_sequence += 1;
967        self.pending_origins.remove(id);
968        self.fills.push(recorded);
969        self.accounts.insert(
970            id.to_owned(),
971            PositionAccount {
972                position_id: id.to_owned(),
973                symbol,
974                side,
975                group,
976                trade_id,
977                open_ts,
978                entry_size: size,
979                remaining_size: size,
980                entry_value: execution.price * size,
981                open_entry_value: execution.price * size,
982                initial_stop,
983                effective_stop: current_stop,
984                native_currency: risk.native_currency.clone(),
985                account_currency: self
986                    .currency_plan
987                    .as_ref()
988                    .map(|plan| plan.account_currency().to_owned()),
989                risk_tranches: vec![risk],
990                close_events: Vec::new(),
991                realized_pnl: -commission.amount,
992                native_realized_pnl: 0.0,
993                entry_commission_total: commission.amount,
994                swap_total: 0.0,
995            },
996        );
997        if commission.amount != 0.0 {
998            self.push_cost_event(
999                id,
1000                &symbol_for_cost,
1001                side,
1002                fill.ts,
1003                CostKind::EntryCommission,
1004                &commission,
1005                size,
1006                None,
1007            );
1008        }
1009        if let Some(origin) = origin {
1010            self.record_pending_terminal(
1011                &origin,
1012                PendingOrderLifecycleState::Filled,
1013                action_id.map(str::to_owned),
1014                size,
1015                Some(execution.price),
1016                fill.ts,
1017            );
1018        }
1019        Ok(())
1020    }
1021
1022    #[allow(clippy::too_many_arguments)]
1023    fn record_scale_in(
1024        &mut self,
1025        id: &str,
1026        fill: &FutureFill,
1027        engine: &TradeEngine,
1028        quote: &PriceQuote,
1029        action_id: Option<&str>,
1030        signal_ts: Option<NaiveDateTime>,
1031        effective_ts: NaiveDateTime,
1032        portfolio_batch: &mut PortfolioBatch,
1033        conversion_quotes: Option<&ConversionQuoteBook>,
1034    ) -> Result<(), FutureExecutorError> {
1035        let position = engine
1036            .get_position(id)
1037            .ok_or_else(|| FutureExecutorError::PositionNotFound(id.to_owned()))?;
1038        if position.data.status != qs_core::types::PositionStatus::Open {
1039            return Err(FutureExecutorError::InvalidFill {
1040                position_id: id.to_owned(),
1041                reason: format!("position is not open: {}", position.data.status),
1042            });
1043        }
1044        if position.data.side != fill.execution.side {
1045            return Err(FutureExecutorError::InvalidFill {
1046                position_id: id.to_owned(),
1047                reason: "execution side does not match position".into(),
1048            });
1049        }
1050        let account = self
1051            .accounts
1052            .get(id)
1053            .ok_or_else(|| FutureExecutorError::AccountNotFound(id.to_owned()))?;
1054        let symbol = account.symbol.clone();
1055        let side = account.side;
1056        let effective_stop = account.effective_stop;
1057        let execution = fill.execution;
1058        let size = fill.size;
1059        let recorded = RecordedFill::from_quote_at(
1060            id.to_owned(),
1061            action_id.map(str::to_owned),
1062            self.fill_sequence,
1063            signal_ts,
1064            effective_ts,
1065            fill.ts,
1066            size,
1067            quote,
1068            execution,
1069        );
1070        let contract_size = self.contract_size(&symbol);
1071        let risk = self.account_risk_tranche(
1072            &symbol,
1073            fill.ts,
1074            RiskTranche::calculate(
1075                Some(recorded.id.clone()),
1076                side,
1077                size,
1078                execution.price,
1079                effective_stop.map(|stop| stop.price),
1080                contract_size,
1081                self.pnl_epsilon,
1082            ),
1083            conversion_quotes,
1084        )?;
1085        let commission = self.resolve_commission(
1086            &symbol,
1087            side,
1088            size,
1089            execution.price,
1090            fill.ts,
1091            conversion_quotes,
1092        )?;
1093        let next_balance = self.balance - commission.amount;
1094        if !next_balance.is_finite()
1095            || (commission.amount != 0.0 && !portfolio_batch.add_realized_pnl(-commission.amount))
1096        {
1097            return Err(FutureExecutorError::PortfolioRejectedRealizedPnl {
1098                position_id: id.to_owned(),
1099                pnl: -commission.amount,
1100            });
1101        }
1102        self.balance = next_balance;
1103        self.fill_sequence += 1;
1104        let account = self.accounts.get_mut(id).expect("account checked above");
1105        account.risk_tranches.push(risk);
1106        account.entry_size += size;
1107        account.remaining_size += size;
1108        account.entry_value += execution.price * size;
1109        account.open_entry_value += execution.price * size;
1110        account.entry_commission_total += commission.amount;
1111        account.realized_pnl -= commission.amount;
1112        self.fills.push(recorded);
1113        if commission.amount != 0.0 {
1114            self.push_cost_event(
1115                id,
1116                &symbol,
1117                side,
1118                fill.ts,
1119                CostKind::EntryCommission,
1120                &commission,
1121                size,
1122                None,
1123            );
1124        }
1125        Ok(())
1126    }
1127
1128    #[allow(clippy::too_many_arguments)]
1129    fn record_close(
1130        &mut self,
1131        id: &str,
1132        reason: CloseReason,
1133        fill: &FutureFill,
1134        quote: &PriceQuote,
1135        action_id: Option<&str>,
1136        signal_ts: Option<NaiveDateTime>,
1137        effective_ts: NaiveDateTime,
1138        portfolio_batch: &mut PortfolioBatch,
1139        full_close: bool,
1140        conversion_quotes: Option<&ConversionQuoteBook>,
1141    ) -> Result<(), FutureExecutorError> {
1142        let account = self
1143            .accounts
1144            .get(id)
1145            .ok_or_else(|| FutureExecutorError::AccountNotFound(id.to_owned()))?;
1146        let tolerance = position_size_tolerance(account.entry_size);
1147        if account.remaining_size <= tolerance {
1148            return Err(FutureExecutorError::InvalidFill {
1149                position_id: id.to_owned(),
1150                reason: "position has no remaining size".into(),
1151            });
1152        }
1153        if account.side != fill.execution.side {
1154            return Err(FutureExecutorError::InvalidFill {
1155                position_id: id.to_owned(),
1156                reason: "execution side does not match account".into(),
1157            });
1158        }
1159        if fill.size > account.remaining_size + tolerance {
1160            return Err(FutureExecutorError::InvalidFill {
1161                position_id: id.to_owned(),
1162                reason: format!(
1163                    "close size {} exceeds remaining size {}",
1164                    fill.size, account.remaining_size
1165                ),
1166            });
1167        }
1168        if full_close && (fill.size - account.remaining_size).abs() > tolerance {
1169            return Err(FutureExecutorError::InvalidFill {
1170                position_id: id.to_owned(),
1171                reason: format!(
1172                    "full-close size {} does not consume remaining size {}",
1173                    fill.size, account.remaining_size
1174                ),
1175            });
1176        }
1177
1178        let execution = fill.execution;
1179        let close_size = if full_close {
1180            account.remaining_size
1181        } else {
1182            fill.size.min(account.remaining_size)
1183        };
1184        let next_remaining = (account.remaining_size - close_size).max(0.0);
1185        if !full_close && next_remaining <= tolerance {
1186            return Err(FutureExecutorError::InvalidFill {
1187                position_id: id.to_owned(),
1188                reason: "partial-close effect consumed the entire account".into(),
1189            });
1190        }
1191        let contract_size = self.contract_size(&account.symbol);
1192        let entry_price = account.average_entry();
1193        let native_pnl = match account.side {
1194            Side::Buy => execution.price - entry_price,
1195            Side::Sell => entry_price - execution.price,
1196        } * close_size
1197            * contract_size;
1198        let accounted =
1199            self.convert_native_amount(&account.symbol, native_pnl, fill.ts, conversion_quotes)?;
1200        let cost_symbol = account.symbol.clone();
1201        // A close trades against the position, so an asymmetric venue charges the opposite side's rate.
1202        let cost_side = account.side.opposite();
1203        let commission = self.resolve_commission(
1204            &cost_symbol,
1205            cost_side,
1206            close_size,
1207            execution.price,
1208            fill.ts,
1209            conversion_quotes,
1210        )?;
1211        let pnl = accounted.amount - commission.amount;
1212        let next_balance = self.balance + pnl;
1213        if !next_balance.is_finite() || !portfolio_batch.add_realized_pnl(pnl) {
1214            return Err(FutureExecutorError::PortfolioRejectedRealizedPnl {
1215                position_id: id.to_owned(),
1216                pnl,
1217            });
1218        }
1219
1220        let recorded = RecordedFill::from_quote_at(
1221            id.to_owned(),
1222            action_id.map(str::to_owned),
1223            self.fill_sequence,
1224            signal_ts,
1225            effective_ts,
1226            fill.ts,
1227            close_size,
1228            quote,
1229            execution,
1230        );
1231        self.fill_sequence += 1;
1232
1233        let account = self.accounts.get_mut(id).expect("account checked above");
1234        account.remaining_size = if full_close { 0.0 } else { next_remaining };
1235        account.open_entry_value = if full_close {
1236            0.0
1237        } else {
1238            (account.open_entry_value - entry_price * close_size).max(0.0)
1239        };
1240        account.realized_pnl += pnl;
1241        account.native_realized_pnl += native_pnl;
1242
1243        let mut event = CloseEvent::new(
1244            id.to_owned(),
1245            self.close_sequence,
1246            account.symbol.clone(),
1247            account.side,
1248            fill.ts,
1249            close_size,
1250            execution.price,
1251            pnl,
1252            reason,
1253        );
1254        self.close_sequence += 1;
1255        event.action_id = action_id.map(str::to_owned);
1256        event.fill_id = Some(recorded.id.clone());
1257        event.entry_price = Some(entry_price);
1258        event.commission = commission.amount;
1259        event.native_pnl = Some(native_pnl);
1260        event.native_currency = accounted.native_currency;
1261        event.pnl_conversion = accounted.conversion;
1262        event.remaining_size = Some(account.remaining_size);
1263        account.close_events.push(event.clone());
1264
1265        self.balance = next_balance;
1266        self.fills.push(recorded);
1267        self.close_events.push(event);
1268        if commission.amount != 0.0 {
1269            self.push_cost_event(
1270                id,
1271                &cost_symbol,
1272                cost_side,
1273                fill.ts,
1274                CostKind::ExitCommission,
1275                &commission,
1276                close_size,
1277                None,
1278            );
1279        }
1280
1281        if full_close {
1282            let account = self.accounts.remove(id).expect("completed account exists");
1283            let final_net_pnl = account.realized_pnl;
1284            let average_entry = account.historical_average_entry();
1285            let mut completed = CompletedPosition::from_close_events(
1286                account.position_id,
1287                account.symbol,
1288                account.side,
1289                account.open_ts,
1290                fill.ts,
1291                account.entry_size,
1292                average_entry,
1293                account.initial_stop,
1294                account.effective_stop,
1295                account.risk_tranches,
1296                account.close_events,
1297                None,
1298                None,
1299                self.pnl_epsilon,
1300            );
1301            completed.group = account.group;
1302            completed.trade_id = account.trade_id;
1303            completed.charge_position_costs(account.entry_commission_total, account.swap_total);
1304            let completed_position_index = self.completed_positions.len();
1305            self.completed_positions.push(completed);
1306            portfolio_batch.finish_campaign(id.to_owned(), final_net_pnl, completed_position_index);
1307        }
1308        Ok(())
1309    }
1310
1311    fn account_risk_tranche(
1312        &self,
1313        symbol: &str,
1314        operation_ts: NaiveDateTime,
1315        mut tranche: RiskTranche,
1316        conversion_quotes: Option<&ConversionQuoteBook>,
1317    ) -> Result<RiskTranche, FutureExecutorError> {
1318        let Some(plan) = self.currency_plan.as_ref() else {
1319            return Ok(tranche);
1320        };
1321        let native_currency = plan
1322            .pnl_currency_for_primary_symbol(symbol)
1323            .ok_or_else(|| FutureExecutorError::MissingCurrencyRoute(symbol.to_owned()))?;
1324        tranche.native_currency = Some(native_currency.to_owned());
1325        if tranche.status != RiskBasisStatus::Available {
1326            return Ok(tranche);
1327        }
1328        let Some(native_risk) = tranche.native_risk_amount else {
1329            return Ok(tranche);
1330        };
1331        let accounted =
1332            self.convert_native_amount(symbol, -native_risk, operation_ts, conversion_quotes)?;
1333        let account_risk = -accounted.amount;
1334        if !account_risk.is_finite() || account_risk < 0.0 {
1335            return Err(FutureExecutorError::InvalidConvertedAmount {
1336                symbol: symbol.to_owned(),
1337                kind: "risk",
1338                amount: account_risk,
1339            });
1340        }
1341        tranche.risk_amount = Some(account_risk);
1342        tranche.risk_conversion = accounted.conversion;
1343        Ok(tranche)
1344    }
1345
1346    fn convert_native_amount(
1347        &self,
1348        symbol: &str,
1349        amount: f64,
1350        operation_ts: NaiveDateTime,
1351        conversion_quotes: Option<&ConversionQuoteBook>,
1352    ) -> Result<AccountedAmount, FutureExecutorError> {
1353        let Some(plan) = self.currency_plan.as_ref() else {
1354            return Ok(AccountedAmount {
1355                amount,
1356                native_currency: None,
1357                conversion: None,
1358            });
1359        };
1360        let native_currency = plan
1361            .pnl_currency_for_primary_symbol(symbol)
1362            .ok_or_else(|| FutureExecutorError::MissingCurrencyRoute(symbol.to_owned()))?;
1363        let route = plan
1364            .route_for_primary_symbol(symbol)
1365            .ok_or_else(|| FutureExecutorError::MissingCurrencyRoute(symbol.to_owned()))?;
1366        let conversion = match conversion_quotes {
1367            Some(quotes) => quotes.convert_route(amount, operation_ts, route),
1368            None => match route {
1369                ConversionRoute::Identity { .. } => Ok(ConversionResult {
1370                    from_currency: route.from_currency().to_owned(),
1371                    to_currency: route.to_currency().to_owned(),
1372                    input_amount: amount,
1373                    output_amount: amount,
1374                    operation_ts,
1375                    route: route.clone(),
1376                    legs: Vec::new(),
1377                }),
1378                _ => Err(ConversionError::NoCausalQuote {
1379                    symbol: route.symbols().next().unwrap_or(symbol).to_owned(),
1380                    operation_ts,
1381                    next_quote_ts: None,
1382                }),
1383            },
1384        }
1385        .map_err(|source| FutureExecutorError::Conversion {
1386            symbol: symbol.to_owned(),
1387            operation_ts,
1388            source,
1389        })?;
1390        if !conversion.output_amount.is_finite() {
1391            return Err(FutureExecutorError::InvalidConvertedAmount {
1392                symbol: symbol.to_owned(),
1393                kind: "P&L",
1394                amount: conversion.output_amount,
1395            });
1396        }
1397        Ok(AccountedAmount {
1398            amount: conversion.output_amount,
1399            native_currency: Some(native_currency.to_owned()),
1400            conversion: Some(conversion),
1401        })
1402    }
1403
1404    fn contract_size(&self, symbol: &str) -> f64 {
1405        self.contract_sizes.get(symbol).copied().unwrap_or(1.0)
1406    }
1407
1408    fn point_size(&self, symbol: &str) -> f64 {
1409        self.point_sizes.get(symbol).copied().unwrap_or(f64::NAN)
1410    }
1411
1412    /// Convert one computed charge into the account currency without touching the ledger.
1413    fn resolve_cost(
1414        &self,
1415        symbol: &str,
1416        operation_ts: NaiveDateTime,
1417        charge: CostCharge,
1418        conversion_quotes: Option<&ConversionQuoteBook>,
1419    ) -> Result<ResolvedCost, FutureExecutorError> {
1420        let resolved = match charge.basis {
1421            CostBasis::AccountCurrency => ResolvedCost {
1422                amount: charge.amount,
1423                ..ResolvedCost::default()
1424            },
1425            CostBasis::InstrumentNative => {
1426                let accounted = self.convert_native_amount(
1427                    symbol,
1428                    charge.amount,
1429                    operation_ts,
1430                    conversion_quotes,
1431                )?;
1432                ResolvedCost {
1433                    amount: accounted.amount,
1434                    native_amount: Some(charge.amount),
1435                    native_currency: accounted.native_currency,
1436                    conversion: accounted.conversion,
1437                }
1438            }
1439        };
1440        if !resolved.amount.is_finite() {
1441            return Err(FutureExecutorError::InvalidConvertedAmount {
1442                symbol: symbol.to_owned(),
1443                kind: "cost",
1444                amount: resolved.amount,
1445            });
1446        }
1447        Ok(resolved)
1448    }
1449
1450    /// Commission for one fill, already converted into the account currency.
1451    fn resolve_commission(
1452        &self,
1453        symbol: &str,
1454        side: Side,
1455        size: f64,
1456        fill_price: f64,
1457        operation_ts: NaiveDateTime,
1458        conversion_quotes: Option<&ConversionQuoteBook>,
1459    ) -> Result<ResolvedCost, FutureExecutorError> {
1460        let Some(costs) = self.costs.get(symbol) else {
1461            return Ok(ResolvedCost::default());
1462        };
1463        let contract_size = self.contract_size(symbol);
1464        let Some(charge) = costs.commission_for_fill(side, size, fill_price, contract_size) else {
1465            return Ok(ResolvedCost::default());
1466        };
1467        self.resolve_cost(symbol, operation_ts, charge, conversion_quotes)
1468    }
1469
1470    #[allow(clippy::too_many_arguments)]
1471    fn push_cost_event(
1472        &mut self,
1473        position_id: &str,
1474        symbol: &str,
1475        side: Side,
1476        ts: NaiveDateTime,
1477        kind: CostKind,
1478        resolved: &ResolvedCost,
1479        size: f64,
1480        nights: Option<u32>,
1481    ) {
1482        let mut event = CostEvent::new(
1483            position_id,
1484            self.cost_sequence,
1485            symbol,
1486            side,
1487            ts,
1488            kind,
1489            resolved.amount,
1490            size,
1491        );
1492        event.native_amount = resolved.native_amount;
1493        event.native_currency = resolved.native_currency.clone();
1494        event.conversion = resolved.conversion.clone();
1495        event.nights = nights;
1496        self.cost_sequence += 1;
1497        self.cost_events.push(event);
1498    }
1499
1500    /// Charge overnight swap for every rollover instant in `(previous, current]`.
1501    ///
1502    /// The runner calls this once per accepted timestamp batch after all fills at that timestamp are recorded, so a position opened and closed inside one batch is never charged, and a weekend gap still charges every instant it skipped over.
1503    ///
1504    /// Returns the number of charges that could not be converted into the account currency and were therefore skipped. A skipped charge is never silent: the caller reports the count in the run's execution metadata.
1505    pub(crate) fn charge_rollovers(
1506        &mut self,
1507        current: NaiveDateTime,
1508        portfolio: &mut PortfolioRecorder,
1509        conversion_quotes: Option<&ConversionQuoteBook>,
1510    ) -> u64 {
1511        let previous = self.last_rollover_ts.replace(current);
1512        if self.costs.is_empty() {
1513            return 0;
1514        }
1515        let open_ids: Vec<String> = self
1516            .accounts
1517            .iter()
1518            .filter(|(_, account)| {
1519                account.remaining_size > position_size_tolerance(account.entry_size)
1520            })
1521            .map(|(id, _)| id.clone())
1522            .collect();
1523        if open_ids.is_empty() {
1524            return 0;
1525        }
1526        // Collect every due charge before applying any, so that charges land in rollover order across positions rather than in position order. The balance therefore falls in the same sequence a broker would have applied it.
1527        let mut due: Vec<(NaiveDateTime, String)> = Vec::new();
1528        for id in open_ids {
1529            let Some(account) = self.accounts.get(&id) else {
1530                continue;
1531            };
1532            let symbol = account.symbol.as_str();
1533            let open_ts = account.open_ts;
1534            let Some(costs) = self.costs.get(symbol) else {
1535                continue;
1536            };
1537            let Some(schedule) = costs.swap.as_ref() else {
1538                continue;
1539            };
1540            let start = match previous {
1541                Some(previous) => previous.max(open_ts),
1542                None => open_ts,
1543            };
1544            for instant in
1545                qs_core::costs::rollover_instants(Some(start), current, schedule.rollover)
1546            {
1547                due.push((instant, id.clone()));
1548            }
1549        }
1550        // `accounts` is ordered, so equal instants keep a stable position order.
1551        due.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
1552
1553        let mut charged = false;
1554        let mut skipped = 0;
1555        for (instant, id) in due {
1556            let Some(account) = self.accounts.get(&id) else {
1557                continue;
1558            };
1559            let symbol = account.symbol.clone();
1560            let side = account.side;
1561            let size = account.remaining_size;
1562            let Some(costs) = self.costs.get(&symbol) else {
1563                continue;
1564            };
1565            let Some(schedule) = costs.swap.as_ref() else {
1566                continue;
1567            };
1568            let nights = schedule.nights_at(instant);
1569            let contract_size = self.contract_size(&symbol);
1570            let point_size = self.point_size(&symbol);
1571            let Some(charge) =
1572                costs.swap_for_rollover(side, size, contract_size, point_size, nights)
1573            else {
1574                continue;
1575            };
1576            let Ok(resolved) = self.resolve_cost(&symbol, instant, charge, conversion_quotes)
1577            else {
1578                skipped += 1;
1579                continue;
1580            };
1581            if resolved.amount == 0.0 {
1582                continue;
1583            }
1584            let next_balance = self.balance - resolved.amount;
1585            if !next_balance.is_finite() {
1586                skipped += 1;
1587                continue;
1588            }
1589            self.balance = next_balance;
1590            if let Some(account) = self.accounts.get_mut(&id) {
1591                account.realized_pnl -= resolved.amount;
1592                account.swap_total += resolved.amount;
1593            }
1594            self.push_cost_event(
1595                &id,
1596                &symbol,
1597                side,
1598                instant,
1599                CostKind::Swap,
1600                &resolved,
1601                size,
1602                Some(nights),
1603            );
1604            charged = true;
1605        }
1606        if charged {
1607            portfolio.set_realized_pnl(self.realized_pnl());
1608        }
1609        skipped
1610    }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615    use super::*;
1616    use chrono::{Duration, NaiveDate};
1617    use qs_core::types::{
1618        Action, ExecutionFill, FillPurpose, OrderType, PositionStatus, TargetSpec,
1619    };
1620
1621    use crate::currency::{ConversionPriceSide, FxPair};
1622
1623    fn ts() -> NaiveDateTime {
1624        NaiveDate::from_ymd_opt(2026, 1, 1)
1625            .unwrap()
1626            .and_hms_opt(10, 0, 0)
1627            .unwrap()
1628    }
1629
1630    fn quote_at(seconds: i64, price: f64) -> PriceQuote {
1631        quote_for("EURUSD", seconds, price, price)
1632    }
1633
1634    fn quote_for(symbol: &str, seconds: i64, bid: f64, ask: f64) -> PriceQuote {
1635        PriceQuote {
1636            symbol: symbol.into(),
1637            ts: ts() + Duration::seconds(seconds),
1638            bid,
1639            ask,
1640        }
1641    }
1642
1643    fn eur_account_plan() -> RunCurrencyPlan {
1644        RunCurrencyPlan::new(
1645            "USD",
1646            ["PRIMARY".to_owned()].into_iter().collect(),
1647            ["EURUSD".to_owned()].into_iter().collect(),
1648            [("PRIMARY".to_owned(), "EUR".to_owned())]
1649                .into_iter()
1650                .collect(),
1651            [(
1652                "EUR".to_owned(),
1653                ConversionRoute::Direct {
1654                    pair: FxPair {
1655                        symbol: "EURUSD".to_owned(),
1656                        base_currency: "EUR".to_owned(),
1657                        quote_currency: "USD".to_owned(),
1658                    },
1659                },
1660            )]
1661            .into_iter()
1662            .collect(),
1663            Vec::new(),
1664        )
1665        .unwrap()
1666    }
1667
1668    fn execution(purpose: FillPurpose, price: f64) -> ExecutionFill {
1669        ExecutionFill {
1670            purpose,
1671            side: Side::Buy,
1672            price,
1673            quote_price: price,
1674            requested_price: None,
1675            slippage_pips: 0.0,
1676        }
1677    }
1678
1679    #[test]
1680    fn close_and_initial_risk_use_signed_account_conversion() {
1681        let plan = eur_account_plan();
1682        let mut engine =
1683            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
1684        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9)
1685            .with_currency_plan(Some(plan.clone()));
1686        let mut portfolio =
1687            PortfolioRecorder::new(10_000.0, HashMap::new()).with_currency_plan(Some(plan));
1688        let mut conversions = ConversionQuoteBook::new(Duration::hours(1)).unwrap();
1689        conversions
1690            .record_canonical_tick(quote_for("EURUSD", 0, 2.0, 3.0))
1691            .unwrap();
1692        conversions
1693            .record_canonical_tick(quote_for("EURUSD", 1, 2.0, 3.0))
1694            .unwrap();
1695
1696        let open_quote = quote_for("PRIMARY", 0, 100.0, 100.0);
1697        let effects = engine
1698            .apply_priced_future_action(
1699                Action::Open {
1700                    symbol: "PRIMARY".into(),
1701                    side: Side::Buy,
1702                    order_type: OrderType::Market,
1703                    price: None,
1704                    size: 1.0,
1705                    stoploss: Some(90.0),
1706                    targets: vec![],
1707                    rules: vec![],
1708                    group: None,
1709                    trade_id: None,
1710                },
1711                &open_quote,
1712                execution(FillPurpose::MarketEntry, 100.0),
1713            )
1714            .unwrap();
1715        let id = match effects[0].effect() {
1716            Effect::PositionOpened { id } => id.clone(),
1717            effect => panic!("unexpected effect: {effect:?}"),
1718        };
1719        executor
1720            .process_future_effects_with_currency(
1721                &effects,
1722                &engine,
1723                &open_quote,
1724                Some("open"),
1725                Some(open_quote.ts),
1726                open_quote.ts,
1727                &mut portfolio,
1728                Some(&conversions),
1729            )
1730            .unwrap();
1731
1732        let close_quote = quote_for("PRIMARY", 1, 110.0, 110.0);
1733        let effects = engine
1734            .apply_priced_future_action(
1735                Action::ClosePosition { position_id: id },
1736                &close_quote,
1737                execution(FillPurpose::MarketExit, 110.0),
1738            )
1739            .unwrap();
1740        executor
1741            .process_future_effects_with_currency(
1742                &effects,
1743                &engine,
1744                &close_quote,
1745                Some("close"),
1746                Some(close_quote.ts),
1747                close_quote.ts,
1748                &mut portfolio,
1749                Some(&conversions),
1750            )
1751            .unwrap();
1752
1753        assert_eq!(executor.realized_pnl(), 20.0);
1754        let close = &executor.close_events[0];
1755        assert_eq!(close.native_pnl, Some(10.0));
1756        assert_eq!(close.native_currency.as_deref(), Some("EUR"));
1757        let pnl_conversion = close.pnl_conversion.as_ref().unwrap();
1758        assert_eq!(pnl_conversion.input_amount, 10.0);
1759        assert_eq!(pnl_conversion.output_amount, 20.0);
1760        assert_eq!(pnl_conversion.legs[0].price_side, ConversionPriceSide::Bid);
1761
1762        let risk = &executor.completed_positions[0].risk_tranches[0];
1763        assert_eq!(risk.native_risk_amount, Some(10.0));
1764        assert_eq!(risk.risk_amount, Some(30.0));
1765        let risk_conversion = risk.risk_conversion.as_ref().unwrap();
1766        assert_eq!(risk_conversion.input_amount, -10.0);
1767        assert_eq!(risk_conversion.output_amount, -30.0);
1768        assert_eq!(risk_conversion.legs[0].price_side, ConversionPriceSide::Ask);
1769        assert_eq!(executor.completed_positions[0].realized_r, Some(2.0 / 3.0));
1770    }
1771
1772    #[test]
1773    fn stale_close_conversion_commits_no_accounting_artifacts() {
1774        let plan = eur_account_plan();
1775        let mut engine =
1776            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
1777        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9)
1778            .with_currency_plan(Some(plan.clone()));
1779        let mut portfolio =
1780            PortfolioRecorder::new(10_000.0, HashMap::new()).with_currency_plan(Some(plan));
1781        let mut conversions = ConversionQuoteBook::new(Duration::zero()).unwrap();
1782        conversions
1783            .record_canonical_tick(quote_for("EURUSD", 0, 2.0, 3.0))
1784            .unwrap();
1785
1786        let open_quote = quote_for("PRIMARY", 0, 100.0, 100.0);
1787        let effects = engine
1788            .apply_priced_future_action(
1789                Action::Open {
1790                    symbol: "PRIMARY".into(),
1791                    side: Side::Buy,
1792                    order_type: OrderType::Market,
1793                    price: None,
1794                    size: 1.0,
1795                    stoploss: None,
1796                    targets: vec![],
1797                    rules: vec![],
1798                    group: None,
1799                    trade_id: None,
1800                },
1801                &open_quote,
1802                execution(FillPurpose::MarketEntry, 100.0),
1803            )
1804            .unwrap();
1805        let id = match effects[0].effect() {
1806            Effect::PositionOpened { id } => id.clone(),
1807            effect => panic!("unexpected effect: {effect:?}"),
1808        };
1809        executor
1810            .process_future_effects_with_currency(
1811                &effects,
1812                &engine,
1813                &open_quote,
1814                None,
1815                None,
1816                open_quote.ts,
1817                &mut portfolio,
1818                Some(&conversions),
1819            )
1820            .unwrap();
1821        portfolio.record_quote(open_quote.clone());
1822        portfolio.record_with_currency(
1823            open_quote.ts,
1824            executor.open_snapshots(),
1825            Some(&conversions),
1826        );
1827        assert!(portfolio.campaign_excursion(&id).is_some());
1828
1829        let close_quote = quote_for("PRIMARY", 1, 110.0, 110.0);
1830        let effects = engine
1831            .apply_priced_future_action(
1832                Action::ClosePosition {
1833                    position_id: id.clone(),
1834                },
1835                &close_quote,
1836                execution(FillPurpose::MarketExit, 110.0),
1837            )
1838            .unwrap();
1839        let error = executor
1840            .process_future_effects_with_currency(
1841                &effects,
1842                &engine,
1843                &close_quote,
1844                None,
1845                None,
1846                close_quote.ts,
1847                &mut portfolio,
1848                Some(&conversions),
1849            )
1850            .unwrap_err();
1851
1852        assert!(matches!(error, FutureExecutorError::Conversion { .. }));
1853        assert_eq!(executor.balance(), 10_000.0);
1854        assert_eq!(executor.fills.len(), 1);
1855        assert!(executor.close_events.is_empty());
1856        assert!(executor.completed_positions.is_empty());
1857        assert_eq!(portfolio.realized_pnl(), 0.0);
1858        assert!(portfolio.campaign_excursion(&id).is_some());
1859    }
1860
1861    #[test]
1862    fn partial_close_scale_in_and_final_close_use_remaining_average_cost() {
1863        let mut engine =
1864            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
1865        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9);
1866        let mut portfolio = PortfolioRecorder::new(10_000.0, HashMap::new());
1867
1868        let open_quote = quote_at(0, 100.0);
1869        let effects = engine
1870            .apply_priced_future_action(
1871                Action::Open {
1872                    symbol: "EURUSD".into(),
1873                    side: Side::Buy,
1874                    order_type: OrderType::Market,
1875                    price: None,
1876                    size: 2.0,
1877                    stoploss: None,
1878                    targets: vec![],
1879                    rules: vec![],
1880                    group: None,
1881                    trade_id: None,
1882                },
1883                &open_quote,
1884                execution(FillPurpose::MarketEntry, 100.0),
1885            )
1886            .unwrap();
1887        let id = match effects[0].effect() {
1888            Effect::PositionOpened { id } => id.clone(),
1889            effect => panic!("unexpected effect: {effect:?}"),
1890        };
1891        executor
1892            .process_future_effects(
1893                &effects,
1894                &engine,
1895                &open_quote,
1896                Some("open"),
1897                Some(open_quote.ts),
1898                open_quote.ts,
1899                &mut portfolio,
1900            )
1901            .unwrap();
1902
1903        let partial_quote = quote_at(1, 110.0);
1904        let effects = engine
1905            .apply_priced_future_action(
1906                Action::ClosePartial {
1907                    position_id: id.clone(),
1908                    ratio: 0.5,
1909                },
1910                &partial_quote,
1911                execution(FillPurpose::MarketExit, 110.0),
1912            )
1913            .unwrap();
1914        executor
1915            .process_future_effects(
1916                &effects,
1917                &engine,
1918                &partial_quote,
1919                Some("partial"),
1920                Some(partial_quote.ts),
1921                partial_quote.ts,
1922                &mut portfolio,
1923            )
1924            .unwrap();
1925
1926        let scale_quote = quote_at(2, 120.0);
1927        let effects = engine
1928            .apply_priced_future_action(
1929                Action::ScaleIn {
1930                    position_id: id.clone(),
1931                    price: None,
1932                    size: 1.0,
1933                    trade_id: None,
1934                },
1935                &scale_quote,
1936                execution(FillPurpose::MarketEntry, 120.0),
1937            )
1938            .unwrap();
1939        executor
1940            .process_future_effects(
1941                &effects,
1942                &engine,
1943                &scale_quote,
1944                Some("scale"),
1945                Some(scale_quote.ts),
1946                scale_quote.ts,
1947                &mut portfolio,
1948            )
1949            .unwrap();
1950        assert_eq!(executor.open_snapshots()[0].average_entry_price, 110.0);
1951
1952        let final_quote = quote_at(3, 130.0);
1953        let effects = engine
1954            .apply_priced_future_action(
1955                Action::ClosePosition {
1956                    position_id: id.clone(),
1957                },
1958                &final_quote,
1959                execution(FillPurpose::MarketExit, 130.0),
1960            )
1961            .unwrap();
1962        executor
1963            .process_future_effects(
1964                &effects,
1965                &engine,
1966                &final_quote,
1967                Some("close"),
1968                Some(final_quote.ts),
1969                final_quote.ts,
1970                &mut portfolio,
1971            )
1972            .unwrap();
1973
1974        assert!(executor.open_snapshots().is_empty());
1975        assert_eq!(executor.close_events.len(), 2);
1976        assert_eq!(executor.close_events[0].entry_price, Some(100.0));
1977        assert_eq!(executor.close_events[0].pnl, 10.0);
1978        assert_eq!(executor.close_events[1].entry_price, Some(110.0));
1979        assert_eq!(executor.close_events[1].pnl, 40.0);
1980        assert_eq!(executor.realized_pnl(), 50.0);
1981    }
1982
1983    #[test]
1984    fn portfolio_rejection_does_not_commit_executor_close_state() {
1985        let mut engine =
1986            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
1987        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9);
1988        let mut portfolio = PortfolioRecorder::new(10_000.0, HashMap::new());
1989        let open_quote = quote_at(0, 100.0);
1990        let effects = engine
1991            .apply_priced_future_action(
1992                Action::Open {
1993                    symbol: "EURUSD".into(),
1994                    side: Side::Buy,
1995                    order_type: OrderType::Market,
1996                    price: None,
1997                    size: 1.0,
1998                    stoploss: None,
1999                    targets: vec![],
2000                    rules: vec![],
2001                    group: None,
2002                    trade_id: None,
2003                },
2004                &open_quote,
2005                execution(FillPurpose::MarketEntry, 100.0),
2006            )
2007            .unwrap();
2008        let id = match effects[0].effect() {
2009            Effect::PositionOpened { id } => id.clone(),
2010            effect => panic!("unexpected effect: {effect:?}"),
2011        };
2012        executor
2013            .process_future_effects(
2014                &effects,
2015                &engine,
2016                &open_quote,
2017                None,
2018                None,
2019                open_quote.ts,
2020                &mut portfolio,
2021            )
2022            .unwrap();
2023        assert!(portfolio.set_realized_pnl(f64::MAX));
2024        let balance_before = executor.balance();
2025        let fills_before = executor.fills.len();
2026
2027        let close_quote = quote_at(1, 1.0e308);
2028        let effects = engine
2029            .apply_priced_future_action(
2030                Action::ClosePosition {
2031                    position_id: id.clone(),
2032                },
2033                &close_quote,
2034                execution(FillPurpose::MarketExit, 1.0e308),
2035            )
2036            .unwrap();
2037        let result = executor.process_future_effects(
2038            &effects,
2039            &engine,
2040            &close_quote,
2041            None,
2042            None,
2043            close_quote.ts,
2044            &mut portfolio,
2045        );
2046
2047        assert!(matches!(
2048            result,
2049            Err(FutureExecutorError::PortfolioRejectedRealizedPnl { .. })
2050        ));
2051        assert_eq!(executor.balance(), balance_before);
2052        assert_eq!(executor.fills.len(), fills_before);
2053        assert!(executor.accounts.contains_key(&id));
2054        assert!(executor.close_events.is_empty());
2055    }
2056
2057    #[test]
2058    fn failed_pending_batch_restores_lifecycle_and_deterministic_sequence() {
2059        let mut engine =
2060            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
2061        let placement = engine
2062            .apply_future_action(
2063                Action::Open {
2064                    symbol: "EURUSD".into(),
2065                    side: Side::Buy,
2066                    order_type: OrderType::Limit,
2067                    price: Some(99.0),
2068                    size: 1.0,
2069                    stoploss: Some(95.0),
2070                    targets: vec![],
2071                    rules: vec![],
2072                    group: None,
2073                    trade_id: None,
2074                },
2075                ts(),
2076            )
2077            .unwrap();
2078        let id = effect_position_id(placement[0].effect()).to_owned();
2079        let mut batch = placement.clone();
2080        batch.push(FutureEffect::plain(Effect::StoplossModified {
2081            id: id.clone(),
2082            old_price: 95.0,
2083            new_price: f64::NAN,
2084        }));
2085        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9);
2086        let mut portfolio = PortfolioRecorder::new(10_000.0, HashMap::new());
2087        let quote = quote_at(0, 100.0);
2088
2089        let result = executor.process_future_effects(
2090            &batch,
2091            &engine,
2092            &quote,
2093            Some("pending"),
2094            Some(ts()),
2095            ts(),
2096            &mut portfolio,
2097        );
2098
2099        assert!(matches!(
2100            result,
2101            Err(FutureExecutorError::InvalidFill { .. })
2102        ));
2103        assert!(executor.pending_origins.is_empty());
2104        assert!(executor.pending_order_lifecycle.is_empty());
2105        assert_eq!(executor.pending_lifecycle_sequence, 0);
2106        executor
2107            .process_future_effects(
2108                &placement,
2109                &engine,
2110                &quote,
2111                Some("pending"),
2112                Some(ts()),
2113                ts(),
2114                &mut portfolio,
2115            )
2116            .unwrap();
2117        assert_eq!(executor.pending_order_lifecycle[0].sequence, 0);
2118        assert_eq!(
2119            executor.pending_order_lifecycle[0].id,
2120            deterministic_event_id(&id, "pending_placed", 0)
2121        );
2122    }
2123
2124    #[test]
2125    fn failed_close_batch_restores_executor_and_leaves_portfolio_unchanged() {
2126        let mut engine =
2127            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
2128        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9);
2129        let mut portfolio = PortfolioRecorder::new(10_000.0, HashMap::new());
2130        let open_quote = quote_at(0, 100.0);
2131        let open_effects = engine
2132            .apply_priced_future_action(
2133                Action::Open {
2134                    symbol: "EURUSD".into(),
2135                    side: Side::Buy,
2136                    order_type: OrderType::Market,
2137                    price: None,
2138                    size: 1.0,
2139                    stoploss: None,
2140                    targets: vec![],
2141                    rules: vec![],
2142                    group: None,
2143                    trade_id: None,
2144                },
2145                &open_quote,
2146                execution(FillPurpose::MarketEntry, 100.0),
2147            )
2148            .unwrap();
2149        let id = effect_position_id(open_effects[0].effect()).to_owned();
2150        executor
2151            .process_future_effects(
2152                &open_effects,
2153                &engine,
2154                &open_quote,
2155                None,
2156                None,
2157                open_quote.ts,
2158                &mut portfolio,
2159            )
2160            .unwrap();
2161        portfolio.record_quote(open_quote.clone());
2162        portfolio.record(open_quote.ts, executor.open_snapshots());
2163        let campaign_before = portfolio.campaign_excursion(&id);
2164
2165        let close_quote = quote_at(1, 110.0);
2166        let close_effects = engine
2167            .apply_priced_future_action(
2168                Action::ClosePosition {
2169                    position_id: id.clone(),
2170                },
2171                &close_quote,
2172                execution(FillPurpose::MarketExit, 110.0),
2173            )
2174            .unwrap();
2175        let mut batch = close_effects.clone();
2176        batch.push(FutureEffect::plain(Effect::StoplossRemoved {
2177            id: "missing".into(),
2178            old_price: 1.0,
2179        }));
2180        let result = executor.process_future_effects(
2181            &batch,
2182            &engine,
2183            &close_quote,
2184            None,
2185            None,
2186            close_quote.ts,
2187            &mut portfolio,
2188        );
2189
2190        assert!(matches!(result, Err(FutureExecutorError::AccountNotFound(id)) if id == "missing"));
2191        assert_eq!(executor.balance(), 10_000.0);
2192        assert_eq!(executor.fills.len(), 1);
2193        assert!(executor.close_events.is_empty());
2194        assert!(executor.completed_positions.is_empty());
2195        assert!(executor.accounts.contains_key(&id));
2196        assert_eq!(executor.fill_sequence, 1);
2197        assert_eq!(executor.close_sequence, 0);
2198        assert_eq!(portfolio.realized_pnl(), 0.0);
2199        assert_eq!(portfolio.campaign_excursion(&id), campaign_before);
2200
2201        executor
2202            .process_future_effects(
2203                &close_effects,
2204                &engine,
2205                &close_quote,
2206                None,
2207                None,
2208                close_quote.ts,
2209                &mut portfolio,
2210            )
2211            .unwrap();
2212        assert_eq!(executor.fills[1].id, deterministic_event_id(&id, "fill", 1));
2213        assert_eq!(
2214            executor.close_events[0].id,
2215            deterministic_event_id(&id, "close", 0)
2216        );
2217        assert_eq!(portfolio.realized_pnl(), 10.0);
2218        assert!(portfolio.campaign_excursion(&id).is_none());
2219    }
2220
2221    #[test]
2222    fn carried_fill_is_consumed_without_repricing_or_engine_synchronization() {
2223        let quote = PriceQuote {
2224            symbol: "EURUSD".into(),
2225            ts: ts(),
2226            bid: 99.0,
2227            ask: 100.0,
2228        };
2229        let execution = ExecutionFill {
2230            purpose: FillPurpose::MarketEntry,
2231            side: Side::Buy,
2232            price: 123.456,
2233            quote_price: 100.0,
2234            requested_price: None,
2235            slippage_pips: 0.0,
2236        };
2237        let mut engine =
2238            TradeEngine::with_fill_model_and_deterministic_ids(qs_core::types::FillModel::BidAsk);
2239        let effects = engine
2240            .apply_priced_future_action(
2241                Action::Open {
2242                    symbol: "EURUSD".into(),
2243                    side: Side::Buy,
2244                    order_type: OrderType::Market,
2245                    price: Some(1.0),
2246                    size: 2.0,
2247                    stoploss: None,
2248                    targets: Vec::<TargetSpec>::new(),
2249                    rules: vec![],
2250                    group: None,
2251                    trade_id: None,
2252                },
2253                &quote,
2254                execution,
2255            )
2256            .unwrap();
2257        let id = match effects[0].effect() {
2258            Effect::PositionOpened { id } => id.clone(),
2259            effect => panic!("unexpected effect: {effect:?}"),
2260        };
2261
2262        let mut executor = FutureExecutor::new(10_000.0, HashMap::new(), 1.0e-9);
2263        let mut portfolio = PortfolioRecorder::new(10_000.0, HashMap::new());
2264        executor
2265            .process_future_effects(
2266                &effects,
2267                &engine,
2268                &quote,
2269                Some("open"),
2270                Some(ts()),
2271                ts(),
2272                &mut portfolio,
2273            )
2274            .unwrap();
2275
2276        assert_eq!(executor.fills.len(), 1);
2277        assert_eq!(executor.fills[0].fill, execution);
2278        assert_eq!(executor.fills[0].size, 2.0);
2279        let position = engine.get_position(&id).unwrap();
2280        assert_eq!(position.data.status, PositionStatus::Open);
2281        assert_eq!(position.data.entries[0].price, execution.price);
2282        assert_eq!(position.data.entries[0].ts, quote.ts);
2283    }
2284}