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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/DATA_LICENSES.md +2 -1
- data/README.md +20 -4
- data/data/cpi/eu.json +422 -397
- data/data/cpi/jp.json +91 -72
- data/data/cpi/uk.json +529 -504
- data/data/cpi/us.json +507 -476
- data/data/cpi/vn.json +368 -37
- data/data/fx/usd/1999.json +281 -524
- data/data/fx/usd/2000.json +277 -516
- data/data/fx/usd/2001.json +276 -511
- data/data/fx/usd/2002.json +277 -513
- data/data/fx/usd/2003.json +277 -513
- data/data/fx/usd/2004.json +281 -521
- data/data/fx/usd/2005.json +279 -520
- data/data/fx/usd/2006.json +277 -513
- data/data/fx/usd/2007.json +277 -513
- data/data/fx/usd/2008.json +278 -515
- data/data/fx/usd/2009.json +278 -515
- data/data/fx/usd/2010.json +280 -522
- data/data/fx/usd/2011.json +279 -520
- data/data/fx/usd/2012.json +278 -515
- data/data/fx/usd/2013.json +277 -513
- data/data/fx/usd/2014.json +277 -513
- data/data/fx/usd/2015.json +278 -515
- data/data/fx/usd/2016.json +279 -520
- data/data/fx/usd/2017.json +277 -513
- data/data/fx/usd/2018.json +277 -513
- data/data/fx/usd/2019.json +277 -513
- data/data/fx/usd/2020.json +279 -517
- data/data/fx/usd/2021.json +280 -522
- data/data/fx/usd/2022.json +279 -520
- data/data/fx/usd/2023.json +277 -513
- data/data/fx/usd/2024.json +278 -515
- data/data/fx/usd/2025.json +22 -3
- data/data/fx/usd/2026.json +22 -3
- data/data/fx/usd/_annual.json +145 -0
- data/data/manifest.json +90 -0
- data/lib/timeprice/cli/presenters/compare.rb +3 -1
- data/lib/timeprice/cli/presenters/inflation.rb +2 -1
- data/lib/timeprice/compare.rb +3 -2
- data/lib/timeprice/cpi_lookup.rb +9 -7
- data/lib/timeprice/data_loader.rb +42 -7
- data/lib/timeprice/errors.rb +4 -4
- data/lib/timeprice/exchange.rb +34 -17
- data/lib/timeprice/granularity.rb +46 -0
- data/lib/timeprice/inflation.rb +6 -19
- data/lib/timeprice/sources/coverage.rb +27 -32
- data/lib/timeprice/sources.rb +5 -5
- data/lib/timeprice/supported.rb +39 -22
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +2 -2
- metadata +4 -15
- data/data/fx/usd/1983.json +0 -12
- data/data/fx/usd/1986.json +0 -12
- data/data/fx/usd/1987.json +0 -12
- data/data/fx/usd/1988.json +0 -12
- data/data/fx/usd/1989.json +0 -12
- data/data/fx/usd/1990.json +0 -12
- data/data/fx/usd/1991.json +0 -12
- data/data/fx/usd/1992.json +0 -12
- data/data/fx/usd/1993.json +0 -12
- data/data/fx/usd/1994.json +0 -12
- data/data/fx/usd/1995.json +0 -12
- data/data/fx/usd/1996.json +0 -12
- data/data/fx/usd/1997.json +0 -12
- data/data/fx/usd/1998.json +0 -12
data/data/fx/usd/2025.json
CHANGED
|
@@ -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":
|
|
1281
|
-
"source": "Frankfurter (ECB) — daily reference rates",
|
|
1282
|
-
"updated_at": "2026-05-11",
|
|
1301
|
+
"schema_version": 3,
|
|
1283
1302
|
"year": 2025
|
|
1284
1303
|
}
|
data/data/fx/usd/2026.json
CHANGED
|
@@ -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":
|
|
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
|
+
}
|
data/data/manifest.json
ADDED
|
@@ -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,
|
|
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
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/timeprice/cpi_lookup.rb
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
@annual = data
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 "
|
|
40
|
-
# @raise [UnsupportedCountry] if `country` is not in {Supported
|
|
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
|
|
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
|
|
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)
|
data/lib/timeprice/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
26
|
+
super("Unsupported currency: #{currency.inspect} (supported: #{Supported.currencies.join(", ")})")
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -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
|
|
36
|
-
raise UnsupportedCurrency, to unless Supported
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|