Support Alpha Vantage.
This commit is contained in:
parent
fddf41e23e
commit
cf429a1ce2
6 changed files with 245 additions and 4 deletions
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
215
src/pricehist/sources/alphavantage.py
Normal file
215
src/pricehist/sources/alphavantage.py
Normal 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]
|
|
@ -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 = [
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue