1use crate::{
2 order::id::{ClientOrderId, OrderId, StrategyId},
3 trade::{AssetFees, TimeSource, Trade, TradeId, TradeOrigin},
4};
5use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
6use chrono_tz::Tz;
7use fnv::FnvHashMap;
8use ibapi::orders::{CommissionReport, Execution, ExecutionData, ExecutionFilter, ExecutionSide};
9use parking_lot::Mutex;
10use rust_decimal::Decimal;
11use rustrade_instrument::{
12 Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
13};
14use smol_str::{SmolStr, format_smolstr};
15use std::{cell::RefCell, collections::hash_map::Entry, sync::Arc, time::Duration};
16use tracing::{debug, warn};
17
18thread_local! {
28 static TZ_CACHE: RefCell<FnvHashMap<SmolStr, Option<Tz>>> =
29 RefCell::new(FnvHashMap::default());
30}
31
32#[derive(Debug, Clone)]
38pub struct ExecutionBuffer {
39 inner: Arc<Mutex<ExecutionBufferInner>>,
40}
41
42#[derive(Debug, Default)]
43struct ExecutionBufferInner {
44 pending: FnvHashMap<String, PendingExecution>,
45}
46
47#[derive(Debug, Clone)]
48struct PendingExecution {
49 execution: ExecutionData,
50 resolved: ResolvedExecution,
51 buffered_at: DateTime<Utc>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub(super) struct ExecutionScope {
59 pub(super) api_client_id: i32,
61 pub(super) other_clients: bool,
64}
65
66impl ExecutionScope {
67 pub(super) fn classify(self, execution: &Execution) -> Option<(OrderId, TradeOrigin)> {
75 let own = execution.client_id == self.api_client_id;
76 let liquidation = execution.liquidation != 0;
77 if !own && !liquidation && !self.other_clients {
78 return None;
79 }
80 let origin = if liquidation {
81 TradeOrigin::Liquidation
82 } else if own {
83 TradeOrigin::Order
84 } else {
85 TradeOrigin::External
86 };
87 let order_id = if own {
88 ib_order_id(execution.order_id)
89 } else {
90 foreign_order_id(execution)
91 };
92 Some((order_id, origin))
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub(super) struct ResolvedExecution {
99 pub(super) instrument: InstrumentNameExchange,
100 pub(super) order_id: OrderId,
101 pub(super) origin: TradeOrigin,
102 pub(super) time: Option<DateTime<Utc>>,
105}
106
107impl ExecutionBuffer {
108 pub fn new() -> Self {
109 Self {
110 inner: Arc::new(Mutex::new(ExecutionBufferInner::default())),
111 }
112 }
113
114 pub(super) fn add_execution(&self, execution: ExecutionData, resolved: ResolvedExecution) {
116 let exec_id = execution.execution.execution_id.clone();
117 let mut inner = self.inner.lock();
118 inner.pending.insert(
119 exec_id,
120 PendingExecution {
121 execution,
122 resolved,
123 buffered_at: Utc::now(),
124 },
125 );
126
127 let pending_count = inner.pending.len();
129 if pending_count > 1000 && pending_count.is_multiple_of(100) {
130 warn!(
131 pending_count,
132 "ExecutionBuffer has >1000 pending entries; commission reports may be delayed or lost"
133 );
134 }
135 }
136
137 pub fn complete_with_commission(
140 &self,
141 report: &CommissionReport,
142 ) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
143 let pending = {
144 let mut inner = self.inner.lock();
145 inner.pending.remove(&report.execution_id)?
146 };
147
148 Some(build_trade(pending, report))
149 }
150
151 pub(super) fn take_without_commission(
157 &self,
158 ) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
159 std::mem::take(&mut self.inner.lock().pending)
160 .into_values()
161 .map(|pending| {
162 let fees = AssetFees::new(
163 AssetNameExchange::from(UNKNOWN_FEE_ASSET),
164 Decimal::ZERO,
165 None,
166 );
167 trade_with_fees(pending, fees)
168 })
169 .collect()
170 }
171
172 pub(super) fn drain_into(&self, target: &ExecutionBuffer) -> usize {
182 let mut target = target.inner.lock();
183 let drained = std::mem::take(&mut self.inner.lock().pending);
184 let count = drained.len();
185 target.pending.extend(drained);
186 count
187 }
188
189 pub fn pending_count(&self) -> usize {
191 self.inner.lock().pending.len()
192 }
193
194 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
207 let now = Utc::now();
208 let mut inner = self.inner.lock();
209 let before = inner.pending.len();
210
211 let max_age_secs = i64::try_from(max_age.as_secs()).unwrap_or(i64::MAX);
212 inner.pending.retain(|_, pending| {
213 let time = pending.resolved.time.unwrap_or(pending.buffered_at);
214 now.signed_duration_since(time).num_seconds() < max_age_secs
215 });
216
217 before - inner.pending.len()
218 }
219}
220
221impl Default for ExecutionBuffer {
222 fn default() -> Self {
223 Self::new()
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub(super) struct ExecutionRevision<'a> {
235 pub(super) execution: &'a str,
237 digits: &'a str,
240 pub(super) revision: u32,
241}
242
243impl<'a> ExecutionRevision<'a> {
244 pub(super) fn parse(execution_id: &'a str) -> Option<Self> {
247 let (execution, digits) = execution_id.rsplit_once('.')?;
248 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
249 return None;
250 }
251 Some(Self {
252 execution,
253 digits,
254 revision: digits.parse().ok()?,
255 })
256 }
257
258 pub(super) fn is_correction(&self) -> bool {
260 self.revision > 1
261 }
262
263 pub(super) fn previous_id(&self) -> Option<TradeId> {
265 self.is_correction().then(|| {
266 TradeId(format_smolstr!(
267 "{}.{:0width$}",
268 self.execution,
269 self.revision - 1,
270 width = self.digits.len()
271 ))
272 })
273 }
274}
275
276pub(super) fn revision_of(id: &TradeId) -> u32 {
279 ExecutionRevision::parse(&id.0).map_or(0, |revision| revision.revision)
280}
281
282pub(super) fn keep_latest_revisions(
286 trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
287) -> Vec<Trade<AssetNameExchange, InstrumentNameExchange>> {
288 let mut latest = FnvHashMap::<SmolStr, (usize, u32)>::with_capacity_and_hasher(
290 trades.len(),
291 Default::default(),
292 );
293 let mut kept = Vec::with_capacity(trades.len());
294 for trade in trades {
295 let Some(revision) = ExecutionRevision::parse(&trade.id.0) else {
296 kept.push(trade);
297 continue;
298 };
299 let (execution, revision) = (SmolStr::new(revision.execution), revision.revision);
300 match latest.entry(execution) {
301 Entry::Vacant(entry) => {
302 entry.insert((kept.len(), revision));
303 kept.push(trade);
304 }
305 Entry::Occupied(mut entry) => {
306 let (position, kept_revision) = *entry.get();
307 if revision == kept_revision {
308 debug!(exec_id = %trade.id, "IBKR execution read twice; keeping one");
309 continue;
310 }
311 let (older, newer) = if revision > kept_revision {
312 entry.insert((position, revision));
313 (
314 std::mem::replace(&mut kept[position], trade),
315 &kept[position],
316 )
317 } else {
318 (trade, &kept[position])
319 };
320 debug!(
321 corrected = %older.id,
322 correction = %newer.id,
323 "IBKR execution corrected; keeping the correction"
324 );
325 }
326 }
327 }
328 kept
329}
330
331pub const UNKNOWN_FEE_ASSET: &str = "UNKNOWN";
336
337pub(super) fn ib_order_id(ib_order_id: i32) -> OrderId {
340 OrderId::new(format_smolstr!("{ib_order_id}"))
341}
342
343pub(super) fn foreign_order_id(execution: &Execution) -> OrderId {
350 if execution.perm_id != 0 {
351 OrderId::new(format_smolstr!("perm:{}", execution.perm_id))
352 } else {
353 OrderId::new(format_smolstr!(
354 "client:{}:{}",
355 execution.client_id,
356 execution.order_id
357 ))
358 }
359}
360
361fn build_trade(
363 pending: PendingExecution,
364 commission: &CommissionReport,
365) -> Trade<AssetNameExchange, InstrumentNameExchange> {
366 let commission_amount = parse_decimal_or_warn(commission.commission, "commission");
367 let fees = AssetFees {
368 asset: AssetNameExchange::from(commission.currency.as_str()),
369 fees: commission_amount,
370 fees_quote: None, };
372 trade_with_fees(pending, fees)
373}
374
375fn trade_with_fees(
377 pending: PendingExecution,
378 fees: AssetFees<AssetNameExchange>,
379) -> Trade<AssetNameExchange, InstrumentNameExchange> {
380 let exec = &pending.execution.execution;
381
382 let side = match exec.side {
385 ExecutionSide::Bought => Side::Buy,
386 ExecutionSide::Sold => Side::Sell,
387 };
388
389 let price = parse_decimal_or_warn(exec.price, "exec.price");
390 let quantity = parse_decimal_or_warn(exec.shares, "exec.shares");
391
392 let ResolvedExecution {
393 instrument,
394 order_id,
395 origin,
396 time,
397 } = pending.resolved;
398 let (time_exchange, time_source) = match time {
401 Some(time) => (time, TimeSource::Venue),
402 None => {
403 warn!(exec_id = %exec.execution_id, time = %exec.time, "IBKR execution time unreadable, using now");
404 (Utc::now(), TimeSource::Host)
405 }
406 };
407 Trade {
408 id: TradeId::new(&exec.execution_id),
409 order_id,
412 instrument,
413 strategy: StrategyId::unknown(),
414 time_exchange,
415 side,
416 price,
417 quantity,
418 order_filled_quantity: Some(parse_decimal_or_warn(
421 exec.cumulative_quantity,
422 "exec.cumulative_quantity",
423 )),
424 fees,
425 origin,
426 position_side: None,
427 client_order_id: (!exec.order_reference.is_empty())
430 .then(|| ClientOrderId::new(exec.order_reference.as_str())),
431 time_source,
432 }
433}
434
435pub fn parse_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Decimal {
445 try_decimal_or_warn(value, field_name).unwrap_or(Decimal::ZERO)
446}
447
448pub fn try_decimal_or_warn(value: f64, field_name: impl std::fmt::Display) -> Option<Decimal> {
454 Decimal::try_from(value)
455 .map_err(
456 |e| warn!(field = %field_name, value = %value, error = %e, "Invalid f64 for Decimal"),
457 )
458 .ok()
459}
460
461pub fn parse_ib_timestamp(s: &str, session_zone: Option<Tz>) -> Option<DateTime<Utc>> {
481 let s = s.trim();
482 let (date, rest) = s
483 .split_once(char::is_whitespace)
484 .map_or((s, ""), |(date, rest)| (date, rest.trim_start()));
485 if let Some((date, time)) = date.split_once('-') {
486 return rest
487 .is_empty()
488 .then(|| naive_ib_time(date, time))
489 .flatten()
490 .map(|naive| naive.and_utc());
491 }
492 let (time, zone) = rest
493 .split_once(char::is_whitespace)
494 .map_or((rest, None), |(time, zone)| (time, Some(zone.trim())));
495 let naive = naive_ib_time(date, time)?;
496 let zone = match zone {
497 Some(name) => resolve_zone(name)?,
498 None => session_zone?,
499 };
500 zone.from_local_datetime(&naive)
501 .earliest()
502 .map(|time| time.with_timezone(&Utc))
503}
504
505fn naive_ib_time(date: &str, time: &str) -> Option<NaiveDateTime> {
507 Some(NaiveDateTime::new(
508 NaiveDate::parse_from_str(date, "%Y%m%d").ok()?,
509 NaiveTime::parse_from_str(time, "%H:%M:%S").ok()?,
510 ))
511}
512
513fn resolve_zone(name: &str) -> Option<Tz> {
515 if let Some(zone) = TZ_CACHE.with(|cache| cache.borrow().get(name).copied()) {
516 return zone;
517 }
518 let zone = name.parse::<Tz>().ok();
519 if zone.is_none() {
520 warn!(
521 timezone = name,
522 "Unknown time zone in IB times; not reading them"
523 );
524 }
525 TZ_CACHE.with(|cache| cache.borrow_mut().insert(SmolStr::new(name), zone));
526 zone
527}
528
529pub(super) fn session_zone(client: &ibapi::client::blocking::Client) -> Option<Tz> {
533 client.time_zone().and_then(chrono_zone)
534}
535
536fn chrono_zone(zone: &time_tz::Tz) -> Option<Tz> {
539 let name = time_tz::TimeZone::name(zone);
540 name.parse()
541 .inspect_err(|_| {
542 warn!(
543 timezone = name,
544 "IB Gateway's time zone unknown to chrono-tz"
545 )
546 })
547 .ok()
548}
549
550pub(super) const EXECUTION_DAYS: i32 = 7;
553
554pub(super) const EXECUTIONS_COVER: Duration = Duration::from_secs(5 * 24 * 60 * 60);
560
561pub(super) fn executions_filter() -> ExecutionFilter {
563 ExecutionFilter {
564 last_n_days: EXECUTION_DAYS,
565 ..ExecutionFilter::default()
566 }
567}
568
569pub(super) fn executions_held_since(
573 start: DateTime<Utc>,
574 now: DateTime<Utc>,
575) -> Option<DateTime<Utc>> {
576 let covered_from = now - EXECUTIONS_COVER;
577 (start < covered_from).then_some(covered_from)
578}
579
580#[cfg(test)]
581#[allow(clippy::unwrap_used)] mod tests {
583 use super::*;
584
585 fn utc(time: &str) -> DateTime<Utc> {
586 DateTime::parse_from_rfc3339(time).unwrap().to_utc()
587 }
588
589 #[test]
592 fn an_ib_time_is_read_in_the_zone_it_names() {
593 let session = Some(chrono_tz::Asia::Tokyo);
594 for (time, expected) in [
595 ("20261027 12:48:41 Asia/Singapore", "2026-10-27T04:48:41Z"),
596 ("20250418 10:30:00 US/Eastern", "2025-04-18T14:30:00Z"),
598 ("20251218 10:30:00 America/New_York", "2025-12-18T15:30:00Z"),
599 ("20260929 14:00:00 UTC", "2026-09-29T14:00:00Z"),
600 ("20260115 09:00:00 MET", "2026-01-15T08:00:00Z"),
601 ] {
602 assert_eq!(
603 parse_ib_timestamp(time, session),
604 Some(utc(expected)),
605 "{time}"
606 );
607 }
608 }
609
610 #[test]
613 fn an_ib_time_without_a_zone_is_read_in_the_sessions() {
614 let time = "20261027 12:48:41";
615 assert_eq!(
616 parse_ib_timestamp(time, Some(chrono_tz::Asia::Singapore)),
617 Some(utc("2026-10-27T04:48:41Z"))
618 );
619 assert_eq!(
620 parse_ib_timestamp("20261027 12:48:41", Some(chrono_tz::Asia::Singapore)),
621 Some(utc("2026-10-27T04:48:41Z")),
622 "older gateways put two spaces between the date and the time"
623 );
624 assert_eq!(parse_ib_timestamp(time, None), None);
625 }
626
627 #[test]
629 fn an_ib_time_in_an_unknown_zone_is_not_read() {
630 for time in [
631 "20261027 12:48:41 Some Standard Time",
632 "20261027 12:48:41 Nowhere/Atlantis",
633 ] {
634 assert_eq!(
635 parse_ib_timestamp(time, Some(chrono_tz::UTC)),
636 None,
637 "{time}"
638 );
639 }
640 }
641
642 #[test]
644 fn an_ib_time_with_a_hyphen_is_utc() {
645 assert_eq!(
646 parse_ib_timestamp("20261027-04:48:41", Some(chrono_tz::Asia::Singapore)),
647 Some(utc("2026-10-27T04:48:41Z"))
648 );
649 assert_eq!(parse_ib_timestamp("20261027-04:48:41 UTC", None), None);
650 }
651
652 #[test]
654 fn an_ib_time_in_a_daylight_saving_change() {
655 assert_eq!(
657 parse_ib_timestamp("20251102 01:30:00 US/Eastern", None),
658 Some(utc("2025-11-02T05:30:00Z"))
659 );
660 assert_eq!(
662 parse_ib_timestamp("20250309 02:30:00 US/Eastern", None),
663 None
664 );
665 }
666
667 #[test]
668 fn an_ib_time_that_is_no_time_is_not_read() {
669 for time in [
670 "",
671 "invalid",
672 "20261027",
673 "20261027 25:00:00 UTC",
674 "20261327 12:00:00 UTC",
675 ] {
676 assert_eq!(
677 parse_ib_timestamp(time, Some(chrono_tz::UTC)),
678 None,
679 "{time:?}"
680 );
681 }
682 }
683
684 #[test]
687 fn a_fill_without_a_time_it_can_read_is_marked_as_this_hosts() {
688 let buffer = ExecutionBuffer::new();
689 let at = utc("2026-10-27T04:48:41Z");
690 for (exec_id, time) in [("read", Some(at)), ("unread", None)] {
691 buffer.add_execution(
692 ExecutionData {
693 execution: ibapi::orders::Execution {
694 execution_id: exec_id.to_string(),
695 ..Default::default()
696 },
697 ..Default::default()
698 },
699 ResolvedExecution {
700 instrument: InstrumentNameExchange::new("AAPL"),
701 order_id: ib_order_id(1),
702 origin: TradeOrigin::Order,
703 time,
704 },
705 );
706 }
707
708 let before = Utc::now();
709 let mut trades = buffer.take_without_commission();
710 trades.sort_by(|a, b| a.id.cmp(&b.id));
711 assert_eq!(trades[0].id, TradeId::new("read"));
712 assert_eq!(
713 (trades[0].time_exchange, trades[0].time_source),
714 (at, TimeSource::Venue)
715 );
716 assert_eq!(trades[1].time_source, TimeSource::Host);
717 assert!((before..=Utc::now()).contains(&trades[1].time_exchange));
718 }
719
720 #[test]
722 fn a_zone_ibapi_resolved_is_the_chrono_zone_of_its_name() {
723 assert_eq!(
724 chrono_zone(time_tz::timezones::db::asia::SINGAPORE),
725 Some(chrono_tz::Asia::Singapore)
726 );
727 assert_eq!(
728 chrono_zone(time_tz::timezones::db::america::NEW_YORK),
729 Some(chrono_tz::America::New_York)
730 );
731 }
732
733 #[test]
736 fn clear_stale_clears_executions_older_than_the_age() {
737 let buffer = ExecutionBuffer::new();
738 let now = Utc::now();
739 let resolved = |time| ResolvedExecution {
740 instrument: InstrumentNameExchange::new("AAPL"),
741 order_id: ib_order_id(1),
742 origin: TradeOrigin::Order,
743 time,
744 };
745 let execution = |exec_id: &str| ExecutionData {
746 execution: ibapi::orders::Execution {
747 execution_id: exec_id.to_string(),
748 ..Default::default()
749 },
750 ..Default::default()
751 };
752 for (exec_id, time) in [
753 ("old", Some(now - chrono::Duration::hours(2))),
754 ("at-age", Some(now - chrono::Duration::hours(1))),
755 ("recent", Some(now - chrono::Duration::minutes(1))),
756 ("unread", None),
757 ] {
758 buffer.add_execution(execution(exec_id), resolved(time));
759 }
760 buffer.inner.lock().pending.insert(
761 "unread-old".to_string(),
762 PendingExecution {
763 execution: execution("unread-old"),
764 resolved: resolved(None),
765 buffered_at: now - chrono::Duration::hours(2),
766 },
767 );
768
769 assert_eq!(buffer.clear_stale(Duration::from_secs(3_600)), 3);
770 let mut kept: Vec<_> = buffer
771 .take_without_commission()
772 .into_iter()
773 .map(|trade| trade.id)
774 .collect();
775 kept.sort();
776 assert_eq!(kept, [TradeId::new("recent"), TradeId::new("unread")]);
777 }
778
779 #[test]
782 fn a_fill_carries_the_order_reference_as_its_client_order_id() {
783 let buffer = ExecutionBuffer::new();
784 for (exec_id, reference) in [("a", "outside-1"), ("b", "")] {
785 buffer.add_execution(
786 ExecutionData {
787 execution: ibapi::orders::Execution {
788 execution_id: exec_id.to_string(),
789 order_reference: reference.to_string(),
790 ..Default::default()
791 },
792 ..Default::default()
793 },
794 ResolvedExecution {
795 instrument: InstrumentNameExchange::new("AAPL"),
796 order_id: ib_order_id(1),
797 origin: TradeOrigin::Order,
798 time: None,
799 },
800 );
801 }
802
803 let mut trades = buffer.take_without_commission();
804 trades.sort_by(|a, b| a.id.cmp(&b.id));
805 assert_eq!(
806 trades[0].client_order_id,
807 Some(ClientOrderId::new("outside-1"))
808 );
809 assert_eq!(trades[1].client_order_id, None);
810 }
811
812 #[test]
813 fn drain_into_moves_everything_and_keeps_the_target() {
814 let execution = |exec_id: &str| ExecutionData {
815 execution: ibapi::orders::Execution {
816 execution_id: exec_id.to_string(),
817 ..Default::default()
818 },
819 ..Default::default()
820 };
821 let add = |buffer: &ExecutionBuffer, exec_id: &str| {
822 buffer.add_execution(
823 execution(exec_id),
824 ResolvedExecution {
825 instrument: InstrumentNameExchange::new("AAPL"),
826 order_id: ib_order_id(1),
827 origin: TradeOrigin::Order,
828 time: None,
829 },
830 );
831 };
832 let source = ExecutionBuffer::new();
833 let target = ExecutionBuffer::new();
834 add(&source, "a");
835 add(&source, "b");
836 add(&target, "c");
837
838 assert_eq!(source.drain_into(&target), 2);
839 assert_eq!(source.pending_count(), 0);
840 assert_eq!(target.pending_count(), 3);
841 }
842
843 #[test]
844 fn execution_revision_reads_the_digits_after_the_final_period() {
845 let original = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.01").unwrap();
846 assert_eq!(original.execution, "0000e0d5.5f8b1c2a.01");
847 assert_eq!(original.revision, 1);
848 assert!(!original.is_correction());
849 assert_eq!(original.previous_id(), None);
850
851 let correction = ExecutionRevision::parse("0000e0d5.5f8b1c2a.01.02").unwrap();
852 assert_eq!(correction.execution, "0000e0d5.5f8b1c2a.01");
853 assert!(correction.is_correction());
854 assert_eq!(
855 correction.previous_id(),
856 Some(TradeId::new("0000e0d5.5f8b1c2a.01.01"))
857 );
858
859 assert_eq!(
861 ExecutionRevision::parse("x.10").unwrap().previous_id(),
862 Some(TradeId::new("x.09"))
863 );
864 assert_eq!(
865 ExecutionRevision::parse("x.3").unwrap().previous_id(),
866 Some(TradeId::new("x.2"))
867 );
868 }
869
870 #[test]
871 fn execution_id_without_a_revision_is_never_a_correction() {
872 for id in ["e1", "abc.", "abc.0x", "abc.1a", "", ".", "abc.99999999999"] {
873 assert_eq!(ExecutionRevision::parse(id), None, "{id:?}");
874 }
875 assert_eq!(revision_of(&TradeId::new("e1")), 0);
876 assert_eq!(revision_of(&TradeId::new("a.b.02")), 2);
877 }
878
879 fn trade(id: &str, price: i64) -> Trade<AssetNameExchange, InstrumentNameExchange> {
880 Trade {
881 id: TradeId::new(id),
882 order_id: crate::order::id::OrderId::new("cid"),
883 instrument: InstrumentNameExchange::new("AAPL"),
884 strategy: StrategyId::unknown(),
885 time_exchange: DateTime::<Utc>::MIN_UTC,
886 side: Side::Buy,
887 price: Decimal::from(price),
888 quantity: Decimal::ONE,
889 order_filled_quantity: Some(Decimal::ONE),
890 fees: AssetFees::new(AssetNameExchange::from("USD"), Decimal::ZERO, None),
891 origin: TradeOrigin::Order,
892 position_side: None,
893 client_order_id: None,
894 time_source: TimeSource::Venue,
895 }
896 }
897
898 #[test]
899 fn keep_latest_revisions_reads_a_corrected_execution_once() {
900 let kept = keep_latest_revisions(vec![
901 trade("a.01.01", 100),
902 trade("b.01.01", 200),
903 trade("a.01.02", 101),
904 trade("a.01.02", 101),
905 trade("plain", 300),
906 trade("b.01.03", 202),
908 trade("b.01.02", 201),
909 ]);
910 let read: Vec<_> = kept
911 .iter()
912 .map(|trade| (trade.id.0.as_str(), trade.price))
913 .collect();
914 assert_eq!(
915 read,
916 [
917 ("a.01.02", Decimal::from(101)),
918 ("b.01.03", Decimal::from(202)),
919 ("plain", Decimal::from(300)),
920 ]
921 );
922 }
923
924 #[test]
925 fn executions_held_since_is_the_cover_start_only_when_the_span_starts_before_it() {
926 let now = Utc.with_ymd_and_hms(2026, 10, 23, 9, 0, 0).unwrap();
927 let covered_from = Utc.with_ymd_and_hms(2026, 10, 18, 9, 0, 0).unwrap();
928 let before = covered_from - chrono::TimeDelta::seconds(1);
929 assert_eq!(executions_held_since(before, now), Some(covered_from));
930 assert_eq!(executions_held_since(covered_from, now), None);
931 assert_eq!(executions_held_since(now, now), None);
932 }
933
934 #[test]
935 fn executions_filter_reads_every_client_over_the_execution_days() {
936 let filter = executions_filter();
937 assert_eq!(filter.last_n_days, 7);
938 assert_eq!(filter.client_id, None);
939 assert!(filter.specific_dates.is_empty());
940 }
941}