Compare commits

...

10 commits

Author SHA1 Message Date
Brett Smith
5a73d3d8f8 historical: Move normalize_rate and _pretty_rate to base Formatter.
This reduces the number of method overrides to help readability,
and gets rid of the annoying format_rate/pretty_rate distinction.
2020-05-20 16:02:47 -04:00
Brett Smith
77393ee80f tests: Make consistent rate test a little more robust. 2020-05-20 15:35:27 -04:00
Brett Smith
c9382a2604 historical: Fix the rate ordering with two dates.
Darnit, I wrote the tests first, and I wrote them right, and then I
mixed up the ordering in the code, and somehow I convinced myself
the code was the right and the tests were wrong. But no, I had the
tests right, this is really what we want. This gets the output to
follow the examples from our bookkeeping documentation.
2020-05-20 15:21:13 -04:00
Brett Smith
ae3e4617d3 historical: Always format rates with the same precision.
When we format a rate as a price, we don't know how much precision
is "enough" to do the conversion, because we don't know what's
being converted to. As a result, we may (=will almost certainly)
end up formatting the rate with different precision on the cost
date vs. the price date, and that causes Beancount/Ledger to fail
to make the connection between them.

Using a constant of 6 is enough to make the current test for
"enough" precision pass, so just do that for now. This might need
further refinement in the future.
2020-05-19 15:56:19 -04:00
Brett Smith
8dede9d139 historical: Swap Ledger and Beancount formatters in the class hierarchy.
This makes sense for a couple of reasons:

* The Beancount formatter has "less features" than the Ledger formatter, so
  this is a more "logical" organization of the hierarchy anyway. Note how
  this eliminates the need for the BeancountFormatter.__init__ override to
  turn off Ledger features.

* Any future work will probably be focused on the Beancount formatter, so
  this reduces the amount of code you have to understand and hold in your
  head to do that.
2020-05-19 15:27:04 -04:00
Brett Smith
c3fd55ec15 historical: Beancount can handle commas in amounts.
And having it looks nicer, is more consistent with our historical
books, is less code for me, and is no more trouble for the user.
2020-05-19 15:22:00 -04:00
Brett Smith
e158eae7d9 gitignore: Modernize. 2020-05-17 14:12:15 -04:00
Brett Smith
30e9f1c1e8 setup: Version 2.0 for all the recent changes. 2020-05-17 14:10:17 -04:00
Brett Smith
5573caf7ee oxrlib_example: Update for Beancount. 2020-05-17 14:08:59 -04:00
Brett Smith
3a3afb7978 historical: Add Beancount output format. 2020-05-17 14:05:49 -04:00
6 changed files with 117 additions and 83 deletions

6
.gitignore vendored
View file

@ -1,4 +1,8 @@
build/
.cache/
*.egg *.egg
*.egg-info/ *.egg-info/
.cache/ .eggs
.mypy_cache/
.tox/
__pycache__/ __pycache__/

View file

@ -14,7 +14,7 @@ except ImportError:
class Formatter: class Formatter:
def __init__(self, cost_rates, price_rates=None, def __init__(self, cost_rates, price_rates=None,
signed_currencies=(), base_fmt='#,##0.###', signed_currencies=(), base_fmt='#,##0.###',
rate_precision=5, denomination=None): rate_precision=6, denomination=None):
self.cost_rates = cost_rates self.cost_rates = cost_rates
self.price_rates = price_rates self.price_rates = price_rates
self.base_fmt = base_fmt self.base_fmt = base_fmt
@ -43,17 +43,33 @@ class Formatter:
return decimal.Decimal(amt_s) return decimal.Decimal(amt_s)
def normalize_rate(self, rate, prec=None): def normalize_rate(self, rate, prec=None):
return rate if prec is None:
prec = self.rate_prec
_, digits, exponent = rate.normalize().as_tuple()
# Return ``prec`` nonzero digits of precision, if available.
prec -= min(0, exponent + len(digits))
quant_to = '1.{}'.format('0' * prec)
try:
qrate = rate.quantize(decimal.Decimal(quant_to))
except decimal.InvalidOperation:
# The original rate doesn't have that much precision, so use it raw.
qrate = rate
return qrate.normalize()
def format_rate(self, rate): def format_rate(self, rate, curr, fmt='{}', prec=None):
return "{:g}".format(rate) rate_s = self.format_currency(
self.normalize_rate(rate, prec),
curr,
currency_digits=False,
)
return fmt.format(rate_s)
def format_rate_pair(self, from_curr, to_curr): def format_rate_pair(self, from_curr, to_curr):
from_amt = 1 from_amt = decimal.Decimal(1)
to_amt = self.cost_rates.convert(from_amt, from_curr, to_curr) to_amt = self.cost_rates.convert(from_amt, from_curr, to_curr)
return "{} {} = {} {}".format( return "{} = {}".format(
self.format_rate(from_amt), from_curr, self.format_rate(from_amt, from_curr),
self.format_rate(to_amt), to_curr, self.format_rate(to_amt, to_curr),
) )
def format_rate_pair_bidir(self, from_curr, to_curr, sep='\n'): def format_rate_pair_bidir(self, from_curr, to_curr, sep='\n'):
@ -71,55 +87,15 @@ class Formatter:
) )
class LedgerFormatter(Formatter): class BeancountFormatter(Formatter):
COST_FMT = '{{={}}}' COST_FMT = '{{{}}}'
PRICE_FMT = ' @ {}' PRICE_FMT = ' @ {}'
def price_rate(self, from_amt, from_curr, to_curr): def price_rate(self, from_amt, from_curr, to_curr):
if self.price_rates is None: if self.price_rates is None:
rates = self.cost_rates return None
else: else:
rates = self.price_rates return self.price_rates.convert(from_amt, from_curr, to_curr)
return rates.convert(from_amt, from_curr, to_curr)
def can_sign_currency(self, code):
return len(babel.numbers.get_currency_symbol(code)) == 1
def normalize_rate(self, rate, prec=None):
if prec is None:
prec = self.rate_prec
_, digits, exponent = rate.normalize().as_tuple()
# Return ``prec`` nonzero digits of precision, if available.
prec -= min(0, exponent + len(digits))
quant_to = '1.{}'.format('0' * prec)
try:
qrate = rate.quantize(decimal.Decimal(quant_to))
except decimal.InvalidOperation:
# The original rate doesn't have that much precision, so use it raw.
qrate = rate
return qrate.normalize()
def normalize_enough(self, rate, curr, from_amt, to_amt, prec=None):
if prec is None:
prec = self.rate_prec
# Starting from prec, find the least amount of precision to
# make sure from_amt converts exactly to to_amt.
for try_prec in itertools.count(prec):
try_rate = self.normalize_rate(rate, try_prec)
got_amt = self.currency_decimal(from_amt * try_rate, curr)
# If got_amt == to_amt, this is enough precision to do the
# conversion exactly, so we're done.
# If try_rate == rate, there's no more precision available, so stop.
if (got_amt == to_amt) or (try_rate == rate):
break
return try_rate
def _pretty_rate(self, fmt, rate, curr, from_amt=None, to_amt=None):
if to_amt is None:
rate = self.normalize_rate(rate)
else:
rate = self.normalize_enough(rate, curr, from_amt, to_amt)
return fmt.format(self.format_currency(rate, curr, currency_digits=False))
def format_rate_pair(self, from_curr, to_curr): def format_rate_pair(self, from_curr, to_curr):
from_amt = 1 from_amt = 1
@ -128,11 +104,11 @@ class LedgerFormatter(Formatter):
if price is None: if price is None:
price_s = '' price_s = ''
else: else:
price_s = self._pretty_rate(self.PRICE_FMT, price, to_curr) price_s = self.format_rate(price, to_curr, self.PRICE_FMT)
return "{} {} {}{}".format( return "{} {} {}{}".format(
from_amt, from_amt,
from_curr, from_curr,
self._pretty_rate(self.COST_FMT, cost, to_curr), self.format_rate(cost, to_curr, self.COST_FMT),
price_s, price_s,
) )
@ -155,12 +131,10 @@ class LedgerFormatter(Formatter):
if price is None: if price is None:
price_s = '' price_s = ''
else: else:
price_s = self._pretty_rate( price_s = self.format_rate(price, denomination, self.PRICE_FMT)
self.PRICE_FMT, price, denomination, amount, to_amt,
)
return "{} {}{}".format( return "{} {}{}".format(
amt_s, amt_s,
self._pretty_rate(self.COST_FMT, cost, denomination, amount, to_amt), self.format_rate(cost, denomination, self.COST_FMT),
price_s, price_s,
) )
@ -172,9 +146,24 @@ class LedgerFormatter(Formatter):
) )
class LedgerFormatter(BeancountFormatter):
COST_FMT = '{{={}}}'
def can_sign_currency(self, code):
return len(babel.numbers.get_currency_symbol(code)) == 1
def price_rate(self, from_amt, from_curr, to_curr):
if self.price_rates is None:
rates = self.cost_rates
else:
rates = self.price_rates
return rates.convert(from_amt, from_curr, to_curr)
class Formats(enum.Enum): class Formats(enum.Enum):
RAW = Formatter RAW = Formatter
LEDGER = LedgerFormatter LEDGER = LedgerFormatter
BEANCOUNT = BeancountFormatter
@classmethod @classmethod
def from_arg(cls, s): def from_arg(cls, s):
@ -190,11 +179,13 @@ def load_rates(config, loaders, date):
def run(config, stdout, stderr): def run(config, stdout, stderr):
loaders = config.get_loaders() loaders = config.get_loaders()
cost_rates = load_rates(config, loaders, config.args.date) date_rates = load_rates(config, loaders, config.args.date)
if config.args.from_date is None: if config.args.from_date is None:
cost_rates = date_rates
price_rates = None price_rates = None
else: else:
price_rates = load_rates(config, loaders, config.args.from_date) cost_rates = load_rates(config, loaders, config.args.from_date)
price_rates = date_rates
formatter = config.args.output_format.value( formatter = config.args.output_format.value(
cost_rates, cost_rates,
price_rates, price_rates,

View file

@ -110,8 +110,9 @@ class Configuration:
hist_parser.add_argument( hist_parser.add_argument(
'--output-format', '--output-format',
type=historical.Formats.from_arg, type=historical.Formats.from_arg,
choices=[fmt.name.lower() for fmt in historical.Formats], help="Output format."
help="Output format. Choices are %(choices)s. Default `raw`.", " Choices are `raw`, `ledger`, `beancount`."
" Default `raw`.",
) )
# --ledger and --no-ledger predate --output-format. # --ledger and --no-ledger predate --output-format.
hist_parser.add_argument( hist_parser.add_argument(

View file

@ -25,10 +25,10 @@ historical = {date}_{base}_rates.json
base = USD base = USD
# Write output in Ledger format. # Write output in Ledger format.
ledger = no output_format = ledger
# Denominate Ledger books in this currency. # Denominate books in this currency.
# Ledger-formatted conversions will always show a rate to convert to this # Formatted conversions will always show a rate to convert to this
# currency, even when converting between two other currencies. # currency, even when converting between two other currencies.
# If not specified, output will show the rate for the currency you're # If not specified, output will show the rate for the currency you're
# converting to. # converting to.

View file

@ -5,7 +5,7 @@ from setuptools import setup
setup( setup(
name='oxrlib', name='oxrlib',
description="Library to query the Open Exchange Rates (OXR) API", description="Library to query the Open Exchange Rates (OXR) API",
version='1.7', version='2.2',
author='Brett Smith', author='Brett Smith',
author_email='brettcsmith@brettcsmith.org', author_email='brettcsmith@brettcsmith.org',
license='GNU AGPLv3+', license='GNU AGPLv3+',

View file

@ -40,6 +40,7 @@ class FakeConfig:
output = pytest.fixture(lambda: io.StringIO()) output = pytest.fixture(lambda: io.StringIO())
parametrize_format = pytest.mark.parametrize('output_format', [ parametrize_format = pytest.mark.parametrize('output_format', [
oxrhist.Formats.LEDGER, oxrhist.Formats.LEDGER,
oxrhist.Formats.BEANCOUNT,
]) ])
@pytest.fixture(scope='module') @pytest.fixture(scope='module')
@ -85,20 +86,37 @@ def lines_from_run(config, output):
def check_fx_amount(config, lines, amount, cost, fx_code, fx_sign=None, price=None): def check_fx_amount(config, lines, amount, cost, fx_code, fx_sign=None, price=None):
if price is None: if price is None:
price = cost price = cost
rate_fmt = f'{{}} {re.escape(fx_code)}'
cost = re.escape(cost) + r'\d*' cost = re.escape(cost) + r'\d*'
price = re.escape(price) + r'\d*' price = re.escape(price) + r'\d*'
if fx_sign is not None and fx_code in config.args.signed_currencies: if config.args.output_format is oxrhist.Formats.LEDGER:
rate_fmt = f'{re.escape(fx_sign)}{{}}' if fx_sign is not None and fx_code in config.args.signed_currencies:
rate_fmt = f'{re.escape(fx_sign)}{{}}'
cost_re = '{{={}}}'.format(rate_fmt.format(cost))
price_re = ' @ {}'.format(rate_fmt.format(price))
else: else:
rate_fmt = f'{{}} {re.escape(fx_code)}' cost_re = '{{{}}}'.format(rate_fmt.format(cost))
pattern = r'^{} {{={}}} @ {}$'.format( if config.args.from_date is None:
re.escape(amount), price_re = ''
rate_fmt.format(cost), else:
rate_fmt.format(price), price_re = ' @ {}'.format(rate_fmt.format(price))
) pattern = r'^{} {}{}$'.format(re.escape(amount), cost_re, price_re)
line = next(lines, "<EOF>") line = next(lines, "<EOF>")
assert re.match(pattern, line) assert re.match(pattern, line)
def check_nonfx_amount(config, lines, amount, code=None, sign=None):
if config.args.output_format is oxrhist.Formats.LEDGER:
if code is None:
code = 'USD'
sign = '$'
if code in config.args.signed_currencies and sign is not None:
expected = f'{sign}{amount}\n'
else:
expected = f'{amount} {code}\n'
else:
expected = f'{amount} {code or "USD"}\n'
assert next(lines, "<EOF>") == expected
def test_rate_list(single_responder, output, any_date): def test_rate_list(single_responder, output, any_date):
config = build_config(single_responder, any_date) config = build_config(single_responder, any_date)
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
@ -144,7 +162,7 @@ def test_ledger_conversion(single_responder, output, any_date, output_format):
amount=300, output_format=output_format) amount=300, output_format=output_format)
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
check_fx_amount(config, lines, '300 ALL', '0.00691', 'USD', '$') check_fx_amount(config, lines, '300 ALL', '0.00691', 'USD', '$')
assert next(lines) == '$2.08\n' check_nonfx_amount(config, lines, '2.08')
assert next(lines, None) is None assert next(lines, None) is None
@parametrize_format @parametrize_format
@ -173,7 +191,7 @@ def test_redundant_denomination(single_responder, output, any_date, output_forma
output_format=output_format, denomination='USD') output_format=output_format, denomination='USD')
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
check_fx_amount(config, lines, '10.00 ANG', '0.558', 'USD', '$') check_fx_amount(config, lines, '10.00 ANG', '0.558', 'USD', '$')
assert next(lines) == '$5.59\n' check_nonfx_amount(config, lines, '5.59')
assert next(lines, None) is None assert next(lines, None) is None
@parametrize_format @parametrize_format
@ -182,7 +200,7 @@ def test_from_denomination(single_responder, output, any_date, output_format):
from_currency='USD', to_currency='ALL', amount=10, from_currency='USD', to_currency='ALL', amount=10,
output_format=output_format, denomination='USD') output_format=output_format, denomination='USD')
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
assert next(lines) == '$10.00\n' check_nonfx_amount(config, lines, '10.00')
check_fx_amount(config, lines, '1,445 ALL', '0.00691', 'USD', '$') check_fx_amount(config, lines, '1,445 ALL', '0.00691', 'USD', '$')
assert next(lines, None) is None assert next(lines, None) is None
@ -197,7 +215,7 @@ def test_rate_precision_added_as_needed(single_responder, output, any_date, outp
# Make sure the rate is specified with enough precision to get the # Make sure the rate is specified with enough precision to get the
# correct conversion amount. # correct conversion amount.
check_fx_amount(config, lines, '63,805.00 RUB', '0.0175204', 'USD', '$') check_fx_amount(config, lines, '63,805.00 RUB', '0.0175204', 'USD', '$')
assert next(lines) == '$1,117.89\n' check_nonfx_amount(config, lines, '1,117.89')
assert next(lines, None) is None assert next(lines, None) is None
@parametrize_format @parametrize_format
@ -207,8 +225,8 @@ def test_from_date_rates(alternate_responder, output, any_date, output_format):
from_date=any_date, output_format=output_format, from_date=any_date, output_format=output_format,
denomination='USD') denomination='USD')
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
check_fx_amount(config, lines, '1 ANG', '2.051', 'AED', None, '1.909') check_fx_amount(config, lines, '1 ANG', '1.909', 'AED', None, '2.051')
check_fx_amount(config, lines, '1 AED', '0.487', 'ANG', None, '0.523') check_fx_amount(config, lines, '1 AED', '0.523', 'ANG', None, '0.487')
assert next(lines, None) is None assert next(lines, None) is None
@parametrize_format @parametrize_format
@ -218,6 +236,26 @@ def test_from_date_conversion(alternate_responder, output, any_date, output_form
from_date=any_date, output_format=output_format, from_date=any_date, output_format=output_format,
denomination='USD') denomination='USD')
lines = lines_from_run(config, output) lines = lines_from_run(config, output)
check_fx_amount(config, lines, '10.00 ANG', '0.558', 'USD', '$', '0.507') check_fx_amount(config, lines, '10.00 ANG', '0.507', 'USD', '$', '0.558')
check_fx_amount(config, lines, '20.52 AED', '0.272', 'USD', '$', '0.265') check_fx_amount(config, lines, '19.10 AED', '0.265', 'USD', '$', '0.272')
assert next(lines, None) is None assert next(lines, None) is None
@parametrize_format
def test_rate_consistent_as_cost_and_price(alternate_responder, any_date, output_format):
config_kwargs = {
'responder': alternate_responder,
'amount': 65000,
'from_currency': 'RUB',
'output_format': output_format,
'signed_currencies': (),
}
config = build_config(date=any_date, **config_kwargs)
with io.StringIO() as output:
lines = lines_from_run(config, output)
amount, _, _ = next(lines).partition('@')
expected = amount.replace('\n', ' ')
future_date = any_date.replace(year=any_date.year + 1)
config = build_config(date=future_date, from_date=any_date, **config_kwargs)
with io.StringIO() as output:
lines = lines_from_run(config, output)
assert next(lines, "<EOF>").startswith(expected)