Skip to main content

qs_core/
currency.rs

1//! Deterministic account-currency conversion from historical FX ticks.
2
3use 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/// Direction in which an FX pair is used for a conversion leg.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum FxPairDirection {
15    /// Convert from the pair base currency to its quote currency.
16    Direct,
17    /// Convert from the pair quote currency to its base currency.
18    Inverse,
19}
20
21/// One available FX symbol and its registered currencies.
22#[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/// One directed currency-conversion leg.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ConversionLeg {
32    pub pair: FxPair,
33    pub direction: FxPairDirection,
34}
35
36impl ConversionLeg {
37    /// Currency consumed by this leg.
38    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    /// Currency produced by this leg.
46    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/// Deterministic route selected from a caller-provided available-symbol set.
55#[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    /// Source currency for this route.
75    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    /// Destination currency for this route.
85    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    /// Enumerate route symbols in execution order.
95    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/// Quote side used to execute one signed conversion leg.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ConversionPriceSide {
111    Bid,
112    Ask,
113}
114
115/// Validation failure for a tick quote submitted to the quote book.
116#[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/// Errors produced by route resolution and quote-backed conversion.
127#[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/// Validation errors for an immutable run currency plan.
179#[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/// Auditable execution details for one route leg.
236#[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/// Result of one identity, direct, inverse, or two-leg conversion.
255#[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/// Immutable, validated currency handoff for one backtest run.
267#[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    /// Construct and validate an immutable run currency plan.
310    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(&quote.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    /// Get the conversion route selected for a primary symbol's P&L currency.
456    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    /// Enumerate all distinct route symbols in canonical order.
463    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
489/// Resolve one directed FX pair from the available-symbol set.
490///
491/// A direct pair is preferred to an inverse pair. Within either orientation,
492/// canonical symbol order provides deterministic tie-breaking.
493pub 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
506/// Resolve an identity, direct, inverse, or deterministic two-leg FX route.
507pub 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/// Historical FX tick store with causal, staleness-bounded lookup.
557#[derive(Debug, Clone)]
558pub struct ConversionQuoteBook {
559    max_staleness: Duration,
560    quotes: BTreeMap<String, BTreeMap<NaiveDateTime, PriceQuote>>,
561}
562
563impl ConversionQuoteBook {
564    /// Create a quote book with one maximum age applied to every conversion leg.
565    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    /// Configured maximum quote age.
578    pub fn max_staleness(&self) -> Duration {
579        self.max_staleness
580    }
581
582    /// Prune replay history while retaining each symbol's latest causal quote.
583    ///
584    /// Quotes after `replay_ts` are retained. This is opt-in because lookups before the retained quote are no longer available after pruning.
585    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    /// Retain only causal predecessors needed by a replay schedule.
602    ///
603    /// The latest quote at or before `replay_ts` is always retained for each symbol. Each required operation time contributes at most one additional predecessor, so intervening ticks do not accumulate while a primary series is sparse. Historical lookup behavior is unchanged unless this method is called.
604    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    /// Validate and record a tick quote under its canonical FX symbol.
631    ///
632    /// A quote at the same symbol and timestamp replaces the prior quote and is
633    /// returned to make duplicate handling auditable by the caller.
634    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    /// Validate and record a caller-canonicalized tick without a symbol registry.
655    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(&quote).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    /// Convert an amount with a previously resolved route.
675    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    /// Resolve a route from the provided source-symbol set and execute it.
732    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                &currency,
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(&registry, "usd", "USD", &symbols).unwrap(),
1132            ConversionRoute::Identity { currency } if currency == "USD"
1133        ));
1134        assert!(matches!(
1135            resolve_conversion_route(&registry, "eur", "usd", &symbols).unwrap(),
1136            ConversionRoute::Direct { pair } if pair.symbol == "eurusd"
1137        ));
1138        assert!(matches!(
1139            resolve_conversion_route(&registry, "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(&registry, "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(&registry, "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(&registry, "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(&registry, 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(&registry, quote("EUR/USD", 0, 1.1, 1.2))
1201            .unwrap();
1202
1203        let direct = resolve_conversion_route(&registry, "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(&registry, "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(&registry, "GBP", "JPY", &symbols).unwrap();
1225        let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1226        book.record_tick(&registry, quote("eurgbp", 0, 0.8, 0.9))
1227            .unwrap();
1228        book.record_tick(&registry, 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(&registry, "EUR", "USD", &symbols).unwrap();
1250        let mut book = ConversionQuoteBook::new(Duration::seconds(10)).unwrap();
1251        book.record_tick(&registry, quote("eurusd", 0, 1.1, 1.2))
1252            .unwrap();
1253        book.record_tick(&registry, 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(&registry, 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(&registry, "EUR", "USD", &symbols).unwrap();
1278        let mut book = ConversionQuoteBook::new(Duration::seconds(5)).unwrap();
1279        book.record_tick(&registry, 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(&registry(), "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(&registry(), "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(&registry(), "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}