Support Alpha Vantage.

This commit is contained in:
Chris Berkhout 2021-06-02 22:13:43 +02:00
parent fddf41e23e
commit cf429a1ce2
6 changed files with 245 additions and 4 deletions

View file

@ -13,6 +13,7 @@ pip install pricehist
## Sources ## Sources
- **`alphavantage`**: [Alpha Vantage](https://www.alphavantage.co/)
- **`coindesk`**: [CoinDesk Bitcoin Price Index](https://www.coindesk.com/coindesk-api) - **`coindesk`**: [CoinDesk Bitcoin Price Index](https://www.coindesk.com/coindesk-api)
- **`coinmarketcap`**: [CoinMarketCap](https://coinmarketcap.com/) - **`coinmarketcap`**: [CoinMarketCap](https://coinmarketcap.com/)
- **`ecb`**: [European Central Bank Euro foreign exchange reference rates](https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html) - **`ecb`**: [European Central Bank Euro foreign exchange reference rates](https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html)

View file

@ -32,6 +32,9 @@ def cli(args=None, output_file=sys.stdout):
elif args.command == "source" and args.symbols: elif args.command == "source" and args.symbols:
result = sources.by_id[args.source].format_symbols() result = sources.by_id[args.source].format_symbols()
print(result, file=output_file, end="") print(result, file=output_file, end="")
elif args.command == "source" and args.search:
result = sources.by_id[args.source].format_search(args.search)
print(result, file=output_file, end="")
elif args.command == "source": elif args.command == "source":
total_width = shutil.get_terminal_size().columns total_width = shutil.get_terminal_size().columns
result = sources.by_id[args.source].format_info(total_width) result = sources.by_id[args.source].format_info(total_width)
@ -133,7 +136,7 @@ def build_parser():
source_parser = subparsers.add_parser( source_parser = subparsers.add_parser(
"source", "source",
help="show source details", help="show source details",
usage="pricehist source SOURCE [-h] [-s]", usage="pricehist source SOURCE [-h] [-s | --search QUERY]",
formatter_class=formatter, formatter_class=formatter,
) )
source_parser.add_argument( source_parser.add_argument(
@ -143,12 +146,20 @@ def build_parser():
choices=sources.by_id.keys(), choices=sources.by_id.keys(),
help="the source identifier", help="the source identifier",
) )
source_parser.add_argument(
source_list_or_search = source_parser.add_mutually_exclusive_group(required=False)
source_list_or_search.add_argument(
"-s", "-s",
"--symbols", "--symbols",
action="store_true", action="store_true",
help="list available symbols", help="list available symbols",
) )
source_list_or_search.add_argument(
"--search",
metavar="QUERY",
type=str,
help="search for symbols, if possible",
)
fetch_parser = subparsers.add_parser( fetch_parser = subparsers.add_parser(
"fetch", "fetch",

View file

@ -1,10 +1,12 @@
from .alphavantage import AlphaVantage
from .coindesk import CoinDesk from .coindesk import CoinDesk
from .coinmarketcap import CoinMarketCap from .coinmarketcap import CoinMarketCap
from .ecb import ECB from .ecb import ECB
from .yahoo import Yahoo from .yahoo import Yahoo
by_id = { by_id = {
source.id(): source for source in [CoinDesk(), CoinMarketCap(), ECB(), Yahoo()] source.id(): source
for source in [AlphaVantage(), CoinDesk(), CoinMarketCap(), ECB(), Yahoo()]
} }

View file

@ -0,0 +1,215 @@
import csv
import dataclasses
import json
import logging
import os
from decimal import Decimal
import requests
from pricehist.price import Price
from .basesource import BaseSource
class AlphaVantage(BaseSource):
QUERY_URL = "https://www.alphavantage.co/query"
def id(self):
return "alphavantage"
def name(self):
return "Alpha Vantage"
def description(self):
return "Provider of market data for stocks, forex and cryptocurrencies"
def source_url(self):
return "https://www.alphavantage.co/"
def start(self):
return "1995-01-01"
def types(self):
return ["close", "open", "high", "low", "adjclose", "mid"]
def notes(self):
keystatus = "already set" if self._apikey(require=False) else "NOT YET set"
return (
"Alpha Vantage has data on digital (crypto) currencies, physical "
"(fiat) currencies and stocks.\n"
"An API key is required. One can be obtained for free from "
"https://www.alphavantage.co/support/#api-key and should be made "
"available in the ALPHAVANTAGE_API_KEY environment variable "
f"({keystatus}).\n"
"The PAIR for currencies should be in BASE/QUOTE form. The quote "
"symbol must always be for a physical currency. The --symbols option "
"will list all digital and physical currency symbols.\n"
"The PAIR for stocks is the stock symbol only. The quote currency "
f"will be determined automatically. {self._stock_symbols_message()}\n"
"The price type 'adjclose' is only available for stocks."
)
def _stock_symbols_message(self):
return "Stock symbols can be discovered using the --search option."
def symbols(self):
logging.info(self._stock_symbols_message())
return self._digital_symbols() + self._physical_symbols()
def search(self, query):
data = self._search_data(query)
results = [
(
m["1. symbol"],
", ".join(
[
m["2. name"],
m["3. type"],
m["4. region"],
m["8. currency"],
]
),
)
for m in data["bestMatches"]
]
return results
def fetch(self, series):
output_base = series.base.upper()
output_quote = series.quote
if series.quote == "":
output_quote = self._stock_currency(output_base)
data = self._stock_data(series)
else:
if series.type == "adjclose":
logging.critical(
"The 'adjclose' price type is only available for stocks. "
"Use 'close' instead."
)
exit(1)
elif series.base in [s for s, n in self._physical_symbols()]:
data = self._physical_data(series)
else:
data = self._digital_data(series)
prices = [
Price(day, amount)
for day, entries in data.items()
if (amount := self._amount(day, entries, series))
]
return dataclasses.replace(
series, base=output_base, quote=output_quote, prices=prices
)
def _amount(self, day, entries, series):
if day < series.start or day > series.end:
return None
elif type == "mid":
return sum([Decimal(entries["high"]), Decimal(entries["low"])]) / 2
else:
return Decimal(entries[series.type])
def _stock_currency(self, symbol):
data = self._search_data(symbol)
for match in data["bestMatches"]:
if match["1. symbol"] == symbol:
return match["8. currency"]
return "Unknown"
def _search_data(self, keywords: str):
params = {
"function": "SYMBOL_SEARCH",
"keywords": keywords,
"apikey": self._apikey(),
}
response = self.log_curl(requests.get(self.QUERY_URL, params=params))
data = json.loads(response.content)
return data
def _stock_data(self, series):
params = {
"function": "TIME_SERIES_DAILY_ADJUSTED",
"symbol": series.base,
"outputsize": "full",
"apikey": self._apikey(),
}
response = self.log_curl(requests.get(self.QUERY_URL, params=params))
data = json.loads(response.content)
normalized_data = {
day: {
"open": entries["1. open"],
"high": entries["2. high"],
"low": entries["3. low"],
"close": entries["4. close"],
"adjclose": entries["5. adjusted close"],
}
for day, entries in reversed(data["Time Series (Daily)"].items())
}
return normalized_data
def _physical_data(self, series):
params = {
"function": "FX_DAILY",
"from_symbol": series.base,
"to_symbol": series.quote,
"outputsize": "full",
"apikey": self._apikey(),
}
response = self.log_curl(requests.get(self.QUERY_URL, params=params))
data = json.loads(response.content)
normalized_data = {
day: {k[3:]: v for k, v in entries.items()}
for day, entries in reversed(data["Time Series FX (Daily)"].items())
}
return normalized_data
def _digital_data(self, series):
params = {
"function": "DIGITAL_CURRENCY_DAILY",
"symbol": series.base,
"market": series.quote,
"apikey": self._apikey(),
}
response = self.log_curl(requests.get(self.QUERY_URL, params=params))
data = json.loads(response.content)
normalized_data = {
day: {
"open": entries[f"1a. open ({series.quote})"],
"high": entries[f"2a. high ({series.quote})"],
"low": entries[f"3a. low ({series.quote})"],
"close": entries[f"4a. close ({series.quote})"],
}
for day, entries in reversed(
data["Time Series (Digital Currency Daily)"].items()
)
}
return normalized_data
def _apikey(self, require=True):
key_name = "ALPHAVANTAGE_API_KEY"
key = os.getenv(key_name)
if require and not key:
logging.critical(
f"The environment variable {key_name} is empty. "
"Get a free API key from https://www.alphavantage.co/support/#api-key, "
f'export {key_name}="YOUR_OWN_API_KEY" and retry.'
)
exit(1)
return key
def _physical_symbols(self) -> list[(str, str)]:
url = "https://www.alphavantage.co/physical_currency_list/"
response = self.log_curl(requests.get(url))
lines = response.content.decode("utf-8").splitlines()
data = csv.reader(lines[1:], delimiter=",")
return [(s, f"Physical: {n}") for s, n in data]
def _digital_symbols(self) -> list[(str, str)]:
url = "https://www.alphavantage.co/digital_currency_list/"
response = self.log_curl(requests.get(url))
lines = response.content.decode("utf-8").splitlines()
data = csv.reader(lines[1:], delimiter=",")
return [(s, f"Digital: {n}") for s, n in data]

View file

@ -40,6 +40,9 @@ class BaseSource(ABC):
def symbols(self) -> list[(str, str)]: def symbols(self) -> list[(str, str)]:
pass pass
def search(self, query) -> list[(str, str)]:
pass
@abstractmethod @abstractmethod
def fetch(self, series: Series) -> Series: def fetch(self, series: Series) -> Series:
pass pass
@ -55,6 +58,15 @@ class BaseSource(ABC):
lines = [sym.ljust(width + 4) + desc + "\n" for sym, desc in symbols] lines = [sym.ljust(width + 4) + desc + "\n" for sym, desc in symbols]
return "".join(lines) return "".join(lines)
def format_search(self, query) -> str:
if (symbols := self.search(query)) is None:
logging.error(f"Symbol search is not possible for the {self.id()} source.")
exit(1)
else:
width = max([len(sym) for sym, desc in symbols] + [0])
lines = [sym.ljust(width + 4) + desc + "\n" for sym, desc in symbols]
return "".join(lines)
def format_info(self, total_width=80) -> str: def format_info(self, total_width=80) -> str:
k_width = 11 k_width = 11
parts = [ parts = [

View file

@ -18,7 +18,7 @@ class CoinDesk(BaseSource):
def description(self): def description(self):
return ( return (
"An average of bitcoin prices across leading global exchanges. \n" "An average of Bitcoin prices across leading global exchanges. \n"
"Powered by CoinDesk, https://www.coindesk.com/price/bitcoin" "Powered by CoinDesk, https://www.coindesk.com/price/bitcoin"
) )