1use 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 entry_value: f64,
84 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 entry_commission_total: f64,
96 swap_total: f64,
98}
99
100#[derive(Debug, Clone, Default)]
102struct ResolvedCost {
103 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#[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 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 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 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 } 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 } 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 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 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 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 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 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 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 "e,
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 "e,
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 "e,
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 "e,
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}