timeprice 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/DATA_LICENSES.md +2 -1
  4. data/README.md +20 -4
  5. data/data/cpi/eu.json +422 -397
  6. data/data/cpi/jp.json +91 -72
  7. data/data/cpi/uk.json +529 -504
  8. data/data/cpi/us.json +507 -476
  9. data/data/cpi/vn.json +368 -37
  10. data/data/fx/usd/1999.json +281 -524
  11. data/data/fx/usd/2000.json +277 -516
  12. data/data/fx/usd/2001.json +276 -511
  13. data/data/fx/usd/2002.json +277 -513
  14. data/data/fx/usd/2003.json +277 -513
  15. data/data/fx/usd/2004.json +281 -521
  16. data/data/fx/usd/2005.json +279 -520
  17. data/data/fx/usd/2006.json +277 -513
  18. data/data/fx/usd/2007.json +277 -513
  19. data/data/fx/usd/2008.json +278 -515
  20. data/data/fx/usd/2009.json +278 -515
  21. data/data/fx/usd/2010.json +280 -522
  22. data/data/fx/usd/2011.json +279 -520
  23. data/data/fx/usd/2012.json +278 -515
  24. data/data/fx/usd/2013.json +277 -513
  25. data/data/fx/usd/2014.json +277 -513
  26. data/data/fx/usd/2015.json +278 -515
  27. data/data/fx/usd/2016.json +279 -520
  28. data/data/fx/usd/2017.json +277 -513
  29. data/data/fx/usd/2018.json +277 -513
  30. data/data/fx/usd/2019.json +277 -513
  31. data/data/fx/usd/2020.json +279 -517
  32. data/data/fx/usd/2021.json +280 -522
  33. data/data/fx/usd/2022.json +279 -520
  34. data/data/fx/usd/2023.json +277 -513
  35. data/data/fx/usd/2024.json +278 -515
  36. data/data/fx/usd/2025.json +22 -3
  37. data/data/fx/usd/2026.json +22 -3
  38. data/data/fx/usd/_annual.json +145 -0
  39. data/data/manifest.json +90 -0
  40. data/lib/timeprice/cli/presenters/compare.rb +3 -1
  41. data/lib/timeprice/cli/presenters/inflation.rb +2 -1
  42. data/lib/timeprice/compare.rb +3 -2
  43. data/lib/timeprice/cpi_lookup.rb +9 -7
  44. data/lib/timeprice/data_loader.rb +42 -7
  45. data/lib/timeprice/errors.rb +4 -4
  46. data/lib/timeprice/exchange.rb +34 -17
  47. data/lib/timeprice/granularity.rb +46 -0
  48. data/lib/timeprice/inflation.rb +6 -19
  49. data/lib/timeprice/sources/coverage.rb +27 -32
  50. data/lib/timeprice/sources.rb +5 -5
  51. data/lib/timeprice/supported.rb +39 -22
  52. data/lib/timeprice/version.rb +1 -1
  53. data/lib/timeprice.rb +2 -2
  54. metadata +4 -15
  55. data/data/fx/usd/1983.json +0 -12
  56. data/data/fx/usd/1986.json +0 -12
  57. data/data/fx/usd/1987.json +0 -12
  58. data/data/fx/usd/1988.json +0 -12
  59. data/data/fx/usd/1989.json +0 -12
  60. data/data/fx/usd/1990.json +0 -12
  61. data/data/fx/usd/1991.json +0 -12
  62. data/data/fx/usd/1992.json +0 -12
  63. data/data/fx/usd/1993.json +0 -12
  64. data/data/fx/usd/1994.json +0 -12
  65. data/data/fx/usd/1995.json +0 -12
  66. data/data/fx/usd/1996.json +0 -12
  67. data/data/fx/usd/1997.json +0 -12
  68. data/data/fx/usd/1998.json +0 -12
@@ -1,5 +1,26 @@
1
1
  {
2
2
  "base": "USD",
3
+ "provenance": [
4
+ {
5
+ "currencies": [
6
+ "EUR",
7
+ "GBP",
8
+ "JPY"
9
+ ],
10
+ "from": "2025-01-02",
11
+ "provider": "frankfurter",
12
+ "series": "daily",
13
+ "to": "2025-12-31"
14
+ }
15
+ ],
16
+ "providers": [
17
+ {
18
+ "fetched_at": "2026-05-11",
19
+ "id": "frankfurter",
20
+ "label": "Frankfurter (ECB) daily reference rates",
21
+ "status": "ok"
22
+ }
23
+ ],
3
24
  "rates": {
4
25
  "2025-01-02": {
5
26
  "EUR": 0.9689,
@@ -1277,8 +1298,6 @@
1277
1298
  "JPY": 156.67
1278
1299
  }
1279
1300
  },
1280
- "schema_version": 1,
1281
- "source": "Frankfurter (ECB) — daily reference rates",
1282
- "updated_at": "2026-05-11",
1301
+ "schema_version": 3,
1283
1302
  "year": 2025
1284
1303
  }
@@ -1,5 +1,26 @@
1
1
  {
2
2
  "base": "USD",
3
+ "provenance": [
4
+ {
5
+ "currencies": [
6
+ "EUR",
7
+ "GBP",
8
+ "JPY"
9
+ ],
10
+ "from": "2026-01-02",
11
+ "provider": "frankfurter",
12
+ "series": "daily",
13
+ "to": "2026-05-08"
14
+ }
15
+ ],
16
+ "providers": [
17
+ {
18
+ "fetched_at": "2026-05-11",
19
+ "id": "frankfurter",
20
+ "label": "Frankfurter (ECB) daily reference rates",
21
+ "status": "ok"
22
+ }
23
+ ],
3
24
  "rates": {
4
25
  "2026-01-02": {
5
26
  "EUR": 0.85317,
@@ -442,8 +463,6 @@
442
463
  "JPY": 156.76
443
464
  }
444
465
  },
445
- "schema_version": 1,
446
- "source": "Frankfurter (ECB) — daily reference rates",
447
- "updated_at": "2026-05-11",
466
+ "schema_version": 3,
448
467
  "year": 2026
449
468
  }
@@ -0,0 +1,145 @@
1
+ {
2
+ "annual": {
3
+ "1983": {
4
+ "VND": 1.0
5
+ },
6
+ "1986": {
7
+ "VND": 22.94
8
+ },
9
+ "1987": {
10
+ "VND": 78.95
11
+ },
12
+ "1988": {
13
+ "VND": 611.65
14
+ },
15
+ "1989": {
16
+ "VND": 4501.69
17
+ },
18
+ "1990": {
19
+ "VND": 6537.6
20
+ },
21
+ "1991": {
22
+ "VND": 10121.89
23
+ },
24
+ "1992": {
25
+ "VND": 11202.19
26
+ },
27
+ "1993": {
28
+ "VND": 10640.96
29
+ },
30
+ "1994": {
31
+ "VND": 10965.67
32
+ },
33
+ "1995": {
34
+ "VND": 11038.25
35
+ },
36
+ "1996": {
37
+ "VND": 11032.58
38
+ },
39
+ "1997": {
40
+ "VND": 11683.33
41
+ },
42
+ "1998": {
43
+ "VND": 13268.0
44
+ },
45
+ "1999": {
46
+ "VND": 13943.17
47
+ },
48
+ "2000": {
49
+ "VND": 14167.75
50
+ },
51
+ "2001": {
52
+ "VND": 14725.17
53
+ },
54
+ "2002": {
55
+ "VND": 15279.5
56
+ },
57
+ "2003": {
58
+ "VND": 15509.58
59
+ },
60
+ "2004": {
61
+ "VND": 15746.0
62
+ },
63
+ "2005": {
64
+ "VND": 15858.92
65
+ },
66
+ "2006": {
67
+ "VND": 15994.25
68
+ },
69
+ "2007": {
70
+ "VND": 16105.13
71
+ },
72
+ "2008": {
73
+ "VND": 16302.25
74
+ },
75
+ "2009": {
76
+ "VND": 17065.08
77
+ },
78
+ "2010": {
79
+ "VND": 18612.92
80
+ },
81
+ "2011": {
82
+ "VND": 20509.75
83
+ },
84
+ "2012": {
85
+ "VND": 20828.0
86
+ },
87
+ "2013": {
88
+ "VND": 20933.42
89
+ },
90
+ "2014": {
91
+ "VND": 21148.0
92
+ },
93
+ "2015": {
94
+ "VND": 21697.57
95
+ },
96
+ "2016": {
97
+ "VND": 21935.0
98
+ },
99
+ "2017": {
100
+ "VND": 22370.09
101
+ },
102
+ "2018": {
103
+ "VND": 22602.05
104
+ },
105
+ "2019": {
106
+ "VND": 23050.24
107
+ },
108
+ "2020": {
109
+ "VND": 23208.37
110
+ },
111
+ "2021": {
112
+ "VND": 23159.78
113
+ },
114
+ "2022": {
115
+ "VND": 23271.21
116
+ },
117
+ "2023": {
118
+ "VND": 23787.32
119
+ },
120
+ "2024": {
121
+ "VND": 24164.89
122
+ }
123
+ },
124
+ "base": "USD",
125
+ "provenance": [
126
+ {
127
+ "currencies": [
128
+ "VND"
129
+ ],
130
+ "from": "1983",
131
+ "provider": "world_bank",
132
+ "series": "annual",
133
+ "to": "2024"
134
+ }
135
+ ],
136
+ "providers": [
137
+ {
138
+ "fetched_at": "2026-05-11",
139
+ "id": "world_bank",
140
+ "label": "World Bank PA.NUS.FCRF",
141
+ "status": "ok"
142
+ }
143
+ ],
144
+ "schema_version": 3
145
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "countries": [
3
+ {
4
+ "code": "EU",
5
+ "cpi_file": "cpi/eu.json",
6
+ "currency": "EUR",
7
+ "granularities": [
8
+ "monthly",
9
+ "annual"
10
+ ]
11
+ },
12
+ {
13
+ "code": "JP",
14
+ "cpi_file": "cpi/jp.json",
15
+ "currency": "JPY",
16
+ "granularities": [
17
+ "annual"
18
+ ]
19
+ },
20
+ {
21
+ "code": "UK",
22
+ "cpi_file": "cpi/uk.json",
23
+ "currency": "GBP",
24
+ "granularities": [
25
+ "monthly",
26
+ "annual"
27
+ ]
28
+ },
29
+ {
30
+ "code": "US",
31
+ "cpi_file": "cpi/us.json",
32
+ "currency": "USD",
33
+ "granularities": [
34
+ "monthly",
35
+ "annual"
36
+ ]
37
+ },
38
+ {
39
+ "code": "VN",
40
+ "cpi_file": "cpi/vn.json",
41
+ "currency": "VND",
42
+ "granularities": [
43
+ "monthly",
44
+ "annual"
45
+ ]
46
+ }
47
+ ],
48
+ "fx": {
49
+ "annual_file": "fx/usd/_annual.json",
50
+ "base": "USD",
51
+ "currencies": [
52
+ "EUR",
53
+ "GBP",
54
+ "JPY",
55
+ "VND"
56
+ ],
57
+ "daily_years": [
58
+ 1999,
59
+ 2000,
60
+ 2001,
61
+ 2002,
62
+ 2003,
63
+ 2004,
64
+ 2005,
65
+ 2006,
66
+ 2007,
67
+ 2008,
68
+ 2009,
69
+ 2010,
70
+ 2011,
71
+ 2012,
72
+ 2013,
73
+ 2014,
74
+ 2015,
75
+ 2016,
76
+ 2017,
77
+ 2018,
78
+ 2019,
79
+ 2020,
80
+ 2021,
81
+ 2022,
82
+ 2023,
83
+ 2024,
84
+ 2025,
85
+ 2026
86
+ ]
87
+ },
88
+ "generated_at": "2026-05-11",
89
+ "schema_version": 3
90
+ }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../formatting"
4
+ require_relative "../../granularity"
4
5
 
5
6
  module Timeprice
6
7
  class CLI < Thor
@@ -35,7 +36,8 @@ module Timeprice
35
36
  "#{final} in #{@result.to_date}",
36
37
  " #{original} (#{@result.from_date})",
37
38
  format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
38
- format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date, @result.granularity),
39
+ format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date,
40
+ Granularity.humanize(@result.granularity)),
39
41
  ]
40
42
  end
41
43
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../formatting"
4
+ require_relative "../../granularity"
4
5
 
5
6
  module Timeprice
6
7
  class CLI < Thor
@@ -27,7 +28,7 @@ module Timeprice
27
28
  format(" %s %s (%s) -> %s %s (%s)",
28
29
  fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
29
30
  fmt_money(@result.amount, @ccy), @ccy, @result.to),
30
- " #{@result.country} · #{@result.granularity} CPI",
31
+ " #{@result.country} · #{Granularity.humanize(@result.granularity)} CPI",
31
32
  ]
32
33
  end
33
34
  end
@@ -5,6 +5,7 @@ require_relative "supported"
5
5
  require_relative "point"
6
6
  require_relative "inflation"
7
7
  require_relative "exchange"
8
+ require_relative "granularity"
8
9
 
9
10
  module Timeprice
10
11
  CompareResult = Data.define(
@@ -37,7 +38,7 @@ module Timeprice
37
38
  # accepts a {Point} or a 2-tuple like `["USD", "2010"]` or `["USD", "2010-06"]`
38
39
  # @param to [Timeprice::Point, Array(String, String)] destination point
39
40
  # @return [CompareResult]
40
- # @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
41
+ # @raise [UnsupportedCurrency] if either currency is not in {Supported.currencies}
41
42
  def run(amount:, from:, to:)
42
43
  from_point, to_point, to_country = resolve_points(from, to)
43
44
 
@@ -70,7 +71,7 @@ module Timeprice
70
71
  fx_rate: fx_result.rate,
71
72
  cpi_ratio: infl.to_index.to_f / infl.from_index,
72
73
  converted_amount: converted,
73
- granularity: infl.granularity
74
+ granularity: Granularity.merge(fx_result.granularity, infl.granularity)
74
75
  )
75
76
  end
76
77
 
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "granularity"
4
5
 
5
6
  module Timeprice
6
7
  # CpiPoint pairs a CPI index value with the granularity of how it was
7
- # resolved (monthly, annual, or annual derived by averaging 12 months).
8
+ # resolved. See {Granularity} for the full set of possible tags.
8
9
  CpiPoint = Data.define(:value, :granularity)
9
10
 
10
11
  # Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
@@ -13,8 +14,8 @@ module Timeprice
13
14
  class CpiLookup
14
15
  def initialize(data)
15
16
  @data = data
16
- @monthly = data["monthly"] || {}
17
- @annual = data["annual"] || {}
17
+ @monthly = data.dig("series", "monthly") || {}
18
+ @annual = data.dig("series", "annual") || {}
18
19
  end
19
20
 
20
21
  # @param key [String] "YYYY" or "YYYY-MM"
@@ -33,21 +34,22 @@ module Timeprice
33
34
  private
34
35
 
35
36
  def monthly_or_annual_fallback(month_key)
36
- return CpiPoint.new(value: @monthly[month_key], granularity: :monthly) if @monthly.key?(month_key)
37
+ return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
37
38
 
38
39
  year = month_key[0, 4]
39
40
  raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
40
41
 
41
- CpiPoint.new(value: @annual[year], granularity: :annual)
42
+ CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
42
43
  end
43
44
 
44
45
  def annual_or_monthly_average(year)
45
- return CpiPoint.new(value: @annual[year], granularity: :annual) if @annual.key?(year)
46
+ return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
46
47
 
47
48
  months = @monthly.select { |k, _| k.start_with?("#{year}-") }
48
49
  raise DataNotFound, missing_message(year) if months.empty?
49
50
 
50
- CpiPoint.new(value: months.values.sum.to_f / months.size, granularity: :annual_from_monthly_avg)
51
+ avg = months.values.sum.to_f / months.size
52
+ CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
51
53
  end
52
54
 
53
55
  def missing_message(key)
@@ -2,19 +2,18 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "errors"
5
- require_relative "supported"
6
5
 
7
6
  module Timeprice
8
7
  # Loads and caches the bundled JSON data files. Override the search root
9
8
  # by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
10
9
  # {DataLoader.data_root=}.
11
10
  module DataLoader
12
- SUPPORTED_SCHEMA_VERSION = 1
11
+ SUPPORTED_SCHEMA_VERSION = 3
13
12
 
14
13
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
15
14
 
16
15
  class << self
17
- # @return [String] absolute path to the directory containing `cpi/` and `fx/`
16
+ # @return [String] absolute path to the directory containing `cpi/`, `fx/`, `manifest.json`.
18
17
  def data_root
19
18
  ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
20
19
  end
@@ -32,19 +31,36 @@ module Timeprice
32
31
  def clear_cache!
33
32
  @cpi_cache = {}
34
33
  @fx_cache = {}
34
+ @manifest_cache = {}
35
+ @annual_fallback_cache = {}
36
+ end
37
+
38
+ # Load the top-level manifest describing the bundled dataset.
39
+ # @return [Hash]
40
+ # @raise [DataNotFound] if `manifest.json` is missing
41
+ def load_manifest
42
+ manifest_cache[data_root] ||= begin
43
+ path = File.join(data_root, "manifest.json")
44
+ unless File.exist?(path)
45
+ raise DataNotFound, "manifest.json missing (looked in #{path}). " \
46
+ "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
47
+ end
48
+
49
+ parse_with_schema(path)
50
+ end
35
51
  end
36
52
 
37
53
  # Load the CPI series for a supported country.
38
54
  # @param country [String]
39
- # @return [Hash] parsed JSON with "monthly" / "annual" / metadata keys
40
- # @raise [UnsupportedCountry] if `country` is not in {Supported::COUNTRIES}
55
+ # @return [Hash] parsed JSON with "series" / "index" / "provenance" / "providers"
56
+ # @raise [UnsupportedCountry] if `country` is not in {Supported.countries}
41
57
  # @raise [DataNotFound] if the file is missing
42
58
  # @raise [UnsupportedSchemaVersion] if the file uses a future schema
43
59
  def load_cpi(country)
44
60
  key = country.to_s.downcase
45
61
  code = country.to_s.upcase
46
62
  cpi_cache[[data_root, key]] ||= begin
47
- raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
63
+ raise UnsupportedCountry, code unless Supported.country?(code)
48
64
 
49
65
  path = File.join(data_root, "cpi", "#{key}.json")
50
66
  unless File.exist?(path)
@@ -58,7 +74,7 @@ module Timeprice
58
74
 
59
75
  # Load the FX rates for a year.
60
76
  # @param year [Integer, String]
61
- # @return [Hash] parsed JSON with a "rates" map of date → currency → Float
77
+ # @return [Hash] parsed JSON with `rates` (and optional `annual`) blocks
62
78
  # @raise [DataNotFound] if the per-year file is missing
63
79
  def load_fx_year(year)
64
80
  key = year.to_i
@@ -70,6 +86,17 @@ module Timeprice
70
86
  end
71
87
  end
72
88
 
89
+ # Load the sparse historical FX annual-only fallback file, if present.
90
+ # Returns nil when no fallback file ships with this data root.
91
+ # @return [Hash, nil]
92
+ def load_fx_annual_fallback
93
+ return @annual_fallback_cache[data_root] if @annual_fallback_cache&.key?(data_root)
94
+
95
+ @annual_fallback_cache ||= {}
96
+ path = File.join(data_root, "fx", "usd", "_annual.json")
97
+ @annual_fallback_cache[data_root] = File.exist?(path) ? parse_with_schema(path) : nil
98
+ end
99
+
73
100
  private
74
101
 
75
102
  def cpi_cache
@@ -80,6 +107,10 @@ module Timeprice
80
107
  @fx_cache ||= {}
81
108
  end
82
109
 
110
+ def manifest_cache
111
+ @manifest_cache ||= {}
112
+ end
113
+
83
114
  def parse_with_schema(path)
84
115
  data = JSON.parse(File.read(path))
85
116
  version = data["schema_version"]
@@ -90,3 +121,7 @@ module Timeprice
90
121
  end
91
122
  end
92
123
  end
124
+
125
+ # Supported is loaded by the top-level entry point. Referenced lazily inside
126
+ # load_cpi to avoid a require cycle (Supported reads the manifest via DataLoader).
127
+ require_relative "supported" unless defined?(Timeprice::Supported)
@@ -7,23 +7,23 @@ module Timeprice
7
7
  # to handle anything the gem can throw at you.
8
8
  class Error < StandardError; end
9
9
 
10
- # Raised when a country code is not in {Supported::COUNTRIES}.
10
+ # Raised when a country code is not in {Supported.countries}.
11
11
  class UnsupportedCountry < Error
12
12
  attr_reader :country
13
13
 
14
14
  def initialize(country)
15
15
  @country = country
16
- super("Unsupported country: #{country.inspect} (supported: #{Supported::COUNTRIES.join(", ")})")
16
+ super("Unsupported country: #{country.inspect} (supported: #{Supported.countries.join(", ")})")
17
17
  end
18
18
  end
19
19
 
20
- # Raised when a currency code is not in {Supported::CURRENCIES}.
20
+ # Raised when a currency code is not in {Supported.currencies}.
21
21
  class UnsupportedCurrency < Error
22
22
  attr_reader :currency
23
23
 
24
24
  def initialize(currency)
25
25
  @currency = currency
26
- super("Unsupported currency: #{currency.inspect} (supported: #{Supported::CURRENCIES.join(", ")})")
26
+ super("Unsupported currency: #{currency.inspect} (supported: #{Supported.currencies.join(", ")})")
27
27
  end
28
28
  end
29
29
 
@@ -4,10 +4,11 @@ require "date"
4
4
  require_relative "errors"
5
5
  require_relative "data_loader"
6
6
  require_relative "supported"
7
+ require_relative "granularity"
7
8
 
8
9
  module Timeprice
9
10
  ExchangeResult = Data.define(
10
- :amount, :original_amount, :from, :to, :date, :effective_date, :rate
11
+ :amount, :original_amount, :from, :to, :date, :effective_date, :rate, :granularity
11
12
  )
12
13
 
13
14
  # Historical FX conversion using bundled per-year USD-base rate files.
@@ -32,12 +33,12 @@ module Timeprice
32
33
  def convert(amount:, from:, to:, date:)
33
34
  from = from.to_s.upcase
34
35
  to = to.to_s.upcase
35
- raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
36
- raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
36
+ raise UnsupportedCurrency, from unless Supported.currency?(from)
37
+ raise UnsupportedCurrency, to unless Supported.currency?(to)
37
38
 
38
39
  d = parse_date(date)
39
40
 
40
- rate, eff_date = resolve_rate(from, to, d)
41
+ rate, eff_date, granularity = resolve_rate(from, to, d)
41
42
  ExchangeResult.new(
42
43
  amount: amount.to_f * rate,
43
44
  original_amount: amount.to_f,
@@ -45,40 +46,44 @@ module Timeprice
45
46
  to: to,
46
47
  date: d.to_s,
47
48
  effective_date: eff_date.to_s,
48
- rate: rate
49
+ rate: rate,
50
+ granularity: granularity
49
51
  )
50
52
  end
51
53
 
52
- # Returns [rate (Float), effective_date (Date)].
54
+ # Returns [rate (Float), effective_date (Date), granularity (Symbol)].
55
+ # Granularity is :daily when the rate came from a per-date entry, :annual
56
+ # when it came from the per-year `annual` fallback block. Triangulation
57
+ # merges both legs via Granularity.merge (worst-precision-wins).
53
58
  # Handles:
54
59
  # - identity (from == to)
55
60
  # - direct lookup of USD-base rate
56
61
  # - inverse (foreign → USD)
57
62
  # - triangulation through USD (both legs must resolve to SAME effective date)
58
63
  def resolve_rate(from, to, d)
59
- return [1.0, d] if from == to
64
+ return [1.0, d, Granularity::DAILY] if from == to
60
65
 
61
66
  if from == BASE
62
- rate, eff = lookup_usd_base(to, d)
63
- [rate, eff]
67
+ lookup_usd_base(to, d)
64
68
  elsif to == BASE
65
- rate, eff = lookup_usd_base(from, d)
66
- [1.0 / rate, eff]
69
+ rate, eff, gran = lookup_usd_base(from, d)
70
+ [1.0 / rate, eff, gran]
67
71
  else
68
72
  # Triangulation: from → USD → to, both legs at the same effective date.
69
- usd_to_from, eff_a = lookup_usd_base(from, d)
70
- usd_to_to, eff_b = lookup_usd_base(to, d)
73
+ usd_to_from, eff_a, gran_a = lookup_usd_base(from, d)
74
+ usd_to_to, eff_b, gran_b = lookup_usd_base(to, d)
71
75
  if eff_a != eff_b
72
76
  raise DataNotFound,
73
77
  "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
74
78
  "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
75
79
  end
76
- [usd_to_to / usd_to_from, eff_a]
80
+ [usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
77
81
  end
78
82
  end
79
83
 
80
- # Walk back up to MAX_FALLBACK_DAYS to find a rate.
81
- # Returns [rate, effective_date].
84
+ # Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
85
+ # back to data/fx/usd/_annual.json (the single source of annual FX truth).
86
+ # Returns [rate, effective_date, granularity].
82
87
  def lookup_usd_base(currency, d)
83
88
  (0..MAX_FALLBACK_DAYS).each do |offset|
84
89
  candidate = d - offset
@@ -94,11 +99,23 @@ module Timeprice
94
99
  rate = rates_for_day[currency]
95
100
  next unless rate
96
101
 
97
- return [rate.to_f, candidate]
102
+ return [rate.to_f, candidate, Granularity::DAILY]
98
103
  end
104
+
105
+ annual_rate = annual_fallback(currency, d.year)
106
+ return [annual_rate, d, Granularity::ANNUAL] if annual_rate
107
+
99
108
  raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
100
109
  end
101
110
 
111
+ # Consult data/fx/usd/_annual.json. Returns Float or nil.
112
+ def annual_fallback(currency, year)
113
+ fallback = DataLoader.load_fx_annual_fallback
114
+ return nil unless fallback
115
+
116
+ fallback.dig("annual", year.to_s, currency)&.to_f
117
+ end
118
+
102
119
  def parse_date(date)
103
120
  case date
104
121
  when Date then date