1use std::collections::{BTreeMap, BTreeSet};
4use std::ops::Bound::{Excluded, Unbounded};
5
6use crate::PriceQuote;
7use chrono::{Duration, NaiveDateTime};
8use qs_symbols::{SymbolRegistry, normalize_currency_code};
9use serde::{Deserialize, Deserializer, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum FxPairDirection {
15 Direct,
17 Inverse,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct FxPair {
24 pub symbol: String,
25 pub base_currency: String,
26 pub quote_currency: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ConversionLeg {
32 pub pair: FxPair,
33 pub direction: FxPairDirection,
34}
35
36impl ConversionLeg {
37 pub fn from_currency(&self) -> &str {
39 match self.direction {
40 FxPairDirection::Direct => &self.pair.base_currency,
41 FxPairDirection::Inverse => &self.pair.quote_currency,
42 }
43 }
44
45 pub fn to_currency(&self) -> &str {
47 match self.direction {
48 FxPairDirection::Direct => &self.pair.quote_currency,
49 FxPairDirection::Inverse => &self.pair.base_currency,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub enum ConversionRoute {
57 Identity {
58 currency: String,
59 },
60 Direct {
61 pair: FxPair,
62 },
63 Inverse {
64 pair: FxPair,
65 },
66 TwoLeg {
67 pivot_currency: String,
68 first: ConversionLeg,
69 second: ConversionLeg,
70 },
71}
72
73impl ConversionRoute {
74 pub fn from_currency(&self) -> &str {
76 match self {
77 Self::Identity { currency } => currency,
78 Self::Direct { pair } => &pair.base_currency,
79 Self::Inverse { pair } => &pair.quote_currency,
80 Self::TwoLeg { first, .. } => first.from_currency(),
81 }
82 }
83
84 pub fn to_currency(&self) -> &str {
86 match self {
87 Self::Identity { currency } => currency,
88 Self::Direct { pair } => &pair.quote_currency,
89 Self::Inverse { pair } => &pair.base_currency,
90 Self::TwoLeg { second, .. } => second.to_currency(),
91 }
92 }
93
94 pub fn symbols(&self) -> impl Iterator<Item = &str> {
96 let symbols = match self {
97 Self::Identity { .. } => [None, None],
98 Self::Direct { pair } | Self::Inverse { pair } => [Some(pair.symbol.as_str()), None],
99 Self::TwoLeg { first, second, .. } => [
100 Some(first.pair.symbol.as_str()),
101 Some(second.pair.symbol.as_str()),
102 ],
103 };
104 symbols.into_iter().flatten()
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ConversionPriceSide {
111 Bid,
112 Ask,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Error)]
117pub enum QuoteValidationError {
118 #[error("bid must be finite and positive, got {0}")]
119 InvalidBid(f64),
120 #[error("ask must be finite and positive, got {0}")]
121 InvalidAsk(f64),
122 #[error("bid {bid} is greater than ask {ask}")]
123 Crossed { bid: f64, ask: f64 },
124}
125
126#[derive(Debug, Clone, PartialEq, Error)]
128pub enum ConversionError {
129 #[error("invalid {role} currency code '{value}': expected 3 ASCII letters")]
130 InvalidCurrencyCode { role: &'static str, value: String },
131 #[error(
132 "no conversion route from {from_currency} to {to_currency} in the available symbol set"
133 )]
134 RouteNotFound {
135 from_currency: String,
136 to_currency: String,
137 },
138 #[error("maximum quote staleness must be non-negative, got {millis} ms")]
139 InvalidStaleness { millis: i64 },
140 #[error("conversion amount must be finite, got {0}")]
141 InvalidAmount(f64),
142 #[error("quote symbol '{0}' is not registered")]
143 UnknownQuoteSymbol(String),
144 #[error("quote symbol '{0}' is not a forex symbol")]
145 NonForexQuoteSymbol(String),
146 #[error("invalid quote for '{symbol}' at {quote_ts}: {reason}")]
147 InvalidQuote {
148 symbol: String,
149 quote_ts: NaiveDateTime,
150 reason: QuoteValidationError,
151 },
152 #[error(
153 "no quote for '{symbol}' at or before {operation_ts}; next future quote is {next_quote_ts:?}"
154 )]
155 NoCausalQuote {
156 symbol: String,
157 operation_ts: NaiveDateTime,
158 next_quote_ts: Option<NaiveDateTime>,
159 },
160 #[error(
161 "stale quote for '{symbol}': {age_millis} ms old at {operation_ts}, maximum is {max_staleness_millis} ms"
162 )]
163 StaleQuote {
164 symbol: String,
165 quote_ts: NaiveDateTime,
166 operation_ts: NaiveDateTime,
167 age_millis: i64,
168 max_staleness_millis: i64,
169 },
170 #[error("invalid conversion route: {0}")]
171 InvalidRoute(String),
172 #[error("conversion produced a non-finite result on '{symbol}'")]
173 NonFiniteResult { symbol: String },
174 #[error("canonical quote symbol must not be empty")]
175 EmptyCanonicalQuoteSymbol,
176}
177
178#[derive(Debug, Clone, PartialEq, Error)]
180pub enum RunCurrencyPlanError {
181 #[error("invalid currency code for {field}: '{value}' must be 3 ASCII letters")]
182 InvalidCurrencyCode { field: String, value: String },
183 #[error("{kind} symbol must not be empty")]
184 EmptySymbol { kind: &'static str },
185 #[error("primary symbol '{symbol}' has no P&L currency mapping")]
186 MissingPrimaryPnlCurrency { symbol: String },
187 #[error("P&L currency mapping references non-primary symbol '{symbol}'")]
188 UnexpectedPrimaryPnlCurrency { symbol: String },
189 #[error("source currency '{source_currency}' has no conversion route")]
190 MissingConversionRoute { source_currency: String },
191 #[error("multiple conversion routes normalize to source currency '{source_currency}'")]
192 DuplicateSourceCurrency { source_currency: String },
193 #[error(
194 "conversion route key '{source_currency}' does not match route source '{route_source_currency}'"
195 )]
196 RouteSourceMismatch {
197 source_currency: String,
198 route_source_currency: String,
199 },
200 #[error(
201 "conversion route for '{source_currency}' ends in '{route_destination_currency}', expected account currency '{account_currency}'"
202 )]
203 RouteDestinationMismatch {
204 source_currency: String,
205 route_destination_currency: String,
206 account_currency: String,
207 },
208 #[error("invalid conversion route for '{source_currency}': {reason}")]
209 InvalidRoute {
210 source_currency: String,
211 reason: String,
212 },
213 #[error(
214 "route for '{source_currency}' uses symbol '{symbol}' which is not in conversion_symbols"
215 )]
216 UndeclaredRouteSymbol {
217 source_currency: String,
218 symbol: String,
219 },
220 #[error("warmup quote symbol '{symbol}' is not in conversion_symbols")]
221 UndeclaredWarmupSymbol { symbol: String },
222 #[error("invalid warmup quote for '{symbol}' at {quote_ts}: {reason}")]
223 InvalidWarmupQuote {
224 symbol: String,
225 quote_ts: NaiveDateTime,
226 reason: QuoteValidationError,
227 },
228 #[error("duplicate warmup quote for '{symbol}' at {quote_ts}")]
229 DuplicateWarmupQuote {
230 symbol: String,
231 quote_ts: NaiveDateTime,
232 },
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct ConversionLegAudit {
238 pub sequence: usize,
239 pub symbol: String,
240 pub direction: FxPairDirection,
241 pub from_currency: String,
242 pub to_currency: String,
243 pub input_amount: f64,
244 pub output_amount: f64,
245 pub quote_ts: NaiveDateTime,
246 pub quote_age_millis: i64,
247 pub bid: f64,
248 pub ask: f64,
249 pub price_side: ConversionPriceSide,
250 pub executable_price: f64,
251 pub conversion_rate: f64,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct ConversionResult {
257 pub from_currency: String,
258 pub to_currency: String,
259 pub input_amount: f64,
260 pub output_amount: f64,
261 pub operation_ts: NaiveDateTime,
262 pub route: ConversionRoute,
263 pub legs: Vec<ConversionLegAudit>,
264}
265
266#[derive(Debug, Clone, Serialize)]
268pub struct RunCurrencyPlan {
269 account_currency: String,
270 primary_symbols: BTreeSet<String>,
271 conversion_symbols: BTreeSet<String>,
272 pnl_currency_by_primary_symbol: BTreeMap<String, String>,
273 conversion_route_by_source_currency: BTreeMap<String, ConversionRoute>,
274 strict_before_warmup_quotes: Vec<PriceQuote>,
275}
276
277impl PartialEq for RunCurrencyPlan {
278 fn eq(&self, other: &Self) -> bool {
279 self.account_currency == other.account_currency
280 && self.primary_symbols == other.primary_symbols
281 && self.conversion_symbols == other.conversion_symbols
282 && self.pnl_currency_by_primary_symbol == other.pnl_currency_by_primary_symbol
283 && self.conversion_route_by_source_currency == other.conversion_route_by_source_currency
284 && self.strict_before_warmup_quotes.len() == other.strict_before_warmup_quotes.len()
285 && self
286 .strict_before_warmup_quotes
287 .iter()
288 .zip(&other.strict_before_warmup_quotes)
289 .all(|(left, right)| {
290 left.symbol == right.symbol
291 && left.ts == right.ts
292 && left.bid.to_bits() == right.bid.to_bits()
293 && left.ask.to_bits() == right.ask.to_bits()
294 })
295 }
296}
297
298#[derive(Deserialize)]
299struct RunCurrencyPlanWire {
300 account_currency: String,
301 primary_symbols: BTreeSet<String>,
302 conversion_symbols: BTreeSet<String>,
303 pnl_currency_by_primary_symbol: BTreeMap<String, String>,
304 conversion_route_by_source_currency: BTreeMap<String, ConversionRoute>,
305 strict_before_warmup_quotes: Vec<PriceQuote>,
306}
307
308impl RunCurrencyPlan {
309 pub fn new(
311 account_currency: impl Into<String>,
312 primary_symbols: BTreeSet<String>,
313 conversion_symbols: BTreeSet<String>,
314 pnl_currency_by_primary_symbol: BTreeMap<String, String>,
315 conversion_route_by_source_currency: BTreeMap<String, ConversionRoute>,
316 mut strict_before_warmup_quotes: Vec<PriceQuote>,
317 ) -> Result<Self, RunCurrencyPlanError> {
318 let account_currency =
319 normalize_plan_currency("account_currency", &account_currency.into())?;
320 validate_plan_symbols("primary", &primary_symbols)?;
321 validate_plan_symbols("conversion", &conversion_symbols)?;
322
323 for symbol in pnl_currency_by_primary_symbol.keys() {
324 if !primary_symbols.contains(symbol) {
325 return Err(RunCurrencyPlanError::UnexpectedPrimaryPnlCurrency {
326 symbol: symbol.clone(),
327 });
328 }
329 }
330
331 let mut normalized_pnl_currencies = BTreeMap::new();
332 for symbol in &primary_symbols {
333 let raw_currency = pnl_currency_by_primary_symbol.get(symbol).ok_or_else(|| {
334 RunCurrencyPlanError::MissingPrimaryPnlCurrency {
335 symbol: symbol.clone(),
336 }
337 })?;
338 let currency = normalize_plan_currency(
339 &format!("pnl_currency_by_primary_symbol[{symbol}]"),
340 raw_currency,
341 )?;
342 normalized_pnl_currencies.insert(symbol.clone(), currency);
343 }
344
345 let mut normalized_routes = BTreeMap::new();
346 for (raw_source_currency, route) in conversion_route_by_source_currency {
347 let source_currency = normalize_plan_currency(
348 "conversion_route_by_source_currency key",
349 &raw_source_currency,
350 )?;
351 if normalized_routes.contains_key(&source_currency) {
352 return Err(RunCurrencyPlanError::DuplicateSourceCurrency { source_currency });
353 }
354 let route = normalize_plan_route(&source_currency, route)?;
355 if route.from_currency() != source_currency {
356 return Err(RunCurrencyPlanError::RouteSourceMismatch {
357 source_currency,
358 route_source_currency: route.from_currency().to_owned(),
359 });
360 }
361 if route.to_currency() != account_currency {
362 return Err(RunCurrencyPlanError::RouteDestinationMismatch {
363 source_currency,
364 route_destination_currency: route.to_currency().to_owned(),
365 account_currency: account_currency.clone(),
366 });
367 }
368 for symbol in route.symbols() {
369 if !conversion_symbols.contains(symbol) {
370 return Err(RunCurrencyPlanError::UndeclaredRouteSymbol {
371 source_currency,
372 symbol: symbol.to_owned(),
373 });
374 }
375 }
376 normalized_routes.insert(source_currency, route);
377 }
378
379 for source_currency in normalized_pnl_currencies.values() {
380 if !normalized_routes.contains_key(source_currency) {
381 return Err(RunCurrencyPlanError::MissingConversionRoute {
382 source_currency: source_currency.clone(),
383 });
384 }
385 }
386
387 let mut warmup_keys = BTreeSet::new();
388 for quote in &strict_before_warmup_quotes {
389 if quote.symbol.is_empty() {
390 return Err(RunCurrencyPlanError::EmptySymbol { kind: "warmup" });
391 }
392 if !conversion_symbols.contains("e.symbol) {
393 return Err(RunCurrencyPlanError::UndeclaredWarmupSymbol {
394 symbol: quote.symbol.clone(),
395 });
396 }
397 validate_quote(quote).map_err(|reason| RunCurrencyPlanError::InvalidWarmupQuote {
398 symbol: quote.symbol.clone(),
399 quote_ts: quote.ts,
400 reason,
401 })?;
402 if !warmup_keys.insert((quote.symbol.clone(), quote.ts)) {
403 return Err(RunCurrencyPlanError::DuplicateWarmupQuote {
404 symbol: quote.symbol.clone(),
405 quote_ts: quote.ts,
406 });
407 }
408 }
409 strict_before_warmup_quotes.sort_by(|left, right| {
410 left.ts
411 .cmp(&right.ts)
412 .then_with(|| left.symbol.cmp(&right.symbol))
413 });
414
415 Ok(Self {
416 account_currency,
417 primary_symbols,
418 conversion_symbols,
419 pnl_currency_by_primary_symbol: normalized_pnl_currencies,
420 conversion_route_by_source_currency: normalized_routes,
421 strict_before_warmup_quotes,
422 })
423 }
424
425 pub fn account_currency(&self) -> &str {
426 &self.account_currency
427 }
428
429 pub fn primary_symbols(&self) -> &BTreeSet<String> {
430 &self.primary_symbols
431 }
432
433 pub fn conversion_symbols(&self) -> &BTreeSet<String> {
434 &self.conversion_symbols
435 }
436
437 pub fn pnl_currency_by_primary_symbol(&self) -> &BTreeMap<String, String> {
438 &self.pnl_currency_by_primary_symbol
439 }
440
441 pub fn conversion_route_by_source_currency(&self) -> &BTreeMap<String, ConversionRoute> {
442 &self.conversion_route_by_source_currency
443 }
444
445 pub fn strict_before_warmup_quotes(&self) -> &[PriceQuote] {
446 &self.strict_before_warmup_quotes
447 }
448
449 pub fn pnl_currency_for_primary_symbol(&self, primary_symbol: &str) -> Option<&str> {
450 self.pnl_currency_by_primary_symbol
451 .get(primary_symbol)
452 .map(String::as_str)
453 }
454
455 pub fn route_for_primary_symbol(&self, primary_symbol: &str) -> Option<&ConversionRoute> {
457 let source_currency = self.pnl_currency_by_primary_symbol.get(primary_symbol)?;
458 self.conversion_route_by_source_currency
459 .get(source_currency)
460 }
461
462 pub fn route_symbols(&self) -> BTreeSet<&str> {
464 self.conversion_route_by_source_currency
465 .values()
466 .flat_map(ConversionRoute::symbols)
467 .collect()
468 }
469}
470
471impl<'de> Deserialize<'de> for RunCurrencyPlan {
472 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
473 where
474 D: Deserializer<'de>,
475 {
476 let wire = RunCurrencyPlanWire::deserialize(deserializer)?;
477 Self::new(
478 wire.account_currency,
479 wire.primary_symbols,
480 wire.conversion_symbols,
481 wire.pnl_currency_by_primary_symbol,
482 wire.conversion_route_by_source_currency,
483 wire.strict_before_warmup_quotes,
484 )
485 .map_err(serde::de::Error::custom)
486 }
487}
488
489pub fn resolve_fx_pair(
494 registry: &SymbolRegistry,
495 from_currency: &str,
496 to_currency: &str,
497 available_symbols: &BTreeSet<String>,
498) -> Result<Option<ConversionLeg>, ConversionError> {
499 let from_currency = normalize_conversion_currency("source", from_currency)?;
500 let to_currency = normalize_conversion_currency("destination", to_currency)?;
501 let available = canonical_available_symbols(registry, available_symbols);
502 let pairs = available_fx_pairs(registry, &available);
503 Ok(find_pair(&pairs, &from_currency, &to_currency))
504}
505
506pub fn resolve_conversion_route(
508 registry: &SymbolRegistry,
509 from_currency: &str,
510 to_currency: &str,
511 available_symbols: &BTreeSet<String>,
512) -> Result<ConversionRoute, ConversionError> {
513 let from_currency = normalize_conversion_currency("source", from_currency)?;
514 let to_currency = normalize_conversion_currency("destination", to_currency)?;
515
516 if from_currency == to_currency {
517 return Ok(ConversionRoute::Identity {
518 currency: from_currency,
519 });
520 }
521
522 let available = canonical_available_symbols(registry, available_symbols);
523 let pairs = available_fx_pairs(registry, &available);
524 if let Some(leg) = find_pair(&pairs, &from_currency, &to_currency) {
525 return Ok(one_leg_route(leg));
526 }
527
528 let mut pivots = BTreeSet::new();
529 for pair in &pairs {
530 pivots.insert(pair.base_currency.clone());
531 pivots.insert(pair.quote_currency.clone());
532 }
533 pivots.remove(&from_currency);
534 pivots.remove(&to_currency);
535
536 for pivot_currency in pivots {
537 let Some(first) = find_pair(&pairs, &from_currency, &pivot_currency) else {
538 continue;
539 };
540 let Some(second) = find_pair(&pairs, &pivot_currency, &to_currency) else {
541 continue;
542 };
543 return Ok(ConversionRoute::TwoLeg {
544 pivot_currency,
545 first,
546 second,
547 });
548 }
549
550 Err(ConversionError::RouteNotFound {
551 from_currency,
552 to_currency,
553 })
554}
555
556#[derive(Debug, Clone)]
558pub struct ConversionQuoteBook {
559 max_staleness: Duration,
560 quotes: BTreeMap<String, BTreeMap<NaiveDateTime, PriceQuote>>,
561}
562
563impl ConversionQuoteBook {
564 pub fn new(max_staleness: Duration) -> Result<Self, ConversionError> {
566 if max_staleness < Duration::zero() {
567 return Err(ConversionError::InvalidStaleness {
568 millis: max_staleness.num_milliseconds(),
569 });
570 }
571 Ok(Self {
572 max_staleness,
573 quotes: BTreeMap::new(),
574 })
575 }
576
577 pub fn max_staleness(&self) -> Duration {
579 self.max_staleness
580 }
581
582 pub fn prune_replay_history(&mut self, replay_ts: NaiveDateTime) -> usize {
586 let mut removed = 0;
587 for series in self.quotes.values_mut() {
588 let latest_causal_ts = series
589 .range(..=replay_ts)
590 .next_back()
591 .map(|(quote_ts, _)| *quote_ts);
592 if let Some(latest_causal_ts) = latest_causal_ts {
593 let previous_len = series.len();
594 *series = series.split_off(&latest_causal_ts);
595 removed += previous_len - series.len();
596 }
597 }
598 removed
599 }
600
601 pub fn retain_replay_causal_predecessors(
605 &mut self,
606 replay_ts: NaiveDateTime,
607 required_operation_times: impl IntoIterator<Item = NaiveDateTime>,
608 ) -> usize {
609 let mut required_times: BTreeSet<_> = required_operation_times.into_iter().collect();
610 required_times.insert(replay_ts);
611
612 let mut removed = 0;
613 for series in self.quotes.values_mut() {
614 let retained_timestamps: BTreeSet<_> = required_times
615 .iter()
616 .filter_map(|operation_ts| {
617 series
618 .range(..=*operation_ts)
619 .next_back()
620 .map(|(quote_ts, _)| *quote_ts)
621 })
622 .collect();
623 let previous_len = series.len();
624 series.retain(|quote_ts, _| retained_timestamps.contains(quote_ts));
625 removed += previous_len - series.len();
626 }
627 removed
628 }
629
630 pub fn record_tick(
635 &mut self,
636 registry: &SymbolRegistry,
637 mut quote: PriceQuote,
638 ) -> Result<Option<PriceQuote>, ConversionError> {
639 let raw_symbol = quote.symbol.clone();
640 let canonical = registry
641 .normalize(&raw_symbol)
642 .ok_or_else(|| ConversionError::UnknownQuoteSymbol(raw_symbol.clone()))?;
643 let spec = registry
644 .spec(canonical)
645 .ok_or(ConversionError::UnknownQuoteSymbol(raw_symbol))?;
646 if spec.category != "forex" {
647 return Err(ConversionError::NonForexQuoteSymbol(canonical.to_owned()));
648 }
649
650 quote.symbol = canonical.to_owned();
651 self.record_canonical_tick(quote)
652 }
653
654 pub fn record_canonical_tick(
656 &mut self,
657 quote: PriceQuote,
658 ) -> Result<Option<PriceQuote>, ConversionError> {
659 if quote.symbol.is_empty() {
660 return Err(ConversionError::EmptyCanonicalQuoteSymbol);
661 }
662 validate_quote("e).map_err(|reason| ConversionError::InvalidQuote {
663 symbol: quote.symbol.clone(),
664 quote_ts: quote.ts,
665 reason,
666 })?;
667 Ok(self
668 .quotes
669 .entry(quote.symbol.clone())
670 .or_default()
671 .insert(quote.ts, quote))
672 }
673
674 pub fn convert_route(
676 &self,
677 amount: f64,
678 operation_ts: NaiveDateTime,
679 route: &ConversionRoute,
680 ) -> Result<ConversionResult, ConversionError> {
681 if !amount.is_finite() {
682 return Err(ConversionError::InvalidAmount(amount));
683 }
684
685 let mut legs = Vec::new();
686 let output_amount = match route {
687 ConversionRoute::Identity { .. } => amount,
688 ConversionRoute::Direct { pair } => {
689 let leg = ConversionLeg {
690 pair: pair.clone(),
691 direction: FxPairDirection::Direct,
692 };
693 self.convert_leg(amount, operation_ts, &leg, 1, &mut legs)?
694 }
695 ConversionRoute::Inverse { pair } => {
696 let leg = ConversionLeg {
697 pair: pair.clone(),
698 direction: FxPairDirection::Inverse,
699 };
700 self.convert_leg(amount, operation_ts, &leg, 1, &mut legs)?
701 }
702 ConversionRoute::TwoLeg {
703 pivot_currency,
704 first,
705 second,
706 } => {
707 if first.to_currency() != pivot_currency
708 || second.from_currency() != pivot_currency
709 || first.to_currency() != second.from_currency()
710 {
711 return Err(ConversionError::InvalidRoute(format!(
712 "two-leg route does not join at pivot {pivot_currency}"
713 )));
714 }
715 let pivot_amount = self.convert_leg(amount, operation_ts, first, 1, &mut legs)?;
716 self.convert_leg(pivot_amount, operation_ts, second, 2, &mut legs)?
717 }
718 };
719
720 Ok(ConversionResult {
721 from_currency: route.from_currency().to_owned(),
722 to_currency: route.to_currency().to_owned(),
723 input_amount: amount,
724 output_amount,
725 operation_ts,
726 route: route.clone(),
727 legs,
728 })
729 }
730
731 pub fn convert_with_symbols(
733 &self,
734 registry: &SymbolRegistry,
735 amount: f64,
736 from_currency: &str,
737 to_currency: &str,
738 operation_ts: NaiveDateTime,
739 available_symbols: &BTreeSet<String>,
740 ) -> Result<ConversionResult, ConversionError> {
741 let route =
742 resolve_conversion_route(registry, from_currency, to_currency, available_symbols)?;
743 self.convert_route(amount, operation_ts, &route)
744 }
745
746 fn convert_leg(
747 &self,
748 amount: f64,
749 operation_ts: NaiveDateTime,
750 leg: &ConversionLeg,
751 sequence: usize,
752 audits: &mut Vec<ConversionLegAudit>,
753 ) -> Result<f64, ConversionError> {
754 let quote = self.causal_quote(&leg.pair.symbol, operation_ts)?;
755 let negative = amount.is_sign_negative();
756 let (price_side, executable_price, conversion_rate) = match (leg.direction, negative) {
757 (FxPairDirection::Direct, false) => (ConversionPriceSide::Bid, quote.bid, quote.bid),
758 (FxPairDirection::Direct, true) => (ConversionPriceSide::Ask, quote.ask, quote.ask),
759 (FxPairDirection::Inverse, false) => {
760 (ConversionPriceSide::Ask, quote.ask, 1.0 / quote.ask)
761 }
762 (FxPairDirection::Inverse, true) => {
763 (ConversionPriceSide::Bid, quote.bid, 1.0 / quote.bid)
764 }
765 };
766 let output_amount = amount * conversion_rate;
767 if !output_amount.is_finite() {
768 return Err(ConversionError::NonFiniteResult {
769 symbol: leg.pair.symbol.clone(),
770 });
771 }
772
773 audits.push(ConversionLegAudit {
774 sequence,
775 symbol: leg.pair.symbol.clone(),
776 direction: leg.direction,
777 from_currency: leg.from_currency().to_owned(),
778 to_currency: leg.to_currency().to_owned(),
779 input_amount: amount,
780 output_amount,
781 quote_ts: quote.ts,
782 quote_age_millis: (operation_ts - quote.ts).num_milliseconds(),
783 bid: quote.bid,
784 ask: quote.ask,
785 price_side,
786 executable_price,
787 conversion_rate,
788 });
789 Ok(output_amount)
790 }
791
792 fn causal_quote(
793 &self,
794 symbol: &str,
795 operation_ts: NaiveDateTime,
796 ) -> Result<&PriceQuote, ConversionError> {
797 let Some(series) = self.quotes.get(symbol) else {
798 return Err(ConversionError::NoCausalQuote {
799 symbol: symbol.to_owned(),
800 operation_ts,
801 next_quote_ts: None,
802 });
803 };
804 let Some((quote_ts, quote)) = series.range(..=operation_ts).next_back() else {
805 let next_quote_ts = series
806 .range((Excluded(operation_ts), Unbounded))
807 .next()
808 .map(|(ts, _)| *ts);
809 return Err(ConversionError::NoCausalQuote {
810 symbol: symbol.to_owned(),
811 operation_ts,
812 next_quote_ts,
813 });
814 };
815
816 let age = operation_ts - *quote_ts;
817 if age > self.max_staleness {
818 return Err(ConversionError::StaleQuote {
819 symbol: symbol.to_owned(),
820 quote_ts: *quote_ts,
821 operation_ts,
822 age_millis: age.num_milliseconds(),
823 max_staleness_millis: self.max_staleness.num_milliseconds(),
824 });
825 }
826 Ok(quote)
827 }
828}
829
830fn normalize_plan_currency(field: &str, value: &str) -> Result<String, RunCurrencyPlanError> {
831 normalize_currency_code(value).ok_or_else(|| RunCurrencyPlanError::InvalidCurrencyCode {
832 field: field.to_owned(),
833 value: value.to_owned(),
834 })
835}
836
837fn validate_plan_symbols(
838 kind: &'static str,
839 symbols: &BTreeSet<String>,
840) -> Result<(), RunCurrencyPlanError> {
841 if symbols.iter().any(|symbol| symbol.is_empty()) {
842 return Err(RunCurrencyPlanError::EmptySymbol { kind });
843 }
844 Ok(())
845}
846
847fn normalize_plan_route(
848 source_currency: &str,
849 route: ConversionRoute,
850) -> Result<ConversionRoute, RunCurrencyPlanError> {
851 let route = match route {
852 ConversionRoute::Identity { currency } => ConversionRoute::Identity {
853 currency: normalize_plan_currency(
854 &format!("route[{source_currency}].currency"),
855 ¤cy,
856 )?,
857 },
858 ConversionRoute::Direct { pair } => ConversionRoute::Direct {
859 pair: normalize_plan_pair(source_currency, pair)?,
860 },
861 ConversionRoute::Inverse { pair } => ConversionRoute::Inverse {
862 pair: normalize_plan_pair(source_currency, pair)?,
863 },
864 ConversionRoute::TwoLeg {
865 pivot_currency,
866 first,
867 second,
868 } => {
869 let pivot_currency = normalize_plan_currency(
870 &format!("route[{source_currency}].pivot_currency"),
871 &pivot_currency,
872 )?;
873 let first = ConversionLeg {
874 pair: normalize_plan_pair(source_currency, first.pair)?,
875 direction: first.direction,
876 };
877 let second = ConversionLeg {
878 pair: normalize_plan_pair(source_currency, second.pair)?,
879 direction: second.direction,
880 };
881 if first.to_currency() != pivot_currency
882 || second.from_currency() != pivot_currency
883 || first.to_currency() != second.from_currency()
884 {
885 return Err(RunCurrencyPlanError::InvalidRoute {
886 source_currency: source_currency.to_owned(),
887 reason: format!("two-leg route does not join at pivot {pivot_currency}"),
888 });
889 }
890 ConversionRoute::TwoLeg {
891 pivot_currency,
892 first,
893 second,
894 }
895 }
896 };
897 Ok(route)
898}
899
900fn normalize_plan_pair(
901 source_currency: &str,
902 pair: FxPair,
903) -> Result<FxPair, RunCurrencyPlanError> {
904 if pair.symbol.is_empty() {
905 return Err(RunCurrencyPlanError::InvalidRoute {
906 source_currency: source_currency.to_owned(),
907 reason: "route pair symbol must not be empty".to_owned(),
908 });
909 }
910 let base_currency = normalize_plan_currency(
911 &format!("route[{source_currency}].base_currency"),
912 &pair.base_currency,
913 )?;
914 let quote_currency = normalize_plan_currency(
915 &format!("route[{source_currency}].quote_currency"),
916 &pair.quote_currency,
917 )?;
918 if base_currency == quote_currency {
919 return Err(RunCurrencyPlanError::InvalidRoute {
920 source_currency: source_currency.to_owned(),
921 reason: format!(
922 "pair '{}' has identical base and quote currencies",
923 pair.symbol
924 ),
925 });
926 }
927 Ok(FxPair {
928 symbol: pair.symbol,
929 base_currency,
930 quote_currency,
931 })
932}
933
934fn normalize_conversion_currency(
935 role: &'static str,
936 value: &str,
937) -> Result<String, ConversionError> {
938 normalize_currency_code(value).ok_or_else(|| ConversionError::InvalidCurrencyCode {
939 role,
940 value: value.to_owned(),
941 })
942}
943
944fn canonical_available_symbols(
945 registry: &SymbolRegistry,
946 available_symbols: &BTreeSet<String>,
947) -> BTreeSet<String> {
948 available_symbols
949 .iter()
950 .filter_map(|symbol| registry.normalize(symbol))
951 .map(str::to_owned)
952 .collect()
953}
954
955fn available_fx_pairs(
956 registry: &SymbolRegistry,
957 available_symbols: &BTreeSet<String>,
958) -> Vec<FxPair> {
959 available_symbols
960 .iter()
961 .filter_map(|symbol| {
962 let spec = registry.spec(symbol)?;
963 if spec.category != "forex" {
964 return None;
965 }
966 let metadata = registry.currency_metadata(symbol)?;
967 Some(FxPair {
968 symbol: symbol.clone(),
969 base_currency: metadata.base_currency.clone()?,
970 quote_currency: metadata.quote_currency.clone()?,
971 })
972 })
973 .collect()
974}
975
976fn find_pair(pairs: &[FxPair], from_currency: &str, to_currency: &str) -> Option<ConversionLeg> {
977 pairs
978 .iter()
979 .find(|pair| pair.base_currency == from_currency && pair.quote_currency == to_currency)
980 .cloned()
981 .map(|pair| ConversionLeg {
982 pair,
983 direction: FxPairDirection::Direct,
984 })
985 .or_else(|| {
986 pairs
987 .iter()
988 .find(|pair| {
989 pair.base_currency == to_currency && pair.quote_currency == from_currency
990 })
991 .cloned()
992 .map(|pair| ConversionLeg {
993 pair,
994 direction: FxPairDirection::Inverse,
995 })
996 })
997}
998
999fn one_leg_route(leg: ConversionLeg) -> ConversionRoute {
1000 match leg.direction {
1001 FxPairDirection::Direct => ConversionRoute::Direct { pair: leg.pair },
1002 FxPairDirection::Inverse => ConversionRoute::Inverse { pair: leg.pair },
1003 }
1004}
1005
1006fn validate_quote(quote: &PriceQuote) -> Result<(), QuoteValidationError> {
1007 if !quote.bid.is_finite() || quote.bid <= 0.0 {
1008 return Err(QuoteValidationError::InvalidBid(quote.bid));
1009 }
1010 if !quote.ask.is_finite() || quote.ask <= 0.0 {
1011 return Err(QuoteValidationError::InvalidAsk(quote.ask));
1012 }
1013 if quote.bid > quote.ask {
1014 return Err(QuoteValidationError::Crossed {
1015 bid: quote.bid,
1016 ask: quote.ask,
1017 });
1018 }
1019 Ok(())
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use std::fmt::Write;
1025
1026 use chrono::NaiveDate;
1027
1028 use super::*;
1029
1030 fn registry() -> SymbolRegistry {
1031 let mut toml = String::new();
1032 for (symbol, base, quote) in [
1033 ("eurusd", "EUR", "USD"),
1034 ("usdjpy", "USD", "JPY"),
1035 ("eurjpy", "EUR", "JPY"),
1036 ("gbpusd", "GBP", "USD"),
1037 ("eurgbp", "EUR", "GBP"),
1038 ("gbpjpy", "GBP", "JPY"),
1039 ("usdchf", "USD", "CHF"),
1040 ("eurchf", "EUR", "CHF"),
1041 ] {
1042 writeln!(
1043 toml,
1044 r#"
1045[[symbol]]
1046canonical = "{symbol}"
1047aliases = []
1048pip_position = 4
1049digits = 5
1050category = "forex"
1051base_currency = "{base}"
1052quote_currency = "{quote}"
1053pnl_currency = "{quote}"
1054lot_base_units = 100000
1055lot_step_units = 1000"#
1056 )
1057 .unwrap();
1058 }
1059 SymbolRegistry::from_toml(&toml).unwrap()
1060 }
1061
1062 fn available(symbols: &[&str]) -> BTreeSet<String> {
1063 symbols.iter().map(|symbol| (*symbol).to_owned()).collect()
1064 }
1065
1066 fn ts(seconds: u32) -> NaiveDateTime {
1067 NaiveDate::from_ymd_opt(2024, 1, 1)
1068 .unwrap()
1069 .and_hms_opt(0, 0, seconds)
1070 .unwrap()
1071 }
1072
1073 fn quote(symbol: &str, seconds: u32, bid: f64, ask: f64) -> PriceQuote {
1074 PriceQuote {
1075 symbol: symbol.to_owned(),
1076 ts: ts(seconds),
1077 bid,
1078 ask,
1079 }
1080 }
1081
1082 type PlanInputs = (
1083 BTreeSet<String>,
1084 BTreeSet<String>,
1085 BTreeMap<String, String>,
1086 BTreeMap<String, ConversionRoute>,
1087 Vec<PriceQuote>,
1088 );
1089
1090 fn plan_inputs() -> PlanInputs {
1091 let primary_symbols = available(&["eurusd", "usdjpy"]);
1092 let conversion_symbols = available(&["usdjpy"]);
1093 let pnl_currencies = BTreeMap::from([
1094 ("eurusd".to_owned(), "usd".to_owned()),
1095 ("usdjpy".to_owned(), "jpy".to_owned()),
1096 ]);
1097 let routes = BTreeMap::from([
1098 (
1099 "usd".to_owned(),
1100 ConversionRoute::Identity {
1101 currency: "usd".to_owned(),
1102 },
1103 ),
1104 (
1105 "jpy".to_owned(),
1106 ConversionRoute::Inverse {
1107 pair: FxPair {
1108 symbol: "usdjpy".to_owned(),
1109 base_currency: "usd".to_owned(),
1110 quote_currency: "jpy".to_owned(),
1111 },
1112 },
1113 ),
1114 ]);
1115 let warmup_quotes = vec![quote("usdjpy", 2, 150.0, 150.1)];
1116 (
1117 primary_symbols,
1118 conversion_symbols,
1119 pnl_currencies,
1120 routes,
1121 warmup_quotes,
1122 )
1123 }
1124
1125 #[test]
1126 fn route_resolution_supports_identity_direct_and_inverse() {
1127 let registry = registry();
1128 let symbols = available(&["EUR/USD"]);
1129
1130 assert!(matches!(
1131 resolve_conversion_route(®istry, "usd", "USD", &symbols).unwrap(),
1132 ConversionRoute::Identity { currency } if currency == "USD"
1133 ));
1134 assert!(matches!(
1135 resolve_conversion_route(®istry, "eur", "usd", &symbols).unwrap(),
1136 ConversionRoute::Direct { pair } if pair.symbol == "eurusd"
1137 ));
1138 assert!(matches!(
1139 resolve_conversion_route(®istry, "USD", "EUR", &symbols).unwrap(),
1140 ConversionRoute::Inverse { pair } if pair.symbol == "eurusd"
1141 ));
1142 }
1143
1144 #[test]
1145 fn two_leg_route_uses_deterministic_pivot_and_available_source_filter() {
1146 let registry = registry();
1147 let symbols = available(&["gbpjpy", "eurgbp", "eurjpy", "gbpusd", "usdjpy"]);
1148 let direct = resolve_conversion_route(®istry, "GBP", "JPY", &symbols).unwrap();
1149 assert!(matches!(direct, ConversionRoute::Direct { pair } if pair.symbol == "gbpjpy"));
1150
1151 let filtered = available(&["eurgbp", "eurjpy", "gbpusd", "usdjpy"]);
1152 let route = resolve_conversion_route(®istry, "GBP", "JPY", &filtered).unwrap();
1153 match route {
1154 ConversionRoute::TwoLeg {
1155 pivot_currency,
1156 first,
1157 second,
1158 } => {
1159 assert_eq!(pivot_currency, "EUR");
1160 assert_eq!(first.pair.symbol, "eurgbp");
1161 assert_eq!(first.direction, FxPairDirection::Inverse);
1162 assert_eq!(second.pair.symbol, "eurjpy");
1163 assert_eq!(second.direction, FxPairDirection::Direct);
1164 }
1165 other => panic!("expected two-leg route, got {other:?}"),
1166 }
1167 }
1168
1169 #[test]
1170 fn route_resolution_rejects_cross_source_fallback() {
1171 let registry = registry();
1172 let symbols = available(&["gbpusd"]);
1173 assert!(matches!(
1174 resolve_conversion_route(®istry, "GBP", "JPY", &symbols),
1175 Err(ConversionError::RouteNotFound { .. })
1176 ));
1177 }
1178
1179 #[test]
1180 fn quote_book_rejects_invalid_ticks() {
1181 let registry = registry();
1182 let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1183 let error = book
1184 .record_tick(®istry, quote("eurusd", 0, 1.2, 1.1))
1185 .unwrap_err();
1186 assert!(matches!(
1187 error,
1188 ConversionError::InvalidQuote {
1189 reason: QuoteValidationError::Crossed { .. },
1190 ..
1191 }
1192 ));
1193 }
1194
1195 #[test]
1196 fn direct_and_inverse_conversion_use_signed_bid_ask() {
1197 let registry = registry();
1198 let symbols = available(&["eurusd"]);
1199 let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1200 book.record_tick(®istry, quote("EUR/USD", 0, 1.1, 1.2))
1201 .unwrap();
1202
1203 let direct = resolve_conversion_route(®istry, "EUR", "USD", &symbols).unwrap();
1204 let positive = book.convert_route(100.0, ts(1), &direct).unwrap();
1205 let negative = book.convert_route(-100.0, ts(1), &direct).unwrap();
1206 assert!((positive.output_amount - 110.0).abs() < 1.0e-12);
1207 assert_eq!(positive.legs[0].price_side, ConversionPriceSide::Bid);
1208 assert!((negative.output_amount + 120.0).abs() < 1.0e-12);
1209 assert_eq!(negative.legs[0].price_side, ConversionPriceSide::Ask);
1210
1211 let inverse = resolve_conversion_route(®istry, "USD", "EUR", &symbols).unwrap();
1212 let positive = book.convert_route(120.0, ts(1), &inverse).unwrap();
1213 let negative = book.convert_route(-110.0, ts(1), &inverse).unwrap();
1214 assert!((positive.output_amount - 100.0).abs() < 1.0e-12);
1215 assert_eq!(positive.legs[0].price_side, ConversionPriceSide::Ask);
1216 assert!((negative.output_amount + 100.0).abs() < 1.0e-12);
1217 assert_eq!(negative.legs[0].price_side, ConversionPriceSide::Bid);
1218 }
1219
1220 #[test]
1221 fn two_leg_conversion_is_signed_and_auditable() {
1222 let registry = registry();
1223 let symbols = available(&["eurgbp", "eurjpy"]);
1224 let route = resolve_conversion_route(®istry, "GBP", "JPY", &symbols).unwrap();
1225 let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1226 book.record_tick(®istry, quote("eurgbp", 0, 0.8, 0.9))
1227 .unwrap();
1228 book.record_tick(®istry, quote("eurjpy", 1, 160.0, 161.0))
1229 .unwrap();
1230
1231 let positive = book.convert_route(90.0, ts(2), &route).unwrap();
1232 assert!((positive.output_amount - 16_000.0).abs() < 1.0e-9);
1233 assert_eq!(positive.legs.len(), 2);
1234 assert_eq!(positive.legs[0].price_side, ConversionPriceSide::Ask);
1235 assert_eq!(positive.legs[1].price_side, ConversionPriceSide::Bid);
1236 assert_eq!(positive.legs[0].quote_ts, ts(0));
1237 assert_eq!(positive.legs[1].quote_ts, ts(1));
1238
1239 let negative = book.convert_route(-80.0, ts(2), &route).unwrap();
1240 assert!((negative.output_amount + 16_100.0).abs() < 1.0e-9);
1241 assert_eq!(negative.legs[0].price_side, ConversionPriceSide::Bid);
1242 assert_eq!(negative.legs[1].price_side, ConversionPriceSide::Ask);
1243 }
1244
1245 #[test]
1246 fn causal_lookup_uses_latest_past_quote_and_never_future_quote() {
1247 let registry = registry();
1248 let symbols = available(&["eurusd"]);
1249 let route = resolve_conversion_route(®istry, "EUR", "USD", &symbols).unwrap();
1250 let mut book = ConversionQuoteBook::new(Duration::seconds(10)).unwrap();
1251 book.record_tick(®istry, quote("eurusd", 0, 1.1, 1.2))
1252 .unwrap();
1253 book.record_tick(®istry, quote("eurusd", 10, 2.1, 2.2))
1254 .unwrap();
1255
1256 let result = book.convert_route(100.0, ts(5), &route).unwrap();
1257 assert!((result.output_amount - 110.0).abs() < 1.0e-12);
1258 assert_eq!(result.legs[0].quote_ts, ts(0));
1259
1260 let mut future_only = ConversionQuoteBook::new(Duration::seconds(10)).unwrap();
1261 future_only
1262 .record_tick(®istry, quote("eurusd", 10, 2.1, 2.2))
1263 .unwrap();
1264 assert!(matches!(
1265 future_only.convert_route(100.0, ts(5), &route),
1266 Err(ConversionError::NoCausalQuote {
1267 next_quote_ts: Some(next),
1268 ..
1269 }) if next == ts(10)
1270 ));
1271 }
1272
1273 #[test]
1274 fn causal_lookup_rejects_stale_quote_without_fallback() {
1275 let registry = registry();
1276 let symbols = available(&["eurusd"]);
1277 let route = resolve_conversion_route(®istry, "EUR", "USD", &symbols).unwrap();
1278 let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1279 book.record_tick(®istry, quote("eurusd", 0, 1.1, 1.2))
1280 .unwrap();
1281
1282 assert!(matches!(
1283 book.convert_route(100.0, ts(6), &route),
1284 Err(ConversionError::StaleQuote {
1285 age_millis: 6_000,
1286 max_staleness_millis: 5_000,
1287 ..
1288 })
1289 ));
1290 }
1291
1292 #[test]
1293 fn replay_pruning_keeps_the_latest_causal_quote_and_future_quotes() {
1294 let symbols = available(&["eurusd"]);
1295 let route = resolve_conversion_route(®istry(), "EUR", "USD", &symbols).unwrap();
1296 let mut book = ConversionQuoteBook::new(Duration::seconds(10)).unwrap();
1297 book.record_canonical_tick(quote("eurusd", 0, 1.0, 1.1))
1298 .unwrap();
1299 book.record_canonical_tick(quote("eurusd", 2, 2.0, 2.1))
1300 .unwrap();
1301 book.record_canonical_tick(quote("eurusd", 4, 4.0, 4.1))
1302 .unwrap();
1303
1304 assert_eq!(book.prune_replay_history(ts(2)), 1);
1305 assert_eq!(book.quotes["eurusd"].len(), 2);
1306 let current = book.convert_route(100.0, ts(3), &route).unwrap();
1307 assert_eq!(current.legs[0].quote_ts, ts(2));
1308 let future = book.convert_route(100.0, ts(4), &route).unwrap();
1309 assert_eq!(future.legs[0].quote_ts, ts(4));
1310 assert!(matches!(
1311 book.convert_route(100.0, ts(1), &route),
1312 Err(ConversionError::NoCausalQuote {
1313 next_quote_ts: Some(next),
1314 ..
1315 }) if next == ts(2)
1316 ));
1317 }
1318
1319 #[test]
1320 fn schedule_retention_keeps_only_required_predecessors_and_latest_replay_quotes() {
1321 let symbols = available(&["eurusd"]);
1322 let route = resolve_conversion_route(®istry(), "EUR", "USD", &symbols).unwrap();
1323 let mut book = ConversionQuoteBook::new(Duration::seconds(200)).unwrap();
1324 for second in 0..=50 {
1325 book.record_canonical_tick(quote(
1326 "eurusd",
1327 second,
1328 1.0 + second as f64,
1329 1.1 + second as f64,
1330 ))
1331 .unwrap();
1332 }
1333
1334 let required = [
1335 ts(10) + Duration::milliseconds(500),
1336 ts(40) + Duration::milliseconds(500),
1337 ];
1338 assert_eq!(book.retain_replay_causal_predecessors(ts(50), required), 48);
1339 assert_eq!(
1340 book.quotes["eurusd"].keys().copied().collect::<Vec<_>>(),
1341 vec![ts(10), ts(40), ts(50)]
1342 );
1343 assert_eq!(
1344 book.convert_route(1.0, required[0], &route).unwrap().legs[0].quote_ts,
1345 ts(10)
1346 );
1347 assert_eq!(
1348 book.convert_route(1.0, required[1], &route).unwrap().legs[0].quote_ts,
1349 ts(40)
1350 );
1351 assert_eq!(
1352 book.convert_route(1.0, ts(50), &route).unwrap().legs[0].quote_ts,
1353 ts(50)
1354 );
1355 }
1356
1357 #[test]
1358 fn schedule_retention_bounds_high_frequency_ticks_before_sparse_primary_quote() {
1359 let mut book = ConversionQuoteBook::new(Duration::seconds(200)).unwrap();
1360 let start = ts(0);
1361 let effective_ts = start + Duration::milliseconds(2_500);
1362
1363 for millisecond in 0..=10_000 {
1364 let replay_ts = start + Duration::milliseconds(millisecond);
1365 let mut tick = quote("eurusd", 0, 1.0, 1.1);
1366 tick.ts = replay_ts;
1367 book.record_canonical_tick(tick).unwrap();
1368 book.retain_replay_causal_predecessors(replay_ts, [effective_ts]);
1369 assert!(
1370 book.quotes["eurusd"].len() <= 2,
1371 "only the scheduled predecessor and latest replay quote may remain"
1372 );
1373 }
1374
1375 assert_eq!(
1376 book.quotes["eurusd"].keys().copied().collect::<Vec<_>>(),
1377 vec![effective_ts, start + Duration::milliseconds(10_000)]
1378 );
1379 }
1380
1381 #[test]
1382 fn historical_quote_book_retains_history_without_explicit_pruning() {
1383 let symbols = available(&["eurusd"]);
1384 let route = resolve_conversion_route(®istry(), "EUR", "USD", &symbols).unwrap();
1385 let mut book = ConversionQuoteBook::new(Duration::seconds(10)).unwrap();
1386 book.record_canonical_tick(quote("eurusd", 0, 1.0, 1.1))
1387 .unwrap();
1388 book.record_canonical_tick(quote("eurusd", 4, 4.0, 4.1))
1389 .unwrap();
1390
1391 let historical = book.convert_route(100.0, ts(1), &route).unwrap();
1392 assert_eq!(historical.legs[0].quote_ts, ts(0));
1393 assert_eq!(book.quotes["eurusd"].len(), 2);
1394 }
1395
1396 #[test]
1397 fn run_currency_plan_is_immutable_normalized_and_serializable() {
1398 let (primary, conversion, pnl, routes, mut warmup) = plan_inputs();
1399 warmup.push(quote("usdjpy", 1, 149.0, 149.1));
1400 let plan = RunCurrencyPlan::new(" usd ", primary, conversion, pnl, routes, warmup).unwrap();
1401
1402 assert_eq!(plan.account_currency(), "USD");
1403 assert_eq!(plan.pnl_currency_for_primary_symbol("usdjpy"), Some("JPY"));
1404 assert!(matches!(
1405 plan.route_for_primary_symbol("eurusd"),
1406 Some(ConversionRoute::Identity { currency }) if currency == "USD"
1407 ));
1408 assert!(matches!(
1409 plan.route_for_primary_symbol("usdjpy"),
1410 Some(ConversionRoute::Inverse { pair }) if pair.symbol == "usdjpy"
1411 ));
1412 assert_eq!(plan.route_symbols(), BTreeSet::from(["usdjpy"]));
1413 assert_eq!(plan.strict_before_warmup_quotes()[0].ts, ts(1));
1414 assert_eq!(plan.strict_before_warmup_quotes()[1].ts, ts(2));
1415
1416 let json = serde_json::to_string(&plan).unwrap();
1417 let decoded: RunCurrencyPlan = serde_json::from_str(&json).unwrap();
1418 assert_eq!(decoded.account_currency(), "USD");
1419 assert_eq!(decoded.primary_symbols(), plan.primary_symbols());
1420 assert_eq!(decoded.conversion_symbols(), plan.conversion_symbols());
1421 assert_eq!(
1422 decoded.pnl_currency_by_primary_symbol(),
1423 plan.pnl_currency_by_primary_symbol()
1424 );
1425 assert_eq!(
1426 decoded.conversion_route_by_source_currency(),
1427 plan.conversion_route_by_source_currency()
1428 );
1429 assert_eq!(decoded.strict_before_warmup_quotes().len(), 2);
1430 }
1431
1432 #[test]
1433 fn run_currency_plan_requires_complete_primary_mappings_and_routes() {
1434 let (primary, conversion, mut pnl, routes, warmup) = plan_inputs();
1435 pnl.remove("usdjpy");
1436 assert!(matches!(
1437 RunCurrencyPlan::new(
1438 "USD",
1439 primary.clone(),
1440 conversion.clone(),
1441 pnl,
1442 routes.clone(),
1443 warmup.clone(),
1444 ),
1445 Err(RunCurrencyPlanError::MissingPrimaryPnlCurrency { symbol })
1446 if symbol == "usdjpy"
1447 ));
1448
1449 let (_, _, pnl, mut routes, _) = plan_inputs();
1450 routes.remove("jpy");
1451 assert!(matches!(
1452 RunCurrencyPlan::new("USD", primary, conversion, pnl, routes, warmup),
1453 Err(RunCurrencyPlanError::MissingConversionRoute { source_currency })
1454 if source_currency == "JPY"
1455 ));
1456 }
1457
1458 #[test]
1459 fn run_currency_plan_rejects_wrong_destination_and_undeclared_route_symbol() {
1460 let (primary, mut conversion, pnl, mut routes, warmup) = plan_inputs();
1461 routes.insert(
1462 "jpy".to_owned(),
1463 ConversionRoute::Inverse {
1464 pair: FxPair {
1465 symbol: "eurjpy".to_owned(),
1466 base_currency: "EUR".to_owned(),
1467 quote_currency: "JPY".to_owned(),
1468 },
1469 },
1470 );
1471 conversion.insert("eurjpy".to_owned());
1472 assert!(matches!(
1473 RunCurrencyPlan::new(
1474 "USD",
1475 primary.clone(),
1476 conversion,
1477 pnl.clone(),
1478 routes,
1479 warmup.clone(),
1480 ),
1481 Err(RunCurrencyPlanError::RouteDestinationMismatch {
1482 source_currency,
1483 route_destination_currency,
1484 ..
1485 }) if source_currency == "JPY" && route_destination_currency == "EUR"
1486 ));
1487
1488 let (_, conversion, _, routes, _) = plan_inputs();
1489 let conversion = conversion
1490 .into_iter()
1491 .filter(|symbol| symbol != "usdjpy")
1492 .collect();
1493 assert!(matches!(
1494 RunCurrencyPlan::new("USD", primary, conversion, pnl, routes, warmup),
1495 Err(RunCurrencyPlanError::UndeclaredRouteSymbol { symbol, .. })
1496 if symbol == "usdjpy"
1497 ));
1498 }
1499
1500 #[test]
1501 fn run_currency_plan_validates_warmup_quotes() {
1502 let (primary, conversion, pnl, routes, _) = plan_inputs();
1503 assert!(matches!(
1504 RunCurrencyPlan::new(
1505 "USD",
1506 primary.clone(),
1507 conversion.clone(),
1508 pnl.clone(),
1509 routes.clone(),
1510 vec![quote("eurusd", 0, 1.0, 1.1)],
1511 ),
1512 Err(RunCurrencyPlanError::UndeclaredWarmupSymbol { symbol })
1513 if symbol == "eurusd"
1514 ));
1515 assert!(matches!(
1516 RunCurrencyPlan::new(
1517 "USD",
1518 primary.clone(),
1519 conversion.clone(),
1520 pnl.clone(),
1521 routes.clone(),
1522 vec![quote("usdjpy", 0, 151.0, 150.0)],
1523 ),
1524 Err(RunCurrencyPlanError::InvalidWarmupQuote {
1525 reason: QuoteValidationError::Crossed { .. },
1526 ..
1527 })
1528 ));
1529 let duplicate = quote("usdjpy", 0, 150.0, 150.1);
1530 assert!(matches!(
1531 RunCurrencyPlan::new(
1532 "USD",
1533 primary,
1534 conversion,
1535 pnl,
1536 routes,
1537 vec![duplicate.clone(), duplicate],
1538 ),
1539 Err(RunCurrencyPlanError::DuplicateWarmupQuote { .. })
1540 ));
1541 }
1542
1543 #[test]
1544 fn record_canonical_tick_does_not_require_registry() {
1545 let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1546 book.record_canonical_tick(quote("eurusd", 0, 1.1, 1.2))
1547 .unwrap();
1548 let route = ConversionRoute::Direct {
1549 pair: FxPair {
1550 symbol: "eurusd".to_owned(),
1551 base_currency: "EUR".to_owned(),
1552 quote_currency: "USD".to_owned(),
1553 },
1554 };
1555 let result = book.convert_route(100.0, ts(1), &route).unwrap();
1556 assert!((result.output_amount - 110.0).abs() < 1.0e-12);
1557
1558 assert!(matches!(
1559 book.record_canonical_tick(quote("eurusd", 2, 1.2, 1.1)),
1560 Err(ConversionError::InvalidQuote {
1561 reason: QuoteValidationError::Crossed { .. },
1562 ..
1563 })
1564 ));
1565 assert!(matches!(
1566 book.record_canonical_tick(quote("", 2, 1.1, 1.2)),
1567 Err(ConversionError::EmptyCanonicalQuoteSymbol)
1568 ));
1569 }
1570}