timeprice 0.5.0 → 0.6.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/DATA_LICENSES.md +16 -1
  4. data/README.md +14 -6
  5. data/data/cpi/eu.json +1 -1
  6. data/data/cpi/jp.json +1 -1
  7. data/data/cpi/uk.json +1 -1
  8. data/data/cpi/us.json +1 -1
  9. data/data/cpi/vn.json +1 -1
  10. data/data/fx/usd/1999.json +1 -1
  11. data/data/fx/usd/2000.json +1 -1
  12. data/data/fx/usd/2001.json +1 -1
  13. data/data/fx/usd/2002.json +1 -1
  14. data/data/fx/usd/2003.json +1 -1
  15. data/data/fx/usd/2004.json +1 -1
  16. data/data/fx/usd/2005.json +1 -1
  17. data/data/fx/usd/2006.json +1 -1
  18. data/data/fx/usd/2007.json +1 -1
  19. data/data/fx/usd/2008.json +1 -1
  20. data/data/fx/usd/2009.json +1 -1
  21. data/data/fx/usd/2010.json +1 -1
  22. data/data/fx/usd/2011.json +1 -1
  23. data/data/fx/usd/2012.json +1 -1
  24. data/data/fx/usd/2013.json +1 -1
  25. data/data/fx/usd/2014.json +1 -1
  26. data/data/fx/usd/2015.json +1 -1
  27. data/data/fx/usd/2016.json +1 -1
  28. data/data/fx/usd/2017.json +1 -1
  29. data/data/fx/usd/2018.json +1 -1
  30. data/data/fx/usd/2019.json +1 -1
  31. data/data/fx/usd/2020.json +1 -1
  32. data/data/fx/usd/2021.json +1 -1
  33. data/data/fx/usd/2022.json +1 -1
  34. data/data/fx/usd/2023.json +1 -1
  35. data/data/fx/usd/2024.json +1 -1
  36. data/data/fx/usd/2025.json +1 -1
  37. data/data/fx/usd/2026.json +1 -1
  38. data/data/fx/usd/_annual.json +1 -1
  39. data/data/manifest.json +1 -1
  40. data/lib/timeprice/cli.rb +3 -3
  41. data/lib/timeprice/cpi_lookup.rb +64 -18
  42. data/lib/timeprice/data_loader.rb +6 -2
  43. data/lib/timeprice/granularity.rb +41 -10
  44. data/lib/timeprice/inflation.rb +3 -3
  45. data/lib/timeprice/supported.rb +1 -1
  46. data/lib/timeprice/version.rb +1 -1
  47. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f5a76db32571e7ab2f09f9e22e1d90d420b5bdcf474baff6b523f2c8998c72b
4
- data.tar.gz: 96e212aaa5aac2cfe4defc28364498ef49241de34161cb3e301f4886a19650d0
3
+ metadata.gz: 5f350843e8e12653e2f798d8f9afec193001e911e0f36910d0473724f33d79f8
4
+ data.tar.gz: 3d1107eb101dd95ec6c2203fe6f1843f9249cf5046cf838577094c67a0fe63c0
5
5
  SHA512:
6
- metadata.gz: 4148766e8c3dee2243ed933868d23cb2d79959db1581d07de654e1bab87325de98eacb2cd41200e1f765f3d94dde03472dc5d02a37e2e2f059fc8a651ac40666
7
- data.tar.gz: a51e3132f616bc0e468b0f9afdff9ab71a2017abd25eed5ffd45cecc4c385d58f4670ffeeb5cc8bc5f4143f639a42d92f88a2d6e4f81957171d6c3207982d5ee
6
+ metadata.gz: a7efbb6d151b4fd4c9c6d4697fc9c755c9a0d2e6b8e1e32d828a1416737dbfbd00d9143c64edde191eaea3f7f758b96ac1d4b93c9f765fbc5d0993bf68b0be7a
7
+ data.tar.gz: 3a5b598b62cf93b992ed4ba5d069f84475c91a0be636600451fb3247caaa5c494c44b39268ed5e6107c34f923606521d42374d3300de42ad5a7449f53ae37e39
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.0] - 2026-05-12
9
+
10
+ ### Added
11
+ - **Five new countries: AU, CA, KR, CN, RU.** CPI + FX coverage:
12
+ - AU (Australia): quarterly CPI from ABS 6401.0, annual baseline from World
13
+ Bank `FP.CPI.TOTL`; AUD daily FX from Frankfurter.
14
+ - CA (Canada): monthly CPI from Statistics Canada WDS (table 18-10-0004-01),
15
+ annual baseline from World Bank; CAD daily FX from Frankfurter.
16
+ - KR (Korea, Rep.): monthly CPI from IMF Data Portal CPI dataflow, annual
17
+ baseline from World Bank; KRW daily FX from Frankfurter. (KOSIS Open API
18
+ is a future upgrade path — see `scripts/sources/kosis.rb`.)
19
+ - CN (China): annual CPI from World Bank, monthly layered from IMF Data
20
+ Portal; CNY daily FX from Frankfurter.
21
+ - RU (Russia): annual CPI from World Bank, monthly layered from IMF; RUB FX
22
+ from IMF IFS `ENDA_XDC_USD_RATE` written as annual averages to
23
+ `_annual.json` (Frankfurter dropped RUB after the ECB suspended reference
24
+ rates in March 2022, so daily RUB is intentionally not bundled).
25
+ - `KRW` added to `Supported::ZERO_DECIMAL_CURRENCIES`.
26
+
27
+ ### Changed
28
+ - **Schema v3 → v4.** Backward-compatible bump:
29
+ - CPI files gain an optional `series.quarterly` block (keyed `YYYY-Qn`)
30
+ alongside `series.monthly` and `series.annual`. Files without quarterly
31
+ data are byte-identical to v3 layout other than the version field.
32
+ - `DataLoader` accepts both v3 and v4 files; new writes are v4.
33
+ - `Granularity` gains `:quarterly`, `:annual_from_quarterly_avg`,
34
+ `:quarterly_from_annual_fallback`, and `:monthly_from_quarterly_fallback`.
35
+ `CpiLookup` resolves "YYYY-Qn" keys and falls back monthly → quarterly →
36
+ annual.
37
+
8
38
  ## [0.5.0] - 2026-05-11
9
39
 
10
40
  ### Changed
data/DATA_LICENSES.md CHANGED
@@ -14,8 +14,15 @@ string.
14
14
  | World Bank | `FP.CPI.TOTL` (JP CPI fallback) | Creative Commons Attribution 4.0 International (CC BY 4.0) | https://datacatalog.worldbank.org/public-licenses#cc-by | Source: World Bank, FP.CPI.TOTL |
15
15
  | World Bank | `FP.CPI.TOTL` (VN CPI, annual fallback) | Creative Commons Attribution 4.0 International (CC BY 4.0) | https://datacatalog.worldbank.org/public-licenses#cc-by | Source: World Bank, FP.CPI.TOTL |
16
16
  | International Monetary Fund | CPI dataflow `VNM.CPI._T.IX.M` via IMF Data Portal (VN CPI, monthly primary) | Free reuse with attribution per IMF terms | https://www.imf.org/external/terms.htm | Source: IMF Data Portal CPI dataflow |
17
- | European Central Bank (via Frankfurter) | Daily reference rates, USD base, EUR/GBP/JPY | ECB reference rates — free reuse; Frankfurter is a non-commercial republisher with no separate license | https://www.ecb.europa.eu/services/disclaimer/html/index.en.html | FX data: European Central Bank reference rates via Frankfurter |
17
+ | European Central Bank (via Frankfurter) | Daily reference rates, USD base, EUR/GBP/JPY/AUD/CAD/KRW/CNY | ECB reference rates — free reuse; Frankfurter is a non-commercial republisher with no separate license | https://www.ecb.europa.eu/services/disclaimer/html/index.en.html | FX data: European Central Bank reference rates via Frankfurter |
18
18
  | World Bank | `PA.NUS.FCRF` (VND annual average FX, broadcast daily) | Creative Commons Attribution 4.0 International (CC BY 4.0) | https://datacatalog.worldbank.org/public-licenses#cc-by | VND FX: World Bank, PA.NUS.FCRF |
19
+ | Australian Bureau of Statistics | `CPI` dataflow, key `3.10001.10.50.Q` (AU CPI, quarterly, all groups, weighted average of eight capital cities) | Creative Commons Attribution 4.0 International (CC BY 4.0) | https://www.abs.gov.au/website-privacy-copyright-and-disclaimer/copyright-and-creative-commons | Source: Australian Bureau of Statistics, 6401.0 Consumer Price Index |
20
+ | Statistics Canada | Table 18-10-0004-01, vector `v41690973` (CA CPI, monthly, all-items, not seasonally adjusted) | Statistics Canada Open License | https://www.statcan.gc.ca/en/reference/licence | Source: Statistics Canada, table 18-10-0004-01 |
21
+ | International Monetary Fund | CPI dataflow `KOR.CPI._T.IX.M` (KR CPI, monthly) | Free reuse with attribution per IMF terms | https://www.imf.org/external/terms.htm | Source: IMF Data Portal CPI dataflow |
22
+ | International Monetary Fund | CPI dataflow `CHN.CPI._T.IX.M` (CN CPI, monthly) | Free reuse with attribution per IMF terms | https://www.imf.org/external/terms.htm | Source: IMF Data Portal CPI dataflow |
23
+ | International Monetary Fund | CPI dataflow `RUS.CPI._T.IX.M` (RU CPI, monthly) | Free reuse with attribution per IMF terms | https://www.imf.org/external/terms.htm | Source: IMF Data Portal CPI dataflow |
24
+ | International Monetary Fund | IFS dataflow `M.RUS.ENDA_XDC_USD_RATE` (RUB/USD, period-average, annual mean written to `_annual.json`) | Free reuse with attribution per IMF terms | https://www.imf.org/external/terms.htm | Source: IMF International Financial Statistics |
25
+ | World Bank | `FP.CPI.TOTL` (AU/CA/KR/CN/RU CPI annual baselines) | Creative Commons Attribution 4.0 International (CC BY 4.0) | https://datacatalog.worldbank.org/public-licenses#cc-by | Source: World Bank, FP.CPI.TOTL |
19
26
 
20
27
  ## Notes
21
28
 
@@ -25,6 +32,14 @@ string.
25
32
  - The Vietnam VND FX series is the **annual average** broadcast to every day in
26
33
  the year — it is intentionally not a daily market rate. Do not use it for
27
34
  intraday or trade-execution purposes.
35
+ - **RUB FX is annual-only** (from IMF IFS period averages). Frankfurter (ECB)
36
+ stopped publishing RUB daily reference rates in March 2022 after the ECB
37
+ suspended the rate, and no other free, no-API-key daily source covers the
38
+ full series. Daily RUB lookups fall back to the annual average and the
39
+ result is tagged so consumers can detect the degradation.
40
+ - **AU CPI is published quarterly only.** Lookups against "YYYY-MM" keys for
41
+ Australia fall back to the quarter that contains the month, and the result
42
+ is tagged `:monthly_from_quarterly_fallback`.
28
43
  - Eurostat HICP is harmonized across the Eurozone and is **not** the same as
29
44
  any national CPI. We use it for the `EU` country code; national CPIs are out
30
45
  of scope for v0.1.
data/README.md CHANGED
@@ -99,11 +99,19 @@ Coverage is derived from the bundled `data/` files. Re-check with `timeprice sou
99
99
  | Eurozone (EA) | EUR | Eurostat HICP (`prc_hicp_midx`) | Monthly + annual | 1996-01 → present |
100
100
  | Japan | JPY | World Bank `FP.CPI.TOTL` (fallback) | Annual | 1960 → 2024 |
101
101
  | Vietnam | VND | IMF Data Portal CPI dataflow (monthly primary) + World Bank `FP.CPI.TOTL` (annual fallback) | Monthly + annual | 1995 → present |
102
-
103
- **FX (USD base):** ECB reference rates via Frankfurter for **EUR / GBP / JPY**, daily
104
- 1999 present. **VND** uses the World Bank annual average (`PA.NUS.FCRF`), one value
105
- per year, from 1983 present. VND results are tagged `granularity: :annual` so callers
106
- know they got the annual fallback rather than a daily rate.
102
+ | Australia | AUD | ABS 6401.0 (quarterly) + World Bank `FP.CPI.TOTL` (annual baseline) | Quarterly + annual | 1948-Q3 → present |
103
+ | Canada | CAD | Statistics Canada WDS (table 18-10-0004-01, vector `v41690973`) + World Bank annual baseline | Monthly + annual | 1914-01 → present |
104
+ | Korea (Rep.) | KRW | IMF Data Portal CPI dataflow + World Bank annual baseline | Monthly + annual | 1990-01 → present |
105
+ | China | CNY | World Bank `FP.CPI.TOTL` (annual primary) + IMF Data Portal CPI dataflow (monthly layer) | Monthly + annual | 1990-01 → present |
106
+ | Russia | RUB | World Bank `FP.CPI.TOTL` (annual primary) + IMF Data Portal CPI dataflow (monthly layer) | Monthly + annual | 1992-01 → present |
107
+
108
+ **FX (USD base):** ECB reference rates via Frankfurter for **EUR / GBP / JPY / AUD /
109
+ CAD / KRW / CNY**, daily 1999 → present. **VND** uses the World Bank annual average
110
+ (`PA.NUS.FCRF`), one value per year, from 1983 → present. **RUB** uses IMF IFS
111
+ period-average rates as annual means (`ENDA_XDC_USD_RATE`) — Frankfurter dropped RUB
112
+ after the ECB suspended its reference rate in March 2022, so daily RUB is intentionally
113
+ not bundled. Annual-fallback results (VND, RUB) are tagged `granularity: :annual` so
114
+ callers know they did not get a daily rate.
107
115
 
108
116
  Triangulated cross-rates (e.g. GBP → JPY) go through USD on the same effective date.
109
117
  Weekend/holiday dates fall back up to 7 days to the nearest prior trading day.
@@ -223,7 +231,7 @@ namespace :inflation do
223
231
  desc "Print 1990→today inflation for the supported countries"
224
232
  task :report do
225
233
  today = Date.today.strftime("%Y-%m")
226
- %w[US UK EU JP VN].each do |c|
234
+ %w[US UK EU JP VN AU CA KR CN RU].each do |c|
227
235
  r = Timeprice.inflation(amount: 100, from: "1990", to: today, country: c)
228
236
  puts "#{c}: 100 in 1990 → #{r.amount.round(2)} in #{today} (#{r.granularity})"
229
237
  end
data/data/cpi/eu.json CHANGED
@@ -26,7 +26,7 @@
26
26
  "status": "ok"
27
27
  }
28
28
  ],
29
- "schema_version": 3,
29
+ "schema_version": 4,
30
30
  "series": {
31
31
  "annual": {
32
32
  "1996": 71.742,
data/data/cpi/jp.json CHANGED
@@ -20,7 +20,7 @@
20
20
  "status": "ok"
21
21
  }
22
22
  ],
23
- "schema_version": 3,
23
+ "schema_version": 4,
24
24
  "series": {
25
25
  "annual": {
26
26
  "1960": 18.9725686020745,
data/data/cpi/uk.json CHANGED
@@ -26,7 +26,7 @@
26
26
  "status": "ok"
27
27
  }
28
28
  ],
29
- "schema_version": 3,
29
+ "schema_version": 4,
30
30
  "series": {
31
31
  "annual": {
32
32
  "1988": 49.6,
data/data/cpi/us.json CHANGED
@@ -32,7 +32,7 @@
32
32
  "status": "ok"
33
33
  }
34
34
  ],
35
- "schema_version": 3,
35
+ "schema_version": 4,
36
36
  "series": {
37
37
  "annual": {
38
38
  "1990": 130.658,
data/data/cpi/vn.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "status": "ok"
39
39
  }
40
40
  ],
41
- "schema_version": 3,
41
+ "schema_version": 4,
42
42
  "series": {
43
43
  "annual": {
44
44
  "1995": 21.083683,
@@ -1318,6 +1318,6 @@
1318
1318
  "JPY": 102.26
1319
1319
  }
1320
1320
  },
1321
- "schema_version": 3,
1321
+ "schema_version": 4,
1322
1322
  "year": 1999
1323
1323
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 114.91
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2000
1303
1303
  }
@@ -1293,6 +1293,6 @@
1293
1293
  "JPY": 130.86
1294
1294
  }
1295
1295
  },
1296
- "schema_version": 3,
1296
+ "schema_version": 4,
1297
1297
  "year": 2001
1298
1298
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 118.61
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2002
1303
1303
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 106.93
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2003
1303
1303
  }
@@ -1318,6 +1318,6 @@
1318
1318
  "JPY": 102.53
1319
1319
  }
1320
1320
  },
1321
- "schema_version": 3,
1321
+ "schema_version": 4,
1322
1322
  "year": 2004
1323
1323
  }
@@ -1308,6 +1308,6 @@
1308
1308
  "JPY": 117.74
1309
1309
  }
1310
1310
  },
1311
- "schema_version": 3,
1311
+ "schema_version": 4,
1312
1312
  "year": 2005
1313
1313
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 119.16
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2006
1303
1303
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 112.04
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2007
1303
1303
  }
@@ -1303,6 +1303,6 @@
1303
1303
  "JPY": 90.64
1304
1304
  }
1305
1305
  },
1306
- "schema_version": 3,
1306
+ "schema_version": 4,
1307
1307
  "year": 2008
1308
1308
  }
@@ -1303,6 +1303,6 @@
1303
1303
  "JPY": 92.43
1304
1304
  }
1305
1305
  },
1306
- "schema_version": 3,
1306
+ "schema_version": 4,
1307
1307
  "year": 2009
1308
1308
  }
@@ -1313,6 +1313,6 @@
1313
1313
  "JPY": 81.31
1314
1314
  }
1315
1315
  },
1316
- "schema_version": 3,
1316
+ "schema_version": 4,
1317
1317
  "year": 2010
1318
1318
  }
@@ -1308,6 +1308,6 @@
1308
1308
  "JPY": 77.44
1309
1309
  }
1310
1310
  },
1311
- "schema_version": 3,
1311
+ "schema_version": 4,
1312
1312
  "year": 2011
1313
1313
  }
@@ -1303,6 +1303,6 @@
1303
1303
  "JPY": 86.11
1304
1304
  }
1305
1305
  },
1306
- "schema_version": 3,
1306
+ "schema_version": 4,
1307
1307
  "year": 2012
1308
1308
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 104.94
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2013
1303
1303
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 119.62
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2014
1303
1303
  }
@@ -1303,6 +1303,6 @@
1303
1303
  "JPY": 120.39
1304
1304
  }
1305
1305
  },
1306
- "schema_version": 3,
1306
+ "schema_version": 4,
1307
1307
  "year": 2015
1308
1308
  }
@@ -1308,6 +1308,6 @@
1308
1308
  "JPY": 117.07
1309
1309
  }
1310
1310
  },
1311
- "schema_version": 3,
1311
+ "schema_version": 4,
1312
1312
  "year": 2016
1313
1313
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 112.57
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2017
1303
1303
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 109.91
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2018
1303
1303
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 108.55
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2019
1303
1303
  }
@@ -1308,6 +1308,6 @@
1308
1308
  "JPY": 103.08
1309
1309
  }
1310
1310
  },
1311
- "schema_version": 3,
1311
+ "schema_version": 4,
1312
1312
  "year": 2020
1313
1313
  }
@@ -1313,6 +1313,6 @@
1313
1313
  "JPY": 115.12
1314
1314
  }
1315
1315
  },
1316
- "schema_version": 3,
1316
+ "schema_version": 4,
1317
1317
  "year": 2021
1318
1318
  }
@@ -1308,6 +1308,6 @@
1308
1308
  "JPY": 131.88
1309
1309
  }
1310
1310
  },
1311
- "schema_version": 3,
1311
+ "schema_version": 4,
1312
1312
  "year": 2022
1313
1313
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 141.48
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2023
1303
1303
  }
@@ -1303,6 +1303,6 @@
1303
1303
  "JPY": 156.95
1304
1304
  }
1305
1305
  },
1306
- "schema_version": 3,
1306
+ "schema_version": 4,
1307
1307
  "year": 2024
1308
1308
  }
@@ -1298,6 +1298,6 @@
1298
1298
  "JPY": 156.67
1299
1299
  }
1300
1300
  },
1301
- "schema_version": 3,
1301
+ "schema_version": 4,
1302
1302
  "year": 2025
1303
1303
  }
@@ -463,6 +463,6 @@
463
463
  "JPY": 156.76
464
464
  }
465
465
  },
466
- "schema_version": 3,
466
+ "schema_version": 4,
467
467
  "year": 2026
468
468
  }
@@ -141,5 +141,5 @@
141
141
  "status": "ok"
142
142
  }
143
143
  ],
144
- "schema_version": 3
144
+ "schema_version": 4
145
145
  }
data/data/manifest.json CHANGED
@@ -86,5 +86,5 @@
86
86
  ]
87
87
  },
88
88
  "generated_at": "2026-05-11",
89
- "schema_version": 3
89
+ "schema_version": 4
90
90
  }
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-MM)"
80
- method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-MM)"
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(
@@ -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-MM") to a CpiPoint against a single
12
- # country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
13
- # "annual" string keys) is isolated here — Inflation just asks for points.
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 = data.dig("series", "monthly") || {}
18
- @annual = data.dig("series", "annual") || {}
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-MM"
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 /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
29
- when /\A\d{4}\z/ then annual_or_monthly_average(key)
30
- else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
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 raise 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 monthly_or_annual_fallback(month_key)
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[0, 4]
40
- raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
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
+ raise DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
41
52
 
42
- CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
53
+ CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
43
54
  end
44
55
 
45
- def annual_or_monthly_average(year)
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
+ raise 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 = @monthly.select { |k, _| k.start_with?("#{year}-") }
49
- raise DataNotFound, missing_message(year) if months.empty?
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
+ raise DataNotFound, missing_message(year)
94
+ end
50
95
 
51
- avg = months.values.sum.to_f / months.size
52
- CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
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}"
@@ -8,7 +8,11 @@ module Timeprice
8
8
  # by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
9
9
  # {DataLoader.data_root=}.
10
10
  module DataLoader
11
- SUPPORTED_SCHEMA_VERSION = 3
11
+ SUPPORTED_SCHEMA_VERSION = 4
12
+
13
+ # Files written by older toolchains remain readable: v3 is monthly+annual
14
+ # only; v4 adds an optional `series.quarterly` block.
15
+ SUPPORTED_SCHEMA_VERSIONS = [3, 4].freeze
12
16
 
13
17
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
14
18
 
@@ -114,7 +118,7 @@ module Timeprice
114
118
  def parse_with_schema(path)
115
119
  data = JSON.parse(File.read(path))
116
120
  version = data["schema_version"]
117
- raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
121
+ raise UnsupportedSchemaVersion.new(version, path) unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
118
122
 
119
123
  data
120
124
  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
@@ -27,11 +27,11 @@ module Timeprice
27
27
 
28
28
  # Adjust `amount` from date `from` to date `to` using country CPI.
29
29
  #
30
- # Dates accept "YYYY" or "YYYY-MM".
30
+ # Dates accept "YYYY", "YYYY-MM", or "YYYY-Qn" (Q1..Q4).
31
31
  #
32
32
  # @param amount [Numeric]
33
- # @param from [String] source date ("YYYY" or "YYYY-MM")
34
- # @param to [String] target date ("YYYY" or "YYYY-MM")
33
+ # @param from [String] source date ("YYYY", "YYYY-MM", or "YYYY-Qn")
34
+ # @param to [String] target date ("YYYY", "YYYY-MM", or "YYYY-Qn")
35
35
  # @param country [String] country code (see {Supported.countries})
36
36
  # @return [InflationResult]
37
37
  # @raise [UnsupportedCountry] if `country` is not supported
@@ -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.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timeprice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick