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