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