timeprice 0.1.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/DATA_LICENSES.md +31 -0
  4. data/LICENSE.txt +21 -0
  5. data/NOTICE +12 -0
  6. data/README.md +187 -0
  7. data/data/cpi/eu.json +401 -0
  8. data/data/cpi/jp.json +75 -0
  9. data/data/cpi/uk.json +508 -0
  10. data/data/cpi/us.json +480 -0
  11. data/data/cpi/vn.json +40 -0
  12. data/data/fx/usd/1983.json +12 -0
  13. data/data/fx/usd/1986.json +12 -0
  14. data/data/fx/usd/1987.json +12 -0
  15. data/data/fx/usd/1988.json +12 -0
  16. data/data/fx/usd/1989.json +12 -0
  17. data/data/fx/usd/1990.json +12 -0
  18. data/data/fx/usd/1991.json +12 -0
  19. data/data/fx/usd/1992.json +12 -0
  20. data/data/fx/usd/1993.json +12 -0
  21. data/data/fx/usd/1994.json +12 -0
  22. data/data/fx/usd/1995.json +12 -0
  23. data/data/fx/usd/1996.json +12 -0
  24. data/data/fx/usd/1997.json +12 -0
  25. data/data/fx/usd/1998.json +12 -0
  26. data/data/fx/usd/1999.json +1566 -0
  27. data/data/fx/usd/2000.json +1542 -0
  28. data/data/fx/usd/2001.json +1533 -0
  29. data/data/fx/usd/2002.json +1539 -0
  30. data/data/fx/usd/2003.json +1539 -0
  31. data/data/fx/usd/2004.json +1563 -0
  32. data/data/fx/usd/2005.json +1554 -0
  33. data/data/fx/usd/2006.json +1539 -0
  34. data/data/fx/usd/2007.json +1539 -0
  35. data/data/fx/usd/2008.json +1545 -0
  36. data/data/fx/usd/2009.json +1545 -0
  37. data/data/fx/usd/2010.json +1560 -0
  38. data/data/fx/usd/2011.json +1554 -0
  39. data/data/fx/usd/2012.json +1545 -0
  40. data/data/fx/usd/2013.json +1539 -0
  41. data/data/fx/usd/2014.json +1539 -0
  42. data/data/fx/usd/2015.json +1545 -0
  43. data/data/fx/usd/2016.json +1554 -0
  44. data/data/fx/usd/2017.json +1539 -0
  45. data/data/fx/usd/2018.json +1539 -0
  46. data/data/fx/usd/2019.json +1539 -0
  47. data/data/fx/usd/2020.json +1551 -0
  48. data/data/fx/usd/2021.json +1560 -0
  49. data/data/fx/usd/2022.json +1554 -0
  50. data/data/fx/usd/2023.json +1539 -0
  51. data/data/fx/usd/2024.json +1545 -0
  52. data/data/fx/usd/2025.json +1284 -0
  53. data/data/fx/usd/2026.json +449 -0
  54. data/exe/timeprice +7 -0
  55. data/lib/timeprice/cli.rb +179 -0
  56. data/lib/timeprice/compare.rb +99 -0
  57. data/lib/timeprice/data_loader.rb +59 -0
  58. data/lib/timeprice/errors.rb +49 -0
  59. data/lib/timeprice/exchange.rb +98 -0
  60. data/lib/timeprice/inflation.rb +89 -0
  61. data/lib/timeprice/sources.rb +128 -0
  62. data/lib/timeprice/version.rb +5 -0
  63. data/lib/timeprice.rb +25 -0
  64. metadata +150 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "inflation"
5
+ require_relative "exchange"
6
+
7
+ module Timeprice
8
+ CompareResult = Data.define(
9
+ :amount, :original_amount,
10
+ :from_currency, :from_date,
11
+ :to_currency, :to_date,
12
+ :country, :fx_rate, :cpi_ratio,
13
+ :converted_amount, :granularity
14
+ )
15
+
16
+ # Compare combines FX and inflation across two (currency, date) points.
17
+ #
18
+ # CONVENTION (critical): convert at SOURCE date first, then inflate in
19
+ # destination currency. See PLAN.md §2 last bullet and §7.
20
+ #
21
+ # This preserves purchasing-power equivalence in the destination economy.
22
+ # The naive alternative (inflate in source currency first, then convert at
23
+ # destination date) double-counts source-country inflation because nominal
24
+ # FX rates already absorb relative inflation between the two currencies.
25
+ #
26
+ # If a future refactor flips the order, the regression test in
27
+ # spec/timeprice/compare_spec.rb will fail.
28
+ module Compare
29
+ # Map ISO currency → CPI country code.
30
+ CURRENCY_TO_COUNTRY = {
31
+ "USD" => "US",
32
+ "GBP" => "UK",
33
+ "EUR" => "EU",
34
+ "JPY" => "JP",
35
+ "VND" => "VN"
36
+ }.freeze
37
+
38
+ module_function
39
+
40
+ # amount: Numeric
41
+ # from: [currency, date_or_year] e.g. ["USD", "2010"] or ["USD", "2010-06"]
42
+ # to: [currency, date_or_year]
43
+ def run(amount:, from:, to:)
44
+ from_currency, from_date = from
45
+ to_currency, to_date = to
46
+ from_currency = from_currency.to_s.upcase
47
+ to_currency = to_currency.to_s.upcase
48
+
49
+ to_country = CURRENCY_TO_COUNTRY[to_currency] ||
50
+ (raise UnsupportedCurrency, to_currency)
51
+ CURRENCY_TO_COUNTRY[from_currency] || (raise UnsupportedCurrency, from_currency)
52
+
53
+ # Step 1: convert at source date into destination currency.
54
+ fx_date = normalize_fx_date(from_date)
55
+ fx_result = Exchange.convert(
56
+ amount: amount,
57
+ from: from_currency,
58
+ to: to_currency,
59
+ date: fx_date
60
+ )
61
+ converted = fx_result.amount
62
+
63
+ # Step 2: inflate that destination-currency amount from source date to
64
+ # destination date using destination-country CPI.
65
+ infl = Inflation.adjust(
66
+ amount: converted,
67
+ from: from_date.to_s,
68
+ to: to_date.to_s,
69
+ country: to_country
70
+ )
71
+
72
+ CompareResult.new(
73
+ amount: infl.amount,
74
+ original_amount: amount.to_f,
75
+ from_currency: from_currency,
76
+ from_date: from_date.to_s,
77
+ to_currency: to_currency,
78
+ to_date: to_date.to_s,
79
+ country: to_country,
80
+ fx_rate: fx_result.rate,
81
+ cpi_ratio: infl.to_index.to_f / infl.from_index.to_f,
82
+ converted_amount: converted,
83
+ granularity: infl.granularity
84
+ )
85
+ end
86
+
87
+ # If the user gave a year like "2010", anchor FX to mid-year (2010-06-30).
88
+ # If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
89
+ def normalize_fx_date(date)
90
+ s = date.to_s
91
+ case s
92
+ when /\A\d{4}\z/ then "#{s}-06-30"
93
+ when /\A\d{4}-\d{2}\z/ then "#{s}-15"
94
+ when /\A\d{4}-\d{2}-\d{2}\z/ then s
95
+ else raise ArgumentError, "Invalid date for compare: #{date.inspect}"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+
6
+ module Timeprice
7
+ module DataLoader
8
+ SUPPORTED_SCHEMA_VERSION = 1
9
+
10
+ DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
11
+
12
+ class << self
13
+ def data_root
14
+ ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
15
+ end
16
+
17
+ def data_root=(path)
18
+ @data_root = path
19
+ clear_cache!
20
+ end
21
+
22
+ def clear_cache!
23
+ @cpi_cache = {}
24
+ @fx_cache = {}
25
+ end
26
+
27
+ def load_cpi(country)
28
+ @cpi_cache ||= {}
29
+ key = country.to_s.downcase
30
+ @cpi_cache[[data_root, key]] ||= begin
31
+ path = File.join(data_root, "cpi", "#{key}.json")
32
+ raise UnsupportedCountry, country.to_s.upcase unless File.exist?(path)
33
+ parse_with_schema(path)
34
+ end
35
+ end
36
+
37
+ def load_fx_year(year)
38
+ @fx_cache ||= {}
39
+ key = year.to_i
40
+ @fx_cache[[data_root, key]] ||= begin
41
+ path = File.join(data_root, "fx", "usd", "#{key}.json")
42
+ raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
43
+ parse_with_schema(path)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def parse_with_schema(path)
50
+ data = JSON.parse(File.read(path))
51
+ version = data["schema_version"]
52
+ unless version == SUPPORTED_SCHEMA_VERSION
53
+ raise UnsupportedSchemaVersion.new(version, path)
54
+ end
55
+ data
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ class Error < StandardError; end
5
+
6
+ class UnsupportedCountry < Error
7
+ attr_reader :country
8
+
9
+ def initialize(country)
10
+ @country = country
11
+ super("Unsupported country: #{country.inspect}")
12
+ end
13
+ end
14
+
15
+ class UnsupportedCurrency < Error
16
+ attr_reader :currency
17
+
18
+ def initialize(currency)
19
+ @currency = currency
20
+ super("Unsupported currency: #{currency.inspect}")
21
+ end
22
+ end
23
+
24
+ class DateOutOfRange < Error
25
+ attr_reader :date, :range
26
+
27
+ def initialize(date, range)
28
+ @date = date
29
+ @range = range
30
+ super("Date #{date.inspect} out of supported range #{range.inspect}")
31
+ end
32
+ end
33
+
34
+ class DataNotFound < Error
35
+ def initialize(message = "Data not found")
36
+ super
37
+ end
38
+ end
39
+
40
+ class UnsupportedSchemaVersion < Error
41
+ attr_reader :version, :path
42
+
43
+ def initialize(version, path)
44
+ @version = version
45
+ @path = path
46
+ super("Unsupported schema_version #{version.inspect} in #{path}")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "errors"
5
+ require_relative "data_loader"
6
+
7
+ module Timeprice
8
+ ExchangeResult = Data.define(
9
+ :amount, :original_amount, :from, :to, :date, :effective_date, :rate
10
+ )
11
+
12
+ module Exchange
13
+ BASE = "USD"
14
+ MAX_FALLBACK_DAYS = 7
15
+
16
+ module_function
17
+
18
+ # Convert `amount` from currency `from` to currency `to` on `date`.
19
+ # date: "YYYY-MM-DD".
20
+ def convert(amount:, from:, to:, date:)
21
+ from = from.to_s.upcase
22
+ to = to.to_s.upcase
23
+ d = parse_date(date)
24
+
25
+ rate, eff_date = resolve_rate(from, to, d)
26
+ ExchangeResult.new(
27
+ amount: amount.to_f * rate,
28
+ original_amount: amount.to_f,
29
+ from: from,
30
+ to: to,
31
+ date: d.to_s,
32
+ effective_date: eff_date.to_s,
33
+ rate: rate
34
+ )
35
+ end
36
+
37
+ # Returns [rate (Float), effective_date (Date)].
38
+ # Handles:
39
+ # - identity (from == to)
40
+ # - direct lookup of USD-base rate
41
+ # - inverse (foreign → USD)
42
+ # - triangulation through USD (both legs must resolve to SAME effective date)
43
+ def resolve_rate(from, to, d)
44
+ return [1.0, d] if from == to
45
+
46
+ if from == BASE
47
+ rate, eff = lookup_usd_base(to, d)
48
+ [rate, eff]
49
+ elsif to == BASE
50
+ rate, eff = lookup_usd_base(from, d)
51
+ [1.0 / rate, eff]
52
+ else
53
+ # Triangulation: from → USD → to, both legs at the same effective date.
54
+ usd_to_from, eff_a = lookup_usd_base(from, d)
55
+ usd_to_to, eff_b = lookup_usd_base(to, d)
56
+ if eff_a != eff_b
57
+ raise DataNotFound,
58
+ "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
59
+ "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
60
+ end
61
+ [usd_to_to / usd_to_from, eff_a]
62
+ end
63
+ end
64
+
65
+ # Walk back up to MAX_FALLBACK_DAYS to find a rate.
66
+ # Returns [rate, effective_date].
67
+ def lookup_usd_base(currency, d)
68
+ (0..MAX_FALLBACK_DAYS).each do |offset|
69
+ candidate = d - offset
70
+ year_data =
71
+ begin
72
+ DataLoader.load_fx_year(candidate.year)
73
+ rescue DataNotFound
74
+ next
75
+ end
76
+ rates_for_day = year_data.dig("rates", candidate.to_s)
77
+ next unless rates_for_day
78
+ rate = rates_for_day[currency]
79
+ next unless rate
80
+ return [rate.to_f, candidate]
81
+ end
82
+ raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
83
+ end
84
+
85
+ def parse_date(date)
86
+ case date
87
+ when Date then date
88
+ when String
89
+ unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
90
+ raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
91
+ end
92
+ Date.parse(date)
93
+ else
94
+ raise ArgumentError, "Invalid date: #{date.inspect}"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "data_loader"
5
+
6
+ module Timeprice
7
+ # Value object returned by Inflation.adjust.
8
+ #
9
+ # granularity is one of:
10
+ # :monthly — both ends resolved on monthly data
11
+ # :annual — at least one end resolved on annual data
12
+ # :annual_from_monthly_avg — at least one end was an annual request resolved
13
+ # by averaging 12 months of monthly data
14
+ InflationResult = Data.define(
15
+ :amount, :original_amount, :from, :to, :country,
16
+ :from_index, :to_index, :granularity
17
+ )
18
+
19
+ module Inflation
20
+ module_function
21
+
22
+ # Adjust `amount` from date `from` to date `to` using country CPI.
23
+ #
24
+ # Dates accept "YYYY" or "YYYY-MM".
25
+ def adjust(amount:, from:, to:, country:)
26
+ data = DataLoader.load_cpi(country)
27
+ from_index, from_gran = lookup_index(data, from)
28
+ to_index, to_gran = lookup_index(data, to)
29
+
30
+ ratio = to_index.to_f / from_index.to_f
31
+ InflationResult.new(
32
+ amount: amount.to_f * ratio,
33
+ original_amount: amount.to_f,
34
+ from: from,
35
+ to: to,
36
+ country: country.to_s.upcase,
37
+ from_index: from_index,
38
+ to_index: to_index,
39
+ granularity: merge_granularity(from_gran, to_gran)
40
+ )
41
+ end
42
+
43
+ # Inflation rate as decimal (e.g. 0.42 = 42%).
44
+ def rate(from:, to:, country:)
45
+ result = adjust(amount: 1.0, from: from, to: to, country: country)
46
+ result.amount - 1.0
47
+ end
48
+
49
+ # Returns [index_value, granularity_symbol]
50
+ def lookup_index(data, key)
51
+ key = key.to_s
52
+ monthly = data["monthly"] || {}
53
+ annual = data["annual"] || {}
54
+
55
+ case key
56
+ when /\A\d{4}-\d{2}\z/
57
+ if monthly.key?(key)
58
+ [monthly[key], :monthly]
59
+ else
60
+ year = key[0, 4]
61
+ if annual.key?(year)
62
+ [annual[year], :annual]
63
+ else
64
+ raise DataNotFound, "No CPI data for #{key.inspect} in #{data["country"]}"
65
+ end
66
+ end
67
+ when /\A\d{4}\z/
68
+ if annual.key?(key)
69
+ [annual[key], :annual]
70
+ else
71
+ months = monthly.select { |k, _| k.start_with?("#{key}-") }
72
+ raise DataNotFound, "No CPI data for #{key.inspect} in #{data["country"]}" if months.empty?
73
+ avg = months.values.sum.to_f / months.size
74
+ [avg, :annual_from_monthly_avg]
75
+ end
76
+ else
77
+ raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
78
+ end
79
+ end
80
+
81
+ # If either end fell back to annual_from_monthly_avg, propagate that label;
82
+ # else if either is annual, propagate :annual; else :monthly.
83
+ def merge_granularity(a, b)
84
+ return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
85
+ return :annual if a == :annual || b == :annual
86
+ :monthly
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "data_loader"
4
+
5
+ module Timeprice
6
+ # Enumerate bundled data sources with license/attribution and the actual
7
+ # coverage range derived from data/ at runtime.
8
+ module Sources
9
+ # Static license & attribution metadata. Coverage is computed dynamically.
10
+ ATTRIBUTIONS = [
11
+ {
12
+ id: "us_cpi",
13
+ kind: "cpi",
14
+ country: "US",
15
+ name: "U.S. Bureau of Labor Statistics — CPI-U (series CUUR0000SA0)",
16
+ license: "U.S. Government work — public domain",
17
+ license_url: "https://www.bls.gov/bls/linksite.htm",
18
+ attribution: "Data: U.S. Bureau of Labor Statistics"
19
+ },
20
+ {
21
+ id: "uk_cpi",
22
+ kind: "cpi",
23
+ country: "UK",
24
+ name: "UK Office for National Statistics — CPI all-items (series D7BT)",
25
+ license: "Open Government Licence v3.0",
26
+ license_url: "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/",
27
+ attribution: "Contains public sector information licensed under the Open Government Licence v3.0"
28
+ },
29
+ {
30
+ id: "eu_hicp",
31
+ kind: "cpi",
32
+ country: "EU",
33
+ name: "Eurostat — HICP prc_hicp_midx (Euro area, all items)",
34
+ license: "Eurostat reuse policy (free reuse with attribution)",
35
+ license_url: "https://ec.europa.eu/eurostat/about-us/policies/copyright",
36
+ attribution: "Source: Eurostat"
37
+ },
38
+ {
39
+ id: "jp_cpi",
40
+ kind: "cpi",
41
+ country: "JP",
42
+ name: "World Bank — FP.CPI.TOTL (annual, JP fallback)",
43
+ license: "CC BY 4.0",
44
+ license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
45
+ attribution: "Source: World Bank, FP.CPI.TOTL"
46
+ },
47
+ {
48
+ id: "vn_cpi",
49
+ kind: "cpi",
50
+ country: "VN",
51
+ name: "World Bank — FP.CPI.TOTL (annual)",
52
+ license: "CC BY 4.0",
53
+ license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
54
+ attribution: "Source: World Bank, FP.CPI.TOTL"
55
+ },
56
+ {
57
+ id: "fx_ecb",
58
+ kind: "fx",
59
+ country: nil,
60
+ name: "European Central Bank reference rates (via Frankfurter)",
61
+ license: "ECB reference rates — free reuse",
62
+ license_url: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html",
63
+ attribution: "FX data: European Central Bank reference rates via Frankfurter"
64
+ },
65
+ {
66
+ id: "fx_vnd",
67
+ kind: "fx",
68
+ country: "VN",
69
+ name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
70
+ license: "CC BY 4.0",
71
+ license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
72
+ attribution: "VND FX: World Bank, PA.NUS.FCRF"
73
+ }
74
+ ].freeze
75
+
76
+ module_function
77
+
78
+ # Returns an array of hashes with :id, :kind, :name, :license, :license_url,
79
+ # :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
80
+ def list
81
+ ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
82
+ end
83
+
84
+ def coverage_for(src)
85
+ case src[:kind]
86
+ when "cpi" then cpi_coverage(src[:country])
87
+ when "fx" then fx_coverage(src[:id])
88
+ else "n/a"
89
+ end
90
+ rescue StandardError => e
91
+ "(coverage unavailable: #{e.message})"
92
+ end
93
+
94
+ def cpi_coverage(country)
95
+ data = DataLoader.load_cpi(country)
96
+ monthly = (data["monthly"] || {}).keys.sort
97
+ annual = (data["annual"] || {}).keys.sort
98
+ parts = []
99
+ parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
100
+ parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
101
+ parts.join(", ")
102
+ end
103
+
104
+ def fx_coverage(id)
105
+ root = File.join(DataLoader.data_root, "fx", "usd")
106
+ years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
107
+ return "no data" if years.empty?
108
+ case id
109
+ when "fx_vnd"
110
+ # VND broadcast-from-annual covers earlier years too.
111
+ with_vnd = years.select do |y|
112
+ d = JSON.parse(File.read(File.join(root, "#{y}.json")))
113
+ d["rates"].any? { |_, v| v.key?("VND") }
114
+ end
115
+ return "no VND data" if with_vnd.empty?
116
+ "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
117
+ else
118
+ # ECB pairs (EUR/GBP/JPY) start 1999
119
+ ecb_years = years.select do |y|
120
+ d = JSON.parse(File.read(File.join(root, "#{y}.json")))
121
+ d["rates"].any? { |_, v| (v.keys & %w[EUR GBP JPY]).any? }
122
+ end
123
+ return "no ECB data" if ecb_years.empty?
124
+ "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ VERSION = "0.1.0"
5
+ end
data/lib/timeprice.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "timeprice/version"
4
+ require_relative "timeprice/errors"
5
+ require_relative "timeprice/data_loader"
6
+ require_relative "timeprice/inflation"
7
+ require_relative "timeprice/exchange"
8
+ require_relative "timeprice/compare"
9
+ require_relative "timeprice/sources"
10
+
11
+ module Timeprice
12
+ module_function
13
+
14
+ def inflation(amount:, from:, to:, country:)
15
+ Inflation.adjust(amount: amount, from: from, to: to, country: country)
16
+ end
17
+
18
+ def exchange(amount:, from:, to:, date:)
19
+ Exchange.convert(amount: amount, from: from, to: to, date: date)
20
+ end
21
+
22
+ def compare(amount:, from:, to:)
23
+ Compare.run(amount: amount, from: from, to: to)
24
+ end
25
+ end