Skip to main content

rustledger_query/
price.rs

1//! Price database for currency conversions.
2//!
3//! This module provides a price database that stores historical prices
4//! and allows looking up prices for currency conversions.
5
6use rust_decimal::Decimal;
7use rustledger_core::{Amount, Directive, NaiveDate, Price as PriceDirective, Transaction};
8use std::collections::HashMap;
9
10/// A price entry.
11///
12/// Marked `#[non_exhaustive]` so future provenance/metadata fields can
13/// be added without breaking downstream struct-literal construction.
14/// Internal construction in this module isn't restricted.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct PriceEntry {
18    /// Date of the price.
19    pub date: NaiveDate,
20    /// Price amount.
21    pub price: Decimal,
22    /// Quote currency.
23    pub currency: rustledger_core::Currency,
24    /// `true` if sourced from an explicit `Price` directive (or a
25    /// plugin-emitted one — same shape after plugin runs); `false` if
26    /// derived from a transaction posting in the executor's pass-2
27    /// fallback. The `#prices` BQL table filters to `explicit: true`
28    /// to match `bean-query`, which only surfaces explicit Price
29    /// directives. Internal price lookups (`get_price`, `getprice()`
30    /// BQL function) still see all entries — that preserves the
31    /// rustledger UX extension where `VALUE()` works without the
32    /// `implicit_prices` plugin being declared (issues #567, #593).
33    pub explicit: bool,
34}
35
36/// Database of currency prices.
37///
38/// Stores prices as a map from base currency to a list of (date, price, quote currency).
39/// Prices are kept sorted by date for efficient lookup.
40#[derive(Debug, Default)]
41pub struct PriceDatabase {
42    /// Prices indexed by base currency.
43    /// Each base currency maps to a list of price entries sorted by date.
44    prices: HashMap<rustledger_core::Currency, Vec<PriceEntry>>,
45}
46
47impl PriceDatabase {
48    /// Create a new empty price database.
49    pub fn new() -> Self {
50        Self {
51            prices: HashMap::new(),
52        }
53    }
54
55    /// Build a price database from directives.
56    ///
57    /// Two passes:
58    /// 1. **Explicit `Price` directives** — added unconditionally.
59    /// 2. **Implicit prices from transaction postings** — added only
60    ///    for `(base, quote, date)` tuples that don't already have an
61    ///    explicit Price entry from pass 1.
62    ///
63    /// The two-pass design fixes issue #1006: when the user enables
64    /// the `implicit_prices` plugin, it emits `Price` directives for
65    /// each priced posting; pass 1 picks those up. Pre-fix, pass 2
66    /// would then ALSO walk the same transactions and re-emit the
67    /// same implicit prices, doubling every entry. Now pass 2 sees
68    /// the explicit entry already exists and skips, so the plugin's
69    /// output is the single source of truth.
70    ///
71    /// When the plugin is NOT enabled (the rustledger-extension case
72    /// from #567 / #593 — `VALUE()` should work on implicit-priced
73    /// transactions automatically), pass 1 adds nothing for those
74    /// dates and pass 2 fills them in. Net effect: implicit prices
75    /// are reachable from BQL without requiring the user to wire up
76    /// a plugin, but never doubled when the plugin IS wired up.
77    ///
78    /// **Behavior note**: an explicit `Price` directive *suppresses*
79    /// any divergent transaction-derived implicit price on the same
80    /// `(base, quote, date)`. This is intentional — explicit Price is
81    /// authoritative — but a behavior change vs pre-#1015, where a
82    /// user-written `2024-01-15 price ABC 1.40 EUR` plus a transaction
83    /// emitting ABC@EUR with a different value on the same date would
84    /// have stored both. Now only the explicit value survives. In
85    /// practice this only surfaces with hand-authored conflicts.
86    ///
87    /// **Provenance tagging** (issue #1048): each entry stores
88    /// `explicit: bool`. Pass-1 entries are `true`, pass-2
89    /// transaction-derived entries are `false`. The `#prices` BQL
90    /// table filters to `explicit: true` via `iter_explicit_entries`
91    /// to match `bean-query`, which only surfaces real `Price`
92    /// directives. Internal `get_price` / `convert` lookups see
93    /// both kinds — that's how `VALUE()` keeps working without the
94    /// `implicit_prices` plugin being declared.
95    pub fn from_directives(directives: &[Directive]) -> Self {
96        let mut db = Self::new();
97
98        // Pass 1: explicit Price directives.
99        for directive in directives {
100            if let Directive::Price(price) = directive {
101                db.add_price(price);
102            }
103        }
104
105        // Snapshot the explicit `(base, quote, date)` tuples — pass 2
106        // skips any transaction-derived price that would land on one
107        // of these (the plugin already filled it in via pass 1).
108        let explicit = db.snapshot_keys();
109
110        // Pass 2: implicit prices from transactions, gated on the
111        // explicit set.
112        for directive in directives {
113            if let Directive::Transaction(txn) = directive {
114                db.add_implicit_prices_from_transaction(txn, &explicit);
115            }
116        }
117
118        // Sort all price lists by date
119        db.sort_prices();
120
121        db
122    }
123
124    /// Sort all price entries by date.
125    ///
126    /// Call this after adding prices to ensure lookups work correctly.
127    pub fn sort_prices(&mut self) {
128        for entries in self.prices.values_mut() {
129            entries.sort_by_key(|e| e.date);
130        }
131    }
132
133    /// Add a price directive to the database.
134    ///
135    /// Marks the entry as `explicit: true` — these entries surface in
136    /// the `#prices` BQL table.
137    pub fn add_price(&mut self, price: &PriceDirective) {
138        let entry = PriceEntry {
139            date: price.date,
140            price: price.amount.number,
141            currency: price.amount.currency.clone(),
142            explicit: true,
143        };
144
145        self.prices
146            .entry(price.currency.clone())
147            .or_default()
148            .push(entry);
149    }
150
151    /// Snapshot every `(base, quote, date)` tuple currently in the
152    /// database. **Internal helper for the two-pass build only** —
153    /// the result reflects whatever is in the DB at the moment of the
154    /// call; it is "explicit" only because callers invoke it after
155    /// pass 1 (which adds explicit `Price` directives) and before
156    /// pass 2 (which adds transaction-derived implicit prices). See
157    /// [`from_directives`] for the protocol.
158    pub(crate) fn snapshot_keys(
159        &self,
160    ) -> std::collections::HashSet<(
161        rustledger_core::Currency,
162        rustledger_core::Currency,
163        NaiveDate,
164    )> {
165        self.prices
166            .iter()
167            .flat_map(|(base, entries)| {
168                let base = base.clone();
169                entries
170                    .iter()
171                    .map(move |e| (base.clone(), e.currency.clone(), e.date))
172            })
173            .collect()
174    }
175
176    /// Add implicit prices from a transaction's postings, skipping
177    /// any `(base, quote, date)` tuple already present in `explicit`.
178    ///
179    /// Delegates per-posting price math to
180    /// [`rustledger_core::extract_per_unit_price`] — the same helper
181    /// used by the native `implicit_prices` plugin
182    /// (`rustledger_plugin::native::plugins::implicit_prices`), so the
183    /// numeric output of both paths stays in sync (issue #992 was the
184    /// pre-shared-helper version where they drifted on `@@` handling).
185    ///
186    /// The `explicit` parameter is the set of `(base, quote, date)`
187    /// tuples already supplied by explicit `Price` directives. When
188    /// the `implicit_prices` plugin runs, it emits Price directives
189    /// for each priced posting, populating this set; pass 2 then
190    /// skips those tuples to avoid the duplication described in
191    /// issue #1006.
192    pub(crate) fn add_implicit_prices_from_transaction(
193        &mut self,
194        txn: &Transaction,
195        explicit: &std::collections::HashSet<(
196            rustledger_core::Currency,
197            rustledger_core::Currency,
198            NaiveDate,
199        )>,
200    ) {
201        for posting in &txn.postings {
202            let Some(units) = posting.amount() else {
203                continue;
204            };
205
206            // Build the helper's annotation descriptor only when both
207            // an amount and currency are available; the helper pairs
208            // the returned per-unit value with the matching currency
209            // by construction.
210            let annotation = posting.price.as_ref().and_then(|annotation| {
211                let amount = annotation.amount()?;
212                Some((
213                    !annotation.is_unit(),
214                    amount.number,
215                    amount.currency.clone(),
216                ))
217            });
218            let cost = posting.cost.as_ref().and_then(|c| {
219                let currency = c.currency.clone()?;
220                Some((c.number, currency))
221            });
222
223            let Some((per_unit, quote)) =
224                rustledger_core::extract_per_unit_price(units.number, annotation, cost)
225            else {
226                continue;
227            };
228
229            // Skip if an explicit Price directive already covers this
230            // (base, quote, date) tuple — the plugin's emission is
231            // authoritative and pass 2 must not duplicate.
232            if explicit.contains(&(units.currency.clone(), quote.clone(), txn.date)) {
233                continue;
234            }
235
236            self.add_implicit_price(txn.date, &units.currency, per_unit, &quote);
237        }
238    }
239
240    /// Add an implicit price entry.
241    ///
242    /// Marks the entry as `explicit: false` — internal lookups still
243    /// see it, but the `#prices` BQL table hides it (matches
244    /// bean-query, which only shows explicit Price directives).
245    fn add_implicit_price(
246        &mut self,
247        date: NaiveDate,
248        base_currency: &rustledger_core::Currency,
249        price: Decimal,
250        quote_currency: &rustledger_core::Currency,
251    ) {
252        let entry = PriceEntry {
253            date,
254            price,
255            currency: quote_currency.clone(),
256            explicit: false,
257        };
258
259        self.prices
260            .entry(base_currency.clone())
261            .or_default()
262            .push(entry);
263    }
264
265    /// Get the price of a currency on or before a given date.
266    ///
267    /// Returns the most recent price for the base currency in terms of the quote currency.
268    /// Tries direct lookup, inverse lookup, and chained lookup (A→B→C).
269    pub fn get_price(&self, base: &str, quote: &str, date: NaiveDate) -> Option<Decimal> {
270        // Same currency = price of 1
271        if base == quote {
272            return Some(Decimal::ONE);
273        }
274
275        // Try direct price lookup
276        if let Some(price) = self.get_direct_price(base, quote, date) {
277            return Some(price);
278        }
279
280        // Try inverse price lookup
281        if let Some(price) = self.get_direct_price(quote, base, date)
282            && price != Decimal::ZERO
283        {
284            return Some(Decimal::ONE / price);
285        }
286
287        // Try chained lookup (A→B→C where B is an intermediate currency)
288        self.get_chained_price(base, quote, date)
289    }
290
291    /// Get direct price (base currency priced in quote currency).
292    fn get_direct_price(&self, base: &str, quote: &str, date: NaiveDate) -> Option<Decimal> {
293        if let Some(entries) = self.prices.get(base) {
294            for entry in entries.iter().rev() {
295                if entry.date <= date && entry.currency == quote {
296                    return Some(entry.price);
297                }
298            }
299        }
300        None
301    }
302
303    /// Try to find a price through an intermediate currency.
304    /// For A→C, try to find A→B and B→C for some intermediate B.
305    fn get_chained_price(&self, base: &str, quote: &str, date: NaiveDate) -> Option<Decimal> {
306        // Collect all currencies that have prices from 'base'
307        let intermediates: Vec<rustledger_core::Currency> =
308            if let Some(entries) = self.prices.get(base) {
309                entries
310                    .iter()
311                    .filter(|e| e.date <= date)
312                    .map(|e| e.currency.clone())
313                    .collect()
314            } else {
315                Vec::new()
316            };
317
318        // Try each intermediate currency
319        for intermediate in intermediates {
320            if intermediate == quote {
321                continue; // Already tried direct
322            }
323
324            // Get price base→intermediate
325            if let Some(price1) = self.get_direct_price(base, &intermediate, date) {
326                // Get price intermediate→quote (try direct, inverse, but not chained to avoid loops)
327                if let Some(price2) = self.get_direct_price(&intermediate, quote, date) {
328                    return Some(price1 * price2);
329                }
330                // Try inverse for second leg
331                if let Some(price2) = self.get_direct_price(quote, &intermediate, date)
332                    && price2 != Decimal::ZERO
333                {
334                    return Some(price1 / price2);
335                }
336            }
337        }
338
339        // Also try currencies that price TO base (inverse first leg)
340        for (currency, entries) in &self.prices {
341            for entry in entries.iter().rev() {
342                if entry.date <= date && entry.currency == base && entry.price != Decimal::ZERO {
343                    // We have currency→base, so base→currency = 1/price
344                    let price1 = Decimal::ONE / entry.price;
345
346                    // Now try currency→quote
347                    if let Some(price2) = self.get_direct_price(currency, quote, date) {
348                        return Some(price1 * price2);
349                    }
350                    if let Some(price2) = self.get_direct_price(quote, currency, date)
351                        && price2 != Decimal::ZERO
352                    {
353                        return Some(price1 / price2);
354                    }
355                }
356            }
357        }
358
359        None
360    }
361
362    /// Get the latest price of a currency (most recent date).
363    ///
364    /// Supports direct lookup, inverse lookup, and chained lookup (A→B→C).
365    pub fn get_latest_price(&self, base: &str, quote: &str) -> Option<Decimal> {
366        // Same currency = price of 1
367        if base == quote {
368            return Some(Decimal::ONE);
369        }
370
371        // Try direct price lookup
372        if let Some(price) = self.get_direct_latest_price(base, quote) {
373            return Some(price);
374        }
375
376        // Try inverse price lookup
377        if let Some(price) = self.get_direct_latest_price(quote, base)
378            && price != Decimal::ZERO
379        {
380            return Some(Decimal::ONE / price);
381        }
382
383        // Try chained lookup (A→B→C where B is an intermediate currency)
384        self.get_chained_latest_price(base, quote)
385    }
386
387    /// Get direct latest price (base currency priced in quote currency).
388    fn get_direct_latest_price(&self, base: &str, quote: &str) -> Option<Decimal> {
389        if let Some(entries) = self.prices.get(base) {
390            // Find the most recent price in the target currency
391            for entry in entries.iter().rev() {
392                if entry.currency == quote {
393                    return Some(entry.price);
394                }
395            }
396        }
397        None
398    }
399
400    /// Try to find the latest price through an intermediate currency.
401    /// For A→C, try to find A→B and B→C for some intermediate B.
402    fn get_chained_latest_price(&self, base: &str, quote: &str) -> Option<Decimal> {
403        // Collect all currencies that have prices from 'base'
404        let intermediates: Vec<rustledger_core::Currency> =
405            if let Some(entries) = self.prices.get(base) {
406                entries.iter().map(|e| e.currency.clone()).collect()
407            } else {
408                Vec::new()
409            };
410
411        // Try each intermediate currency
412        for intermediate in intermediates {
413            if intermediate == quote {
414                continue; // Already tried direct
415            }
416
417            // Get price base→intermediate
418            if let Some(price1) = self.get_direct_latest_price(base, &intermediate) {
419                // Get price intermediate→quote (try direct, inverse, but not chained to avoid loops)
420                if let Some(price2) = self.get_direct_latest_price(&intermediate, quote) {
421                    return Some(price1 * price2);
422                }
423                // Try inverse for second leg
424                if let Some(price2) = self.get_direct_latest_price(quote, &intermediate)
425                    && price2 != Decimal::ZERO
426                {
427                    return Some(price1 / price2);
428                }
429            }
430        }
431
432        // Also try currencies that price TO base (inverse first leg)
433        for (currency, entries) in &self.prices {
434            for entry in entries.iter().rev() {
435                if entry.currency == base && entry.price != Decimal::ZERO {
436                    // We have currency→base, so base→currency = 1/price
437                    let price1 = Decimal::ONE / entry.price;
438
439                    // Now try currency→quote
440                    if let Some(price2) = self.get_direct_latest_price(currency, quote) {
441                        return Some(price1 * price2);
442                    }
443                    if let Some(price2) = self.get_direct_latest_price(quote, currency)
444                        && price2 != Decimal::ZERO
445                    {
446                        return Some(price1 / price2);
447                    }
448                }
449            }
450        }
451
452        None
453    }
454
455    /// Convert an amount to a target currency.
456    ///
457    /// Returns the converted amount, or None if no price is available.
458    pub fn convert(&self, amount: &Amount, to_currency: &str, date: NaiveDate) -> Option<Amount> {
459        if amount.currency == to_currency {
460            return Some(amount.clone());
461        }
462
463        self.get_price(&amount.currency, to_currency, date)
464            .map(|price| Amount::new(amount.number * price, to_currency))
465    }
466
467    /// Convert an amount using the latest available price.
468    pub fn convert_latest(&self, amount: &Amount, to_currency: &str) -> Option<Amount> {
469        if amount.currency == to_currency {
470            return Some(amount.clone());
471        }
472
473        self.get_latest_price(&amount.currency, to_currency)
474            .map(|price| Amount::new(amount.number * price, to_currency))
475    }
476
477    /// Get all currencies that have prices defined.
478    pub fn currencies(&self) -> impl Iterator<Item = &str> {
479        self.prices.keys().map(rustledger_core::Currency::as_str)
480    }
481
482    /// Check if a currency has any prices defined.
483    pub fn has_prices(&self, currency: &str) -> bool {
484        self.prices.contains_key(currency)
485    }
486
487    /// Get the number of price entries.
488    pub fn len(&self) -> usize {
489        self.prices.values().map(Vec::len).sum()
490    }
491
492    /// Check if the database is empty.
493    pub fn is_empty(&self) -> bool {
494        self.prices.is_empty()
495    }
496
497    /// Iterate over explicit price entries only — those sourced from
498    /// `Price` directives (either user-written or plugin-emitted).
499    /// Excludes transaction-derived entries added by the executor's
500    /// pass-2 fallback. Used by the `#prices` BQL table to match
501    /// `bean-query`'s behavior.
502    ///
503    /// For internal price *lookups* (e.g. `VALUE()`, `getprice()`),
504    /// use `get_price` / `convert` / `convert_latest` — those walk
505    /// the underlying entries without filtering, which preserves the
506    /// rustledger UX extension where implicit prices are usable for
507    /// conversion without declaring the `implicit_prices` plugin.
508    pub fn iter_explicit_entries(&self) -> impl Iterator<Item = (&str, NaiveDate, Decimal, &str)> {
509        self.prices.iter().flat_map(|(base, entries)| {
510            entries
511                .iter()
512                .filter(|e| e.explicit)
513                .map(move |e| (base.as_str(), e.date, e.price, e.currency.as_str()))
514        })
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use rust_decimal_macros::dec;
522
523    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
524        rustledger_core::naive_date(y, m, d).unwrap()
525    }
526
527    #[test]
528    fn test_price_lookup() {
529        let mut db = PriceDatabase::new();
530
531        // Add some prices
532        db.add_price(&PriceDirective {
533            date: date(2024, 1, 1),
534            currency: "AAPL".into(),
535            amount: Amount::new(dec!(150.00), "USD"),
536            meta: Default::default(),
537        });
538
539        db.add_price(&PriceDirective {
540            date: date(2024, 6, 1),
541            currency: "AAPL".into(),
542            amount: Amount::new(dec!(180.00), "USD"),
543            meta: Default::default(),
544        });
545
546        // Sort after adding
547        for entries in db.prices.values_mut() {
548            entries.sort_by_key(|e| e.date);
549        }
550
551        // Lookup on exact date
552        assert_eq!(
553            db.get_price("AAPL", "USD", date(2024, 1, 1)),
554            Some(dec!(150.00))
555        );
556
557        // Lookup on later date gets most recent
558        assert_eq!(
559            db.get_price("AAPL", "USD", date(2024, 6, 15)),
560            Some(dec!(180.00))
561        );
562
563        // Lookup between dates gets earlier price
564        assert_eq!(
565            db.get_price("AAPL", "USD", date(2024, 3, 15)),
566            Some(dec!(150.00))
567        );
568
569        // Lookup before any price returns None
570        assert_eq!(db.get_price("AAPL", "USD", date(2023, 12, 31)), None);
571    }
572
573    #[test]
574    fn test_inverse_price() {
575        let mut db = PriceDatabase::new();
576
577        // Add USD in terms of EUR
578        db.add_price(&PriceDirective {
579            date: date(2024, 1, 1),
580            currency: "USD".into(),
581            amount: Amount::new(dec!(0.92), "EUR"),
582            meta: Default::default(),
583        });
584
585        // Sort
586        for entries in db.prices.values_mut() {
587            entries.sort_by_key(|e| e.date);
588        }
589
590        // Can lookup USD->EUR
591        assert_eq!(
592            db.get_price("USD", "EUR", date(2024, 1, 1)),
593            Some(dec!(0.92))
594        );
595
596        // Can lookup EUR->USD via inverse
597        let inverse = db.get_price("EUR", "USD", date(2024, 1, 1)).unwrap();
598        // 1/0.92 ≈ 1.087
599        assert!(inverse > dec!(1.08) && inverse < dec!(1.09));
600    }
601
602    #[test]
603    fn test_convert() {
604        let mut db = PriceDatabase::new();
605
606        db.add_price(&PriceDirective {
607            date: date(2024, 1, 1),
608            currency: "AAPL".into(),
609            amount: Amount::new(dec!(150.00), "USD"),
610            meta: Default::default(),
611        });
612
613        for entries in db.prices.values_mut() {
614            entries.sort_by_key(|e| e.date);
615        }
616
617        let shares = Amount::new(dec!(10), "AAPL");
618        let usd = db.convert(&shares, "USD", date(2024, 1, 1)).unwrap();
619
620        assert_eq!(usd.number, dec!(1500.00));
621        assert_eq!(usd.currency, "USD");
622    }
623
624    #[test]
625    fn test_same_currency_convert() {
626        let db = PriceDatabase::new();
627        let amount = Amount::new(dec!(100), "USD");
628
629        let result = db.convert(&amount, "USD", date(2024, 1, 1)).unwrap();
630        assert_eq!(result.number, dec!(100));
631        assert_eq!(result.currency, "USD");
632    }
633
634    #[test]
635    fn test_from_directives() {
636        let directives = vec![
637            Directive::Price(PriceDirective {
638                date: date(2024, 1, 1),
639                currency: "AAPL".into(),
640                amount: Amount::new(dec!(150.00), "USD"),
641                meta: Default::default(),
642            }),
643            Directive::Price(PriceDirective {
644                date: date(2024, 1, 1),
645                currency: "EUR".into(),
646                amount: Amount::new(dec!(1.10), "USD"),
647                meta: Default::default(),
648            }),
649        ];
650
651        let db = PriceDatabase::from_directives(&directives);
652
653        assert_eq!(db.len(), 2);
654        assert!(db.has_prices("AAPL"));
655        assert!(db.has_prices("EUR"));
656    }
657
658    #[test]
659    fn test_chained_price_lookup() {
660        let mut db = PriceDatabase::new();
661
662        // Add AAPL -> USD price
663        db.add_price(&PriceDirective {
664            date: date(2024, 1, 1),
665            currency: "AAPL".into(),
666            amount: Amount::new(dec!(150.00), "USD"),
667            meta: Default::default(),
668        });
669
670        // Add USD -> EUR price
671        db.add_price(&PriceDirective {
672            date: date(2024, 1, 1),
673            currency: "USD".into(),
674            amount: Amount::new(dec!(0.92), "EUR"),
675            meta: Default::default(),
676        });
677
678        // Sort
679        for entries in db.prices.values_mut() {
680            entries.sort_by_key(|e| e.date);
681        }
682
683        // Direct lookup AAPL -> USD works
684        assert_eq!(
685            db.get_price("AAPL", "USD", date(2024, 1, 1)),
686            Some(dec!(150.00))
687        );
688
689        // Direct lookup USD -> EUR works
690        assert_eq!(
691            db.get_price("USD", "EUR", date(2024, 1, 1)),
692            Some(dec!(0.92))
693        );
694
695        // Chained lookup AAPL -> EUR should work (AAPL -> USD -> EUR)
696        // 150 USD * 0.92 EUR/USD = 138 EUR
697        let chained = db.get_price("AAPL", "EUR", date(2024, 1, 1)).unwrap();
698        assert_eq!(chained, dec!(138.00));
699    }
700
701    #[test]
702    fn test_chained_price_with_inverse() {
703        let mut db = PriceDatabase::new();
704
705        // Add BTC -> USD price
706        db.add_price(&PriceDirective {
707            date: date(2024, 1, 1),
708            currency: "BTC".into(),
709            amount: Amount::new(dec!(40000.00), "USD"),
710            meta: Default::default(),
711        });
712
713        // Add EUR -> USD price (inverse of what we need for USD -> EUR)
714        db.add_price(&PriceDirective {
715            date: date(2024, 1, 1),
716            currency: "EUR".into(),
717            amount: Amount::new(dec!(1.10), "USD"),
718            meta: Default::default(),
719        });
720
721        // Sort
722        for entries in db.prices.values_mut() {
723            entries.sort_by_key(|e| e.date);
724        }
725
726        // BTC -> EUR should work via BTC -> USD -> EUR
727        // BTC -> USD = 40000
728        // USD -> EUR = 1/1.10 ≈ 0.909
729        // BTC -> EUR = 40000 / 1.10 ≈ 36363.63
730        let chained = db.get_price("BTC", "EUR", date(2024, 1, 1)).unwrap();
731        // 40000 / 1.10 = 36363.636363...
732        assert!(chained > dec!(36363) && chained < dec!(36364));
733    }
734
735    #[test]
736    fn test_chained_price_no_path() {
737        let mut db = PriceDatabase::new();
738
739        // Add AAPL -> USD price
740        db.add_price(&PriceDirective {
741            date: date(2024, 1, 1),
742            currency: "AAPL".into(),
743            amount: Amount::new(dec!(150.00), "USD"),
744            meta: Default::default(),
745        });
746
747        // Add GBP -> EUR price (disconnected from USD)
748        db.add_price(&PriceDirective {
749            date: date(2024, 1, 1),
750            currency: "GBP".into(),
751            amount: Amount::new(dec!(1.17), "EUR"),
752            meta: Default::default(),
753        });
754
755        // Sort
756        for entries in db.prices.values_mut() {
757            entries.sort_by_key(|e| e.date);
758        }
759
760        // No path from AAPL to GBP
761        assert_eq!(db.get_price("AAPL", "GBP", date(2024, 1, 1)), None);
762    }
763
764    // ============================================================================
765    // Implicit-price extraction tests
766    // ============================================================================
767    //
768    // `from_directives` does TWO passes:
769    //   1. Add explicit `Price` directives.
770    //   2. Walk Transaction postings; extract implicit prices ONLY for
771    //      `(base, quote, date)` tuples not already covered by pass 1.
772    //
773    // This preserves the rustledger extension from #567 / #593 (BQL
774    // `VALUE()` works on implicit-priced transactions automatically,
775    // without requiring the `implicit_prices` plugin) AND fixes the
776    // duplication from #1006 (when the plugin IS enabled, its emitted
777    // Price directives suppress the same-tuple BQL extraction).
778
779    /// Transaction with `@` annotation, no plugin → BQL extracts the
780    /// implicit price (no explicit Price directive to suppress it).
781    /// Preserves the #567/#593 rustledger-extension behavior.
782    #[test]
783    fn test_implicit_price_from_annotation() {
784        use rustledger_core::{CostSpec, Posting, PriceAnnotation, Transaction};
785
786        let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
787            .with_synthesized_posting(
788                Posting::new("Assets:Stocks", Amount::new(dec!(-5), "ABC"))
789                    .with_cost(
790                        CostSpec::default()
791                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
792                            .with_currency("EUR"),
793                    )
794                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
795            )
796            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(7.00), "EUR")));
797
798        let db = PriceDatabase::from_directives(&[Directive::Transaction(txn)]);
799        assert_eq!(
800            db.get_price("ABC", "EUR", date(2024, 1, 15)),
801            Some(dec!(1.40))
802        );
803    }
804
805    /// Cost spec only, no annotation → cost-derived implicit price.
806    #[test]
807    fn test_implicit_price_from_cost_only() {
808        use rustledger_core::{CostSpec, Posting, Transaction};
809
810        let txn = Transaction::new(date(2024, 1, 10), "Buy stock")
811            .with_synthesized_posting(
812                Posting::new("Assets:Stocks", Amount::new(dec!(10), "XYZ")).with_cost(
813                    CostSpec::default()
814                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(50.00) })
815                        .with_currency("USD"),
816                ),
817            )
818            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-500), "USD")));
819
820        let db = PriceDatabase::from_directives(&[Directive::Transaction(txn)]);
821        assert_eq!(
822            db.get_price("XYZ", "USD", date(2024, 1, 10)),
823            Some(dec!(50.00))
824        );
825    }
826
827    /// `@@` total annotation — divided by units. Pins the #992 fix
828    /// is preserved end-to-end through the BQL extraction path.
829    #[test]
830    fn test_implicit_price_from_total_annotation() {
831        use rustledger_core::{Posting, PriceAnnotation, Transaction};
832
833        let txn = Transaction::new(date(2024, 1, 15), "Sell")
834            .with_synthesized_posting(
835                Posting::new("Assets:Stocks", Amount::new(dec!(-10), "ABC"))
836                    .with_price(PriceAnnotation::total(Amount::new(dec!(1500), "USD"))),
837            )
838            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
839
840        let db = PriceDatabase::from_directives(&[Directive::Transaction(txn)]);
841        // 1500 USD / 10 = 150 USD per unit
842        assert_eq!(
843            db.get_price("ABC", "USD", date(2024, 1, 15)),
844            Some(dec!(150))
845        );
846    }
847
848    /// Both annotation and cost present — annotation wins.
849    #[test]
850    fn test_implicit_price_annotation_takes_priority_over_cost() {
851        use rustledger_core::{CostSpec, Posting, PriceAnnotation, Transaction};
852
853        let txn = Transaction::new(date(2024, 1, 15), "Sell")
854            .with_synthesized_posting(
855                Posting::new("Assets:Stocks", Amount::new(dec!(-5), "ABC"))
856                    .with_cost(
857                        CostSpec::default()
858                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
859                            .with_currency("EUR"),
860                    )
861                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
862            )
863            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(7.00), "EUR")));
864
865        let db = PriceDatabase::from_directives(&[Directive::Transaction(txn)]);
866        assert_eq!(
867            db.get_price("ABC", "EUR", date(2024, 1, 15)),
868            Some(dec!(1.40))
869        );
870    }
871
872    /// Zero-units `@@` falls through to cost — regression for the
873    /// currency-pairing fix in #997 on the BQL path.
874    #[test]
875    fn test_implicit_price_zero_units_total_annotation_uses_cost_currency() {
876        use rustledger_core::{CostSpec, Posting, PriceAnnotation, Transaction};
877
878        let txn = Transaction::new(date(2024, 1, 15), "Close position").with_synthesized_posting(
879            Posting::new("Assets:Stocks", Amount::new(dec!(0), "ABC"))
880                .with_cost(
881                    CostSpec::default()
882                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(50) })
883                        .with_currency("USD"),
884                )
885                .with_price(PriceAnnotation::total(Amount::new(dec!(100), "EUR"))),
886        );
887
888        let db = PriceDatabase::from_directives(&[Directive::Transaction(txn)]);
889        assert_eq!(
890            db.get_price("ABC", "USD", date(2024, 1, 15)),
891            Some(dec!(50))
892        );
893        // ABC→EUR has no path; the (50, EUR) bug from #997 stays fixed.
894        assert_eq!(db.get_price("ABC", "EUR", date(2024, 1, 15)), None);
895    }
896
897    /// Combined explicit + implicit on different dates: explicit
898    /// price for an earlier date, implicit price (from transaction)
899    /// for the later date. Both reachable.
900    #[test]
901    fn test_implicit_price_combined_with_explicit() {
902        use rustledger_core::{CostSpec, Posting, PriceAnnotation, Transaction};
903
904        let explicit = PriceDirective {
905            date: date(2024, 1, 10),
906            currency: "ABC".into(),
907            amount: Amount::new(dec!(1.30), "EUR"),
908            meta: Default::default(),
909        };
910        let txn = Transaction::new(date(2024, 1, 15), "Sell")
911            .with_synthesized_posting(
912                Posting::new("Assets:Stocks", Amount::new(dec!(-5), "ABC"))
913                    .with_cost(
914                        CostSpec::default()
915                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
916                            .with_currency("EUR"),
917                    )
918                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
919            )
920            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(7.00), "EUR")));
921
922        let directives = vec![Directive::Price(explicit), Directive::Transaction(txn)];
923        let db = PriceDatabase::from_directives(&directives);
924        assert_eq!(
925            db.get_price("ABC", "EUR", date(2024, 1, 10)),
926            Some(dec!(1.30))
927        );
928        assert_eq!(db.get_latest_price("ABC", "EUR"), Some(dec!(1.40)));
929    }
930
931    // ============================================================================
932    // Issue #1006 regression — duplication when plugin runs
933    // ============================================================================
934
935    /// Plugin-emitted Price directive on the same `(base, quote, date)`
936    /// as a transaction's implicit price → exactly ONE entry in the DB.
937    /// Pre-fix this would have doubled (the BQL pass would re-extract
938    /// the same price the plugin already emitted).
939    #[test]
940    fn test_plugin_emitted_price_suppresses_bql_extraction_for_same_tuple() {
941        use rustledger_core::{CostSpec, Posting, PriceAnnotation, Transaction};
942
943        let directives = vec![
944            // Simulates `implicit_prices` plugin output.
945            Directive::Price(PriceDirective {
946                date: date(2024, 1, 15),
947                currency: "ABC".into(),
948                amount: Amount::new(dec!(1.40), "EUR"),
949                meta: Default::default(),
950            }),
951            // The original transaction the plugin derived from — still
952            // in the directive list, since plugins append rather than
953            // replace.
954            Directive::Transaction(
955                Transaction::new(date(2024, 1, 15), "Sell stock")
956                    .with_synthesized_posting(
957                        Posting::new("Assets:Stocks", Amount::new(dec!(-5), "ABC"))
958                            .with_cost(
959                                CostSpec::default()
960                                    .with_number(rustledger_core::CostNumber::PerUnit {
961                                        value: dec!(1.25),
962                                    })
963                                    .with_currency("EUR"),
964                            )
965                            .with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
966                    )
967                    .with_synthesized_posting(Posting::new(
968                        "Assets:Cash",
969                        Amount::new(dec!(7.00), "EUR"),
970                    )),
971            ),
972        ];
973        let db = PriceDatabase::from_directives(&directives);
974
975        assert_eq!(
976            db.len(),
977            1,
978            "exactly one ABC→EUR entry; pre-fix this would be 2 (plugin + BQL)"
979        );
980        assert_eq!(
981            db.get_price("ABC", "EUR", date(2024, 1, 15)),
982            Some(dec!(1.40))
983        );
984    }
985
986    /// Two separate transactions on the same date emitting the same
987    /// implicit price — both legitimate, both should remain. Pre-fix
988    /// these were already kept (no dedup at insert) — verify the
989    /// new two-pass design preserves that.
990    #[test]
991    fn test_two_transactions_same_date_same_price_both_kept() {
992        use rustledger_core::{CostSpec, Posting, Transaction};
993
994        let directives = vec![
995            Directive::Transaction(
996                Transaction::new(date(2017, 12, 15), "Sale 1")
997                    .with_synthesized_posting(
998                        Posting::new("Assets:Stock", Amount::new(dec!(-10), "BAM")).with_cost(
999                            CostSpec::default()
1000                                .with_number(rustledger_core::CostNumber::PerUnit {
1001                                    value: dec!(0.5113),
1002                                })
1003                                .with_currency("EUR"),
1004                        ),
1005                    )
1006                    .with_synthesized_posting(Posting::new(
1007                        "Assets:Cash",
1008                        Amount::new(dec!(5.113), "EUR"),
1009                    )),
1010            ),
1011            Directive::Transaction(
1012                Transaction::new(date(2017, 12, 15), "Sale 2")
1013                    .with_synthesized_posting(
1014                        Posting::new("Assets:Stock", Amount::new(dec!(-20), "BAM")).with_cost(
1015                            CostSpec::default()
1016                                .with_number(rustledger_core::CostNumber::PerUnit {
1017                                    value: dec!(0.5113),
1018                                })
1019                                .with_currency("EUR"),
1020                        ),
1021                    )
1022                    .with_synthesized_posting(Posting::new(
1023                        "Assets:Cash",
1024                        Amount::new(dec!(10.226), "EUR"),
1025                    )),
1026            ),
1027        ];
1028        let db = PriceDatabase::from_directives(&directives);
1029
1030        // Both transactions emit BAM→EUR at 0.5113 on the same date.
1031        // No explicit Price suppresses pass 2 → both kept (BQL extracts
1032        // both since neither is in `explicit`).
1033        assert_eq!(
1034            db.len(),
1035            2,
1036            "two distinct transactions both emit implicit prices on the same date"
1037        );
1038    }
1039
1040    /// The actual 2017-12-15 case from issue #1006: the
1041    /// `implicit_prices` plugin runs and emits one Price directive per
1042    /// priced posting (NOT one per unique tuple). When two distinct
1043    /// transactions on the same date emit the same `(base, quote)`
1044    /// pair, the plugin produces two Price directives — pass 1 keeps
1045    /// both, pass 2 skips both transactions (the tuple is in
1046    /// `explicit`). Net: two entries, matching what `bean-query`
1047    /// shows for that date. Pins the plugin+multi-txn interaction
1048    /// that the original PR's tests left implicit.
1049    #[test]
1050    fn test_plugin_emits_per_posting_two_txns_same_tuple_both_kept() {
1051        use rustledger_core::{CostSpec, Posting, Transaction};
1052
1053        let directives = vec![
1054            // Plugin output: one Price per priced posting. Two
1055            // postings on the same date with the same (base, quote)
1056            // → two Price directives at the same tuple.
1057            Directive::Price(PriceDirective {
1058                date: date(2017, 12, 15),
1059                currency: "BAM".into(),
1060                amount: Amount::new(dec!(0.5113), "EUR"),
1061                meta: Default::default(),
1062            }),
1063            Directive::Price(PriceDirective {
1064                date: date(2017, 12, 15),
1065                currency: "BAM".into(),
1066                amount: Amount::new(dec!(0.5113), "EUR"),
1067                meta: Default::default(),
1068            }),
1069            // The original transactions the plugin derived from.
1070            // Pass 2 must skip both (the (BAM, EUR, 2017-12-15) tuple
1071            // is already in `explicit` from pass 1's first add).
1072            Directive::Transaction(
1073                Transaction::new(date(2017, 12, 15), "Sale 1")
1074                    .with_synthesized_posting(
1075                        Posting::new("Assets:Stock", Amount::new(dec!(-10), "BAM")).with_cost(
1076                            CostSpec::default()
1077                                .with_number(rustledger_core::CostNumber::PerUnit {
1078                                    value: dec!(0.5113),
1079                                })
1080                                .with_currency("EUR"),
1081                        ),
1082                    )
1083                    .with_synthesized_posting(Posting::new(
1084                        "Assets:Cash",
1085                        Amount::new(dec!(5.113), "EUR"),
1086                    )),
1087            ),
1088            Directive::Transaction(
1089                Transaction::new(date(2017, 12, 15), "Sale 2")
1090                    .with_synthesized_posting(
1091                        Posting::new("Assets:Stock", Amount::new(dec!(-20), "BAM")).with_cost(
1092                            CostSpec::default()
1093                                .with_number(rustledger_core::CostNumber::PerUnit {
1094                                    value: dec!(0.5113),
1095                                })
1096                                .with_currency("EUR"),
1097                        ),
1098                    )
1099                    .with_synthesized_posting(Posting::new(
1100                        "Assets:Cash",
1101                        Amount::new(dec!(10.226), "EUR"),
1102                    )),
1103            ),
1104        ];
1105        let db = PriceDatabase::from_directives(&directives);
1106
1107        // Two entries — both from pass 1 (the plugin), zero from
1108        // pass 2 (gated). Pre-#1015 fix this would have been four
1109        // (2 plugin + 2 BQL re-extraction). Mirrors the bean-query
1110        // behavior reported in the issue.
1111        assert_eq!(
1112            db.len(),
1113            2,
1114            "plugin emits one Price per priced posting; pass 2 must skip both transactions"
1115        );
1116        assert_eq!(
1117            db.get_price("BAM", "EUR", date(2017, 12, 15)),
1118            Some(dec!(0.5113))
1119        );
1120    }
1121}