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, "e);
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}