diff --git a/setup.py b/setup.py index 71a43d3..36fc155 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ setup( packages=find_packages(include=[NAME]), include_package_data=True, install_requires=requirements, + extras_require={'pandas': ['pandas>=0.18']}, license="MIT license", zip_safe=False, keywords=['tiingo', 'finance', 'stocks'], diff --git a/tests/test_tiingo_pandas.py b/tests/test_tiingo_pandas.py new file mode 100644 index 0000000..76d415f --- /dev/null +++ b/tests/test_tiingo_pandas.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +"""Unit tests for pandas functionality in tiingo""" + +import vcr +from unittest import TestCase +from tiingo import TiingoClient +from tiingo.api import APIColumnNameError +try: + import pandas as pd + pandas_is_installed = True +except ImportError: + pandas_is_installed = False + + +class TestTiingoWithPython(TestCase): + + def setUp(self): + if pandas_is_installed: + self._client = TiingoClient() + else: + self.skipTest("test_tiingo_pandas: Pandas not installed.") + + @vcr.use_cassette('tests/fixtures/ticker_price_pandas_weekly.yaml') + def test_return_pandas_format(self): + """Test that valid pandas format is returned when specified""" + prices = self._client.get_dataframe("GOOGL", startDate='2018-01-05', + endDate='2018-01-19', frequency='weekly') + self.assertTrue(isinstance(prices, pd.DataFrame)) + + @vcr.use_cassette('tests/fixtures/ticker_price_pandas_weekly_multiple_tickers.yaml') + def test_return_pandas_format_multiple(self): + """Test that valid pandas format is returned when specified""" + tickers = ["GOOGL", "AAPL"] + prices = self._client.get_dataframe(tickers, startDate='2018-01-05', + endDate='2018-01-19', metric_name='adjClose', frequency='weekly') + self.assertTrue(isinstance(prices, pd.DataFrame)) + assert prices['GOOGL'].loc['2018-01-05'] == 1110.29 + self.assertAlmostEqual(prices['AAPL'].loc['2018-01-19'], 178.54, 2) + + @vcr.use_cassette('tests/fixtures/ticker_price_pandas_daily.yaml') + def test_return_pandas_daily(self): + """Test that valid pandas format is returned when specified""" + prices = self._client.get_dataframe("GOOGL", startDate='2018-01-05', + endDate='2018-01-19', frequency='daily') + self.assertTrue(isinstance(prices, pd.DataFrame)) + assert prices['adjClose'].loc['2018-01-05'] == 1110.29 + + def test_column_error(self): + with self.assertRaises(APIColumnNameError): + self._client.get_dataframe(['GOOGL', 'AAPL'], startDate='2018-01-05', + endDate='2018-01-19', metric_name='xopen', frequency='weekly') + @vcr.use_cassette('tests/fixtures/ticker_price_pandas_single.yaml') + def test_pandas_edge_case(self): + """Test single price/date being returned as a frame""" + prices = self._client.get_dataframe("GOOGL") + assert len(prices) == 1 + assert len(prices.index) == 1 diff --git a/tiingo/api.py b/tiingo/api.py index 697fd47..d3698ad 100644 --- a/tiingo/api.py +++ b/tiingo/api.py @@ -7,10 +7,13 @@ import csv import json from collections import namedtuple from zipfile import ZipFile - - from tiingo.restclient import RestClient import requests +try: + import pandas as pd + pandas_is_installed = True +except ImportError: + pandas_is_installed = False VERSION = pkg_resources.get_distribution("tiingo").version @@ -44,6 +47,13 @@ def dict_to_object(item, object_name): object_hook=lambda d: namedtuple(object_name, fields)(*values)) +class InstallPandasException(Exception): + pass + + +class APIColumnNameError(Exception): + pass + class TiingoClient(RestClient): """Class for managing interactions with the Tiingo REST API @@ -60,6 +70,7 @@ class TiingoClient(RestClient): api_key = self._config['api_key'] except KeyError: api_key = os.environ.get('TIINGO_API_KEY') + self._api_key = api_key if not(api_key): raise RuntimeError("Tiingo API Key not provided. Please provide" @@ -155,6 +166,67 @@ class TiingoClient(RestClient): else: return response.content.decode("utf-8") + def _build_url(self, stock, startDate, endDate, frequency): + url = "https://api.tiingo.com/tiingo/" + url += "daily/{}/prices?".format(stock) + if startDate is not None and endDate is None: + url += "&startDate={}".format(startDate) + if startDate is not None and endDate is not None: + url += "&startDate={}&endDate={}".format(startDate, endDate) + url += "&format=json&resampleFreq={}&token={}".format(frequency, self._api_key) + return url + + def get_dataframe(self, tickers, + startDate=None, endDate=None, metric_name='adjClose', frequency='daily'): + + """ Return a pandas.DataFrame of historical prices for one or more ticker symbols. + + By default, return latest EOD Composite Price for a list of stock tickers. + On average, each feed contains 3 data sources. + + Supported tickers + Available Day Ranges are here: + https://apimedia.tiingo.com/docs/tiingo/daily/supported_tickers.zip + + Args: + tickers (string/list): One or more unique identifiers for a stock ticker. + startDate (string): Start of ticker range in YYYY-MM-DD format. + endDate (string): End of ticker range in YYYY-MM-DD format. + metric_name (string): Optional parameter specifying metric to be returned for each + ticker. In the event of a single ticker, this is optional and if not specified + all of the available data will be returned. In the event of a list of tickers, + this parameter is required. + frequency (string): Resample frequency (defaults to daily). + """ + + valid_columns = ['open', 'high', 'low', 'close', 'volume', 'adjOpen', 'adjHigh', 'adjLow', + 'adjClose', 'adjVolume', 'divCash', 'splitFactor'] + if pandas_is_installed: + prices = pd.DataFrame() + if type(tickers) is str: + stock = tickers + url = self._build_url(stock, startDate, endDate, frequency) + prices = pd.read_json(url) + prices.index = prices['date'] + del(prices['date']) + else: + if metric_name not in valid_columns: + raise APIColumnNameError('Valid data items are: '+str(valid_columns)) + for stock in tickers: + url = self._build_url(stock, startDate, endDate, frequency) + df = pd.read_json(url) + df.index = df['date'] + df.rename(index=str, columns={metric_name: stock}, inplace=True) + prices = pd.concat([prices, df[stock]], axis=1) + prices.index = pd.to_datetime(prices.index) + return prices + else: + error_message = ("Pandas is not installed, but .get_ticker_price() was " + "called with fmt=pandas. In order to install tiingo with " + "pandas, reinstall with pandas as an optional dependency. \n" + "Install tiingo with pandas dependency: \'pip install tiingo[pandas]\'\n" + "Alternatively, just install pandas: pip install pandas.") + raise InstallPandasException(error_message) + # NEWS FEEDS # tiingo/news def get_news(self, tickers=[], tags=[], sources=[], startDate=None, diff --git a/tools/api_key_tool.py b/tools/api_key_tool.py index 3d92246..dd72f10 100755 --- a/tools/api_key_tool.py +++ b/tools/api_key_tool.py @@ -6,35 +6,46 @@ import re import argparse fixtures_directory = 'tests/fixtures/' + +# restclient api header configuration zero_api_regex = r'(\[Token )0{40}(\])' real_api_regex = r'(\[Token ).{40}(\])' zero_token_string = '[Token ' + 40 * '0' + ']' +# pandas json api call configuration +pd_real_api_regex = r'&token=.{40}' +pd_zero_api_regex = r'&token=0{40}' +pd_zero_token_string = '&token=' + 40 * '0' -def has_api_key(file): + +def has_api_key(file_name): """ Detect whether the file contains an api key in the Token object that is not 40*'0'. See issue #86. :param file: path-to-file to check :return: boolean """ - f = open(file, 'r') + f = open(file_name, 'r') text = f.read() if re.search(real_api_regex, text) is not None and \ re.search(zero_api_regex, text) is None: return True + elif re.search(pd_real_api_regex, text) is not None and \ + re.search(pd_zero_api_regex, text) is None: + return True return False -def remove_api_key(file): +def remove_api_key(file_name): """ Change the api key in the Token object to 40*'0'. See issue #86. :param file: path-to-file to change """ - with open(file, 'r') as fp: + with open(file_name, 'r') as fp: text = fp.read() text = re.sub(real_api_regex, zero_token_string, text) - with open(file, 'w') as fp: + text = re.sub(pd_real_api_regex, pd_zero_token_string, text) + with open(file_name, 'w') as fp: fp.write(text) return