timeprice 0.2.0 → 0.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/DATA_LICENSES.md +2 -1
  4. data/README.md +15 -6
  5. data/data/cpi/eu.json +23 -1
  6. data/data/cpi/jp.json +18 -2
  7. data/data/cpi/uk.json +23 -1
  8. data/data/cpi/us.json +29 -1
  9. data/data/cpi/vn.json +362 -34
  10. data/data/fx/usd/1983.json +7 -8
  11. data/data/fx/usd/1986.json +7 -8
  12. data/data/fx/usd/1987.json +7 -8
  13. data/data/fx/usd/1988.json +7 -8
  14. data/data/fx/usd/1989.json +7 -8
  15. data/data/fx/usd/1990.json +7 -8
  16. data/data/fx/usd/1991.json +7 -8
  17. data/data/fx/usd/1992.json +7 -8
  18. data/data/fx/usd/1993.json +7 -8
  19. data/data/fx/usd/1994.json +7 -8
  20. data/data/fx/usd/1995.json +7 -8
  21. data/data/fx/usd/1996.json +7 -8
  22. data/data/fx/usd/1997.json +7 -8
  23. data/data/fx/usd/1998.json +7 -8
  24. data/data/fx/usd/1999.json +266 -525
  25. data/data/fx/usd/2000.json +262 -517
  26. data/data/fx/usd/2001.json +261 -512
  27. data/data/fx/usd/2002.json +262 -514
  28. data/data/fx/usd/2003.json +262 -514
  29. data/data/fx/usd/2004.json +266 -522
  30. data/data/fx/usd/2005.json +264 -521
  31. data/data/fx/usd/2006.json +262 -514
  32. data/data/fx/usd/2007.json +262 -514
  33. data/data/fx/usd/2008.json +263 -516
  34. data/data/fx/usd/2009.json +263 -516
  35. data/data/fx/usd/2010.json +265 -523
  36. data/data/fx/usd/2011.json +264 -521
  37. data/data/fx/usd/2012.json +263 -516
  38. data/data/fx/usd/2013.json +262 -514
  39. data/data/fx/usd/2014.json +262 -514
  40. data/data/fx/usd/2015.json +263 -516
  41. data/data/fx/usd/2016.json +264 -521
  42. data/data/fx/usd/2017.json +262 -514
  43. data/data/fx/usd/2018.json +262 -514
  44. data/data/fx/usd/2019.json +262 -514
  45. data/data/fx/usd/2020.json +264 -518
  46. data/data/fx/usd/2021.json +265 -523
  47. data/data/fx/usd/2022.json +264 -521
  48. data/data/fx/usd/2023.json +262 -514
  49. data/data/fx/usd/2024.json +263 -516
  50. data/data/fx/usd/2025.json +5 -5
  51. data/data/fx/usd/2026.json +5 -5
  52. data/lib/timeprice/cli/formatting.rb +34 -0
  53. data/lib/timeprice/cli/presenters/compare.rb +46 -0
  54. data/lib/timeprice/cli/presenters/exchange.rb +45 -0
  55. data/lib/timeprice/cli/presenters/inflation.rb +37 -0
  56. data/lib/timeprice/cli/presenters/sources.rb +65 -0
  57. data/lib/timeprice/cli.rb +83 -114
  58. data/lib/timeprice/compare.rb +17 -34
  59. data/lib/timeprice/cpi_lookup.rb +64 -0
  60. data/lib/timeprice/data_loader.rb +13 -6
  61. data/lib/timeprice/exchange.rb +35 -17
  62. data/lib/timeprice/granularity.rb +46 -0
  63. data/lib/timeprice/inflation.rb +20 -71
  64. data/lib/timeprice/point.rb +30 -11
  65. data/lib/timeprice/sources/coverage.rb +71 -0
  66. data/lib/timeprice/sources.rb +7 -54
  67. data/lib/timeprice/supported.rb +12 -5
  68. data/lib/timeprice/version.rb +1 -1
  69. metadata +9 -1
@@ -1,5 +1,9 @@
1
1
  {
2
+ "schema_version": 2,
2
3
  "base": "USD",
4
+ "year": 2025,
5
+ "source": "Frankfurter (ECB) — daily reference rates",
6
+ "updated_at": "2026-05-11",
3
7
  "rates": {
4
8
  "2025-01-02": {
5
9
  "EUR": 0.9689,
@@ -1276,9 +1280,5 @@
1276
1280
  "GBP": 0.74264,
1277
1281
  "JPY": 156.67
1278
1282
  }
1279
- },
1280
- "schema_version": 1,
1281
- "source": "Frankfurter (ECB) — daily reference rates",
1282
- "updated_at": "2026-05-11",
1283
- "year": 2025
1283
+ }
1284
1284
  }
@@ -1,5 +1,9 @@
1
1
  {
2
+ "schema_version": 2,
2
3
  "base": "USD",
4
+ "year": 2026,
5
+ "source": "Frankfurter (ECB) — daily reference rates",
6
+ "updated_at": "2026-05-11",
3
7
  "rates": {
4
8
  "2026-01-02": {
5
9
  "EUR": 0.85317,
@@ -441,9 +445,5 @@
441
445
  "GBP": 0.73472,
442
446
  "JPY": 156.76
443
447
  }
444
- },
445
- "schema_version": 1,
446
- "source": "Frankfurter (ECB) — daily reference rates",
447
- "updated_at": "2026-05-11",
448
- "year": 2026
448
+ }
449
449
  }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../supported"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ # Number/currency formatting helpers shared by every CLI emitter.
8
+ # Lives as a mixin (rather than a free-standing module function set) so
9
+ # callers can use the helpers as plain methods inside `no_commands` blocks.
10
+ module Formatting
11
+ def fmt_money(amount, currency)
12
+ with_commas(format("%.#{Supported.decimals_for(currency)}f", amount))
13
+ end
14
+
15
+ # Two decimals once we're past the unit threshold; six decimals for
16
+ # sub-unit rates so tiny rates (e.g. 0.000045) still carry signal.
17
+ def fmt_rate(rate)
18
+ decimals = rate.to_f.abs >= 1 ? 2 : 6
19
+ with_commas(format("%.#{decimals}f", rate))
20
+ end
21
+
22
+ def round_money(amount, currency)
23
+ amount.to_f.round(Supported.decimals_for(currency))
24
+ end
25
+
26
+ def with_commas(num_str)
27
+ sign = num_str.start_with?("-") ? "-" : ""
28
+ whole, frac = num_str.sub(/\A-/, "").split(".", 2)
29
+ whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
30
+ frac ? "#{sign}#{whole}.#{frac}" : "#{sign}#{whole}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+ require_relative "../../granularity"
5
+
6
+ module Timeprice
7
+ class CLI < Thor
8
+ module Presenters
9
+ # Renders a CompareResult for the CLI in text and JSON formats.
10
+ class Compare
11
+ include Formatting
12
+
13
+ def initialize(result)
14
+ @result = result
15
+ end
16
+
17
+ def json_hash
18
+ @result.to_h.merge(
19
+ amount: round_money(@result.amount, @result.to_currency),
20
+ original_amount: round_money(@result.original_amount, @result.from_currency),
21
+ converted_amount: round_money(@result.converted_amount, @result.to_currency),
22
+ fx_rate: @result.fx_rate.to_f.round(6),
23
+ cpi_ratio: @result.cpi_ratio.to_f.round(6)
24
+ )
25
+ end
26
+
27
+ # Headline + left-to-right chain so the FX + CPI composition reads naturally.
28
+ def text_lines
29
+ final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
30
+ original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
31
+ converted = "#{fmt_money(@result.converted_amount, @result.to_currency)} #{@result.to_currency}"
32
+ step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
33
+ step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
34
+ width = [step1.length, step2.length].max
35
+ [
36
+ "#{final} in #{@result.to_date}",
37
+ " #{original} (#{@result.from_date})",
38
+ format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
39
+ format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date,
40
+ Granularity.humanize(@result.granularity)),
41
+ ]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+
5
+ module Timeprice
6
+ class CLI < Thor
7
+ module Presenters
8
+ # Renders an ExchangeResult for the CLI in text and JSON formats.
9
+ class Exchange
10
+ include Formatting
11
+
12
+ def initialize(result)
13
+ @result = result
14
+ end
15
+
16
+ def json_hash
17
+ @result.to_h.merge(
18
+ amount: round_money(@result.amount, @result.to),
19
+ original_amount: round_money(@result.original_amount, @result.from),
20
+ rate: @result.rate.to_f.round(6)
21
+ )
22
+ end
23
+
24
+ def text_lines
25
+ [
26
+ "#{fmt_money(@result.amount, @result.to)} #{@result.to} on #{@result.date}",
27
+ format(" %s %s -> %s %s",
28
+ fmt_money(@result.original_amount, @result.from), @result.from,
29
+ fmt_money(@result.amount, @result.to), @result.to),
30
+ " #{rate_line}",
31
+ ]
32
+ end
33
+
34
+ private
35
+
36
+ def rate_line
37
+ line = "rate #{fmt_rate(@result.rate)}"
38
+ return line unless @result.effective_date && @result.effective_date != @result.date
39
+
40
+ "#{line} from #{@result.effective_date} (fallback)"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+ require_relative "../../granularity"
5
+
6
+ module Timeprice
7
+ class CLI < Thor
8
+ module Presenters
9
+ # Renders an InflationResult for the CLI in text and JSON formats.
10
+ class Inflation
11
+ include Formatting
12
+
13
+ def initialize(result)
14
+ @result = result
15
+ @ccy = result.country_currency_label
16
+ end
17
+
18
+ def json_hash
19
+ @result.to_h.merge(
20
+ amount: round_money(@result.amount, @ccy),
21
+ original_amount: round_money(@result.original_amount, @ccy)
22
+ )
23
+ end
24
+
25
+ def text_lines
26
+ [
27
+ "#{fmt_money(@result.amount, @ccy)} #{@ccy} in #{@result.to}",
28
+ format(" %s %s (%s) -> %s %s (%s)",
29
+ fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
30
+ fmt_money(@result.amount, @ccy), @ccy, @result.to),
31
+ " #{@result.country} · #{Granularity.humanize(@result.granularity)} CPI",
32
+ ]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ class CLI < Thor
5
+ module Presenters
6
+ # Renders the sources list in compact-table, verbose, and JSON formats.
7
+ class Sources
8
+ MAX_SOURCE_NAME = 60
9
+
10
+ def initialize(list, verbose: false)
11
+ @list = list
12
+ @verbose = verbose
13
+ end
14
+
15
+ def json_hash
16
+ @list
17
+ end
18
+
19
+ def text_lines
20
+ @verbose ? verbose_lines : table_lines
21
+ end
22
+
23
+ private
24
+
25
+ def table_lines
26
+ rows = @list.map do |s|
27
+ [s[:id].to_s, short_source_name(s[:name]), s[:license].to_s, s[:coverage].to_s]
28
+ end
29
+ headers = %w[ID SOURCE LICENSE COVERAGE]
30
+ widths = headers.each_with_index.map { |h, i| [h.length, *rows.map { |r| r[i].length }].max }
31
+ fmt = " %-#{widths[0]}s %-#{widths[1]}s %-#{widths[2]}s %s"
32
+ [
33
+ format(fmt, *headers),
34
+ *rows.map { |r| format(fmt, *r) },
35
+ "",
36
+ "Run `timeprice sources --verbose` for license URLs and full attribution.",
37
+ ]
38
+ end
39
+
40
+ def verbose_lines
41
+ @list.flat_map do |s|
42
+ [
43
+ s[:name].to_s,
44
+ " id: #{s[:id]}",
45
+ " license: #{s[:license]}",
46
+ " license_url: #{s[:license_url]}",
47
+ " attribution: #{s[:attribution]}",
48
+ " coverage: #{s[:coverage]}",
49
+ "",
50
+ ]
51
+ end
52
+ end
53
+
54
+ # Cap the source-name column width. Truncation is last resort — the full
55
+ # name (with series code) is preserved in `--verbose` output.
56
+ def short_source_name(name)
57
+ s = name.to_s
58
+ return s if s.length <= MAX_SOURCE_NAME
59
+
60
+ "#{s[0, MAX_SOURCE_NAME - 1]}…"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/timeprice/cli.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  require "thor"
4
4
  require "json"
5
5
  require_relative "../timeprice"
6
+ require_relative "cli/presenters/inflation"
7
+ require_relative "cli/presenters/exchange"
8
+ require_relative "cli/presenters/compare"
9
+ require_relative "cli/presenters/sources"
6
10
 
7
11
  module Timeprice
8
12
  class CLI < Thor
@@ -16,8 +20,59 @@ module Timeprice
16
20
 
17
21
  class_option :json, type: :boolean, default: false, desc: "Output result as JSON"
18
22
 
23
+ # Return false so Thor::Error propagates to our wrapper in `start`, where
24
+ # we prettify the message and add a `See: timeprice help COMMAND` hint.
19
25
  def self.exit_on_failure?
20
- true
26
+ false
27
+ end
28
+
29
+ KNOWN_COMMANDS = %w[inflation fx compare sources version].freeze
30
+
31
+ # Top-level help lists command names + descriptions only — matching git,
32
+ # gh, cargo. Arg signatures live in per-command help (`timeprice help fx`).
33
+ HELP_ROWS = [
34
+ ["inflation", "Inflation-adjust an amount between two dates"],
35
+ ["fx", "Convert an amount between currencies on a date"],
36
+ ["compare", "Combine FX + inflation across (year, currency) points"],
37
+ ["sources", "List bundled data sources and coverage"],
38
+ ["version", "Print the installed timeprice version"],
39
+ ].freeze
40
+
41
+ # Pass debug: true so Thor re-raises Thor::Error (including option-parse
42
+ # failures) instead of printing its own message and silently continuing
43
+ # when exit_on_failure? is false. We catch and format ourselves.
44
+ def self.start(given_args = ARGV, config = {})
45
+ super(given_args, config.merge(debug: true))
46
+ rescue Thor::Error => e
47
+ warn "Error: #{prettify_thor_message(e.message)}"
48
+ cmd = given_args.first
49
+ warn " See: timeprice help #{cmd}" if KNOWN_COMMANDS.include?(cmd)
50
+ exit 1
51
+ end
52
+
53
+ def self.prettify_thor_message(msg)
54
+ msg
55
+ .sub(/\ANo value provided for required options /, "missing required options: ")
56
+ .gsub("'", "")
57
+ end
58
+
59
+ # Thor's API dictates the positional boolean signature — keyword arg
60
+ # would break the override.
61
+ def self.help(shell, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
62
+ return super if subcommand
63
+
64
+ shell.say "timeprice — offline historical inflation & FX for Ruby"
65
+ shell.say ""
66
+ shell.say "Commands:"
67
+ width = HELP_ROWS.map { |usage, _| usage.length }.max
68
+ HELP_ROWS.each do |usage, desc|
69
+ shell.say format(" %-#{width}s %s", usage, desc)
70
+ end
71
+ shell.say ""
72
+ shell.say "Global options:"
73
+ shell.say " --json Output result as JSON"
74
+ shell.say ""
75
+ shell.say "Run `timeprice help COMMAND` for usage and options."
21
76
  end
22
77
 
23
78
  desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
@@ -27,12 +82,12 @@ module Timeprice
27
82
  def inflation(amount)
28
83
  with_error_handling do
29
84
  result = Timeprice.inflation(
30
- amount: Float(amount),
85
+ amount: parse_amount(amount),
31
86
  from: options[:from],
32
87
  to: options[:to],
33
88
  country: options[:country]
34
89
  )
35
- emit_inflation(result)
90
+ render Presenters::Inflation.new(result)
36
91
  end
37
92
  end
38
93
 
@@ -41,12 +96,12 @@ module Timeprice
41
96
  def fx(amount, from_currency, to_currency)
42
97
  with_error_handling do
43
98
  result = Timeprice.exchange(
44
- amount: Float(amount),
99
+ amount: parse_amount(amount),
45
100
  from: from_currency,
46
101
  to: to_currency,
47
102
  date: options[:date]
48
103
  )
49
- emit_exchange(result)
104
+ render Presenters::Exchange.new(result)
50
105
  end
51
106
  end
52
107
 
@@ -58,30 +113,19 @@ module Timeprice
58
113
  from_tuple = parse_compare_token(options[:from], label: "--from")
59
114
  to_tuple = parse_compare_token(options[:to], label: "--to")
60
115
  result = Timeprice.compare(
61
- amount: Float(amount),
116
+ amount: parse_amount(amount),
62
117
  from: from_tuple,
63
118
  to: to_tuple
64
119
  )
65
- emit_compare(result)
120
+ render Presenters::Compare.new(result)
66
121
  end
67
122
  end
68
123
 
69
124
  desc "sources", "List bundled data sources and coverage"
125
+ method_option :verbose, type: :boolean, default: false, aliases: "-v",
126
+ desc: "Include license URLs and full attribution"
70
127
  def sources
71
- list = Timeprice::Sources.list
72
- if options[:json]
73
- say JSON.generate(list)
74
- else
75
- list.each do |s|
76
- say s[:name].to_s
77
- say " id: #{s[:id]}"
78
- say " license: #{s[:license]}"
79
- say " license_url: #{s[:license_url]}"
80
- say " attribution: #{s[:attribution]}"
81
- say " coverage: #{s[:coverage]}"
82
- say ""
83
- end
84
- end
128
+ render Presenters::Sources.new(Timeprice::Sources.list, verbose: options[:verbose])
85
129
  end
86
130
 
87
131
  desc "version", "Print the installed timeprice version"
@@ -94,19 +138,27 @@ module Timeprice
94
138
  end
95
139
 
96
140
  no_commands do
97
- # Currencies with no minor unit — render whole numbers, no decimals.
98
- ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
141
+ def render(presenter)
142
+ if options[:json]
143
+ say JSON.generate(presenter.json_hash)
144
+ else
145
+ presenter.text_lines.each { |line| say line }
146
+ end
147
+ end
99
148
 
100
149
  def with_error_handling
101
150
  yield
102
- rescue Timeprice::Error => e
103
- warn "Error: #{e.message}"
104
- exit 1
105
- rescue ArgumentError => e
151
+ rescue Timeprice::Error, ArgumentError => e
106
152
  warn "Error: #{e.message}"
107
153
  exit 1
108
154
  end
109
155
 
156
+ def parse_amount(raw)
157
+ Float(raw)
158
+ rescue ArgumentError, TypeError
159
+ raise ArgumentError, "AMOUNT must be a number, got #{raw.inspect}"
160
+ end
161
+
110
162
  def parse_compare_token(token, label:)
111
163
  raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
112
164
 
@@ -115,96 +167,13 @@ module Timeprice
115
167
  raise ArgumentError,
116
168
  "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
117
169
  end
118
- year = parts.find { |p| p.match?(/\A\d{4}\z/) }
119
- currency = parts.find { |p| p.match?(/\A[A-Za-z]{3}\z/) }
120
- if year.nil? || currency.nil?
121
- raise ArgumentError,
122
- "#{label} must contain a 4-digit year and a 3-letter currency code, got #{token.inspect}"
123
- end
124
- [currency.upcase, year]
125
- end
126
-
127
- def fmt_money(amount, currency)
128
- decimals = ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
129
- format("%.#{decimals}f", amount)
130
- end
131
-
132
- def fmt_rate(rate)
133
- abs = rate.to_f.abs
134
- decimals = if abs >= 1000 then 0
135
- elsif abs >= 100 then 2
136
- elsif abs >= 10 then 3
137
- else 4
138
- end
139
- format("%.#{decimals}f", rate)
140
- end
141
-
142
- # Granularity is loud noise on the happy path. Only surface it when the
143
- # answer actually used annual data — that's where users want a heads-up.
144
- def granularity_suffix(granularity)
145
- return "" if granularity == :monthly
146
-
147
- " (granularity: #{granularity})"
148
- end
149
-
150
- def emit_inflation(result)
151
- if options[:json]
152
- say JSON.generate(result.to_h)
153
- else
154
- ccy = result.country_currency_label
155
- say format(
156
- "%s %s in %s is %s %s in %s [%s]%s",
157
- fmt_money(result.original_amount, ccy), ccy, result.from,
158
- fmt_money(result.amount, ccy), ccy, result.to,
159
- result.country, granularity_suffix(result.granularity)
160
- )
161
- end
162
- end
163
170
 
164
- def emit_exchange(result)
165
- if options[:json]
166
- say JSON.generate(result.to_h)
167
- else
168
- line = format(
169
- "%s %s on %s = %s %s (rate: %s)",
170
- fmt_money(result.original_amount, result.from), result.from, result.date,
171
- fmt_money(result.amount, result.to), result.to, fmt_rate(result.rate)
172
- )
173
- if result.effective_date && result.effective_date != result.date
174
- line += " [effective: #{result.effective_date}, fallback]"
175
- end
176
- say line
177
- end
178
- end
171
+ Point.coerce(parts)
172
+ rescue ArgumentError => e
173
+ raise if e.message.start_with?(label)
179
174
 
180
- def emit_compare(result)
181
- if options[:json]
182
- say JSON.generate(result.to_h)
183
- else
184
- say format(
185
- "%s %s in %s -> %s %s in %s",
186
- fmt_money(result.original_amount, result.from_currency), result.from_currency, result.from_date,
187
- fmt_money(result.amount, result.to_currency), result.to_currency, result.to_date
188
- )
189
- say format(
190
- " steps: %s %s -> %s %s (fx %s on %s), then inflate in %s x%.4f%s",
191
- fmt_money(result.original_amount, result.from_currency), result.from_currency,
192
- fmt_money(result.converted_amount, result.to_currency), result.to_currency,
193
- fmt_rate(result.fx_rate), result.from_date,
194
- result.country, result.cpi_ratio, granularity_suffix(result.granularity)
195
- )
196
- end
175
+ raise ArgumentError, "#{label}: #{e.message}"
197
176
  end
198
177
  end
199
178
  end
200
179
  end
201
-
202
- # Tiny shim so we can include currency context in the inflation line without
203
- # bloating the value object — the result doesn't carry currency, only country.
204
- module Timeprice
205
- class InflationResult
206
- def country_currency_label
207
- Supported.currency_for_country(country) || country.to_s.upcase
208
- end
209
- end
210
- 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(
@@ -28,10 +29,6 @@ module Timeprice
28
29
  # If a future refactor flips the order, the regression test in
29
30
  # spec/timeprice/compare_spec.rb will fail.
30
31
  module Compare
31
- # Map ISO currency → CPI country code. Kept as a back-compat alias;
32
- # the canonical map lives in {Supported::CURRENCY_TO_COUNTRY}.
33
- CURRENCY_TO_COUNTRY = Supported::CURRENCY_TO_COUNTRY
34
-
35
32
  module_function
36
33
 
37
34
  # Compare an amount across two (currency, date) points.
@@ -43,15 +40,14 @@ module Timeprice
43
40
  # @return [CompareResult]
44
41
  # @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
45
42
  def run(amount:, from:, to:)
46
- from_currency, from_date, to_currency, to_date, to_country = resolve_points(from, to)
43
+ from_point, to_point, to_country = resolve_points(from, to)
47
44
 
48
45
  # Step 1: convert at source date into destination currency.
49
- fx_date = normalize_fx_date(from_date)
50
46
  fx_result = Exchange.convert(
51
47
  amount: amount,
52
- from: from_currency,
53
- to: to_currency,
54
- date: fx_date
48
+ from: from_point.currency,
49
+ to: to_point.currency,
50
+ date: from_point.fx_anchor_date
55
51
  )
56
52
  converted = fx_result.amount
57
53
 
@@ -59,49 +55,36 @@ module Timeprice
59
55
  # destination date using destination-country CPI.
60
56
  infl = Inflation.adjust(
61
57
  amount: converted,
62
- from: from_date.to_s,
63
- to: to_date.to_s,
58
+ from: from_point.date.to_s,
59
+ to: to_point.date.to_s,
64
60
  country: to_country
65
61
  )
66
62
 
67
63
  CompareResult.new(
68
64
  amount: infl.amount,
69
65
  original_amount: amount.to_f,
70
- from_currency: from_currency,
71
- from_date: from_date.to_s,
72
- to_currency: to_currency,
73
- to_date: to_date.to_s,
66
+ from_currency: from_point.currency,
67
+ from_date: from_point.date.to_s,
68
+ to_currency: to_point.currency,
69
+ to_date: to_point.date.to_s,
74
70
  country: to_country,
75
71
  fx_rate: fx_result.rate,
76
72
  cpi_ratio: infl.to_index.to_f / infl.from_index,
77
73
  converted_amount: converted,
78
- granularity: infl.granularity
74
+ granularity: Granularity.merge(fx_result.granularity, infl.granularity)
79
75
  )
80
76
  end
81
77
 
82
- # Coerce both points and resolve to_country. Returns a 5-element tuple.
78
+ # Coerce both points and resolve to_country.
83
79
  def resolve_points(from, to)
84
80
  from_point = Point.coerce(from)
85
81
  to_point = Point.coerce(to)
86
- from_currency = from_point.currency
87
- to_currency = to_point.currency
88
- to_country = Supported.country_for_currency(to_currency) ||
89
- (raise UnsupportedCurrency, to_currency)
90
- Supported.country_for_currency(from_currency) || (raise UnsupportedCurrency, from_currency)
82
+ raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
91
83
 
92
- [from_currency, from_point.date, to_currency, to_point.date, to_country]
93
- end
84
+ to_country = Supported.country_for_currency(to_point.currency)
85
+ raise UnsupportedCurrency, to_point.currency unless to_country
94
86
 
95
- # If the user gave a year like "2010", anchor FX to mid-year (2010-06-30).
96
- # If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
97
- def normalize_fx_date(date)
98
- s = date.to_s
99
- case s
100
- when /\A\d{4}\z/ then "#{s}-06-30"
101
- when /\A\d{4}-\d{2}\z/ then "#{s}-15"
102
- when /\A\d{4}-\d{2}-\d{2}\z/ then s
103
- else raise ArgumentError, "Invalid date for compare: #{date.inspect}"
104
- end
87
+ [from_point, to_point, to_country]
105
88
  end
106
89
  end
107
90
  end