timeprice 0.2.0 → 0.3.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 +49 -0
- data/README.md +13 -4
- data/lib/timeprice/cli/formatting.rb +34 -0
- data/lib/timeprice/cli/presenters/compare.rb +44 -0
- data/lib/timeprice/cli/presenters/exchange.rb +45 -0
- data/lib/timeprice/cli/presenters/inflation.rb +36 -0
- data/lib/timeprice/cli/presenters/sources.rb +65 -0
- data/lib/timeprice/cli.rb +83 -114
- data/lib/timeprice/compare.rb +15 -33
- data/lib/timeprice/cpi_lookup.rb +62 -0
- data/lib/timeprice/data_loader.rb +12 -5
- data/lib/timeprice/exchange.rb +3 -2
- data/lib/timeprice/inflation.rb +17 -55
- data/lib/timeprice/point.rb +30 -11
- data/lib/timeprice/sources/coverage.rb +71 -0
- data/lib/timeprice/sources.rb +2 -49
- data/lib/timeprice/supported.rb +12 -5
- data/lib/timeprice/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b400f9dc1652d4476d884f59a2721e4f07338e76c5cc7afab50896267e1123c
|
|
4
|
+
data.tar.gz: 1a894e2464aa1f1495ddaa24c284b819a8afc07309cd3080f7c8c70744959ef2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f2c0b9ae5a4f8de71bdfdfb28f911b676773d406f86149b0c12ad5fbeb002ad51e42d43ed9f21d41c6ddd1a5b2760c2df58796759f66e7b78ac0dc784cee971
|
|
7
|
+
data.tar.gz: 8dc9e2ff73ddf7d0126538794bb4612a8709881628b713a1e878fe8fba7eddd510597f5a4f0a07e8c9d3b63cc751880df9150db208616f7fc52306ae2474bd10
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,55 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-05-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `Timeprice::CpiLookup` and `Timeprice::CpiPoint` (Data.define of value +
|
|
12
|
+
granularity). Owns all knowledge of the parsed CPI JSON shape so
|
|
13
|
+
`Inflation.adjust` is a 6-line orchestration.
|
|
14
|
+
- `Timeprice::Sources::Coverage` — isolates runtime filesystem walking
|
|
15
|
+
(FX year scan, JSON.parse of rate files) from the attribution registry.
|
|
16
|
+
- `Timeprice::Point#fx_anchor_date` — resolves a year / month / day `Point`
|
|
17
|
+
to the day-resolved string FX lookup needs (mid-year for `YYYY`,
|
|
18
|
+
mid-month for `YYYY-MM`).
|
|
19
|
+
- `Timeprice::Supported.decimals_for(currency)` — single source of truth
|
|
20
|
+
for ISO 4217 minor-unit counts; non-CLI callers of `Timeprice.exchange`
|
|
21
|
+
can now format results consistently.
|
|
22
|
+
- `Timeprice::CLI::Presenters::{Inflation, Exchange, Compare, Sources}` —
|
|
23
|
+
each presenter exposes `#text_lines` and `#json_hash`; the CLI dispatches
|
|
24
|
+
via a single `#render(presenter)` helper.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- CLI output redesigned for readability: every `inflation`, `fx`, and `compare`
|
|
28
|
+
command now leads with the answer on line 1 (e.g. `3,530,921 VND in 2024`),
|
|
29
|
+
followed by the calculation chain indented below. `head -1` extracts just
|
|
30
|
+
the headline. Numbers are comma-grouped; JSON output is rounded to currency
|
|
31
|
+
precision (no more `1861291.9999999998`).
|
|
32
|
+
- `timeprice sources` now renders as an aligned `ID / SOURCE / LICENSE /
|
|
33
|
+
COVERAGE` table by default. Use `timeprice sources --verbose` (`-v`) for the
|
|
34
|
+
previous detailed view with license URLs and full attribution.
|
|
35
|
+
- Top-level `timeprice help` rewritten — no more truncated descriptions; lists
|
|
36
|
+
command names + descriptions, matching the `git` / `gh` / `cargo` convention.
|
|
37
|
+
- `Point.coerce` rewritten with pattern matching; the CLI's
|
|
38
|
+
`parse_compare_token` now delegates to it instead of re-implementing
|
|
39
|
+
the shape rules.
|
|
40
|
+
- `Compare.resolve_points` uses explicit `raise … unless` guards instead of
|
|
41
|
+
`… || (raise …)` nil-pun.
|
|
42
|
+
|
|
43
|
+
### Removed
|
|
44
|
+
- Undocumented back-compat constants: `Timeprice::SUPPORTED_COUNTRIES`,
|
|
45
|
+
`Timeprice::SUPPORTED_CURRENCIES`, and `Timeprice::Compare::CURRENCY_TO_COUNTRY`.
|
|
46
|
+
Use `Supported::COUNTRIES`, `Supported::CURRENCIES`, and
|
|
47
|
+
`Supported::CURRENCY_TO_COUNTRY` directly.
|
|
48
|
+
- `Lint/DuplicateBranch` RuboCop exclusion for `cli.rb` — the duplicate
|
|
49
|
+
was collapsed into a single `rescue Timeprice::Error, ArgumentError`.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- Friendlier error messages: `Error: AMOUNT must be a number, got "abc"`
|
|
53
|
+
instead of Ruby's raw `invalid value for Float(): "abc"`. Missing-options
|
|
54
|
+
errors now say `missing required options: --from, --to` with a `See:
|
|
55
|
+
timeprice help inflation` hint.
|
|
56
|
+
|
|
8
57
|
## [0.2.0] - 2026-05-11
|
|
9
58
|
|
|
10
59
|
### Added
|
data/README.md
CHANGED
|
@@ -32,16 +32,25 @@ Requires Ruby >= 3.2.
|
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
$ timeprice inflation 100 --from 1990-01 --to 2024-01 --country US
|
|
35
|
-
|
|
35
|
+
242.09 USD in 2024-01
|
|
36
|
+
100.00 USD (1990-01) -> 242.09 USD (2024-01)
|
|
37
|
+
US · monthly CPI
|
|
36
38
|
|
|
37
39
|
$ timeprice fx 100 USD JPY --date 2010-06-15
|
|
38
|
-
|
|
40
|
+
9,118 JPY on 2010-06-15
|
|
41
|
+
100.00 USD -> 9,118 JPY
|
|
42
|
+
rate 91.18
|
|
39
43
|
|
|
40
44
|
$ timeprice compare 100 --from "2010 USD" --to "2024 VND"
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
3,530,921 VND in 2024
|
|
46
|
+
100.00 USD (2010)
|
|
47
|
+
-> fx @ 18,612.92 -> 1,861,292 VND (2010)
|
|
48
|
+
-> inflate x1.8970 VN -> 3,530,921 VND (2024, annual)
|
|
43
49
|
```
|
|
44
50
|
|
|
51
|
+
The first line of each result is the answer — pipe through `head -1` if a
|
|
52
|
+
script only needs the headline figure.
|
|
53
|
+
|
|
45
54
|
Every command supports `--json` for machine-readable output:
|
|
46
55
|
|
|
47
56
|
```bash
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../supported"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
# Number/currency formatting helpers shared by every CLI emitter.
|
|
8
|
+
# Lives as a mixin (rather than a free-standing module function set) so
|
|
9
|
+
# callers can use the helpers as plain methods inside `no_commands` blocks.
|
|
10
|
+
module Formatting
|
|
11
|
+
def fmt_money(amount, currency)
|
|
12
|
+
with_commas(format("%.#{Supported.decimals_for(currency)}f", amount))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Two decimals once we're past the unit threshold; six decimals for
|
|
16
|
+
# sub-unit rates so tiny rates (e.g. 0.000045) still carry signal.
|
|
17
|
+
def fmt_rate(rate)
|
|
18
|
+
decimals = rate.to_f.abs >= 1 ? 2 : 6
|
|
19
|
+
with_commas(format("%.#{decimals}f", rate))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def round_money(amount, currency)
|
|
23
|
+
amount.to_f.round(Supported.decimals_for(currency))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def with_commas(num_str)
|
|
27
|
+
sign = num_str.start_with?("-") ? "-" : ""
|
|
28
|
+
whole, frac = num_str.sub(/\A-/, "").split(".", 2)
|
|
29
|
+
whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
|
|
30
|
+
frac ? "#{sign}#{whole}.#{frac}" : "#{sign}#{whole}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
module Presenters
|
|
8
|
+
# Renders a CompareResult for the CLI in text and JSON formats.
|
|
9
|
+
class Compare
|
|
10
|
+
include Formatting
|
|
11
|
+
|
|
12
|
+
def initialize(result)
|
|
13
|
+
@result = result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def json_hash
|
|
17
|
+
@result.to_h.merge(
|
|
18
|
+
amount: round_money(@result.amount, @result.to_currency),
|
|
19
|
+
original_amount: round_money(@result.original_amount, @result.from_currency),
|
|
20
|
+
converted_amount: round_money(@result.converted_amount, @result.to_currency),
|
|
21
|
+
fx_rate: @result.fx_rate.to_f.round(6),
|
|
22
|
+
cpi_ratio: @result.cpi_ratio.to_f.round(6)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Headline + left-to-right chain so the FX + CPI composition reads naturally.
|
|
27
|
+
def text_lines
|
|
28
|
+
final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
|
|
29
|
+
original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
|
|
30
|
+
converted = "#{fmt_money(@result.converted_amount, @result.to_currency)} #{@result.to_currency}"
|
|
31
|
+
step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
|
|
32
|
+
step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
|
|
33
|
+
width = [step1.length, step2.length].max
|
|
34
|
+
[
|
|
35
|
+
"#{final} in #{@result.to_date}",
|
|
36
|
+
" #{original} (#{@result.from_date})",
|
|
37
|
+
format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
|
|
38
|
+
format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date, @result.granularity),
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
module Presenters
|
|
8
|
+
# Renders an ExchangeResult for the CLI in text and JSON formats.
|
|
9
|
+
class Exchange
|
|
10
|
+
include Formatting
|
|
11
|
+
|
|
12
|
+
def initialize(result)
|
|
13
|
+
@result = result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def json_hash
|
|
17
|
+
@result.to_h.merge(
|
|
18
|
+
amount: round_money(@result.amount, @result.to),
|
|
19
|
+
original_amount: round_money(@result.original_amount, @result.from),
|
|
20
|
+
rate: @result.rate.to_f.round(6)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_lines
|
|
25
|
+
[
|
|
26
|
+
"#{fmt_money(@result.amount, @result.to)} #{@result.to} on #{@result.date}",
|
|
27
|
+
format(" %s %s -> %s %s",
|
|
28
|
+
fmt_money(@result.original_amount, @result.from), @result.from,
|
|
29
|
+
fmt_money(@result.amount, @result.to), @result.to),
|
|
30
|
+
" #{rate_line}",
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def rate_line
|
|
37
|
+
line = "rate #{fmt_rate(@result.rate)}"
|
|
38
|
+
return line unless @result.effective_date && @result.effective_date != @result.date
|
|
39
|
+
|
|
40
|
+
"#{line} from #{@result.effective_date} (fallback)"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
module Presenters
|
|
8
|
+
# Renders an InflationResult for the CLI in text and JSON formats.
|
|
9
|
+
class Inflation
|
|
10
|
+
include Formatting
|
|
11
|
+
|
|
12
|
+
def initialize(result)
|
|
13
|
+
@result = result
|
|
14
|
+
@ccy = result.country_currency_label
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def json_hash
|
|
18
|
+
@result.to_h.merge(
|
|
19
|
+
amount: round_money(@result.amount, @ccy),
|
|
20
|
+
original_amount: round_money(@result.original_amount, @ccy)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_lines
|
|
25
|
+
[
|
|
26
|
+
"#{fmt_money(@result.amount, @ccy)} #{@ccy} in #{@result.to}",
|
|
27
|
+
format(" %s %s (%s) -> %s %s (%s)",
|
|
28
|
+
fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
|
|
29
|
+
fmt_money(@result.amount, @ccy), @ccy, @result.to),
|
|
30
|
+
" #{@result.country} · #{@result.granularity} CPI",
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
module Presenters
|
|
6
|
+
# Renders the sources list in compact-table, verbose, and JSON formats.
|
|
7
|
+
class Sources
|
|
8
|
+
MAX_SOURCE_NAME = 60
|
|
9
|
+
|
|
10
|
+
def initialize(list, verbose: false)
|
|
11
|
+
@list = list
|
|
12
|
+
@verbose = verbose
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def json_hash
|
|
16
|
+
@list
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def text_lines
|
|
20
|
+
@verbose ? verbose_lines : table_lines
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def table_lines
|
|
26
|
+
rows = @list.map do |s|
|
|
27
|
+
[s[:id].to_s, short_source_name(s[:name]), s[:license].to_s, s[:coverage].to_s]
|
|
28
|
+
end
|
|
29
|
+
headers = %w[ID SOURCE LICENSE COVERAGE]
|
|
30
|
+
widths = headers.each_with_index.map { |h, i| [h.length, *rows.map { |r| r[i].length }].max }
|
|
31
|
+
fmt = " %-#{widths[0]}s %-#{widths[1]}s %-#{widths[2]}s %s"
|
|
32
|
+
[
|
|
33
|
+
format(fmt, *headers),
|
|
34
|
+
*rows.map { |r| format(fmt, *r) },
|
|
35
|
+
"",
|
|
36
|
+
"Run `timeprice sources --verbose` for license URLs and full attribution.",
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verbose_lines
|
|
41
|
+
@list.flat_map do |s|
|
|
42
|
+
[
|
|
43
|
+
s[:name].to_s,
|
|
44
|
+
" id: #{s[:id]}",
|
|
45
|
+
" license: #{s[:license]}",
|
|
46
|
+
" license_url: #{s[:license_url]}",
|
|
47
|
+
" attribution: #{s[:attribution]}",
|
|
48
|
+
" coverage: #{s[:coverage]}",
|
|
49
|
+
"",
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Cap the source-name column width. Truncation is last resort — the full
|
|
55
|
+
# name (with series code) is preserved in `--verbose` output.
|
|
56
|
+
def short_source_name(name)
|
|
57
|
+
s = name.to_s
|
|
58
|
+
return s if s.length <= MAX_SOURCE_NAME
|
|
59
|
+
|
|
60
|
+
"#{s[0, MAX_SOURCE_NAME - 1]}…"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../timeprice"
|
|
6
|
+
require_relative "cli/presenters/inflation"
|
|
7
|
+
require_relative "cli/presenters/exchange"
|
|
8
|
+
require_relative "cli/presenters/compare"
|
|
9
|
+
require_relative "cli/presenters/sources"
|
|
6
10
|
|
|
7
11
|
module Timeprice
|
|
8
12
|
class CLI < Thor
|
|
@@ -16,8 +20,59 @@ module Timeprice
|
|
|
16
20
|
|
|
17
21
|
class_option :json, type: :boolean, default: false, desc: "Output result as JSON"
|
|
18
22
|
|
|
23
|
+
# Return false so Thor::Error propagates to our wrapper in `start`, where
|
|
24
|
+
# we prettify the message and add a `See: timeprice help COMMAND` hint.
|
|
19
25
|
def self.exit_on_failure?
|
|
20
|
-
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
KNOWN_COMMANDS = %w[inflation fx compare sources version].freeze
|
|
30
|
+
|
|
31
|
+
# Top-level help lists command names + descriptions only — matching git,
|
|
32
|
+
# gh, cargo. Arg signatures live in per-command help (`timeprice help fx`).
|
|
33
|
+
HELP_ROWS = [
|
|
34
|
+
["inflation", "Inflation-adjust an amount between two dates"],
|
|
35
|
+
["fx", "Convert an amount between currencies on a date"],
|
|
36
|
+
["compare", "Combine FX + inflation across (year, currency) points"],
|
|
37
|
+
["sources", "List bundled data sources and coverage"],
|
|
38
|
+
["version", "Print the installed timeprice version"],
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Pass debug: true so Thor re-raises Thor::Error (including option-parse
|
|
42
|
+
# failures) instead of printing its own message and silently continuing
|
|
43
|
+
# when exit_on_failure? is false. We catch and format ourselves.
|
|
44
|
+
def self.start(given_args = ARGV, config = {})
|
|
45
|
+
super(given_args, config.merge(debug: true))
|
|
46
|
+
rescue Thor::Error => e
|
|
47
|
+
warn "Error: #{prettify_thor_message(e.message)}"
|
|
48
|
+
cmd = given_args.first
|
|
49
|
+
warn " See: timeprice help #{cmd}" if KNOWN_COMMANDS.include?(cmd)
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.prettify_thor_message(msg)
|
|
54
|
+
msg
|
|
55
|
+
.sub(/\ANo value provided for required options /, "missing required options: ")
|
|
56
|
+
.gsub("'", "")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Thor's API dictates the positional boolean signature — keyword arg
|
|
60
|
+
# would break the override.
|
|
61
|
+
def self.help(shell, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
62
|
+
return super if subcommand
|
|
63
|
+
|
|
64
|
+
shell.say "timeprice — offline historical inflation & FX for Ruby"
|
|
65
|
+
shell.say ""
|
|
66
|
+
shell.say "Commands:"
|
|
67
|
+
width = HELP_ROWS.map { |usage, _| usage.length }.max
|
|
68
|
+
HELP_ROWS.each do |usage, desc|
|
|
69
|
+
shell.say format(" %-#{width}s %s", usage, desc)
|
|
70
|
+
end
|
|
71
|
+
shell.say ""
|
|
72
|
+
shell.say "Global options:"
|
|
73
|
+
shell.say " --json Output result as JSON"
|
|
74
|
+
shell.say ""
|
|
75
|
+
shell.say "Run `timeprice help COMMAND` for usage and options."
|
|
21
76
|
end
|
|
22
77
|
|
|
23
78
|
desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
|
|
@@ -27,12 +82,12 @@ module Timeprice
|
|
|
27
82
|
def inflation(amount)
|
|
28
83
|
with_error_handling do
|
|
29
84
|
result = Timeprice.inflation(
|
|
30
|
-
amount:
|
|
85
|
+
amount: parse_amount(amount),
|
|
31
86
|
from: options[:from],
|
|
32
87
|
to: options[:to],
|
|
33
88
|
country: options[:country]
|
|
34
89
|
)
|
|
35
|
-
|
|
90
|
+
render Presenters::Inflation.new(result)
|
|
36
91
|
end
|
|
37
92
|
end
|
|
38
93
|
|
|
@@ -41,12 +96,12 @@ module Timeprice
|
|
|
41
96
|
def fx(amount, from_currency, to_currency)
|
|
42
97
|
with_error_handling do
|
|
43
98
|
result = Timeprice.exchange(
|
|
44
|
-
amount:
|
|
99
|
+
amount: parse_amount(amount),
|
|
45
100
|
from: from_currency,
|
|
46
101
|
to: to_currency,
|
|
47
102
|
date: options[:date]
|
|
48
103
|
)
|
|
49
|
-
|
|
104
|
+
render Presenters::Exchange.new(result)
|
|
50
105
|
end
|
|
51
106
|
end
|
|
52
107
|
|
|
@@ -58,30 +113,19 @@ module Timeprice
|
|
|
58
113
|
from_tuple = parse_compare_token(options[:from], label: "--from")
|
|
59
114
|
to_tuple = parse_compare_token(options[:to], label: "--to")
|
|
60
115
|
result = Timeprice.compare(
|
|
61
|
-
amount:
|
|
116
|
+
amount: parse_amount(amount),
|
|
62
117
|
from: from_tuple,
|
|
63
118
|
to: to_tuple
|
|
64
119
|
)
|
|
65
|
-
|
|
120
|
+
render Presenters::Compare.new(result)
|
|
66
121
|
end
|
|
67
122
|
end
|
|
68
123
|
|
|
69
124
|
desc "sources", "List bundled data sources and coverage"
|
|
125
|
+
method_option :verbose, type: :boolean, default: false, aliases: "-v",
|
|
126
|
+
desc: "Include license URLs and full attribution"
|
|
70
127
|
def sources
|
|
71
|
-
|
|
72
|
-
if options[:json]
|
|
73
|
-
say JSON.generate(list)
|
|
74
|
-
else
|
|
75
|
-
list.each do |s|
|
|
76
|
-
say s[:name].to_s
|
|
77
|
-
say " id: #{s[:id]}"
|
|
78
|
-
say " license: #{s[:license]}"
|
|
79
|
-
say " license_url: #{s[:license_url]}"
|
|
80
|
-
say " attribution: #{s[:attribution]}"
|
|
81
|
-
say " coverage: #{s[:coverage]}"
|
|
82
|
-
say ""
|
|
83
|
-
end
|
|
84
|
-
end
|
|
128
|
+
render Presenters::Sources.new(Timeprice::Sources.list, verbose: options[:verbose])
|
|
85
129
|
end
|
|
86
130
|
|
|
87
131
|
desc "version", "Print the installed timeprice version"
|
|
@@ -94,19 +138,27 @@ module Timeprice
|
|
|
94
138
|
end
|
|
95
139
|
|
|
96
140
|
no_commands do
|
|
97
|
-
|
|
98
|
-
|
|
141
|
+
def render(presenter)
|
|
142
|
+
if options[:json]
|
|
143
|
+
say JSON.generate(presenter.json_hash)
|
|
144
|
+
else
|
|
145
|
+
presenter.text_lines.each { |line| say line }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
99
148
|
|
|
100
149
|
def with_error_handling
|
|
101
150
|
yield
|
|
102
|
-
rescue Timeprice::Error => e
|
|
103
|
-
warn "Error: #{e.message}"
|
|
104
|
-
exit 1
|
|
105
|
-
rescue ArgumentError => e
|
|
151
|
+
rescue Timeprice::Error, ArgumentError => e
|
|
106
152
|
warn "Error: #{e.message}"
|
|
107
153
|
exit 1
|
|
108
154
|
end
|
|
109
155
|
|
|
156
|
+
def parse_amount(raw)
|
|
157
|
+
Float(raw)
|
|
158
|
+
rescue ArgumentError, TypeError
|
|
159
|
+
raise ArgumentError, "AMOUNT must be a number, got #{raw.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
110
162
|
def parse_compare_token(token, label:)
|
|
111
163
|
raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
112
164
|
|
|
@@ -115,96 +167,13 @@ module Timeprice
|
|
|
115
167
|
raise ArgumentError,
|
|
116
168
|
"#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
|
|
117
169
|
end
|
|
118
|
-
year = parts.find { |p| p.match?(/\A\d{4}\z/) }
|
|
119
|
-
currency = parts.find { |p| p.match?(/\A[A-Za-z]{3}\z/) }
|
|
120
|
-
if year.nil? || currency.nil?
|
|
121
|
-
raise ArgumentError,
|
|
122
|
-
"#{label} must contain a 4-digit year and a 3-letter currency code, got #{token.inspect}"
|
|
123
|
-
end
|
|
124
|
-
[currency.upcase, year]
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def fmt_money(amount, currency)
|
|
128
|
-
decimals = ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
|
|
129
|
-
format("%.#{decimals}f", amount)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def fmt_rate(rate)
|
|
133
|
-
abs = rate.to_f.abs
|
|
134
|
-
decimals = if abs >= 1000 then 0
|
|
135
|
-
elsif abs >= 100 then 2
|
|
136
|
-
elsif abs >= 10 then 3
|
|
137
|
-
else 4
|
|
138
|
-
end
|
|
139
|
-
format("%.#{decimals}f", rate)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Granularity is loud noise on the happy path. Only surface it when the
|
|
143
|
-
# answer actually used annual data — that's where users want a heads-up.
|
|
144
|
-
def granularity_suffix(granularity)
|
|
145
|
-
return "" if granularity == :monthly
|
|
146
|
-
|
|
147
|
-
" (granularity: #{granularity})"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def emit_inflation(result)
|
|
151
|
-
if options[:json]
|
|
152
|
-
say JSON.generate(result.to_h)
|
|
153
|
-
else
|
|
154
|
-
ccy = result.country_currency_label
|
|
155
|
-
say format(
|
|
156
|
-
"%s %s in %s is %s %s in %s [%s]%s",
|
|
157
|
-
fmt_money(result.original_amount, ccy), ccy, result.from,
|
|
158
|
-
fmt_money(result.amount, ccy), ccy, result.to,
|
|
159
|
-
result.country, granularity_suffix(result.granularity)
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
else
|
|
168
|
-
line = format(
|
|
169
|
-
"%s %s on %s = %s %s (rate: %s)",
|
|
170
|
-
fmt_money(result.original_amount, result.from), result.from, result.date,
|
|
171
|
-
fmt_money(result.amount, result.to), result.to, fmt_rate(result.rate)
|
|
172
|
-
)
|
|
173
|
-
if result.effective_date && result.effective_date != result.date
|
|
174
|
-
line += " [effective: #{result.effective_date}, fallback]"
|
|
175
|
-
end
|
|
176
|
-
say line
|
|
177
|
-
end
|
|
178
|
-
end
|
|
171
|
+
Point.coerce(parts)
|
|
172
|
+
rescue ArgumentError => e
|
|
173
|
+
raise if e.message.start_with?(label)
|
|
179
174
|
|
|
180
|
-
|
|
181
|
-
if options[:json]
|
|
182
|
-
say JSON.generate(result.to_h)
|
|
183
|
-
else
|
|
184
|
-
say format(
|
|
185
|
-
"%s %s in %s -> %s %s in %s",
|
|
186
|
-
fmt_money(result.original_amount, result.from_currency), result.from_currency, result.from_date,
|
|
187
|
-
fmt_money(result.amount, result.to_currency), result.to_currency, result.to_date
|
|
188
|
-
)
|
|
189
|
-
say format(
|
|
190
|
-
" steps: %s %s -> %s %s (fx %s on %s), then inflate in %s x%.4f%s",
|
|
191
|
-
fmt_money(result.original_amount, result.from_currency), result.from_currency,
|
|
192
|
-
fmt_money(result.converted_amount, result.to_currency), result.to_currency,
|
|
193
|
-
fmt_rate(result.fx_rate), result.from_date,
|
|
194
|
-
result.country, result.cpi_ratio, granularity_suffix(result.granularity)
|
|
195
|
-
)
|
|
196
|
-
end
|
|
175
|
+
raise ArgumentError, "#{label}: #{e.message}"
|
|
197
176
|
end
|
|
198
177
|
end
|
|
199
178
|
end
|
|
200
179
|
end
|
|
201
|
-
|
|
202
|
-
# Tiny shim so we can include currency context in the inflation line without
|
|
203
|
-
# bloating the value object — the result doesn't carry currency, only country.
|
|
204
|
-
module Timeprice
|
|
205
|
-
class InflationResult
|
|
206
|
-
def country_currency_label
|
|
207
|
-
Supported.currency_for_country(country) || country.to_s.upcase
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -28,10 +28,6 @@ module Timeprice
|
|
|
28
28
|
# If a future refactor flips the order, the regression test in
|
|
29
29
|
# spec/timeprice/compare_spec.rb will fail.
|
|
30
30
|
module Compare
|
|
31
|
-
# Map ISO currency → CPI country code. Kept as a back-compat alias;
|
|
32
|
-
# the canonical map lives in {Supported::CURRENCY_TO_COUNTRY}.
|
|
33
|
-
CURRENCY_TO_COUNTRY = Supported::CURRENCY_TO_COUNTRY
|
|
34
|
-
|
|
35
31
|
module_function
|
|
36
32
|
|
|
37
33
|
# Compare an amount across two (currency, date) points.
|
|
@@ -43,15 +39,14 @@ module Timeprice
|
|
|
43
39
|
# @return [CompareResult]
|
|
44
40
|
# @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
|
|
45
41
|
def run(amount:, from:, to:)
|
|
46
|
-
|
|
42
|
+
from_point, to_point, to_country = resolve_points(from, to)
|
|
47
43
|
|
|
48
44
|
# Step 1: convert at source date into destination currency.
|
|
49
|
-
fx_date = normalize_fx_date(from_date)
|
|
50
45
|
fx_result = Exchange.convert(
|
|
51
46
|
amount: amount,
|
|
52
|
-
from:
|
|
53
|
-
to:
|
|
54
|
-
date:
|
|
47
|
+
from: from_point.currency,
|
|
48
|
+
to: to_point.currency,
|
|
49
|
+
date: from_point.fx_anchor_date
|
|
55
50
|
)
|
|
56
51
|
converted = fx_result.amount
|
|
57
52
|
|
|
@@ -59,18 +54,18 @@ module Timeprice
|
|
|
59
54
|
# destination date using destination-country CPI.
|
|
60
55
|
infl = Inflation.adjust(
|
|
61
56
|
amount: converted,
|
|
62
|
-
from:
|
|
63
|
-
to:
|
|
57
|
+
from: from_point.date.to_s,
|
|
58
|
+
to: to_point.date.to_s,
|
|
64
59
|
country: to_country
|
|
65
60
|
)
|
|
66
61
|
|
|
67
62
|
CompareResult.new(
|
|
68
63
|
amount: infl.amount,
|
|
69
64
|
original_amount: amount.to_f,
|
|
70
|
-
from_currency:
|
|
71
|
-
from_date:
|
|
72
|
-
to_currency:
|
|
73
|
-
to_date:
|
|
65
|
+
from_currency: from_point.currency,
|
|
66
|
+
from_date: from_point.date.to_s,
|
|
67
|
+
to_currency: to_point.currency,
|
|
68
|
+
to_date: to_point.date.to_s,
|
|
74
69
|
country: to_country,
|
|
75
70
|
fx_rate: fx_result.rate,
|
|
76
71
|
cpi_ratio: infl.to_index.to_f / infl.from_index,
|
|
@@ -79,29 +74,16 @@ module Timeprice
|
|
|
79
74
|
)
|
|
80
75
|
end
|
|
81
76
|
|
|
82
|
-
# Coerce both points and resolve to_country.
|
|
77
|
+
# Coerce both points and resolve to_country.
|
|
83
78
|
def resolve_points(from, to)
|
|
84
79
|
from_point = Point.coerce(from)
|
|
85
80
|
to_point = Point.coerce(to)
|
|
86
|
-
|
|
87
|
-
to_currency = to_point.currency
|
|
88
|
-
to_country = Supported.country_for_currency(to_currency) ||
|
|
89
|
-
(raise UnsupportedCurrency, to_currency)
|
|
90
|
-
Supported.country_for_currency(from_currency) || (raise UnsupportedCurrency, from_currency)
|
|
81
|
+
raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
|
|
91
82
|
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
to_country = Supported.country_for_currency(to_point.currency)
|
|
84
|
+
raise UnsupportedCurrency, to_point.currency unless to_country
|
|
94
85
|
|
|
95
|
-
|
|
96
|
-
# If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
|
|
97
|
-
def normalize_fx_date(date)
|
|
98
|
-
s = date.to_s
|
|
99
|
-
case s
|
|
100
|
-
when /\A\d{4}\z/ then "#{s}-06-30"
|
|
101
|
-
when /\A\d{4}-\d{2}\z/ then "#{s}-15"
|
|
102
|
-
when /\A\d{4}-\d{2}-\d{2}\z/ then s
|
|
103
|
-
else raise ArgumentError, "Invalid date for compare: #{date.inspect}"
|
|
104
|
-
end
|
|
86
|
+
[from_point, to_point, to_country]
|
|
105
87
|
end
|
|
106
88
|
end
|
|
107
89
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
# CpiPoint pairs a CPI index value with the granularity of how it was
|
|
7
|
+
# resolved (monthly, annual, or annual derived by averaging 12 months).
|
|
8
|
+
CpiPoint = Data.define(:value, :granularity)
|
|
9
|
+
|
|
10
|
+
# Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
|
|
11
|
+
# country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
|
|
12
|
+
# "annual" string keys) is isolated here — Inflation just asks for points.
|
|
13
|
+
class CpiLookup
|
|
14
|
+
def initialize(data)
|
|
15
|
+
@data = data
|
|
16
|
+
@monthly = data["monthly"] || {}
|
|
17
|
+
@annual = data["annual"] || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param key [String] "YYYY" or "YYYY-MM"
|
|
21
|
+
# @return [CpiPoint]
|
|
22
|
+
# @raise [DataNotFound] if no CPI value covers `key`
|
|
23
|
+
# @raise [ArgumentError] on malformed `key`
|
|
24
|
+
def at(key)
|
|
25
|
+
key = key.to_s
|
|
26
|
+
case key
|
|
27
|
+
when /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
|
|
28
|
+
when /\A\d{4}\z/ then annual_or_monthly_average(key)
|
|
29
|
+
else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def monthly_or_annual_fallback(month_key)
|
|
36
|
+
return CpiPoint.new(value: @monthly[month_key], granularity: :monthly) if @monthly.key?(month_key)
|
|
37
|
+
|
|
38
|
+
year = month_key[0, 4]
|
|
39
|
+
raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
|
|
40
|
+
|
|
41
|
+
CpiPoint.new(value: @annual[year], granularity: :annual)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def annual_or_monthly_average(year)
|
|
45
|
+
return CpiPoint.new(value: @annual[year], granularity: :annual) if @annual.key?(year)
|
|
46
|
+
|
|
47
|
+
months = @monthly.select { |k, _| k.start_with?("#{year}-") }
|
|
48
|
+
raise DataNotFound, missing_message(year) if months.empty?
|
|
49
|
+
|
|
50
|
+
CpiPoint.new(value: months.values.sum.to_f / months.size, granularity: :annual_from_monthly_avg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def missing_message(key)
|
|
54
|
+
country = @data["country"]
|
|
55
|
+
ranges = []
|
|
56
|
+
ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
|
|
57
|
+
ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
|
|
58
|
+
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
59
|
+
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "supported"
|
|
5
6
|
|
|
6
7
|
module Timeprice
|
|
7
8
|
# Loads and caches the bundled JSON data files. Override the search root
|
|
@@ -40,11 +41,10 @@ module Timeprice
|
|
|
40
41
|
# @raise [DataNotFound] if the file is missing
|
|
41
42
|
# @raise [UnsupportedSchemaVersion] if the file uses a future schema
|
|
42
43
|
def load_cpi(country)
|
|
43
|
-
@cpi_cache ||= {}
|
|
44
44
|
key = country.to_s.downcase
|
|
45
45
|
code = country.to_s.upcase
|
|
46
|
-
|
|
47
|
-
raise UnsupportedCountry, code unless
|
|
46
|
+
cpi_cache[[data_root, key]] ||= begin
|
|
47
|
+
raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
|
|
48
48
|
|
|
49
49
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
50
50
|
unless File.exist?(path)
|
|
@@ -61,9 +61,8 @@ module Timeprice
|
|
|
61
61
|
# @return [Hash] parsed JSON with a "rates" map of date → currency → Float
|
|
62
62
|
# @raise [DataNotFound] if the per-year file is missing
|
|
63
63
|
def load_fx_year(year)
|
|
64
|
-
@fx_cache ||= {}
|
|
65
64
|
key = year.to_i
|
|
66
|
-
|
|
65
|
+
fx_cache[[data_root, key]] ||= begin
|
|
67
66
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
68
67
|
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
69
68
|
|
|
@@ -73,6 +72,14 @@ module Timeprice
|
|
|
73
72
|
|
|
74
73
|
private
|
|
75
74
|
|
|
75
|
+
def cpi_cache
|
|
76
|
+
@cpi_cache ||= {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fx_cache
|
|
80
|
+
@fx_cache ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
76
83
|
def parse_with_schema(path)
|
|
77
84
|
data = JSON.parse(File.read(path))
|
|
78
85
|
version = data["schema_version"]
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "data_loader"
|
|
6
|
+
require_relative "supported"
|
|
6
7
|
|
|
7
8
|
module Timeprice
|
|
8
9
|
ExchangeResult = Data.define(
|
|
@@ -31,8 +32,8 @@ module Timeprice
|
|
|
31
32
|
def convert(amount:, from:, to:, date:)
|
|
32
33
|
from = from.to_s.upcase
|
|
33
34
|
to = to.to_s.upcase
|
|
34
|
-
raise UnsupportedCurrency, from unless
|
|
35
|
-
raise UnsupportedCurrency, to unless
|
|
35
|
+
raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
|
|
36
|
+
raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
|
|
36
37
|
|
|
37
38
|
d = parse_date(date)
|
|
38
39
|
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
require_relative "data_loader"
|
|
5
|
+
require_relative "cpi_lookup"
|
|
5
6
|
|
|
6
7
|
module Timeprice
|
|
7
8
|
# Value object returned by Inflation.adjust.
|
|
@@ -14,7 +15,15 @@ module Timeprice
|
|
|
14
15
|
InflationResult = Data.define(
|
|
15
16
|
:amount, :original_amount, :from, :to, :country,
|
|
16
17
|
:from_index, :to_index, :granularity
|
|
17
|
-
)
|
|
18
|
+
) do
|
|
19
|
+
# The country's primary currency (e.g. "USD" for "US"). Falls back to the
|
|
20
|
+
# uppercased country code if the country isn't in the supported map —
|
|
21
|
+
# callers can still render *some* unit rather than crashing.
|
|
22
|
+
def country_currency_label
|
|
23
|
+
require_relative "supported"
|
|
24
|
+
Supported.currency_for_country(country) || country.to_s.upcase
|
|
25
|
+
end
|
|
26
|
+
end
|
|
18
27
|
|
|
19
28
|
# CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
|
|
20
29
|
module Inflation
|
|
@@ -32,20 +41,20 @@ module Timeprice
|
|
|
32
41
|
# @raise [UnsupportedCountry] if `country` is not supported
|
|
33
42
|
# @raise [DataNotFound] if no CPI data covers the requested period
|
|
34
43
|
def adjust(amount:, from:, to:, country:)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
lookup = CpiLookup.new(DataLoader.load_cpi(country))
|
|
45
|
+
from_point = lookup.at(from)
|
|
46
|
+
to_point = lookup.at(to)
|
|
38
47
|
|
|
39
|
-
ratio =
|
|
48
|
+
ratio = to_point.value.to_f / from_point.value
|
|
40
49
|
InflationResult.new(
|
|
41
50
|
amount: amount.to_f * ratio,
|
|
42
51
|
original_amount: amount.to_f,
|
|
43
52
|
from: from,
|
|
44
53
|
to: to,
|
|
45
54
|
country: country.to_s.upcase,
|
|
46
|
-
from_index:
|
|
47
|
-
to_index:
|
|
48
|
-
granularity: merge_granularity(
|
|
55
|
+
from_index: from_point.value,
|
|
56
|
+
to_index: to_point.value,
|
|
57
|
+
granularity: merge_granularity(from_point.granularity, to_point.granularity)
|
|
49
58
|
)
|
|
50
59
|
end
|
|
51
60
|
|
|
@@ -60,53 +69,6 @@ module Timeprice
|
|
|
60
69
|
result.amount - 1.0
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
# Returns [index_value, granularity_symbol]
|
|
64
|
-
def lookup_index(data, key)
|
|
65
|
-
key = key.to_s
|
|
66
|
-
monthly = data["monthly"] || {}
|
|
67
|
-
annual = data["annual"] || {}
|
|
68
|
-
|
|
69
|
-
case key
|
|
70
|
-
when /\A\d{4}-\d{2}\z/
|
|
71
|
-
if monthly.key?(key)
|
|
72
|
-
[monthly[key], :monthly]
|
|
73
|
-
else
|
|
74
|
-
year = key[0, 4]
|
|
75
|
-
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
|
|
76
|
-
|
|
77
|
-
[annual[year], :annual]
|
|
78
|
-
|
|
79
|
-
end
|
|
80
|
-
when /\A\d{4}\z/
|
|
81
|
-
if annual.key?(key)
|
|
82
|
-
[annual[key], :annual]
|
|
83
|
-
else
|
|
84
|
-
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
85
|
-
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
|
|
86
|
-
|
|
87
|
-
avg = months.values.sum.to_f / months.size
|
|
88
|
-
[avg, :annual_from_monthly_avg]
|
|
89
|
-
end
|
|
90
|
-
else
|
|
91
|
-
raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def missing_cpi_message(key, data, monthly, annual)
|
|
96
|
-
country = data["country"]
|
|
97
|
-
ranges = []
|
|
98
|
-
if monthly.any?
|
|
99
|
-
ks = monthly.keys.sort
|
|
100
|
-
ranges << "monthly #{ks.first}..#{ks.last}"
|
|
101
|
-
end
|
|
102
|
-
if annual.any?
|
|
103
|
-
ks = annual.keys.sort
|
|
104
|
-
ranges << "annual #{ks.first}..#{ks.last}"
|
|
105
|
-
end
|
|
106
|
-
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
107
|
-
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
72
|
# If either end fell back to annual_from_monthly_avg, propagate that label;
|
|
111
73
|
# else if either is annual, propagate :annual; else :monthly.
|
|
112
74
|
def merge_granularity(a, b)
|
data/lib/timeprice/point.rb
CHANGED
|
@@ -21,23 +21,42 @@ module Timeprice
|
|
|
21
21
|
# @return [Point]
|
|
22
22
|
# @raise [ArgumentError] if shape can't be recognised
|
|
23
23
|
def self.coerce(input)
|
|
24
|
-
|
|
24
|
+
case input
|
|
25
|
+
in Point
|
|
26
|
+
input
|
|
27
|
+
in [_, _]
|
|
28
|
+
a, b = input.map(&:to_s)
|
|
29
|
+
currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
|
|
30
|
+
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?
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
new(currency: currency.upcase, date: date)
|
|
34
|
+
else
|
|
27
35
|
raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
|
|
28
36
|
end
|
|
37
|
+
end
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
currency
|
|
32
|
-
|
|
39
|
+
def self.malformed_pair_message(input)
|
|
40
|
+
"Could not detect currency + date in #{input.inspect} " \
|
|
41
|
+
"(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
|
|
42
|
+
end
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
# Resolve `date` to a full YYYY-MM-DD for FX lookup.
|
|
45
|
+
#
|
|
46
|
+
# Coarser grains anchor to a representative day:
|
|
47
|
+
# - "YYYY" → mid-year (YYYY-06-30)
|
|
48
|
+
# - "YYYY-MM" → mid-month (YYYY-MM-15)
|
|
49
|
+
# - "YYYY-MM-DD" → passes through
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
# @raise [ArgumentError] if `date` doesn't match any supported shape
|
|
53
|
+
def fx_anchor_date
|
|
54
|
+
case date.to_s
|
|
55
|
+
when /\A\d{4}\z/ then "#{date}-06-30"
|
|
56
|
+
when /\A\d{4}-\d{2}\z/ then "#{date}-15"
|
|
57
|
+
when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
|
|
58
|
+
else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
|
|
38
59
|
end
|
|
39
|
-
|
|
40
|
-
new(currency: currency.upcase, date: date)
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../data_loader"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
module Sources
|
|
8
|
+
# Computes coverage strings for bundled data sources at runtime. All
|
|
9
|
+
# filesystem reads happen here so the Sources attribution registry stays
|
|
10
|
+
# a pure data table.
|
|
11
|
+
module Coverage
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param src [Hash] one entry from Sources::ATTRIBUTIONS
|
|
15
|
+
# @return [String]
|
|
16
|
+
def for(src)
|
|
17
|
+
case src[:kind]
|
|
18
|
+
when "cpi" then cpi(src[:country])
|
|
19
|
+
when "fx" then fx(src[:id])
|
|
20
|
+
else "n/a"
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
"(coverage unavailable: #{e.message})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cpi(country)
|
|
27
|
+
data = DataLoader.load_cpi(country)
|
|
28
|
+
monthly = (data["monthly"] || {}).keys.sort
|
|
29
|
+
annual = (data["annual"] || {}).keys.sort
|
|
30
|
+
parts = []
|
|
31
|
+
parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
|
|
32
|
+
parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
|
|
33
|
+
parts.join(", ")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fx(id)
|
|
37
|
+
years = fx_years
|
|
38
|
+
return "no data" if years.empty?
|
|
39
|
+
|
|
40
|
+
id == "fx_vnd" ? vnd_summary(years) : ecb_summary(years)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fx_years
|
|
44
|
+
Dir[File.join(fx_root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def vnd_summary(years)
|
|
48
|
+
with_vnd = years.select { |y| year_has_currency?(y, %w[VND]) }
|
|
49
|
+
return "no VND data" if with_vnd.empty?
|
|
50
|
+
|
|
51
|
+
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ecb_summary(years)
|
|
55
|
+
ecb_years = years.select { |y| year_has_currency?(y, %w[EUR GBP JPY]) }
|
|
56
|
+
return "no ECB data" if ecb_years.empty?
|
|
57
|
+
|
|
58
|
+
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def year_has_currency?(year, codes)
|
|
62
|
+
rates = JSON.parse(File.read(File.join(fx_root, "#{year}.json")))["rates"]
|
|
63
|
+
rates.any? { |_, v| v.keys.intersect?(codes) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fx_root
|
|
67
|
+
File.join(DataLoader.data_root, "fx", "usd")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/timeprice/sources.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "sources/coverage"
|
|
4
4
|
|
|
5
5
|
module Timeprice
|
|
6
6
|
# Enumerate bundled data sources with license/attribution and the actual
|
|
@@ -79,54 +79,7 @@ module Timeprice
|
|
|
79
79
|
# :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
|
|
80
80
|
# @return [Array<Hash>]
|
|
81
81
|
def list
|
|
82
|
-
ATTRIBUTIONS.map { |s| s.merge(coverage:
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def coverage_for(src)
|
|
86
|
-
case src[:kind]
|
|
87
|
-
when "cpi" then cpi_coverage(src[:country])
|
|
88
|
-
when "fx" then fx_coverage(src[:id])
|
|
89
|
-
else "n/a"
|
|
90
|
-
end
|
|
91
|
-
rescue StandardError => e
|
|
92
|
-
"(coverage unavailable: #{e.message})"
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def cpi_coverage(country)
|
|
96
|
-
data = DataLoader.load_cpi(country)
|
|
97
|
-
monthly = (data["monthly"] || {}).keys.sort
|
|
98
|
-
annual = (data["annual"] || {}).keys.sort
|
|
99
|
-
parts = []
|
|
100
|
-
parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
|
|
101
|
-
parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
|
|
102
|
-
parts.join(", ")
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def fx_coverage(id)
|
|
106
|
-
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
107
|
-
years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
108
|
-
return "no data" if years.empty?
|
|
109
|
-
|
|
110
|
-
case id
|
|
111
|
-
when "fx_vnd"
|
|
112
|
-
# VND broadcast-from-annual covers earlier years too.
|
|
113
|
-
with_vnd = years.select do |y|
|
|
114
|
-
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
115
|
-
d["rates"].any? { |_, v| v.key?("VND") }
|
|
116
|
-
end
|
|
117
|
-
return "no VND data" if with_vnd.empty?
|
|
118
|
-
|
|
119
|
-
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
120
|
-
else
|
|
121
|
-
# ECB pairs (EUR/GBP/JPY) start 1999
|
|
122
|
-
ecb_years = years.select do |y|
|
|
123
|
-
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
124
|
-
d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
|
|
125
|
-
end
|
|
126
|
-
return "no ECB data" if ecb_years.empty?
|
|
127
|
-
|
|
128
|
-
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
129
|
-
end
|
|
82
|
+
ATTRIBUTIONS.map { |s| s.merge(coverage: Coverage.for(s)) }
|
|
130
83
|
end
|
|
131
84
|
end
|
|
132
85
|
end
|
data/lib/timeprice/supported.rb
CHANGED
|
@@ -21,8 +21,20 @@ module Timeprice
|
|
|
21
21
|
|
|
22
22
|
CURRENCY_TO_COUNTRY = COUNTRY_TO_CURRENCY.invert.freeze
|
|
23
23
|
|
|
24
|
+
# Currencies with no minor unit — formatted as whole numbers.
|
|
25
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
|
|
26
|
+
|
|
24
27
|
module_function
|
|
25
28
|
|
|
29
|
+
# ISO 4217 minor-unit count for a currency. Falls back to 2 for unknown
|
|
30
|
+
# codes so callers can still render *some* value rather than crashing.
|
|
31
|
+
#
|
|
32
|
+
# @param currency [String]
|
|
33
|
+
# @return [Integer]
|
|
34
|
+
def decimals_for(currency)
|
|
35
|
+
ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
# @param country [String]
|
|
27
39
|
# @return [Boolean]
|
|
28
40
|
def country?(country)
|
|
@@ -47,9 +59,4 @@ module Timeprice
|
|
|
47
59
|
COUNTRY_TO_CURRENCY[country.to_s.upcase]
|
|
48
60
|
end
|
|
49
61
|
end
|
|
50
|
-
|
|
51
|
-
# Back-compat aliases — keep the old top-level constants pointing at the
|
|
52
|
-
# canonical lists so existing requires of "errors" keep working.
|
|
53
|
-
SUPPORTED_COUNTRIES = Supported::COUNTRIES
|
|
54
|
-
SUPPORTED_CURRENCIES = Supported::CURRENCIES
|
|
55
62
|
end
|
data/lib/timeprice/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -157,13 +157,20 @@ files:
|
|
|
157
157
|
- exe/timeprice
|
|
158
158
|
- lib/timeprice.rb
|
|
159
159
|
- lib/timeprice/cli.rb
|
|
160
|
+
- lib/timeprice/cli/formatting.rb
|
|
161
|
+
- lib/timeprice/cli/presenters/compare.rb
|
|
162
|
+
- lib/timeprice/cli/presenters/exchange.rb
|
|
163
|
+
- lib/timeprice/cli/presenters/inflation.rb
|
|
164
|
+
- lib/timeprice/cli/presenters/sources.rb
|
|
160
165
|
- lib/timeprice/compare.rb
|
|
166
|
+
- lib/timeprice/cpi_lookup.rb
|
|
161
167
|
- lib/timeprice/data_loader.rb
|
|
162
168
|
- lib/timeprice/errors.rb
|
|
163
169
|
- lib/timeprice/exchange.rb
|
|
164
170
|
- lib/timeprice/inflation.rb
|
|
165
171
|
- lib/timeprice/point.rb
|
|
166
172
|
- lib/timeprice/sources.rb
|
|
173
|
+
- lib/timeprice/sources/coverage.rb
|
|
167
174
|
- lib/timeprice/supported.rb
|
|
168
175
|
- lib/timeprice/version.rb
|
|
169
176
|
homepage: https://github.com/patrick204nqh/timeprice
|