timeprice 0.2.0 → 0.4.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 +82 -0
- data/DATA_LICENSES.md +2 -1
- data/README.md +15 -6
- data/data/cpi/eu.json +23 -1
- data/data/cpi/jp.json +18 -2
- data/data/cpi/uk.json +23 -1
- data/data/cpi/us.json +29 -1
- data/data/cpi/vn.json +362 -34
- data/data/fx/usd/1983.json +7 -8
- data/data/fx/usd/1986.json +7 -8
- data/data/fx/usd/1987.json +7 -8
- data/data/fx/usd/1988.json +7 -8
- data/data/fx/usd/1989.json +7 -8
- data/data/fx/usd/1990.json +7 -8
- data/data/fx/usd/1991.json +7 -8
- data/data/fx/usd/1992.json +7 -8
- data/data/fx/usd/1993.json +7 -8
- data/data/fx/usd/1994.json +7 -8
- data/data/fx/usd/1995.json +7 -8
- data/data/fx/usd/1996.json +7 -8
- data/data/fx/usd/1997.json +7 -8
- data/data/fx/usd/1998.json +7 -8
- data/data/fx/usd/1999.json +266 -525
- data/data/fx/usd/2000.json +262 -517
- data/data/fx/usd/2001.json +261 -512
- data/data/fx/usd/2002.json +262 -514
- data/data/fx/usd/2003.json +262 -514
- data/data/fx/usd/2004.json +266 -522
- data/data/fx/usd/2005.json +264 -521
- data/data/fx/usd/2006.json +262 -514
- data/data/fx/usd/2007.json +262 -514
- data/data/fx/usd/2008.json +263 -516
- data/data/fx/usd/2009.json +263 -516
- data/data/fx/usd/2010.json +265 -523
- data/data/fx/usd/2011.json +264 -521
- data/data/fx/usd/2012.json +263 -516
- data/data/fx/usd/2013.json +262 -514
- data/data/fx/usd/2014.json +262 -514
- data/data/fx/usd/2015.json +263 -516
- data/data/fx/usd/2016.json +264 -521
- data/data/fx/usd/2017.json +262 -514
- data/data/fx/usd/2018.json +262 -514
- data/data/fx/usd/2019.json +262 -514
- data/data/fx/usd/2020.json +264 -518
- data/data/fx/usd/2021.json +265 -523
- data/data/fx/usd/2022.json +264 -521
- data/data/fx/usd/2023.json +262 -514
- data/data/fx/usd/2024.json +263 -516
- data/data/fx/usd/2025.json +5 -5
- data/data/fx/usd/2026.json +5 -5
- data/lib/timeprice/cli/formatting.rb +34 -0
- data/lib/timeprice/cli/presenters/compare.rb +46 -0
- data/lib/timeprice/cli/presenters/exchange.rb +45 -0
- data/lib/timeprice/cli/presenters/inflation.rb +37 -0
- data/lib/timeprice/cli/presenters/sources.rb +65 -0
- data/lib/timeprice/cli.rb +83 -114
- data/lib/timeprice/compare.rb +17 -34
- data/lib/timeprice/cpi_lookup.rb +64 -0
- data/lib/timeprice/data_loader.rb +13 -6
- data/lib/timeprice/exchange.rb +35 -17
- data/lib/timeprice/granularity.rb +46 -0
- data/lib/timeprice/inflation.rb +20 -71
- data/lib/timeprice/point.rb +30 -11
- data/lib/timeprice/sources/coverage.rb +71 -0
- data/lib/timeprice/sources.rb +7 -54
- data/lib/timeprice/supported.rb +12 -5
- data/lib/timeprice/version.rb +1 -1
- metadata +9 -1
data/data/fx/usd/2025.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"schema_version": 2,
|
|
2
3
|
"base": "USD",
|
|
4
|
+
"year": 2025,
|
|
5
|
+
"source": "Frankfurter (ECB) — daily reference rates",
|
|
6
|
+
"updated_at": "2026-05-11",
|
|
3
7
|
"rates": {
|
|
4
8
|
"2025-01-02": {
|
|
5
9
|
"EUR": 0.9689,
|
|
@@ -1276,9 +1280,5 @@
|
|
|
1276
1280
|
"GBP": 0.74264,
|
|
1277
1281
|
"JPY": 156.67
|
|
1278
1282
|
}
|
|
1279
|
-
}
|
|
1280
|
-
"schema_version": 1,
|
|
1281
|
-
"source": "Frankfurter (ECB) — daily reference rates",
|
|
1282
|
-
"updated_at": "2026-05-11",
|
|
1283
|
-
"year": 2025
|
|
1283
|
+
}
|
|
1284
1284
|
}
|
data/data/fx/usd/2026.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"schema_version": 2,
|
|
2
3
|
"base": "USD",
|
|
4
|
+
"year": 2026,
|
|
5
|
+
"source": "Frankfurter (ECB) — daily reference rates",
|
|
6
|
+
"updated_at": "2026-05-11",
|
|
3
7
|
"rates": {
|
|
4
8
|
"2026-01-02": {
|
|
5
9
|
"EUR": 0.85317,
|
|
@@ -441,9 +445,5 @@
|
|
|
441
445
|
"GBP": 0.73472,
|
|
442
446
|
"JPY": 156.76
|
|
443
447
|
}
|
|
444
|
-
}
|
|
445
|
-
"schema_version": 1,
|
|
446
|
-
"source": "Frankfurter (ECB) — daily reference rates",
|
|
447
|
-
"updated_at": "2026-05-11",
|
|
448
|
-
"year": 2026
|
|
448
|
+
}
|
|
449
449
|
}
|
|
@@ -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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
require_relative "../../granularity"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
module Presenters
|
|
9
|
+
# Renders a CompareResult for the CLI in text and JSON formats.
|
|
10
|
+
class Compare
|
|
11
|
+
include Formatting
|
|
12
|
+
|
|
13
|
+
def initialize(result)
|
|
14
|
+
@result = result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def json_hash
|
|
18
|
+
@result.to_h.merge(
|
|
19
|
+
amount: round_money(@result.amount, @result.to_currency),
|
|
20
|
+
original_amount: round_money(@result.original_amount, @result.from_currency),
|
|
21
|
+
converted_amount: round_money(@result.converted_amount, @result.to_currency),
|
|
22
|
+
fx_rate: @result.fx_rate.to_f.round(6),
|
|
23
|
+
cpi_ratio: @result.cpi_ratio.to_f.round(6)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Headline + left-to-right chain so the FX + CPI composition reads naturally.
|
|
28
|
+
def text_lines
|
|
29
|
+
final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
|
|
30
|
+
original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
|
|
31
|
+
converted = "#{fmt_money(@result.converted_amount, @result.to_currency)} #{@result.to_currency}"
|
|
32
|
+
step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
|
|
33
|
+
step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
|
|
34
|
+
width = [step1.length, step2.length].max
|
|
35
|
+
[
|
|
36
|
+
"#{final} in #{@result.to_date}",
|
|
37
|
+
" #{original} (#{@result.from_date})",
|
|
38
|
+
format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
|
|
39
|
+
format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date,
|
|
40
|
+
Granularity.humanize(@result.granularity)),
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
require_relative "../../granularity"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
module Presenters
|
|
9
|
+
# Renders an InflationResult for the CLI in text and JSON formats.
|
|
10
|
+
class Inflation
|
|
11
|
+
include Formatting
|
|
12
|
+
|
|
13
|
+
def initialize(result)
|
|
14
|
+
@result = result
|
|
15
|
+
@ccy = result.country_currency_label
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def json_hash
|
|
19
|
+
@result.to_h.merge(
|
|
20
|
+
amount: round_money(@result.amount, @ccy),
|
|
21
|
+
original_amount: round_money(@result.original_amount, @ccy)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def text_lines
|
|
26
|
+
[
|
|
27
|
+
"#{fmt_money(@result.amount, @ccy)} #{@ccy} in #{@result.to}",
|
|
28
|
+
format(" %s %s (%s) -> %s %s (%s)",
|
|
29
|
+
fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
|
|
30
|
+
fmt_money(@result.amount, @ccy), @ccy, @result.to),
|
|
31
|
+
" #{@result.country} · #{Granularity.humanize(@result.granularity)} CPI",
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
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
|
@@ -5,6 +5,7 @@ require_relative "supported"
|
|
|
5
5
|
require_relative "point"
|
|
6
6
|
require_relative "inflation"
|
|
7
7
|
require_relative "exchange"
|
|
8
|
+
require_relative "granularity"
|
|
8
9
|
|
|
9
10
|
module Timeprice
|
|
10
11
|
CompareResult = Data.define(
|
|
@@ -28,10 +29,6 @@ module Timeprice
|
|
|
28
29
|
# If a future refactor flips the order, the regression test in
|
|
29
30
|
# spec/timeprice/compare_spec.rb will fail.
|
|
30
31
|
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
32
|
module_function
|
|
36
33
|
|
|
37
34
|
# Compare an amount across two (currency, date) points.
|
|
@@ -43,15 +40,14 @@ module Timeprice
|
|
|
43
40
|
# @return [CompareResult]
|
|
44
41
|
# @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
|
|
45
42
|
def run(amount:, from:, to:)
|
|
46
|
-
|
|
43
|
+
from_point, to_point, to_country = resolve_points(from, to)
|
|
47
44
|
|
|
48
45
|
# Step 1: convert at source date into destination currency.
|
|
49
|
-
fx_date = normalize_fx_date(from_date)
|
|
50
46
|
fx_result = Exchange.convert(
|
|
51
47
|
amount: amount,
|
|
52
|
-
from:
|
|
53
|
-
to:
|
|
54
|
-
date:
|
|
48
|
+
from: from_point.currency,
|
|
49
|
+
to: to_point.currency,
|
|
50
|
+
date: from_point.fx_anchor_date
|
|
55
51
|
)
|
|
56
52
|
converted = fx_result.amount
|
|
57
53
|
|
|
@@ -59,49 +55,36 @@ module Timeprice
|
|
|
59
55
|
# destination date using destination-country CPI.
|
|
60
56
|
infl = Inflation.adjust(
|
|
61
57
|
amount: converted,
|
|
62
|
-
from:
|
|
63
|
-
to:
|
|
58
|
+
from: from_point.date.to_s,
|
|
59
|
+
to: to_point.date.to_s,
|
|
64
60
|
country: to_country
|
|
65
61
|
)
|
|
66
62
|
|
|
67
63
|
CompareResult.new(
|
|
68
64
|
amount: infl.amount,
|
|
69
65
|
original_amount: amount.to_f,
|
|
70
|
-
from_currency:
|
|
71
|
-
from_date:
|
|
72
|
-
to_currency:
|
|
73
|
-
to_date:
|
|
66
|
+
from_currency: from_point.currency,
|
|
67
|
+
from_date: from_point.date.to_s,
|
|
68
|
+
to_currency: to_point.currency,
|
|
69
|
+
to_date: to_point.date.to_s,
|
|
74
70
|
country: to_country,
|
|
75
71
|
fx_rate: fx_result.rate,
|
|
76
72
|
cpi_ratio: infl.to_index.to_f / infl.from_index,
|
|
77
73
|
converted_amount: converted,
|
|
78
|
-
granularity: infl.granularity
|
|
74
|
+
granularity: Granularity.merge(fx_result.granularity, infl.granularity)
|
|
79
75
|
)
|
|
80
76
|
end
|
|
81
77
|
|
|
82
|
-
# Coerce both points and resolve to_country.
|
|
78
|
+
# Coerce both points and resolve to_country.
|
|
83
79
|
def resolve_points(from, to)
|
|
84
80
|
from_point = Point.coerce(from)
|
|
85
81
|
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)
|
|
82
|
+
raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
|
|
91
83
|
|
|
92
|
-
|
|
93
|
-
|
|
84
|
+
to_country = Supported.country_for_currency(to_point.currency)
|
|
85
|
+
raise UnsupportedCurrency, to_point.currency unless to_country
|
|
94
86
|
|
|
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
|
|
87
|
+
[from_point, to_point, to_country]
|
|
105
88
|
end
|
|
106
89
|
end
|
|
107
90
|
end
|