timeprice 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/DATA_LICENSES.md +16 -1
  4. data/README.md +46 -7
  5. data/data/cpi/au.json +419 -0
  6. data/data/cpi/ca.json +1501 -0
  7. data/data/cpi/cn.json +487 -0
  8. data/data/cpi/eu.json +2 -2
  9. data/data/cpi/jp.json +2 -2
  10. data/data/cpi/kr.json +549 -0
  11. data/data/cpi/ru.json +487 -0
  12. data/data/cpi/uk.json +2 -2
  13. data/data/cpi/us.json +2 -2
  14. data/data/cpi/vn.json +27 -27
  15. data/data/fx/usd/1999.json +1043 -263
  16. data/data/fx/usd/2000.json +1275 -259
  17. data/data/fx/usd/2001.json +1278 -258
  18. data/data/fx/usd/2002.json +1283 -259
  19. data/data/fx/usd/2003.json +1283 -259
  20. data/data/fx/usd/2004.json +1303 -263
  21. data/data/fx/usd/2005.json +1293 -261
  22. data/data/fx/usd/2006.json +1283 -259
  23. data/data/fx/usd/2007.json +1283 -259
  24. data/data/fx/usd/2008.json +1288 -260
  25. data/data/fx/usd/2009.json +1288 -260
  26. data/data/fx/usd/2010.json +1298 -262
  27. data/data/fx/usd/2011.json +1293 -261
  28. data/data/fx/usd/2012.json +1288 -260
  29. data/data/fx/usd/2013.json +1283 -259
  30. data/data/fx/usd/2014.json +1283 -259
  31. data/data/fx/usd/2015.json +1288 -260
  32. data/data/fx/usd/2016.json +1293 -261
  33. data/data/fx/usd/2017.json +1283 -259
  34. data/data/fx/usd/2018.json +1283 -259
  35. data/data/fx/usd/2019.json +1283 -259
  36. data/data/fx/usd/2020.json +1293 -261
  37. data/data/fx/usd/2021.json +1298 -262
  38. data/data/fx/usd/2022.json +1293 -261
  39. data/data/fx/usd/2023.json +1283 -259
  40. data/data/fx/usd/2024.json +1288 -260
  41. data/data/fx/usd/2025.json +1283 -259
  42. data/data/fx/usd/2026.json +458 -93
  43. data/data/fx/usd/_annual.json +47 -2
  44. data/data/manifest.json +156 -8
  45. data/lib/timeprice/cli.rb +6 -6
  46. data/lib/timeprice/compare.rb +36 -3
  47. data/lib/timeprice/cpi_lookup.rb +64 -18
  48. data/lib/timeprice/data_loader.rb +8 -13
  49. data/lib/timeprice/date.rb +62 -0
  50. data/lib/timeprice/exchange.rb +49 -23
  51. data/lib/timeprice/granularity.rb +41 -10
  52. data/lib/timeprice/inflation.rb +15 -7
  53. data/lib/timeprice/metadata.rb +121 -0
  54. data/lib/timeprice/metadata_snapshot.rb +23 -0
  55. data/lib/timeprice/point.rb +11 -3
  56. data/lib/timeprice/schema.rb +78 -0
  57. data/lib/timeprice/supported.rb +1 -1
  58. data/lib/timeprice/version.rb +1 -1
  59. data/lib/timeprice.rb +14 -1
  60. metadata +24 -1
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Timeprice
6
+ # Raised when a user-supplied date string can't be parsed into a
7
+ # {Timeprice::Date} value.
8
+ class InvalidDate < Error; end
9
+
10
+ # Immutable value object representing "a date at some granularity": a
11
+ # year, a year+month, a year+quarter, or a full calendar day. Used as
12
+ # the canonical input shape for the public API (`Timeprice.inflation`,
13
+ # `Timeprice.exchange`, `Timeprice.compare`) — strings are accepted for
14
+ # convenience and coerced via {.coerce} at the boundary.
15
+ # rubocop:disable Lint/ConstantDefinitionInBlock
16
+ Date = Data.define(:year, :month, :quarter, :day) do
17
+ ANNUAL_RE = /\A(\d{4})\z/
18
+ MONTHLY_RE = /\A(\d{4})-(\d{2})\z/
19
+ QUARTERLY_RE = /\A(\d{4})-Q([1-4])\z/i
20
+ DAILY_RE = /\A(\d{4})-(\d{2})-(\d{2})\z/
21
+
22
+ def self.parse(str)
23
+ case str.to_s
24
+ when DAILY_RE
25
+ new(year: ::Regexp.last_match(1).to_i, month: ::Regexp.last_match(2).to_i,
26
+ quarter: nil, day: ::Regexp.last_match(3).to_i)
27
+ when QUARTERLY_RE
28
+ new(year: ::Regexp.last_match(1).to_i, month: nil,
29
+ quarter: ::Regexp.last_match(2).to_i, day: nil)
30
+ when MONTHLY_RE
31
+ new(year: ::Regexp.last_match(1).to_i, month: ::Regexp.last_match(2).to_i,
32
+ quarter: nil, day: nil)
33
+ when ANNUAL_RE
34
+ new(year: ::Regexp.last_match(1).to_i, month: nil, quarter: nil, day: nil)
35
+ else
36
+ fail InvalidDate, "Cannot parse #{str.inspect} as a Timeprice::Date"
37
+ end
38
+ end
39
+
40
+ def self.coerce(input)
41
+ input.is_a?(self) ? input : parse(input)
42
+ end
43
+
44
+ def granularity
45
+ return :daily if day
46
+ return :monthly if month
47
+ return :quarterly if quarter
48
+
49
+ :annual
50
+ end
51
+
52
+ def to_s
53
+ case granularity
54
+ when :daily then format("%04d-%02d-%02d", year, month, day)
55
+ when :monthly then format("%04d-%02d", year, month)
56
+ when :quarterly then format("%04d-Q%d", year, quarter)
57
+ else format("%04d", year)
58
+ end
59
+ end
60
+ end
61
+ # rubocop:enable Lint/ConstantDefinitionInBlock
62
+ end
@@ -5,6 +5,7 @@ require_relative "errors"
5
5
  require_relative "data_loader"
6
6
  require_relative "supported"
7
7
  require_relative "granularity"
8
+ require_relative "date"
8
9
 
9
10
  module Timeprice
10
11
  ExchangeResult = Data.define(
@@ -15,6 +16,11 @@ module Timeprice
15
16
  # Handles identity (USD→USD), direct lookup, inverse, and triangulation
16
17
  # through USD. Weekend/holiday dates fall back up to {MAX_FALLBACK_DAYS}
17
18
  # days to the nearest prior trading day.
19
+ #
20
+ # @api private
21
+ # The supported public entry point is {Timeprice.exchange}. Direct
22
+ # references will move to `Timeprice::Internal::Exchange` in a future
23
+ # release.
18
24
  module Exchange
19
25
  BASE = "USD"
20
26
  MAX_FALLBACK_DAYS = 7
@@ -33,8 +39,8 @@ module Timeprice
33
39
  def convert(amount:, from:, to:, date:)
34
40
  from = from.to_s.upcase
35
41
  to = to.to_s.upcase
36
- raise UnsupportedCurrency, from unless Supported.currency?(from)
37
- raise UnsupportedCurrency, to unless Supported.currency?(to)
42
+ fail UnsupportedCurrency, from unless Supported.currency?(from)
43
+ fail UnsupportedCurrency, to unless Supported.currency?(to)
38
44
 
39
45
  d = parse_date(date)
40
46
 
@@ -69,18 +75,32 @@ module Timeprice
69
75
  rate, eff, gran = lookup_usd_base(from, d)
70
76
  [1.0 / rate, eff, gran]
71
77
  else
72
- # Triangulation: from → USD → to, both legs at the same effective date.
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)
75
- if eff_a != eff_b
76
- raise DataNotFound,
77
- "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
78
- "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
79
- end
80
- [usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
78
+ # Triangulation: from → USD → to. Daily legs must agree on the
79
+ # effective date; an annual leg is valid for any date in its year, so
80
+ # we adopt the daily leg's date and let Granularity.merge demote.
81
+ rate_a, *leg_a = lookup_usd_base(from, d)
82
+ rate_b, *leg_b = lookup_usd_base(to, d)
83
+ eff = reconcile_triangulation_dates(from, to, d, leg_a, leg_b)
84
+ [rate_b / rate_a, eff, Granularity.merge(leg_a[1], leg_b[1])]
81
85
  end
82
86
  end
83
87
 
88
+ # Pick a single effective date for a triangulated rate. Daily legs must
89
+ # agree; an annual leg is year-wide so it adopts the daily leg's date.
90
+ # When both legs are annual we fall back to the requested date.
91
+ def reconcile_triangulation_dates(from, to, d, leg_a, leg_b)
92
+ eff_a, gran_a = leg_a
93
+ eff_b, gran_b = leg_b
94
+ return eff_a if eff_a == eff_b
95
+ return d if gran_a == Granularity::ANNUAL && gran_b == Granularity::ANNUAL
96
+ return eff_b if gran_a == Granularity::ANNUAL
97
+ return eff_a if gran_b == Granularity::ANNUAL
98
+
99
+ fail DataNotFound,
100
+ "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
101
+ "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
102
+ end
103
+
84
104
  # Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
85
105
  # back to data/fx/usd/_annual.json (the single source of annual FX truth).
86
106
  # Returns [rate, effective_date, granularity].
@@ -105,7 +125,7 @@ module Timeprice
105
125
  annual_rate = annual_fallback(currency, d.year)
106
126
  return [annual_rate, d, Granularity::ANNUAL] if annual_rate
107
127
 
108
- raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
128
+ fail DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
109
129
  end
110
130
 
111
131
  # Consult data/fx/usd/_annual.json. Returns Float or nil.
@@ -118,20 +138,26 @@ module Timeprice
118
138
 
119
139
  def parse_date(date)
120
140
  case date
121
- when Date then date
141
+ when ::Date
142
+ date
143
+ when Timeprice::Date
144
+ require_daily!(date)
145
+ ::Date.new(date.year, date.month, date.day)
122
146
  when String
123
- unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
124
- raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
125
- end
126
-
127
- begin
128
- Date.parse(date)
129
- rescue Date::Error
130
- raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
131
- end
147
+ parsed = Timeprice::Date.coerce(date)
148
+ require_daily!(parsed)
149
+ ::Date.new(parsed.year, parsed.month, parsed.day)
132
150
  else
133
- raise ArgumentError, "Invalid date: #{date.inspect}"
151
+ fail ArgumentError, "Invalid date: #{date.inspect}"
134
152
  end
153
+ rescue ::Date::Error
154
+ raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
155
+ end
156
+
157
+ def require_daily!(date)
158
+ return if date.granularity == :daily
159
+
160
+ fail ArgumentError, "Invalid date: Exchange needs YYYY-MM-DD, got #{date}"
135
161
  end
136
162
  end
137
163
  end
@@ -4,20 +4,44 @@ module Timeprice
4
4
  # Closed set of CPI-resolution granularities and the rules for combining /
5
5
  # rendering them. Owns the lattice so callers don't hand-maintain it.
6
6
  module Granularity
7
- DAILY = :daily
8
- MONTHLY = :monthly
9
- ANNUAL = :annual
10
- ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
11
- MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
7
+ DAILY = :daily
8
+ MONTHLY = :monthly
9
+ QUARTERLY = :quarterly
10
+ ANNUAL = :annual
11
+ ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
12
+ ANNUAL_FROM_QUARTERLY_AVG = :annual_from_quarterly_avg
13
+ ANNUAL_FROM_PARTIAL_MONTHS = :annual_from_partial_months
14
+ ANNUAL_FROM_PARTIAL_QUARTERS = :annual_from_partial_quarters
15
+ QUARTERLY_FROM_ANNUAL_FALLBACK = :quarterly_from_annual_fallback
16
+ QUARTERLY_FROM_MONTHLY_AVG = :quarterly_from_monthly_avg
17
+ MONTHLY_FROM_QUARTERLY_FALLBACK = :monthly_from_quarterly_fallback
18
+ MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
12
19
 
13
- # Most-degraded first — `merge` returns the first match.
14
- # DAILY is the highest-precision FX tag; MONTHLY is the highest-precision
15
- # CPI tag. Compare uses merge() across both legs, so the most-degraded
16
- # tag in either leg wins.
20
+ # Most-degraded first — `merge` returns the first match. DAILY is the
21
+ # highest-precision FX tag; MONTHLY is the highest-precision CPI tag.
22
+ # Compare uses merge() across both legs, so the most-degraded tag in
23
+ # either leg wins.
24
+ #
25
+ # Ordering rationale (worst → best):
26
+ # 1. Cross-grain fallbacks where the asked resolution is finer than
27
+ # what's available (annual stretched to month/quarter).
28
+ # 2. Partial-period averages — asked annual but only some months/
29
+ # quarters in the year are populated. Highly biased by seasonality.
30
+ # 3. Same-or-coarser fallback (quarter stretched to month).
31
+ # 4. Full-period derived averages (complete 4-quarter or 12-month mean
32
+ # standing in for the asked coarser resolution).
33
+ # 5. Native series at the asked resolution.
17
34
  PRECEDENCE = [
18
35
  MONTHLY_FROM_ANNUAL_FALLBACK,
36
+ ANNUAL_FROM_PARTIAL_QUARTERS,
37
+ ANNUAL_FROM_PARTIAL_MONTHS,
38
+ QUARTERLY_FROM_ANNUAL_FALLBACK,
39
+ MONTHLY_FROM_QUARTERLY_FALLBACK,
40
+ ANNUAL_FROM_QUARTERLY_AVG,
41
+ QUARTERLY_FROM_MONTHLY_AVG,
19
42
  ANNUAL_FROM_MONTHLY_AVG,
20
43
  ANNUAL,
44
+ QUARTERLY,
21
45
  MONTHLY,
22
46
  DAILY,
23
47
  ].freeze
@@ -25,9 +49,16 @@ module Timeprice
25
49
  HUMAN_LABELS = {
26
50
  DAILY => "daily",
27
51
  MONTHLY => "monthly",
52
+ QUARTERLY => "quarterly",
28
53
  ANNUAL => "annual",
29
54
  ANNUAL_FROM_MONTHLY_AVG => "annual (avg of months)",
30
- MONTHLY_FROM_ANNUAL_FALLBACK => "annual (month unavailable)",
55
+ ANNUAL_FROM_QUARTERLY_AVG => "annual (avg of quarters)",
56
+ ANNUAL_FROM_PARTIAL_MONTHS => "annual (partial-year, avg of available months)",
57
+ ANNUAL_FROM_PARTIAL_QUARTERS => "annual (partial-year, avg of available quarters)",
58
+ QUARTERLY_FROM_ANNUAL_FALLBACK => "quarter (annual fallback)",
59
+ QUARTERLY_FROM_MONTHLY_AVG => "quarter (avg of months)",
60
+ MONTHLY_FROM_QUARTERLY_FALLBACK => "month (quarter unavailable)",
61
+ MONTHLY_FROM_ANNUAL_FALLBACK => "month (annual fallback)",
31
62
  }.freeze
32
63
 
33
64
  module_function
@@ -4,6 +4,7 @@ require_relative "errors"
4
4
  require_relative "data_loader"
5
5
  require_relative "cpi_lookup"
6
6
  require_relative "granularity"
7
+ require_relative "date"
7
8
 
8
9
  module Timeprice
9
10
  # Value object returned by Inflation.adjust. See {Granularity} for the set
@@ -22,31 +23,38 @@ module Timeprice
22
23
  end
23
24
 
24
25
  # CPI-based inflation adjustment for the {Supported.countries} list.
26
+ #
27
+ # @api private
28
+ # The supported public entry point is {Timeprice.inflation}. Direct
29
+ # references to this module will move to `Timeprice::Internal::Inflation`
30
+ # in a future release.
25
31
  module Inflation
26
32
  module_function
27
33
 
28
34
  # Adjust `amount` from date `from` to date `to` using country CPI.
29
35
  #
30
- # Dates accept "YYYY" or "YYYY-MM".
36
+ # Dates accept "YYYY", "YYYY-MM", or "YYYY-Qn" (Q1..Q4).
31
37
  #
32
38
  # @param amount [Numeric]
33
- # @param from [String] source date ("YYYY" or "YYYY-MM")
34
- # @param to [String] target date ("YYYY" or "YYYY-MM")
39
+ # @param from [String] source date ("YYYY", "YYYY-MM", or "YYYY-Qn")
40
+ # @param to [String] target date ("YYYY", "YYYY-MM", or "YYYY-Qn")
35
41
  # @param country [String] country code (see {Supported.countries})
36
42
  # @return [InflationResult]
37
43
  # @raise [UnsupportedCountry] if `country` is not supported
38
44
  # @raise [DataNotFound] if no CPI data covers the requested period
39
45
  def adjust(amount:, from:, to:, country:)
46
+ from = Timeprice::Date.coerce(from)
47
+ to = Timeprice::Date.coerce(to)
40
48
  lookup = CpiLookup.new(DataLoader.load_cpi(country))
41
- from_point = lookup.at(from)
42
- to_point = lookup.at(to)
49
+ from_point = lookup.at(from.to_s)
50
+ to_point = lookup.at(to.to_s)
43
51
 
44
52
  ratio = to_point.value.to_f / from_point.value
45
53
  InflationResult.new(
46
54
  amount: amount.to_f * ratio,
47
55
  original_amount: amount.to_f,
48
- from: from,
49
- to: to,
56
+ from: from.to_s,
57
+ to: to.to_s,
50
58
  country: country.to_s.upcase,
51
59
  from_index: from_point.value,
52
60
  to_index: to_point.value,
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "data_loader"
4
+ require_relative "supported"
5
+ require_relative "version"
6
+ require_relative "metadata_snapshot"
7
+
8
+ module Timeprice
9
+ # Describes the bundled dataset so external surfaces (the website, other
10
+ # tools) can render dropdowns, date pickers, and version pills without
11
+ # hardcoding country lists, currency lists, or date ranges.
12
+ #
13
+ # See {Timeprice.metadata} for the public entry point.
14
+ #
15
+ # @api private
16
+ # Direct references will move to `Timeprice::Internal::Metadata` in a
17
+ # future release.
18
+ module Metadata
19
+ # ISO 3166-style display names for the countries shipped today.
20
+ COUNTRY_NAMES = {
21
+ "AU" => "Australia",
22
+ "CA" => "Canada",
23
+ "CN" => "China",
24
+ "EU" => "Eurozone",
25
+ "JP" => "Japan",
26
+ "KR" => "South Korea",
27
+ "RU" => "Russia",
28
+ "UK" => "United Kingdom",
29
+ "US" => "United States",
30
+ "VN" => "Vietnam",
31
+ }.freeze
32
+
33
+ # ISO 4217 display names for the currencies shipped today.
34
+ CURRENCY_NAMES = {
35
+ "AUD" => "Australian dollar",
36
+ "CAD" => "Canadian dollar",
37
+ "CNY" => "Chinese yuan",
38
+ "EUR" => "Euro",
39
+ "GBP" => "British pound",
40
+ "JPY" => "Japanese yen",
41
+ "KRW" => "South Korean won",
42
+ "RUB" => "Russian ruble",
43
+ "USD" => "US dollar",
44
+ "VND" => "Vietnamese dong",
45
+ }.freeze
46
+
47
+ module_function
48
+
49
+ # Build the metadata snapshot.
50
+ # @return [MetadataSnapshot]
51
+ def build
52
+ manifest = DataLoader.load_manifest
53
+ countries = (manifest["countries"] || []).map { |c| country_entry(c) }
54
+ currencies = Supported.currencies.map { |code| { code: code, name: CURRENCY_NAMES[code] || code } }
55
+ MetadataSnapshot.new(
56
+ version: VERSION,
57
+ generated_at: manifest["generated_at"],
58
+ countries: deep_freeze(countries),
59
+ currencies: deep_freeze(currencies),
60
+ fx: deep_freeze(fx_entry(manifest))
61
+ )
62
+ end
63
+
64
+ # Range info comes from the manifest (`cpi_ranges`), pre-computed at
65
+ # manifest generation time. Falls back to walking the CPI file for any
66
+ # country missing the field — older manifests, or local data roots
67
+ # produced by hand.
68
+ def country_entry(country)
69
+ code = country["code"]
70
+ ranges = country["cpi_ranges"] || derive_cpi_ranges(code)
71
+ per_granularity = ranges.each_with_object({}) do |(gran, range), acc|
72
+ acc[gran.to_sym] = { min: range["min"], max: range["max"] }
73
+ end
74
+ {
75
+ code: code,
76
+ name: COUNTRY_NAMES[code] || code,
77
+ currency: country["currency"],
78
+ granularities: country["granularities"] || per_granularity.keys.map(&:to_s),
79
+ cpi: per_granularity,
80
+ }
81
+ end
82
+
83
+ def derive_cpi_ranges(code)
84
+ cpi = DataLoader.load_cpi(code)
85
+ series = cpi["series"] || {}
86
+ series.each_with_object({}) do |(granularity, points), acc|
87
+ next unless points.is_a?(Hash) && !points.empty?
88
+
89
+ keys = points.keys.sort
90
+ acc[granularity] = { "min" => keys.first, "max" => keys.last }
91
+ end
92
+ end
93
+
94
+ # Bounds come from the manifest (`fx.daily_min`/`fx.daily_max`). Older
95
+ # manifests without those keys: peek at the earliest/latest year files.
96
+ def fx_entry(manifest)
97
+ fx = manifest["fx"] || {}
98
+ base = fx["base"]
99
+ years = fx["daily_years"] || []
100
+ return { base: base, daily_min: nil, daily_max: nil } if years.empty?
101
+
102
+ daily_min = fx["daily_min"]
103
+ daily_max = fx["daily_max"]
104
+ if daily_min.nil? || daily_max.nil?
105
+ first = DataLoader.load_fx_year(years.min)
106
+ last = DataLoader.load_fx_year(years.max)
107
+ daily_min ||= (first["rates"] || {}).keys.min
108
+ daily_max ||= (last["rates"] || {}).keys.max
109
+ end
110
+ { base: base, daily_min: daily_min, daily_max: daily_max }
111
+ end
112
+
113
+ def deep_freeze(value)
114
+ case value
115
+ when Hash then value.each_value { |v| deep_freeze(v) }.freeze
116
+ when Array then value.each { |v| deep_freeze(v) }.freeze
117
+ else value.frozen? ? value : value.freeze
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Timeprice
6
+ # Frozen value object describing the bundled dataset: version, refresh
7
+ # date, country list with CPI ranges, currency list with display names,
8
+ # and FX coverage. Replaces the previous Hash return shape on
9
+ # {Timeprice.metadata}.
10
+ #
11
+ # `[]`, `to_h`, and `to_json` are kept compatible with the old Hash
12
+ # interface so downstream consumers (the website, this gem's specs)
13
+ # don't need a coordinated rewrite.
14
+ MetadataSnapshot = Data.define(:version, :generated_at, :countries, :currencies, :fx) do
15
+ def [](key)
16
+ to_h[key]
17
+ end
18
+
19
+ def to_json(*args)
20
+ to_h.to_json(*args)
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "date"
4
+
3
5
  module Timeprice
4
6
  # A (currency, date) pair used as input to {Timeprice.compare}.
5
7
  #
@@ -13,6 +15,12 @@ module Timeprice
13
15
  # Timeprice::Point.coerce(["USD", "2010"])
14
16
  # Timeprice::Point.coerce(["2010", "USD"])
15
17
  Point = Data.define(:currency, :date) do
18
+ # Canonical constructor. Accepts a stdlib-string or Timeprice::Date
19
+ # for the date argument; stores the canonical string form.
20
+ def self.parse(currency, date)
21
+ new(currency: currency.to_s.upcase, date: Timeprice::Date.coerce(date).to_s)
22
+ end
23
+
16
24
  # Coerce input into a Point. Accepts:
17
25
  # - {Point} (returned as-is)
18
26
  # - 2-element Array of [currency, date] in either order
@@ -28,11 +36,11 @@ module Timeprice
28
36
  a, b = input.map(&:to_s)
29
37
  currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
30
38
  date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
31
- raise ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
39
+ fail ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
32
40
 
33
41
  new(currency: currency.upcase, date: date)
34
42
  else
35
- raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
43
+ fail ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
36
44
  end
37
45
  end
38
46
 
@@ -55,7 +63,7 @@ module Timeprice
55
63
  when /\A\d{4}\z/ then "#{date}-06-30"
56
64
  when /\A\d{4}-\d{2}\z/ then "#{date}-15"
57
65
  when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
58
- else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
66
+ else fail ArgumentError, "Invalid date for Point: #{date.inspect}"
59
67
  end
60
68
  end
61
69
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Timeprice
6
+ # Single source of truth for the on-disk v4 CPI/manifest format. Both the
7
+ # reader ({DataLoader}) and the writer (today: pipeline `CountryFile`)
8
+ # route through here so the schema lives in exactly one place.
9
+ module Schema
10
+ CURRENT_VERSION = 4
11
+ SUPPORTED_VERSIONS = [3, 4].freeze
12
+
13
+ KEY_SCHEMA_VERSION = "schema_version"
14
+ KEY_COUNTRY = "country"
15
+ KEY_INDEX = "index"
16
+ KEY_SERIES = "series"
17
+ KEY_PROVENANCE = "provenance"
18
+ KEY_PROVIDERS = "providers"
19
+
20
+ GRANULARITIES = %i[monthly quarterly annual].freeze
21
+
22
+ BASE_YEAR_RE = /\A(?<period>.+?)=100(?:\s*\(rebased\s+(?<rebased>\d{4}-\d{2}-\d{2})\))?\z/
23
+
24
+ module_function
25
+
26
+ def supported?(version)
27
+ SUPPORTED_VERSIONS.include?(version)
28
+ end
29
+
30
+ def assert_supported!(version, path)
31
+ return if supported?(version)
32
+
33
+ fail UnsupportedSchemaVersion.new(version, path)
34
+ end
35
+
36
+ # Build a CPI payload ready for JSON.dump. Series keys are emitted in a
37
+ # stable order (annual, monthly[, quarterly]) so file diffs stay tight.
38
+ def dump_cpi(country:, base_year:, monthly:, annual:, providers:, provenance:, quarterly: {})
39
+ series = { "annual" => annual, "monthly" => monthly }
40
+ series["quarterly"] = quarterly unless quarterly.empty?
41
+ {
42
+ KEY_SCHEMA_VERSION => CURRENT_VERSION,
43
+ KEY_COUNTRY => country.to_s.upcase,
44
+ KEY_INDEX => serialise_base_year(base_year),
45
+ KEY_SERIES => series,
46
+ KEY_PROVENANCE => provenance,
47
+ KEY_PROVIDERS => providers,
48
+ }
49
+ end
50
+
51
+ # Validate a parsed payload (read from disk) against the schema, then
52
+ # return it unchanged. Raises UnsupportedSchemaVersion if the version
53
+ # field is missing or unknown.
54
+ def load_cpi(parsed, path:)
55
+ assert_supported!(parsed[KEY_SCHEMA_VERSION], path)
56
+ parsed
57
+ end
58
+
59
+ def serialise_base_year(str)
60
+ m = BASE_YEAR_RE.match(str.to_s)
61
+ if m
62
+ { "base_period" => m[:period], "rebased_at" => m[:rebased] }
63
+ else
64
+ { "base_period" => str.to_s, "rebased_at" => nil }
65
+ end
66
+ end
67
+
68
+ def deserialise_base_year(index)
69
+ return nil unless index.is_a?(Hash)
70
+
71
+ period = index["base_period"]
72
+ rebased = index["rebased_at"]
73
+ return nil if period.nil?
74
+
75
+ rebased ? "#{period}=100 (rebased #{rebased})" : "#{period}=100"
76
+ end
77
+ end
78
+ end
@@ -10,7 +10,7 @@ module Timeprice
10
10
  module Supported
11
11
  # Currencies with no minor unit — formatted as whole numbers. This is
12
12
  # ISO 4217 metadata, not bundled data, so it stays hardcoded.
13
- ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
13
+ ZERO_DECIMAL_CURRENCIES = %w[JPY KRW VND].freeze
14
14
 
15
15
  module_function
16
16
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/timeprice.rb CHANGED
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "timeprice/version"
4
+ require_relative "timeprice/errors"
5
+ require_relative "timeprice/schema"
6
+ require_relative "timeprice/date"
4
7
  require_relative "timeprice/data_loader"
5
8
  require_relative "timeprice/supported"
6
- require_relative "timeprice/errors"
7
9
  require_relative "timeprice/point"
8
10
  require_relative "timeprice/inflation"
9
11
  require_relative "timeprice/exchange"
10
12
  require_relative "timeprice/compare"
11
13
  require_relative "timeprice/sources"
14
+ require_relative "timeprice/metadata"
12
15
 
13
16
  # Offline historical inflation & FX for Ruby.
14
17
  #
@@ -62,4 +65,14 @@ module Timeprice
62
65
  def compare(amount:, from:, to:)
63
66
  Compare.run(amount: amount, from: from, to: to)
64
67
  end
68
+
69
+ # Snapshot describing the bundled dataset: version, refresh date, country
70
+ # list with CPI ranges, currency list with display names, and FX coverage.
71
+ # Intended as the single source of truth for downstream UIs (the website
72
+ # in particular) so dropdowns and date pickers never drift from the data.
73
+ #
74
+ # @return [Hash] frozen, JSON-serialisable
75
+ def metadata
76
+ Metadata.build
77
+ end
65
78
  end