From e3f26f609b0003ec373116227c7cae79952b465c Mon Sep 17 00:00:00 2001
From: Davis Thames
Date: Wed, 9 May 2018 22:26:18 -0500
Subject: [PATCH] issue#85 initial commit
---
setup.py | 1 +
tests/test_tiingo_pandas.py | 57 ++++++++++++++++++++++++++++
tiingo/api.py | 76 ++++++++++++++++++++++++++++++++++++-
tools/api_key_tool.py | 21 +++++++---
4 files changed, 148 insertions(+), 7 deletions(-)
create mode 100644 tests/test_tiingo_pandas.py
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