beancount.prices
Fetch prices from the internet and output them as Beancount price directives.
This script accepts a list of Beancount input filenames, and fetches prices required to compute market values for current positions:
bean-price /home/joe/finances/joe.beancount
The list of fetching jobs to carry out is derived automatically from the input file (see section below for full details). It is also possible to provide a list of specific price fetching jobs to run, e.g.,
bean-price -e google/TSE:XUS yahoo/AAPL mysources.morningstar/RBF1005
The general format of each of these "source strings" is
<module>/[^]<ticker>
The "module" is the name of a Python module that contains a Source class which can be instantiated and connect to a data source to extract price data. These modules are automatically imported by name and instantiated in order to pull the price from a particular data source. This allows you to write your own supplementary fetcher codes without having to modify this script.
Default implementations are provided to provide access to prices from Yahoo! Finance or Google Finance, which cover a large universe of common public investment types (e.g. stock tickers). As a convenience, the module name is always first searched under the "beancount.prices.sources" package, where those default source implementations live. This is how, for example, in order to use the provided Google Finance data fetcher you don't have to write "beancount.prices.sources.yahoo/AAPL" but simply "yahoo/AAPL".
Date
By default, this script will fetch prices at the latest available date & time. You can use an option to fetch historical prices for a desired date instead:
bean-price --date=2015-02-03
Inverse
Sometimes, prices are available for the inverse of an instrument. This is often the case for currencies. For example, the price of "CAD" in USD" is provided by the USD/CAD market, which gives the price of a US dollar in Canadian dollars. In order specify this, you can prepend "^" to the instrument to instruct the driver to compute the inverse of the given price:
bean-price -e USD:google/^CURRENCY:USDCAD
If a source price is to be inverted, like this, the precision could be different than what is fetched. For instance, if the price of USD/CAD is 1.32759, it would output be this from the above directive:
2015-10-28 price CAD 0.753244601119 USD
By default, inverted rates will be rounded similarly to how other Price directives were rounding those numbers.
Swap Inverted
If you prefer to have the output Price entries with swapped currencies instead of inverting the rate itself, you can use the --swap-inverted option. In the previous example for the price of CAD, it would output this:
2015-10-28 price USD 1.32759 CAD
This works since the Beancount price database computes and interpolates the reciprocals automatically for all pairs of commodities in its database.
Prices Needed for a Beancount File
You can also provide a filename to extract the list of tickers to fetch from a Beancount input file, e.g.:
bean-price /home/joe/finances/joe.beancount
There are many ways to extract a list of commodities with needed prices from a Beancount input file:
-
Prices for all the holdings that were seen held-at-cost at a particular date.
-
Prices for holdings held at a particular date which were price converted from some other commodity in the past (i.e., for currencies).
-
The list of all Commodity directives present in the file. For each of those holdings, the corresponding Commodity directive is consulted and its "ticker" metadata field is used to specify where to attempt to fetch prices. You should have directives like this in your input file:
2007-07-20 commodity VEA price: "google/NYSEARCA:VEA"
The "price" metadata can be a comma-separated list of sources to try out, in which case each of the sources will be looked at :
2007-07-20 commodity VEA
price: "google/CURRENCY:USDCAD,yahoo/USDCAD"
- Existing price directives for the same data are excluded by default, since the price is already in the file.
By default, the list of tickers to be fetched includes only the intersection of these lists. The general intent of the user of this script is to fetch missing prices, and only needed ones, for a particular date.
- Use the --date option to change the applied date.
- Use the --all option to fetch the entire set of prices, regardless of holdings and date.
- Use --clobber to ignore existing price directives.
You can also print the list of prices to be fetched with the --dry-run option, which stops short of actually fetching the missing prices (it just prints the list of fetches it would otherwise attempt).
Caching
Prices are automatically cached. You can disable the cache with an option:
bean-price --no-cache
You can also instruct the script to clear the cache before fetching its prices:
bean-price --clear-cache
About Sources and Data Availability
IMPORTANT: Note that each source may support a different routine for getting its latest data and for fetching historical/dated data, and that each of these may differ in their support. For example, Google Finance does not support fetching historical data for its CURRENCY:* instruments.
beancount.prices.find_prices
A library of codes create price fetching jobs from strings and files.
beancount.prices.find_prices.DatedPrice (tuple)
DatedPrice(base, quote, date, sources)
beancount.prices.find_prices.DatedPrice.__getnewargs__(self)
special
Return self as a plain tuple. Used by copy and pickle.
Source code in beancount/prices/find_prices.py
def __getnewargs__(self):
'Return self as a plain tuple. Used by copy and pickle.'
return _tuple(self)
beancount.prices.find_prices.DatedPrice.__new__(_cls, base, quote, date, sources)
special
staticmethod
Create new instance of DatedPrice(base, quote, date, sources)
beancount.prices.find_prices.DatedPrice.__repr__(self)
special
Return a nicely formatted representation string
Source code in beancount/prices/find_prices.py
def __repr__(self):
'Return a nicely formatted representation string'
return self.__class__.__name__ + repr_fmt % self
beancount.prices.find_prices.PriceSource (tuple)
PriceSource(module, symbol, invert)
beancount.prices.find_prices.PriceSource.__getnewargs__(self)
special
Return self as a plain tuple. Used by copy and pickle.
Source code in beancount/prices/find_prices.py
def __getnewargs__(self):
'Return self as a plain tuple. Used by copy and pickle.'
return _tuple(self)
beancount.prices.find_prices.PriceSource.__new__(_cls, module, symbol, invert)
special
staticmethod
Create new instance of PriceSource(module, symbol, invert)
beancount.prices.find_prices.PriceSource.__repr__(self)
special
Return a nicely formatted representation string
Source code in beancount/prices/find_prices.py
def __repr__(self):
'Return a nicely formatted representation string'
return self.__class__.__name__ + repr_fmt % self
beancount.prices.find_prices.find_balance_currencies(entries, date=None)
Return currencies relevant for the given date.
This computes the account balances as of the date, and returns the union of: a) The currencies held at cost, and b) Currency pairs from previous conversions, but only for currencies with non-zero balances.
This is intended to produce the list of currencies whose prices are relevant at a particular date, based on previous history.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def find_balance_currencies(entries, date=None):
"""Return currencies relevant for the given date.
This computes the account balances as of the date, and returns the union of:
a) The currencies held at cost, and
b) Currency pairs from previous conversions, but only for currencies with
non-zero balances.
This is intended to produce the list of currencies whose prices are relevant
at a particular date, based on previous history.
Args:
entries: A list of directives.
date: A datetime.date instance.
Returns:
A set of (base, quote) currencies.
"""
# Compute the balances.
currencies = set()
currencies_on_books = set()
balances, _ = summarize.balance_by_account(entries, date)
for _, balance in balances.items():
for pos in balance:
if pos.cost is not None:
# Add currencies held at cost.
currencies.add((pos.units.currency, pos.cost.currency))
else:
# Add regular currencies.
currencies_on_books.add(pos.units.currency)
# Create currency pairs from the currencies which are on account balances.
# In order to figure out the quote currencies, we use the list of price
# conversions until this date.
converted = (find_currencies_converted(entries, date) |
find_currencies_priced(entries, date))
for cbase in currencies_on_books:
for base_quote in converted:
base, quote = base_quote
if base == cbase:
currencies.add(base_quote)
return currencies
beancount.prices.find_prices.find_currencies_at_cost(entries)
Return all currencies that were held at cost at some point.
This returns all of them, even if not on the books at a particular point in time. This code does not look at account balances.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def find_currencies_at_cost(entries):
"""Return all currencies that were held at cost at some point.
This returns all of them, even if not on the books at a particular point in
time. This code does not look at account balances.
Args:
entries: A list of directives.
date: A datetime.date instance.
Returns:
A list of (base, quote) currencies.
"""
currencies = set()
for entry in entries:
if not isinstance(entry, data.Transaction):
continue
for posting in entry.postings:
if posting.cost is not None and posting.cost.number is not None:
currencies.add((posting.units.currency, posting.cost.currency))
return currencies
beancount.prices.find_prices.find_currencies_converted(entries, date=None)
Return currencies from price conversions.
This function looks at all price conversions that occurred until some date and produces a list of them. Note: This does not include Price directives, only postings with price conversions.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def find_currencies_converted(entries, date=None):
"""Return currencies from price conversions.
This function looks at all price conversions that occurred until some date
and produces a list of them. Note: This does not include Price directives,
only postings with price conversions.
Args:
entries: A list of directives.
date: A datetime.date instance.
Returns:
A list of (base, quote) currencies.
"""
currencies = set()
for entry in entries:
if not isinstance(entry, data.Transaction):
continue
if date and entry.date >= date:
break
for posting in entry.postings:
price = posting.price
if posting.cost is not None or price is None:
continue
currencies.add((posting.units.currency, price.currency))
return currencies
beancount.prices.find_prices.find_currencies_declared(entries, date=None)
Return currencies declared in Commodity directives.
If a 'price' metadata field is provided, include all the quote currencies there-in. Otherwise, the Commodity directive is ignored.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def find_currencies_declared(entries, date=None):
"""Return currencies declared in Commodity directives.
If a 'price' metadata field is provided, include all the quote currencies
there-in. Otherwise, the Commodity directive is ignored.
Args:
entries: A list of directives.
date: A datetime.date instance.
Returns:
A list of (base, quote, list of PriceSource) currencies. The list of
(base, quote) pairs is guaranteed to be unique.
"""
currencies = []
for entry in entries:
if not isinstance(entry, data.Commodity):
continue
if date and entry.date >= date:
break
# Here we have to infer which quote currencies the commodity is for
# (maybe down the road this should be better handled by providing a list
# of quote currencies in the Commodity directive itself).
#
# First, we look for a "price" metadata field, which defines conversions
# for various currencies. Each of these quote currencies generates a
# pair in the output.
source_str = entry.meta.get('price', None)
if source_str is not None:
if source_str == "":
logging.debug("Skipping ignored currency (with empty price): %s",
entry.currency)
continue
try:
source_map = parse_source_map(source_str)
except ValueError:
logging.warning("Ignoring currency with invalid 'price' source: %s",
entry.currency)
else:
for quote, psources in source_map.items():
currencies.append((entry.currency, quote, psources))
else:
# Otherwise we simply ignore the declaration. That is, a Commodity
# directive without any "price" metadata would not register as a
# declared currency.
logging.debug("Ignoring currency with no metadata: %s", entry.currency)
return currencies
beancount.prices.find_prices.find_currencies_priced(entries, date=None)
Return currencies seen in Price directives.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def find_currencies_priced(entries, date=None):
"""Return currencies seen in Price directives.
Args:
entries: A list of directives.
date: A datetime.date instance.
Returns:
A list of (base, quote) currencies.
"""
currencies = set()
for entry in entries:
if not isinstance(entry, data.Price):
continue
if date and entry.date >= date:
break
currencies.add((entry.currency, entry.amount.currency))
return currencies
beancount.prices.find_prices.format_dated_price_str(dprice)
Convert a dated price to a one-line printable string.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def format_dated_price_str(dprice):
"""Convert a dated price to a one-line printable string.
Args:
dprice: A DatedPrice instance.
Returns:
The string for a DatedPrice instance.
"""
psstrs = ['{}({}{})'.format(psource.module.__name__,
'1/' if psource.invert else '',
psource.symbol)
for psource in dprice.sources]
base_quote = '{} /{}'.format(dprice.base, dprice.quote)
return '{:<32} @ {:10} [ {} ]'.format(
base_quote,
dprice.date.isoformat() if dprice.date else 'latest',
','.join(psstrs))
beancount.prices.find_prices.get_price_jobs_at_date(entries, date=None, inactive=False, undeclared_source=None)
Get a list of prices to fetch from a stream of entries.
The active holdings held on the given date are included.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/find_prices.py
def get_price_jobs_at_date(entries, date=None, inactive=False, undeclared_source=None):
"""Get a list of prices to fetch from a stream of entries.
The active holdings held on the given date are included.
Args:
filename: A string, the name of a file to process.
date: A datetime.date instance.
inactive: Include currencies with no balance at the given date. The default
is to only include those currencies which have a non-zero balance.
undeclared_source: A string, the name of the default source module to use to
pull prices for commodities without a price source metadata on their
Commodity directive declaration.
Returns:
A list of DatedPrice instances.
"""
# Find the list of declared currencies, and from it build a mapping for
# tickers for each (base, quote) pair. This is the only place tickers
# appear.
declared_triples = find_currencies_declared(entries, date)
currency_map = {(base, quote): psources
for base, quote, psources in declared_triples}
# Compute the initial list of currencies to consider.
if undeclared_source:
# Use the full set of possible currencies.
cur_at_cost = find_currencies_at_cost(entries)
cur_converted = find_currencies_converted(entries, date)
cur_priced = find_currencies_priced(entries, date)
currencies = cur_at_cost | cur_converted | cur_priced
log_currency_list("Currency held at cost", cur_at_cost)
log_currency_list("Currency converted", cur_converted)
log_currency_list("Currency priced", cur_priced)
default_source = import_source(undeclared_source)
else:
# Use the currencies from the Commodity directives.
currencies = set(currency_map.keys())
default_source = None
log_currency_list("Currencies in primary list", currencies)
# By default, restrict to only the currencies with non-zero balances at the
# given date.
if not inactive:
balance_currencies = find_balance_currencies(entries, date)
log_currency_list("Currencies held in assets", balance_currencies)
currencies = currencies & balance_currencies
log_currency_list("Currencies to fetch", currencies)
# Build up the list of jobs to fetch prices for.
jobs = []
for base_quote in currencies:
psources = currency_map.get(base_quote, None)
base, quote = base_quote
# If there are no sources, create a default one.
if not psources:
psources = [PriceSource(default_source, base, False)]
jobs.append(DatedPrice(base, quote, date, psources))
return sorted(jobs)
beancount.prices.find_prices.import_source(module_name)
Import the source module defined by the given name.
The default location is handled here.
Parameters: |
|
---|
Returns: |
|
---|
Exceptions: |
|
---|
Source code in beancount/prices/find_prices.py
def import_source(module_name):
"""Import the source module defined by the given name.
The default location is handled here.
Args:
short_module_name: A string, the name of a Python module, which may
be within the default package or a full name.
Returns:
A corresponding Python module object.
Raises:
ImportError: If the module cannot be imported.
"""
default_name = '{}.{}'.format(DEFAULT_PACKAGE, module_name)
try:
__import__(default_name)
return sys.modules[default_name]
except ImportError:
try:
__import__(module_name)
return sys.modules[module_name]
except ImportError as exc:
raise ImportError('Could not find price source module "{}": {}'.format(
module_name, exc))
beancount.prices.find_prices.log_currency_list(message, currencies)
Log a list of currencies to debug output.
Parameters: |
|
---|
Source code in beancount/prices/find_prices.py
def log_currency_list(message, currencies):
"""Log a list of currencies to debug output.
Args:
message: A message string to prepend.
currencies: A list of (base, quote) currency pair.
"""
logging.debug("-------- {}:".format(message))
for base, quote in currencies:
logging.debug(" {:>32}".format('{} /{}'.format(base, quote)))
beancount.prices.find_prices.parse_single_source(source)
Parse a single source string.
Source specifications follow the syntax:
<module>/[^]<ticker>
The <module> is resolved against the Python path, but first looked up under the package where the default price extractors lie.
Parameters: |
|
---|
Returns: |
|
---|
Exceptions: |
|
---|
Source code in beancount/prices/find_prices.py
def parse_single_source(source):
"""Parse a single source string.
Source specifications follow the syntax:
<module>/[^]<ticker>
The <module> is resolved against the Python path, but first looked up
under the package where the default price extractors lie.
Args:
source: A single source string specification.
Returns:
A PriceSource tuple, or
Raises:
ValueError: If invalid.
"""
match = re.match(r'([a-zA-Z]+[a-zA-Z0-9\._]+)/(\^?)([a-zA-Z0-9:=_\-\.]+)$', source)
if not match:
raise ValueError('Invalid source name: "{}"'.format(source))
short_module_name, invert, symbol = match.groups()
module = import_source(short_module_name)
return PriceSource(module, symbol, bool(invert))
beancount.prices.find_prices.parse_source_map(source_map_spec)
Parse a source map specification string.
Source map specifications allow the specification of multiple sources for multiple quote currencies and follow the following syntax:
<currency1>:<source1>,<source2>,... <currency2>:<source1>,...
Where a <source> itself follows:
<module>/[^]<ticker>
The <module> is resolved against the Python path, but first looked up under the package where the default price extractors lie. The presence of a '^' character indicates that we should use the inverse of the rate pull from this source.
For example, for prices of AAPL in USD:
USD:google/NASDAQ:AAPL,yahoo/AAPL
Or for the exchange rate of a currency, such as INR in USD or in CAD:
USD:google/^CURRENCY:USDINR CAD:google/^CURRENCY:CADINR
Parameters: |
|
---|
Returns: |
|
---|
Exceptions: |
|
---|
Source code in beancount/prices/find_prices.py
def parse_source_map(source_map_spec):
"""Parse a source map specification string.
Source map specifications allow the specification of multiple sources for
multiple quote currencies and follow the following syntax:
<currency1>:<source1>,<source2>,... <currency2>:<source1>,...
Where a <source> itself follows:
<module>/[^]<ticker>
The <module> is resolved against the Python path, but first looked up under
the package where the default price extractors lie. The presence of a '^'
character indicates that we should use the inverse of the rate pull from
this source.
For example, for prices of AAPL in USD:
USD:google/NASDAQ:AAPL,yahoo/AAPL
Or for the exchange rate of a currency, such as INR in USD or in CAD:
USD:google/^CURRENCY:USDINR CAD:google/^CURRENCY:CADINR
Args:
source_map_spec: A string, a full source map specification to be parsed.
Returns:
FIXME: TODO
Raises:
ValueError: If an invalid pattern has been specified.
"""
source_map = collections.defaultdict(list)
for source_list_spec in re.split('[ ;]', source_map_spec):
match = re.match('({}):(.*)$'.format(amount.CURRENCY_RE),
source_list_spec)
if not match:
raise ValueError('Invalid source map pattern: "{}"'.format(source_list_spec))
currency, source_strs = match.groups()
source_map[currency].extend(
parse_single_source(source_str)
for source_str in source_strs.split(','))
return source_map
beancount.prices.price
Driver code for the price script.
beancount.prices.price.fetch_cached_price(source, symbol, date)
Call Source to fetch a price, but look and/or update the cache first.
This function entirely deals with caching and correct expiration. It keeps old prices if they were fetched in the past, and it quickly expires intra-day prices if they are fetched on the same day.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/price.py
def fetch_cached_price(source, symbol, date):
"""Call Source to fetch a price, but look and/or update the cache first.
This function entirely deals with caching and correct expiration. It keeps
old prices if they were fetched in the past, and it quickly expires
intra-day prices if they are fetched on the same day.
Args:
source: A Python module object.
symbol: A string, the ticker to fetch.
date: A datetime.date instance, None if we're to fetch the latest date.
Returns:
A SourcePrice instance.
"""
# Compute a suitable timestamp from the date, if specified.
if date is not None:
# We query as for 4pm for the given date of the current timezone, if
# specified.
query_time = datetime.time(16, 0, 0)
time_local = datetime.datetime.combine(date, query_time, tzinfo=tz.tzlocal())
time = time_local.astimezone(tz.tzutc())
else:
time = None
if _CACHE is None:
# The cache is disabled; just call and return.
result = (source.get_latest_price(symbol)
if time is None else
source.get_historical_price(symbol, time))
else:
# The cache is enabled and we have to compute the current/latest price.
# Try to fetch from the cache but miss if the price is too old.
md5 = hashlib.md5()
md5.update(str((type(source).__module__, symbol, date)).encode('utf-8'))
key = md5.hexdigest()
timestamp_now = int(now().timestamp())
try:
timestamp_created, result_naive = _CACHE[key]
# Convert naive timezone to UTC, which is what the cache is always
# assumed to store. (The reason for this is that timezones from
# aware datetime objects cannot be serialized properly due to bug.)
if result_naive.time is not None:
result = result_naive._replace(
time=result_naive.time.replace(tzinfo=tz.tzutc()))
else:
result = result_naive
if (timestamp_now - timestamp_created) > _CACHE.expiration.total_seconds():
raise KeyError
except KeyError:
logging.info("Fetching: %s (time: %s)", symbol, time)
try:
result = (source.get_latest_price(symbol)
if time is None else
source.get_historical_price(symbol, time))
except ValueError as exc:
logging.error("Error fetching %s: %s", symbol, exc)
result = None
# Make sure the timezone is UTC and make naive before serialization.
if result and result.time is not None:
time_utc = result.time.astimezone(tz.tzutc())
time_naive = time_utc.replace(tzinfo=None)
result_naive = result._replace(time=time_naive)
else:
result_naive = result
if result_naive is not None:
_CACHE[key] = (timestamp_now, result_naive)
return result
beancount.prices.price.fetch_price(dprice, swap_inverted=False)
Fetch a price for the DatedPrice job.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/price.py
def fetch_price(dprice, swap_inverted=False):
"""Fetch a price for the DatedPrice job.
Args:
dprice: A DatedPrice instances.
swap_inverted: A boolean, true if we should invert currencies instead of
rate for an inverted price source.
Returns:
A Price entry corresponding to the output of the jobs processed.
"""
for psource in dprice.sources:
try:
source = psource.module.Source()
except AttributeError:
continue
srcprice = fetch_cached_price(source, psource.symbol, dprice.date)
if srcprice is not None:
break
else:
if dprice.sources:
logging.error("Could not fetch for job: %s", dprice)
return None
base = dprice.base
quote = dprice.quote or srcprice.quote_currency
price = srcprice.price
# Invert the rate if requested.
if psource.invert:
if swap_inverted:
base, quote = quote, base
else:
price = ONE/price
assert base is not None
fileloc = data.new_metadata('<{}>'.format(type(psource.module).__name__), 0)
# The datetime instance is required to be aware. We always convert to the
# user's timezone before extracting the date. This means that if the market
# returns a timestamp for a particular date, once we convert to the user's
# timezone the returned date may be different by a day. The intent is that
# whatever we print is assumed coherent with the user's timezone. See
# discussion at
# https://groups.google.com/d/msg/beancount/9j1E_HLEMBQ/fYRuCQK_BwAJ
srctime = srcprice.time
if srctime.tzinfo is None:
raise ValueError("Time returned by the price source is not timezone aware.")
date = srctime.astimezone(tz.tzlocal()).date()
return data.Price(fileloc, date, base,
amount.Amount(price, quote or UNKNOWN_CURRENCY))
beancount.prices.price.filter_redundant_prices(price_entries, existing_entries, diffs=False)
Filter out new entries that are redundant from an existing set.
If the price differs, we override it with the new entry only on demand. This is because this would create conflict with existing price entries when parsing, if the new entries are simply inserted into the input.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/price.py
def filter_redundant_prices(price_entries, existing_entries, diffs=False):
"""Filter out new entries that are redundant from an existing set.
If the price differs, we override it with the new entry only on demand. This
is because this would create conflict with existing price entries when
parsing, if the new entries are simply inserted into the input.
Args:
price_entries: A list of newly created, proposed to be added Price directives.
existing_entries: A list of existing entries we are proposing to add to.
diffs: A boolean, true if we should output differing price entries
at the same date.
Returns:
A filtered list of remaining entries, and a list of ignored entries.
"""
# Note: We have to be careful with the dates, because requesting the latest
# price for a date may yield the price at a previous date. Clobber needs to
# take this into account. See {1cfa25e37fc1}.
existing_prices = {(entry.date, entry.currency): entry
for entry in existing_entries
if isinstance(entry, data.Price)}
filtered_prices = []
ignored_prices = []
for entry in price_entries:
key = (entry.date, entry.currency)
if key in existing_prices:
if diffs:
existing_entry = existing_prices[key]
if existing_entry.amount == entry.amount:
output = ignored_prices
else:
output = ignored_prices
else:
output = filtered_prices
output.append(entry)
return filtered_prices, ignored_prices
beancount.prices.price.now()
Indirection in order to be able to mock it out in the tests.
Source code in beancount/prices/price.py
def now():
"Indirection in order to be able to mock it out in the tests."
return datetime.datetime.now(datetime.timezone.utc)
beancount.prices.price.process_args()
Process the arguments. This also initializes the logging module.
Returns: |
|
---|
Source code in beancount/prices/price.py
def process_args():
"""Process the arguments. This also initializes the logging module.
Returns:
A tuple of:
args: The argparse receiver of command-line arguments.
jobs: A list of DatedPrice job objects.
entries: A list of all the parsed entries.
"""
parser = version.ArgumentParser(description=beancount.prices.__doc__.splitlines()[0])
# Input sources or filenames.
parser.add_argument('sources', nargs='+', help=(
'A list of filenames (or source "module/symbol", if -e is '
'specified) from which to create a list of jobs.'))
parser.add_argument('-e', '--expressions', '--expression', action='store_true', help=(
'Interpret the arguments as "module/symbol" source strings.'))
# Regular options.
parser.add_argument('-v', '--verbose', action='count', help=(
"Print out progress log. Specify twice for debugging info."))
parser.add_argument('-d', '--date', action='store',
type=date_utils.parse_date_liberally, help=(
"Specify the date for which to fetch the prices."))
parser.add_argument('-i', '--inactive', action='store_true', help=(
"Select all commodities from input files, not just the ones active on the date"))
parser.add_argument('-u', '--undeclared', action='store', help=(
"Include commodities viewed in the file even without a "
"corresponding Commodity directive, from this default source. "
"The currency name itself is used as the lookup symbol in this default source."))
parser.add_argument('-c', '--clobber', action='store_true', help=(
"Do not skip prices which are already present in input files; fetch them anyway."))
parser.add_argument('-a', '--all', action='store_true', help=(
"A shorthand for --inactive, --undeclared, --clobber."))
parser.add_argument('-s', '--swap-inverted', action='store_true', help=(
"For inverted sources, swap currencies instead of inverting the rate. "
"For example, if fetching the rate for CAD from 'USD:google/^CURRENCY:USDCAD' "
"results in 1.25, by default we would output \"price CAD 0.8000 USD\". "
"Using this option we would instead output \" price USD 1.2500 CAD\"."))
parser.add_argument('-n', '--dry-run', action='store_true', help=(
"Don't actually fetch the prices, just print the list of the ones to be fetched."))
# Caching options.
cache_group = parser.add_argument_group('cache')
cache_filename = path.join(tempfile.gettempdir(),
"{}.cache".format(path.basename(sys.argv[0])))
cache_group.add_argument('--cache', dest='cache_filename',
action='store', default=cache_filename,
help="Enable the cache and with the given cache name.")
cache_group.add_argument('--no-cache', dest='cache_filename',
action='store_const', const=None,
help="Disable the price cache.")
cache_group.add_argument('--clear-cache', action='store_true',
help="Clear the cache prior to startup")
args = parser.parse_args()
verbose_levels = {None: logging.WARN,
0: logging.WARN,
1: logging.INFO,
2: logging.DEBUG}
logging.basicConfig(level=verbose_levels[args.verbose],
format='%(levelname)-8s: %(message)s')
if args.all:
args.inactive = args.clobber = True
args.undeclared = DEFAULT_SOURCE
# Setup for processing.
setup_cache(args.cache_filename, args.clear_cache)
# Get the list of DatedPrice jobs to get from the arguments.
logging.info("Processing at date: %s", args.date or datetime.date.today())
jobs = []
all_entries = []
dcontext = None
if args.expressions:
# Interpret the arguments as price sources.
for source_str in args.sources:
psources = []
try:
psource_map = find_prices.parse_source_map(source_str)
except ValueError:
extra = "; did you provide a filename?" if path.exists(source_str) else ''
msg = ('Invalid source "{{}}"{}. '.format(extra) +
'Supported format is "CCY:module/SYMBOL"')
parser.error(msg.format(source_str))
else:
for currency, psources in psource_map.items():
jobs.append(find_prices.DatedPrice(
psources[0].symbol, currency, args.date, psources))
else:
# Interpret the arguments as Beancount input filenames.
for filename in args.sources:
if not path.exists(filename) or not path.isfile(filename):
parser.error('File does not exist: "{}"; '
'did you mean to use -e?'.format(filename))
continue
logging.info('Loading "%s"', filename)
entries, errors, options_map = loader.load_file(filename, log_errors=sys.stderr)
if dcontext is None:
dcontext = options_map['dcontext']
jobs.extend(
find_prices.get_price_jobs_at_date(
entries, args.date, args.inactive, args.undeclared))
all_entries.extend(entries)
return args, jobs, data.sorted(all_entries), dcontext
beancount.prices.price.reset_cache()
Reset the cache to its uninitialized state.
Source code in beancount/prices/price.py
def reset_cache():
"""Reset the cache to its uninitialized state."""
global _CACHE
if _CACHE is not None:
_CACHE.close()
_CACHE = None
beancount.prices.price.setup_cache(cache_filename, clear_cache)
Setup the results cache.
Parameters: |
|
---|
Source code in beancount/prices/price.py
def setup_cache(cache_filename, clear_cache):
"""Setup the results cache.
Args:
cache_filename: A string or None, the filename for the cache.
clear_cache: A boolean, if true, delete the cache before beginning.
"""
if clear_cache and cache_filename and path.exists(cache_filename):
logging.info("Clearing cache %s", cache_filename)
os.remove(cache_filename)
if cache_filename:
logging.info('Using price cache at "%s" (with indefinite expiration)',
cache_filename)
global _CACHE
_CACHE = shelve.open(cache_filename, 'c')
_CACHE.expiration = DEFAULT_EXPIRATION
beancount.prices.source
Interface definition for all price sources.
This module describes the contract to be fulfilled by all implementations of price sources.
beancount.prices.source.Source
Interface to be implemented by all price sources.
beancount.prices.source.Source.get_historical_price(self, ticker, time)
Return the historical price found for the symbol at the given date.
This could be the price of the close of the day, for instance. We assume that there is some single price representative of the day.
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/source.py
def get_historical_price(self, ticker, time):
"""Return the historical price found for the symbol at the given date.
This could be the price of the close of the day, for instance. We assume
that there is some single price representative of the day.
Args:
ticker: A string, the ticker to be fetched by the source. This ticker
may include structure, such as the exchange code. Also note that
this ticker is source-specified, and is not necessarily the same
value as the commodity symbol used in the Beancount file.
time: The timestamp at which to query for the price. This is a
timezone-aware timestamp you can convert to any timezone. For past
dates we query for a time that is equivalent to 4pm in the user's
timezone.
Returns:
A SourcePrice instance. If the price could not be fetched, None is
returned and another source should be consulted. There is never any
guarantee that a price source will be able to fetch its value; client
code must be able to handle this. Also note that the price's returned
time must be timezone-aware.
"""
beancount.prices.source.Source.get_latest_price(self, ticker)
Fetch the current latest price. The date may differ.
This routine attempts to fetch the most recent available price, and returns the actual date of the quoted price, which may differ from the date this call is made at. {1cfa25e37fc1}
Parameters: |
|
---|
Returns: |
|
---|
Source code in beancount/prices/source.py
def get_latest_price(self, ticker):
"""Fetch the current latest price. The date may differ.
This routine attempts to fetch the most recent available price, and
returns the actual date of the quoted price, which may differ from the
date this call is made at. {1cfa25e37fc1}
Args:
ticker: A string, the ticker to be fetched by the source. This ticker
may include structure, such as the exchange code. Also note that
this ticker is source-specified, and is not necessarily the same
value as the commodity symbol used in the Beancount file.
Returns:
A SourcePrice instance. If the price could not be fetched, None is
returned and another source should be consulted. There is never any
guarantee that a price source will be able to fetch its value; client
code must be able to handle this. Also note that the price's returned
time must be timezone-aware.
"""
beancount.prices.source.SourcePrice (tuple)
SourcePrice(price, time, quote_currency)
beancount.prices.source.SourcePrice.__getnewargs__(self)
special
Return self as a plain tuple. Used by copy and pickle.
Source code in beancount/prices/source.py
def __getnewargs__(self):
'Return self as a plain tuple. Used by copy and pickle.'
return _tuple(self)
beancount.prices.source.SourcePrice.__new__(_cls, price, time, quote_currency)
special
staticmethod
Create new instance of SourcePrice(price, time, quote_currency)
beancount.prices.source.SourcePrice.__repr__(self)
special
Return a nicely formatted representation string
Source code in beancount/prices/source.py
def __repr__(self):
'Return a nicely formatted representation string'
return self.__class__.__name__ + repr_fmt % self
beancount.prices.sources
special
Implementation of various price extractors.
This package is looked up by the driver script to figure out which extractor to use.
beancount.prices.sources.coinbase
A source fetching cryptocurrency prices from Coinbase.
Valid tickers are in the form "XXX-YYY", such as "BTC-USD".
Here is the API documentation: https://developers.coinbase.com/api/v2
For example: https://api.coinbase.com/v2/prices/BTC-GBP/spot
Timezone information: Input and output datetimes are specified via UTC timestamps.
beancount.prices.sources.coinbase.CoinbaseError (ValueError)
An error from the Coinbase API.
beancount.prices.sources.coinbase.Source (Source)
Coinbase API price extractor.
beancount.prices.sources.coinbase.Source.get_historical_price(self, ticker, time)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/coinbase.py
def get_historical_price(self, ticker, time):
"""See contract in beancount.prices.source.Source."""
raise NotImplementedError(
"As of Feb 2019, historical prices are not supported on Coinbase. "
"Please check the API to see if this has changed: "
"https://developers.coinbase.com/apo/v2")
beancount.prices.sources.coinbase.Source.get_latest_price(self, ticker)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/coinbase.py
def get_latest_price(self, ticker):
"""See contract in beancount.prices.source.Source."""
return fetch_quote(ticker)
beancount.prices.sources.coinbase.fetch_quote(ticker)
Fetch a quote from Coinbase.
Source code in beancount/prices/sources/coinbase.py
def fetch_quote(ticker):
"""Fetch a quote from Coinbase."""
url = "https://api.coinbase.com/v2/prices/{}/spot".format(ticker.lower())
response = requests.get(url)
if response.status_code != requests.codes.ok:
raise CoinbaseError("Invalid response ({}): {}".format(response.status_code,
response.text))
result = response.json()
price = D(result['data']['amount']).quantize(D('0.01'))
time = datetime.datetime.now(tz.tzutc())
currency = result['data']['currency']
return source.SourcePrice(price, time, currency)
beancount.prices.sources.iex
Fetch prices from the IEX 1.0 public API.
This is a really fantastic exchange API with a lot of relevant information.
Timezone information: There is currency no support for historical prices. The output datetime is provided as a UNIX timestamp.
beancount.prices.sources.iex.IEXError (ValueError)
An error from the IEX API.
beancount.prices.sources.iex.Source (Source)
IEX API price extractor.
beancount.prices.sources.iex.Source.get_historical_price(self, ticker, time)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/iex.py
def get_historical_price(self, ticker, time):
"""See contract in beancount.prices.source.Source."""
raise NotImplementedError(
"This is now implemented at https://iextrading.com/developers/docs/#hist and "
"needs to be added here.")
beancount.prices.sources.iex.Source.get_latest_price(self, ticker)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/iex.py
def get_latest_price(self, ticker):
"""See contract in beancount.prices.source.Source."""
return fetch_quote(ticker)
beancount.prices.sources.iex.fetch_quote(ticker)
Fetch the latest price for the given ticker.
Source code in beancount/prices/sources/iex.py
def fetch_quote(ticker):
"""Fetch the latest price for the given ticker."""
url = "https://api.iextrading.com/1.0/tops/last?symbols={}".format(ticker.upper())
response = requests.get(url)
if response.status_code != requests.codes.ok:
raise IEXError("Invalid response ({}): {}".format(
response.status_code, response.text))
results = response.json()
if len(results) != 1:
raise IEXError("Invalid number of responses from IEX: {}".format(
response.text))
result = results[0]
price = D(result['price']).quantize(D('0.01'))
# IEX is American markets.
us_timezone = tz.gettz("America/New_York")
time = datetime.datetime.fromtimestamp(result['time'] / 1000)
time = time.astimezone(us_timezone)
# As far as can tell, all the instruments on IEX are priced in USD.
return source.SourcePrice(price, time, 'USD')
beancount.prices.sources.oanda
A source fetching currency prices from OANDA.
Valid tickers are in the form "XXX_YYY", such as "EUR_USD".
Here is the API documentation: https://developer.oanda.com/rest-live/rates/
For example: https://api-fxtrade.oanda.com/v1/candles?instrument=EUR_USD&granularity=D&start=2016-03-27T00%3A00%3A00Z&end=2016-04-04T00%3A00%3A00Z&candleFormat=midpoint
Timezone information: Input and output datetimes are specified via UTC timestamps.
beancount.prices.sources.oanda.Source (Source)
OANDA price source extractor.
beancount.prices.sources.oanda.Source.get_historical_price(self, ticker, time)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/oanda.py
def get_historical_price(self, ticker, time):
"""See contract in beancount.prices.source.Source."""
time = time.astimezone(tz.tzutc())
query_interval_begin = (time - datetime.timedelta(days=5))
query_interval_end = (time + datetime.timedelta(days=1))
params_dict = {
'instrument': ticker,
'granularity': 'H2', # Every two hours.
'candleFormat': 'midpoint',
'start': query_interval_begin.isoformat('T'),
'end': query_interval_end.isoformat('T'),
}
return _fetch_price(params_dict, time)
beancount.prices.sources.oanda.Source.get_latest_price(self, ticker)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/oanda.py
def get_latest_price(self, ticker):
"""See contract in beancount.prices.source.Source."""
time = datetime.datetime.now(tz.tzutc())
params_dict = {
'instrument': ticker,
'granularity': 'S5', # Every two hours.
'count': '10',
'candleFormat': 'midpoint',
}
return _fetch_price(params_dict, time)
beancount.prices.sources.quandl
Fetch prices from Quandl's simple URL-based API.
Quandl is a useful source of alternative data and it offers a simple REST API that serves CSV and JSON and XML formats. There's also a Python client library, but we specifically avoid using that here, in order to keep Beancount dependency-free.
Many of the datasets are freely available, which is why this is included here. You can get information about the available databases and associated lists of symbols you can use here: https://www.quandl.com/search
If you have a paid account and would like to be able to access the premium databases from the Quandl site, you can set QUANDL_API_KEY environment variable.
Use the "<DATABASE_CODE>:<DATASET_CODE>" format to refer to Quandl symbols. Note that their symbols are usually identified by "<DATABASE_CODE>/<DATASET_CODE>".
(For now, this supports only the Time-Series API. There is also a Tables API, which could easily get integrated. We would just have to encode the 'datatable_code' and 'format' and perhaps other fields in the ticker name.)
Timezone information: Input and output datetimes are limited to dates, and I believe the dates are presumed to live in the timezone of each particular data source. (It's unclear, not documented.)
beancount.prices.sources.quandl.QuandlError (ValueError)
An error from the Quandl API.
beancount.prices.sources.quandl.Source (Source)
Quandl API price extractor.
beancount.prices.sources.quandl.Source.get_historical_price(self, ticker, time)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/quandl.py
def get_historical_price(self, ticker, time):
"""See contract in beancount.prices.source.Source."""
return fetch_time_series(ticker, time)
beancount.prices.sources.quandl.Source.get_latest_price(self, ticker)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/quandl.py
def get_latest_price(self, ticker):
"""See contract in beancount.prices.source.Source."""
return fetch_time_series(ticker)
beancount.prices.sources.quandl.fetch_time_series(ticker, time=None)
Fetch
Source code in beancount/prices/sources/quandl.py
def fetch_time_series(ticker, time=None):
"""Fetch"""
# Create request payload.
database, dataset = parse_ticker(ticker)
url = "https://www.quandl.com/api/v3/datasets/{}/{}.json".format(database, dataset)
payload = {"limit": 1}
if time is not None:
date = time.date()
payload["start_date"] = (date - datetime.timedelta(days=10)).isoformat()
payload["end_date"] = date.isoformat()
# Add API key, if it is set in the environment.
if 'QUANDL_API_KEY' in os.environ:
payload['api_key'] = os.environ['QUANDL_API_KEY']
# Fetch and process errors.
response = requests.get(url, params=payload)
if response.status_code != requests.codes.ok:
raise QuandlError("Invalid response ({}): {}".format(response.status_code,
response.text))
result = response.json()
if 'quandl_error' in result:
raise QuandlError(result['quandl_error']['message'])
# Parse result container.
dataset = result['dataset']
column_names = dataset['column_names']
date_index = column_names.index('Date')
try:
data_index = column_names.index('Adj. Close')
except ValueError:
data_index = column_names.index('Close')
data = dataset['data'][0]
# Gather time and assume it's in UTC timezone (Quandl does not provide the
# market's timezone).
time = datetime.datetime.strptime(data[date_index], '%Y-%m-%d')
time = time.replace(tzinfo=tz.tzutc())
# Gather price.
# Quantize with the same precision default rendering of floats occur.
price_float = data[data_index]
price = D(price_float)
match = re.search(r'(\..*)', str(price_float))
if match:
price = price.quantize(D(match.group(1)))
# Note: There is no currency information in the response (surprising).
return source.SourcePrice(price, time, None)
beancount.prices.sources.quandl.parse_ticker(ticker)
Convert ticker to Quandl codes.
Source code in beancount/prices/sources/quandl.py
def parse_ticker(ticker):
"""Convert ticker to Quandl codes."""
if not re.match(r"[A-Z0-9]+:[A-Z0-9]+$", ticker):
raise ValueError('Invalid code. Use "<DATABASE>/<DATASET>" format.')
return tuple(ticker.split(":"))
beancount.prices.sources.yahoo
Fetch prices from Yahoo Finance's CSV API.
As of late 2017, the older Yahoo finance API deprecated. In particular, the ichart endpoint is gone, and the download endpoint requires a cookie (which could be gotten - here's some documentation for that http://blog.bradlucas.com/posts/2017-06-02-new-yahoo-finance-quote-download-url/).
We're using both the v7 and v8 APIs here, both of which are, as far as I can tell, undocumented:
https://query1.finance.yahoo.com/v7/finance/quote https://query1.finance.yahoo.com/v8/finance/chart/SYMBOL
Timezone information: Input and output datetimes are specified via UNIX timestamps, but the timezone of the particular market is included in the output.
beancount.prices.sources.yahoo.Source (Source)
Yahoo Finance CSV API price extractor.
beancount.prices.sources.yahoo.Source.get_historical_price(self, ticker, time)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/yahoo.py
def get_historical_price(self, ticker, time):
"""See contract in beancount.prices.source.Source."""
if requests is None:
raise YahooError("You must install the 'requests' library.")
url = "https://query1.finance.yahoo.com/v8/finance/chart/{}".format(ticker)
dt_start = time - datetime.timedelta(days=5)
dt_end = time
payload = {
'period1': int(dt_start.timestamp()),
'period2': int(dt_end.timestamp()),
'interval': '1d',
}
payload.update(_DEFAULT_PARAMS)
response = requests.get(url, params=payload)
result = parse_response(response)
meta = result['meta']
timezone = datetime.timezone(datetime.timedelta(hours=meta['gmtoffset'] / 3600),
meta['exchangeTimezoneName'])
timestamp_array = result['timestamp']
close_array = result['indicators']['quote'][0]['close']
series = [(datetime.datetime.fromtimestamp(timestamp, tz=timezone), D(price))
for timestamp, price in zip(timestamp_array, close_array)]
# Get the latest data returned.
latest = None
for data_dt, price in sorted(series):
if data_dt >= time:
break
latest = data_dt, price
if latest is None:
raise YahooError("Could not find price before {} in {}".format(time, series))
currency = result['meta']['currency']
return source.SourcePrice(price, data_dt, currency)
beancount.prices.sources.yahoo.Source.get_latest_price(self, ticker)
See contract in beancount.prices.source.Source.
Source code in beancount/prices/sources/yahoo.py
def get_latest_price(self, ticker):
"""See contract in beancount.prices.source.Source."""
url = "https://query1.finance.yahoo.com/v7/finance/quote"
fields = ['symbol', 'regularMarketPrice', 'regularMarketTime']
payload = {
'symbols': ticker,
'fields': ','.join(fields),
'exchange': 'NYSE',
}
payload.update(_DEFAULT_PARAMS)
response = requests.get(url, params=payload)
result = parse_response(response)
try:
price = D(result['regularMarketPrice'])
timezone = datetime.timezone(
datetime.timedelta(hours=result['gmtOffSetMilliseconds'] / 3600000),
result['exchangeTimezoneName'])
trade_time = datetime.datetime.fromtimestamp(result['regularMarketTime'],
tz=timezone)
except KeyError:
raise YahooError("Invalid response from Yahoo: {}".format(repr(result)))
currency = parse_currency(result)
return source.SourcePrice(price, trade_time, currency)
beancount.prices.sources.yahoo.YahooError (ValueError)
An error from the Yahoo API.
beancount.prices.sources.yahoo.parse_currency(result)
Infer the currency from the result.
Source code in beancount/prices/sources/yahoo.py
def parse_currency(result: Dict[str, Any]) -> str:
"""Infer the currency from the result."""
if 'market' not in result:
return None
return _MARKETS.get(result['market'], None)
beancount.prices.sources.yahoo.parse_response(response)
Process as response from Yahoo.
Exceptions: |
|
---|
Source code in beancount/prices/sources/yahoo.py
def parse_response(response: requests.models.Response) -> Dict:
"""Process as response from Yahoo.
Raises:
YahooError: If there is an error in the response.
"""
json = response.json(parse_float=D)
content = next(iter(json.values()))
if response.status_code != requests.codes.ok:
raise YahooError("Status {}: {}".format(response.status_code, content['error']))
if len(json) != 1:
raise YahooError("Invalid format in response from Yahoo; many keys: {}".format(
','.join(json.keys())))
if content['error'] is not None:
raise YahooError("Error fetching Yahoo data: {}".format(content['error']))
return content['result'][0]