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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/DATA_LICENSES.md +16 -1
- data/README.md +46 -7
- data/data/cpi/au.json +419 -0
- data/data/cpi/ca.json +1501 -0
- data/data/cpi/cn.json +487 -0
- data/data/cpi/eu.json +2 -2
- data/data/cpi/jp.json +2 -2
- data/data/cpi/kr.json +549 -0
- data/data/cpi/ru.json +487 -0
- data/data/cpi/uk.json +2 -2
- data/data/cpi/us.json +2 -2
- data/data/cpi/vn.json +27 -27
- data/data/fx/usd/1999.json +1043 -263
- data/data/fx/usd/2000.json +1275 -259
- data/data/fx/usd/2001.json +1278 -258
- data/data/fx/usd/2002.json +1283 -259
- data/data/fx/usd/2003.json +1283 -259
- data/data/fx/usd/2004.json +1303 -263
- data/data/fx/usd/2005.json +1293 -261
- data/data/fx/usd/2006.json +1283 -259
- data/data/fx/usd/2007.json +1283 -259
- data/data/fx/usd/2008.json +1288 -260
- data/data/fx/usd/2009.json +1288 -260
- data/data/fx/usd/2010.json +1298 -262
- data/data/fx/usd/2011.json +1293 -261
- data/data/fx/usd/2012.json +1288 -260
- data/data/fx/usd/2013.json +1283 -259
- data/data/fx/usd/2014.json +1283 -259
- data/data/fx/usd/2015.json +1288 -260
- data/data/fx/usd/2016.json +1293 -261
- data/data/fx/usd/2017.json +1283 -259
- data/data/fx/usd/2018.json +1283 -259
- data/data/fx/usd/2019.json +1283 -259
- data/data/fx/usd/2020.json +1293 -261
- data/data/fx/usd/2021.json +1298 -262
- data/data/fx/usd/2022.json +1293 -261
- data/data/fx/usd/2023.json +1283 -259
- data/data/fx/usd/2024.json +1288 -260
- data/data/fx/usd/2025.json +1283 -259
- data/data/fx/usd/2026.json +458 -93
- data/data/fx/usd/_annual.json +47 -2
- data/data/manifest.json +156 -8
- data/lib/timeprice/cli.rb +6 -6
- data/lib/timeprice/compare.rb +36 -3
- data/lib/timeprice/cpi_lookup.rb +64 -18
- data/lib/timeprice/data_loader.rb +8 -13
- data/lib/timeprice/date.rb +62 -0
- data/lib/timeprice/exchange.rb +49 -23
- data/lib/timeprice/granularity.rb +41 -10
- data/lib/timeprice/inflation.rb +15 -7
- data/lib/timeprice/metadata.rb +121 -0
- data/lib/timeprice/metadata_snapshot.rb +23 -0
- data/lib/timeprice/point.rb +11 -3
- data/lib/timeprice/schema.rb +78 -0
- data/lib/timeprice/supported.rb +1 -1
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +14 -1
- metadata +24 -1
data/data/fx/usd/_annual.json
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"VND": 11202.19
|
|
26
26
|
},
|
|
27
27
|
"1993": {
|
|
28
|
+
"RUB": 0.9917,
|
|
28
29
|
"VND": 10640.96
|
|
29
30
|
},
|
|
30
31
|
"1994": {
|
|
@@ -34,90 +35,119 @@
|
|
|
34
35
|
"VND": 11038.25
|
|
35
36
|
},
|
|
36
37
|
"1996": {
|
|
38
|
+
"RUB": 5.1208,
|
|
37
39
|
"VND": 11032.58
|
|
38
40
|
},
|
|
39
41
|
"1997": {
|
|
42
|
+
"RUB": 5.7848,
|
|
40
43
|
"VND": 11683.33
|
|
41
44
|
},
|
|
42
45
|
"1998": {
|
|
46
|
+
"RUB": 9.7051,
|
|
43
47
|
"VND": 13268.0
|
|
44
48
|
},
|
|
45
49
|
"1999": {
|
|
50
|
+
"RUB": 24.6199,
|
|
46
51
|
"VND": 13943.17
|
|
47
52
|
},
|
|
48
53
|
"2000": {
|
|
54
|
+
"RUB": 28.1292,
|
|
49
55
|
"VND": 14167.75
|
|
50
56
|
},
|
|
51
57
|
"2001": {
|
|
58
|
+
"RUB": 29.1685,
|
|
52
59
|
"VND": 14725.17
|
|
53
60
|
},
|
|
54
61
|
"2002": {
|
|
62
|
+
"RUB": 31.3485,
|
|
55
63
|
"VND": 15279.5
|
|
56
64
|
},
|
|
57
65
|
"2003": {
|
|
66
|
+
"RUB": 30.692,
|
|
58
67
|
"VND": 15509.58
|
|
59
68
|
},
|
|
60
69
|
"2004": {
|
|
70
|
+
"RUB": 28.8137,
|
|
61
71
|
"VND": 15746.0
|
|
62
72
|
},
|
|
63
73
|
"2005": {
|
|
74
|
+
"RUB": 28.2844,
|
|
64
75
|
"VND": 15858.92
|
|
65
76
|
},
|
|
66
77
|
"2006": {
|
|
78
|
+
"RUB": 27.191,
|
|
67
79
|
"VND": 15994.25
|
|
68
80
|
},
|
|
69
81
|
"2007": {
|
|
82
|
+
"RUB": 25.5808,
|
|
70
83
|
"VND": 16105.13
|
|
71
84
|
},
|
|
72
85
|
"2008": {
|
|
86
|
+
"RUB": 24.8529,
|
|
73
87
|
"VND": 16302.25
|
|
74
88
|
},
|
|
75
89
|
"2009": {
|
|
90
|
+
"RUB": 31.7404,
|
|
76
91
|
"VND": 17065.08
|
|
77
92
|
},
|
|
78
93
|
"2010": {
|
|
94
|
+
"RUB": 30.3679,
|
|
79
95
|
"VND": 18612.92
|
|
80
96
|
},
|
|
81
97
|
"2011": {
|
|
98
|
+
"RUB": 29.3823,
|
|
82
99
|
"VND": 20509.75
|
|
83
100
|
},
|
|
84
101
|
"2012": {
|
|
102
|
+
"RUB": 30.8398,
|
|
85
103
|
"VND": 20828.0
|
|
86
104
|
},
|
|
87
105
|
"2013": {
|
|
106
|
+
"RUB": 31.8371,
|
|
88
107
|
"VND": 20933.42
|
|
89
108
|
},
|
|
90
109
|
"2014": {
|
|
110
|
+
"RUB": 38.3782,
|
|
91
111
|
"VND": 21148.0
|
|
92
112
|
},
|
|
93
113
|
"2015": {
|
|
114
|
+
"RUB": 60.9377,
|
|
94
115
|
"VND": 21697.57
|
|
95
116
|
},
|
|
96
117
|
"2016": {
|
|
118
|
+
"RUB": 67.0559,
|
|
97
119
|
"VND": 21935.0
|
|
98
120
|
},
|
|
99
121
|
"2017": {
|
|
122
|
+
"RUB": 58.3428,
|
|
100
123
|
"VND": 22370.09
|
|
101
124
|
},
|
|
102
125
|
"2018": {
|
|
126
|
+
"RUB": 62.6681,
|
|
103
127
|
"VND": 22602.05
|
|
104
128
|
},
|
|
105
129
|
"2019": {
|
|
130
|
+
"RUB": 64.7377,
|
|
106
131
|
"VND": 23050.24
|
|
107
132
|
},
|
|
108
133
|
"2020": {
|
|
134
|
+
"RUB": 72.1049,
|
|
109
135
|
"VND": 23208.37
|
|
110
136
|
},
|
|
111
137
|
"2021": {
|
|
138
|
+
"RUB": 73.6544,
|
|
112
139
|
"VND": 23159.78
|
|
113
140
|
},
|
|
114
141
|
"2022": {
|
|
142
|
+
"RUB": 68.4849,
|
|
115
143
|
"VND": 23271.21
|
|
116
144
|
},
|
|
117
145
|
"2023": {
|
|
146
|
+
"RUB": 85.162,
|
|
118
147
|
"VND": 23787.32
|
|
119
148
|
},
|
|
120
149
|
"2024": {
|
|
150
|
+
"RUB": 92.5524,
|
|
121
151
|
"VND": 24164.89
|
|
122
152
|
}
|
|
123
153
|
},
|
|
@@ -131,15 +161,30 @@
|
|
|
131
161
|
"provider": "world_bank",
|
|
132
162
|
"series": "annual",
|
|
133
163
|
"to": "2024"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"currencies": [
|
|
167
|
+
"RUB"
|
|
168
|
+
],
|
|
169
|
+
"from": "1993",
|
|
170
|
+
"provider": "imf",
|
|
171
|
+
"series": "annual",
|
|
172
|
+
"to": "2024"
|
|
134
173
|
}
|
|
135
174
|
],
|
|
136
175
|
"providers": [
|
|
137
176
|
{
|
|
138
|
-
"fetched_at": "2026-05-
|
|
177
|
+
"fetched_at": "2026-05-12",
|
|
139
178
|
"id": "world_bank",
|
|
140
179
|
"label": "World Bank PA.NUS.FCRF",
|
|
141
180
|
"status": "ok"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"fetched_at": "2026-05-12",
|
|
184
|
+
"id": "imf",
|
|
185
|
+
"label": "IMF ER dataflow XDC_USD/PA_RT (period-average, annual mean)",
|
|
186
|
+
"status": "ok"
|
|
142
187
|
}
|
|
143
188
|
],
|
|
144
|
-
"schema_version":
|
|
189
|
+
"schema_version": 4
|
|
145
190
|
}
|
data/data/manifest.json
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"countries": [
|
|
3
|
+
{
|
|
4
|
+
"code": "AU",
|
|
5
|
+
"cpi_file": "cpi/au.json",
|
|
6
|
+
"currency": "AUD",
|
|
7
|
+
"granularities": [
|
|
8
|
+
"quarterly",
|
|
9
|
+
"annual"
|
|
10
|
+
],
|
|
11
|
+
"cpi_ranges": {
|
|
12
|
+
"annual": {
|
|
13
|
+
"min": "1960",
|
|
14
|
+
"max": "2024"
|
|
15
|
+
},
|
|
16
|
+
"quarterly": {
|
|
17
|
+
"min": "1948-Q3",
|
|
18
|
+
"max": "2026-Q1"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"code": "CA",
|
|
24
|
+
"cpi_file": "cpi/ca.json",
|
|
25
|
+
"currency": "CAD",
|
|
26
|
+
"granularities": [
|
|
27
|
+
"monthly",
|
|
28
|
+
"annual"
|
|
29
|
+
],
|
|
30
|
+
"cpi_ranges": {
|
|
31
|
+
"annual": {
|
|
32
|
+
"min": "1914",
|
|
33
|
+
"max": "2025"
|
|
34
|
+
},
|
|
35
|
+
"monthly": {
|
|
36
|
+
"min": "1914-01",
|
|
37
|
+
"max": "2026-03"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"code": "CN",
|
|
43
|
+
"cpi_file": "cpi/cn.json",
|
|
44
|
+
"currency": "CNY",
|
|
45
|
+
"granularities": [
|
|
46
|
+
"monthly",
|
|
47
|
+
"annual"
|
|
48
|
+
],
|
|
49
|
+
"cpi_ranges": {
|
|
50
|
+
"annual": {
|
|
51
|
+
"min": "1986",
|
|
52
|
+
"max": "2025"
|
|
53
|
+
},
|
|
54
|
+
"monthly": {
|
|
55
|
+
"min": "1993-01",
|
|
56
|
+
"max": "2026-03"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
3
60
|
{
|
|
4
61
|
"code": "EU",
|
|
5
62
|
"cpi_file": "cpi/eu.json",
|
|
@@ -7,7 +64,17 @@
|
|
|
7
64
|
"granularities": [
|
|
8
65
|
"monthly",
|
|
9
66
|
"annual"
|
|
10
|
-
]
|
|
67
|
+
],
|
|
68
|
+
"cpi_ranges": {
|
|
69
|
+
"annual": {
|
|
70
|
+
"min": "1996",
|
|
71
|
+
"max": "2025"
|
|
72
|
+
},
|
|
73
|
+
"monthly": {
|
|
74
|
+
"min": "1996-01",
|
|
75
|
+
"max": "2025-12"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
11
78
|
},
|
|
12
79
|
{
|
|
13
80
|
"code": "JP",
|
|
@@ -15,7 +82,51 @@
|
|
|
15
82
|
"currency": "JPY",
|
|
16
83
|
"granularities": [
|
|
17
84
|
"annual"
|
|
18
|
-
]
|
|
85
|
+
],
|
|
86
|
+
"cpi_ranges": {
|
|
87
|
+
"annual": {
|
|
88
|
+
"min": "1960",
|
|
89
|
+
"max": "2024"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"code": "KR",
|
|
95
|
+
"cpi_file": "cpi/kr.json",
|
|
96
|
+
"currency": "KRW",
|
|
97
|
+
"granularities": [
|
|
98
|
+
"monthly",
|
|
99
|
+
"annual"
|
|
100
|
+
],
|
|
101
|
+
"cpi_ranges": {
|
|
102
|
+
"annual": {
|
|
103
|
+
"min": "1960",
|
|
104
|
+
"max": "2025"
|
|
105
|
+
},
|
|
106
|
+
"monthly": {
|
|
107
|
+
"min": "1990-01",
|
|
108
|
+
"max": "2026-03"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"code": "RU",
|
|
114
|
+
"cpi_file": "cpi/ru.json",
|
|
115
|
+
"currency": "RUB",
|
|
116
|
+
"granularities": [
|
|
117
|
+
"monthly",
|
|
118
|
+
"annual"
|
|
119
|
+
],
|
|
120
|
+
"cpi_ranges": {
|
|
121
|
+
"annual": {
|
|
122
|
+
"min": "1992",
|
|
123
|
+
"max": "2025"
|
|
124
|
+
},
|
|
125
|
+
"monthly": {
|
|
126
|
+
"min": "1992-01",
|
|
127
|
+
"max": "2026-03"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
19
130
|
},
|
|
20
131
|
{
|
|
21
132
|
"code": "UK",
|
|
@@ -24,7 +135,17 @@
|
|
|
24
135
|
"granularities": [
|
|
25
136
|
"monthly",
|
|
26
137
|
"annual"
|
|
27
|
-
]
|
|
138
|
+
],
|
|
139
|
+
"cpi_ranges": {
|
|
140
|
+
"annual": {
|
|
141
|
+
"min": "1988",
|
|
142
|
+
"max": "2025"
|
|
143
|
+
},
|
|
144
|
+
"monthly": {
|
|
145
|
+
"min": "1988-01",
|
|
146
|
+
"max": "2026-03"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
28
149
|
},
|
|
29
150
|
{
|
|
30
151
|
"code": "US",
|
|
@@ -33,7 +154,17 @@
|
|
|
33
154
|
"granularities": [
|
|
34
155
|
"monthly",
|
|
35
156
|
"annual"
|
|
36
|
-
]
|
|
157
|
+
],
|
|
158
|
+
"cpi_ranges": {
|
|
159
|
+
"annual": {
|
|
160
|
+
"min": "1990",
|
|
161
|
+
"max": "2024"
|
|
162
|
+
},
|
|
163
|
+
"monthly": {
|
|
164
|
+
"min": "1990-01",
|
|
165
|
+
"max": "2026-03"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
37
168
|
},
|
|
38
169
|
{
|
|
39
170
|
"code": "VN",
|
|
@@ -42,16 +173,31 @@
|
|
|
42
173
|
"granularities": [
|
|
43
174
|
"monthly",
|
|
44
175
|
"annual"
|
|
45
|
-
]
|
|
176
|
+
],
|
|
177
|
+
"cpi_ranges": {
|
|
178
|
+
"annual": {
|
|
179
|
+
"min": "1995",
|
|
180
|
+
"max": "2025"
|
|
181
|
+
},
|
|
182
|
+
"monthly": {
|
|
183
|
+
"min": "2001-12",
|
|
184
|
+
"max": "2026-03"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
46
187
|
}
|
|
47
188
|
],
|
|
48
189
|
"fx": {
|
|
49
190
|
"annual_file": "fx/usd/_annual.json",
|
|
50
191
|
"base": "USD",
|
|
51
192
|
"currencies": [
|
|
193
|
+
"AUD",
|
|
194
|
+
"CAD",
|
|
195
|
+
"CNY",
|
|
52
196
|
"EUR",
|
|
53
197
|
"GBP",
|
|
54
198
|
"JPY",
|
|
199
|
+
"KRW",
|
|
200
|
+
"RUB",
|
|
55
201
|
"VND"
|
|
56
202
|
],
|
|
57
203
|
"daily_years": [
|
|
@@ -83,8 +229,10 @@
|
|
|
83
229
|
2024,
|
|
84
230
|
2025,
|
|
85
231
|
2026
|
|
86
|
-
]
|
|
232
|
+
],
|
|
233
|
+
"daily_min": "1999-01-04",
|
|
234
|
+
"daily_max": "2026-05-11"
|
|
87
235
|
},
|
|
88
|
-
"generated_at": "2026-05-
|
|
89
|
-
"schema_version":
|
|
236
|
+
"generated_at": "2026-05-12",
|
|
237
|
+
"schema_version": 4
|
|
90
238
|
}
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -76,9 +76,9 @@ module Timeprice
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
|
|
79
|
-
method_option :from, type: :string, required: true, desc: "Source date (YYYY or YYYY-
|
|
80
|
-
method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-
|
|
81
|
-
method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN)"
|
|
79
|
+
method_option :from, type: :string, required: true, desc: "Source date (YYYY, YYYY-MM, or YYYY-Qn)"
|
|
80
|
+
method_option :to, type: :string, required: true, desc: "Target date (YYYY, YYYY-MM, or YYYY-Qn)"
|
|
81
|
+
method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN, AU, CA, KR, CN, RU)"
|
|
82
82
|
def inflation(amount)
|
|
83
83
|
with_error_handling do
|
|
84
84
|
result = Timeprice.inflation(
|
|
@@ -160,12 +160,12 @@ module Timeprice
|
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
def parse_compare_token(token, label:)
|
|
163
|
-
|
|
163
|
+
fail ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
164
164
|
|
|
165
165
|
parts = token.strip.split(/\s+/)
|
|
166
166
|
unless parts.size == 2
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
fail ArgumentError,
|
|
168
|
+
"#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
Point.coerce(parts)
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -28,6 +28,11 @@ module Timeprice
|
|
|
28
28
|
#
|
|
29
29
|
# If a future refactor flips the order, the regression test in
|
|
30
30
|
# spec/timeprice/compare_spec.rb will fail.
|
|
31
|
+
#
|
|
32
|
+
# @api private
|
|
33
|
+
# The supported public entry point is {Timeprice.compare}. Direct
|
|
34
|
+
# references will move to `Timeprice::Internal::Compare` in a future
|
|
35
|
+
# release.
|
|
31
36
|
module Compare
|
|
32
37
|
module_function
|
|
33
38
|
|
|
@@ -52,7 +57,17 @@ module Timeprice
|
|
|
52
57
|
converted = fx_result.amount
|
|
53
58
|
|
|
54
59
|
# Step 2: inflate that destination-currency amount from source date to
|
|
55
|
-
# destination date using destination-country CPI.
|
|
60
|
+
# destination date using destination-country CPI. When both points
|
|
61
|
+
# share a date there's no time-elapsed inflation to apply — short-
|
|
62
|
+
# circuit with a ratio of 1.0 so daily-grain FX dates (which CPI's
|
|
63
|
+
# monthly-max resolution can't accept) still resolve cleanly.
|
|
64
|
+
if from_point.date == to_point.date
|
|
65
|
+
return fx_only_result(
|
|
66
|
+
amount: amount, from_point: from_point, to_point: to_point,
|
|
67
|
+
to_country: to_country, fx_result: fx_result
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
56
71
|
infl = Inflation.adjust(
|
|
57
72
|
amount: converted,
|
|
58
73
|
from: from_point.date.to_s,
|
|
@@ -75,14 +90,32 @@ module Timeprice
|
|
|
75
90
|
)
|
|
76
91
|
end
|
|
77
92
|
|
|
93
|
+
# Same-date branch: no time-elapsed inflation, so the FX leg alone is
|
|
94
|
+
# the answer. Builds a CompareResult with cpi_ratio=1.0.
|
|
95
|
+
def fx_only_result(amount:, from_point:, to_point:, to_country:, fx_result:)
|
|
96
|
+
CompareResult.new(
|
|
97
|
+
amount: fx_result.amount,
|
|
98
|
+
original_amount: amount.to_f,
|
|
99
|
+
from_currency: from_point.currency,
|
|
100
|
+
from_date: from_point.date.to_s,
|
|
101
|
+
to_currency: to_point.currency,
|
|
102
|
+
to_date: to_point.date.to_s,
|
|
103
|
+
country: to_country,
|
|
104
|
+
fx_rate: fx_result.rate,
|
|
105
|
+
cpi_ratio: 1.0,
|
|
106
|
+
converted_amount: fx_result.amount,
|
|
107
|
+
granularity: fx_result.granularity
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
78
111
|
# Coerce both points and resolve to_country.
|
|
79
112
|
def resolve_points(from, to)
|
|
80
113
|
from_point = Point.coerce(from)
|
|
81
114
|
to_point = Point.coerce(to)
|
|
82
|
-
|
|
115
|
+
fail UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
|
|
83
116
|
|
|
84
117
|
to_country = Supported.country_for_currency(to_point.currency)
|
|
85
|
-
|
|
118
|
+
fail UnsupportedCurrency, to_point.currency unless to_country
|
|
86
119
|
|
|
87
120
|
[from_point, to_point, to_country]
|
|
88
121
|
end
|
data/lib/timeprice/cpi_lookup.rb
CHANGED
|
@@ -8,54 +8,100 @@ module Timeprice
|
|
|
8
8
|
# resolved. See {Granularity} for the full set of possible tags.
|
|
9
9
|
CpiPoint = Data.define(:value, :granularity)
|
|
10
10
|
|
|
11
|
-
# Resolves CPI keys ("YYYY" or "YYYY-
|
|
12
|
-
# country's parsed CPI data hash. Knowing the JSON shape ("monthly"
|
|
13
|
-
# "annual" string keys) is isolated here — Inflation just
|
|
11
|
+
# Resolves CPI keys ("YYYY", "YYYY-MM", or "YYYY-Qn") to a CpiPoint against
|
|
12
|
+
# a single country's parsed CPI data hash. Knowing the JSON shape ("monthly"
|
|
13
|
+
# / "quarterly" / "annual" string keys) is isolated here — Inflation just
|
|
14
|
+
# asks for points.
|
|
14
15
|
class CpiLookup
|
|
16
|
+
QUARTER_RE = /\A(\d{4})-Q([1-4])\z/
|
|
17
|
+
|
|
15
18
|
def initialize(data)
|
|
16
19
|
@data = data
|
|
17
|
-
@monthly
|
|
18
|
-
@
|
|
20
|
+
@monthly = data.dig("series", "monthly") || {}
|
|
21
|
+
@quarterly = data.dig("series", "quarterly") || {}
|
|
22
|
+
@annual = data.dig("series", "annual") || {}
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
# @param key [String] "YYYY" or "YYYY-
|
|
25
|
+
# @param key [String] "YYYY", "YYYY-MM", or "YYYY-Qn"
|
|
22
26
|
# @return [CpiPoint]
|
|
23
27
|
# @raise [DataNotFound] if no CPI value covers `key`
|
|
24
28
|
# @raise [ArgumentError] on malformed `key`
|
|
25
29
|
def at(key)
|
|
26
30
|
key = key.to_s
|
|
27
31
|
case key
|
|
28
|
-
when
|
|
29
|
-
when /\A\d{4}\z/
|
|
30
|
-
|
|
32
|
+
when QUARTER_RE then quarterly_or_fallbacks(key)
|
|
33
|
+
when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
|
|
34
|
+
when /\A\d{4}\z/ then annual_or_derived(key)
|
|
35
|
+
else fail ArgumentError, "Invalid date format: #{key.inspect} (use YYYY, YYYY-MM, or YYYY-Qn)"
|
|
31
36
|
end
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
private
|
|
35
40
|
|
|
36
|
-
def
|
|
41
|
+
def monthly_or_fallbacks(month_key)
|
|
37
42
|
return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
|
|
38
43
|
|
|
39
|
-
year = month_key
|
|
40
|
-
|
|
44
|
+
year, month = month_key.split("-").map(&:to_i)
|
|
45
|
+
qkey = format("%04d-Q%d", year, ((month - 1) / 3) + 1)
|
|
46
|
+
if @quarterly.key?(qkey)
|
|
47
|
+
return CpiPoint.new(value: @quarterly[qkey], granularity: Granularity::MONTHLY_FROM_QUARTERLY_FALLBACK)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
year_key = month_key[0, 4]
|
|
51
|
+
fail DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
|
|
41
52
|
|
|
42
|
-
CpiPoint.new(value: @annual[
|
|
53
|
+
CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
|
|
43
54
|
end
|
|
44
55
|
|
|
45
|
-
def
|
|
56
|
+
def quarterly_or_fallbacks(quarter_key)
|
|
57
|
+
if @quarterly.key?(quarter_key)
|
|
58
|
+
return CpiPoint.new(value: @quarterly[quarter_key],
|
|
59
|
+
granularity: Granularity::QUARTERLY)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
year_int, q = quarter_key.match(QUARTER_RE).captures.map(&:to_i)
|
|
63
|
+
first_month = ((q - 1) * 3) + 1
|
|
64
|
+
last_month = q * 3
|
|
65
|
+
months = (first_month..last_month).map { |m| format("%04d-%02d", year_int, m) }
|
|
66
|
+
.map { |k| @monthly[k] }
|
|
67
|
+
.compact
|
|
68
|
+
if months.size == 3
|
|
69
|
+
return CpiPoint.new(value: months.sum.to_f / 3,
|
|
70
|
+
granularity: Granularity::QUARTERLY_FROM_MONTHLY_AVG)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
year = quarter_key[0, 4]
|
|
74
|
+
fail DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
|
|
75
|
+
|
|
76
|
+
CpiPoint.new(value: @annual[year], granularity: Granularity::QUARTERLY_FROM_ANNUAL_FALLBACK)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def annual_or_derived(year)
|
|
46
80
|
return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
|
|
47
81
|
|
|
48
|
-
months
|
|
49
|
-
|
|
82
|
+
months = @monthly.select { |k, _| k.start_with?("#{year}-") }
|
|
83
|
+
quarters = @quarterly.select { |k, _| k.start_with?("#{year}-Q") }
|
|
84
|
+
|
|
85
|
+
# Prefer complete-period averages over partials, and within each, prefer
|
|
86
|
+
# monthly resolution. Partial tags distinguish biased estimates (e.g.
|
|
87
|
+
# only Jan-Feb populated) from a true full-year mean.
|
|
88
|
+
return average(months, 12, Granularity::ANNUAL_FROM_MONTHLY_AVG) if months.size == 12
|
|
89
|
+
return average(quarters, 4, Granularity::ANNUAL_FROM_QUARTERLY_AVG) if quarters.size == 4
|
|
90
|
+
return average(months, months.size, Granularity::ANNUAL_FROM_PARTIAL_MONTHS) if months.any?
|
|
91
|
+
return average(quarters, quarters.size, Granularity::ANNUAL_FROM_PARTIAL_QUARTERS) if quarters.any?
|
|
92
|
+
|
|
93
|
+
fail DataNotFound, missing_message(year)
|
|
94
|
+
end
|
|
50
95
|
|
|
51
|
-
|
|
52
|
-
CpiPoint.new(value:
|
|
96
|
+
def average(series, divisor, granularity)
|
|
97
|
+
CpiPoint.new(value: series.values.sum.to_f / divisor, granularity: granularity)
|
|
53
98
|
end
|
|
54
99
|
|
|
55
100
|
def missing_message(key)
|
|
56
101
|
country = @data["country"]
|
|
57
102
|
ranges = []
|
|
58
103
|
ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
|
|
104
|
+
ranges << "quarterly #{@quarterly.keys.min}..#{@quarterly.keys.max}" if @quarterly.any?
|
|
59
105
|
ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
|
|
60
106
|
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
61
107
|
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "schema"
|
|
5
6
|
|
|
6
7
|
module Timeprice
|
|
7
8
|
# Loads and caches the bundled JSON data files. Override the search root
|
|
8
9
|
# by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
|
|
9
10
|
# {DataLoader.data_root=}.
|
|
10
11
|
module DataLoader
|
|
11
|
-
SUPPORTED_SCHEMA_VERSION = 3
|
|
12
|
-
|
|
13
12
|
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
14
13
|
|
|
15
14
|
class << self
|
|
@@ -42,8 +41,8 @@ module Timeprice
|
|
|
42
41
|
manifest_cache[data_root] ||= begin
|
|
43
42
|
path = File.join(data_root, "manifest.json")
|
|
44
43
|
unless File.exist?(path)
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
fail DataNotFound, "manifest.json missing (looked in #{path}). " \
|
|
45
|
+
"Check TIMEPRICE_DATA_ROOT or reinstall the gem."
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
parse_with_schema(path)
|
|
@@ -60,12 +59,12 @@ module Timeprice
|
|
|
60
59
|
key = country.to_s.downcase
|
|
61
60
|
code = country.to_s.upcase
|
|
62
61
|
cpi_cache[[data_root, key]] ||= begin
|
|
63
|
-
|
|
62
|
+
fail UnsupportedCountry, code unless Supported.country?(code)
|
|
64
63
|
|
|
65
64
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
66
65
|
unless File.exist?(path)
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
fail DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
|
|
67
|
+
"Check TIMEPRICE_DATA_ROOT or reinstall the gem."
|
|
69
68
|
end
|
|
70
69
|
|
|
71
70
|
parse_with_schema(path)
|
|
@@ -80,7 +79,7 @@ module Timeprice
|
|
|
80
79
|
key = year.to_i
|
|
81
80
|
fx_cache[[data_root, key]] ||= begin
|
|
82
81
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
83
|
-
|
|
82
|
+
fail DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
84
83
|
|
|
85
84
|
parse_with_schema(path)
|
|
86
85
|
end
|
|
@@ -112,11 +111,7 @@ module Timeprice
|
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
def parse_with_schema(path)
|
|
115
|
-
|
|
116
|
-
version = data["schema_version"]
|
|
117
|
-
raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
|
|
118
|
-
|
|
119
|
-
data
|
|
114
|
+
Schema.load_cpi(JSON.parse(File.read(path)), path: path)
|
|
120
115
|
end
|
|
121
116
|
end
|
|
122
117
|
end
|