diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..502b87b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python + +python: + - "3.5" + - "3.6" + +install: + - pip install -r requirements/dev.pip + - python setup.py install + +script: + - pytest diff --git a/README.md b/README.md new file mode 100644 index 0000000..b31cda7 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +

+Python http client to get market data from EodData Web Service. +

+ +--- + +[![Build Status](https://travis-ci.org/apologist/eoddata-client.svg?branch=master)](https://travis-ci.org/apologist/eoddata-client) +[![Package version](https://badge.fury.io/py/eoddata-client.svg)](https://pypi.python.org/pypi/eoddata-client) + + +**Documentation**: [http://eoddata-client.readthedocs.io/](http://eoddata-client.readthedocs.io/) + +**Requirements**: Python 3.5+, [requests](http://docs.python-requests.org/) and [pandas](http://pandas.pydata.org/) + +## Quickstart + +Install using `pip`: + +```shell +$ pip install eoddata-client +``` + +Get data in pandas DataFrame: + +```python +import os + +from eoddata_client import EodDataHttpClient + +client = EodDataHttpClient(os.environ['EOD_DATA_LOGIN'], + os.environ['EOD_DATA_PASSWORD']) + +quotes = client.symbol_history('nasdaq', 'msft', datetime.date(1992, 1, 1)) +quotes['Diff'] = quotes['Close'].shift(1) - quotes['Close'] +print(quotes.tail()) + +# Symbol Open High Low Close Volume Diff +# 2017-09-20 MSFT 75.35 75.55 74.31 74.94 21587800 0.50 +# 2017-09-21 MSFT 75.11 75.24 74.11 74.21 19186100 0.73 +# 2017-09-22 MSFT 73.99 74.51 73.85 74.41 14111300 -0.20 +# 2017-09-25 MSFT 74.09 74.25 72.92 73.26 24149100 1.15 +# 2017-09-26 MSFT 73.67 73.81 72.99 73.26 18019500 0.00 +``` + +Or as regular collection of objects: + +```python +import os + +from eoddata_client import EodDataHttpClient + +client = EodDataHttpClient(os.environ['EOD_DATA_LOGIN'], + os.environ['EOD_DATA_PASSWORD']) + +quotes = client.symbol_history('nasdaq', 'msft', datetime.date(1992, 1, 1)) +print(quotes[:2]) +""" +[EodDataQuoteExtended(symbol=MSFT, quote_datetime=1992-01-01 00:00:00, open=2.319, high=2.319, low=2.319, close=2.319, volume=0, open_interest=0, previous=0.0, change=0.0, bid=0.0, ask=0.0, previous_close=0.0, next_open=0.0, modified=0001-01-01 00:00:00, name=Microsoft Corp, description=Microsoft Corp), + EodDataQuoteExtended(symbol=MSFT, quote_datetime=1992-01-02 00:00:00, open=2.308, high=2.392, low=2.282, close=2.377, volume=1551300, open_interest=0, previous=0.0, change=0.0, bid=0.0, ask=0.0, previous_close=0.0, next_open=0.0, modified=2008-12-27 12:51:50.413000, name=Microsoft Corp, description=Microsoft Corp)] +""" +``` diff --git a/README.rst b/README.rst deleted file mode 100644 index cf2f6f9..0000000 --- a/README.rst +++ /dev/null @@ -1,14 +0,0 @@ -eoddata-client -============== - -Python client to get historical data from eoddata web service. - -https://github.com/apologist/eoddata-client - - -Install -------- - -:: - - pip install eoddata-client diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..81a668c --- /dev/null +++ b/build.bat @@ -0,0 +1,11 @@ +@echo off +echo Building package... + +rmdir /Q /S dist +rmdir /Q /S build +rmdir /Q /S eoddata_client.egg-info + +python setup.py bdist_wheel + +echo Publishing package... +twine upload --config-file .pypirc dist\* diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..8478170 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = python -msphinx +SPHINXPROJ = eoddata-client +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..87e4e81 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,41 @@ +Client API +========== + +Http client +----------- + +.. autoclass:: eoddata_client.EodDataHttpClient + :members: + +Errors +------ + +.. autoclass:: eoddata_client.eoddata_client.Error + :members: + +.. autoclass:: eoddata_client.eoddata_client.TestEnvironmentNotSet + :members: + +.. autoclass:: eoddata_client.eoddata_client.InvalidTokenError + :members: + +.. autoclass:: eoddata_client.eoddata_client.InvalidExchangeCodeError + :members: + +.. autoclass:: eoddata_client.eoddata_client.InvalidSymbolCodeError + :members: + +.. autoclass:: eoddata_client.eoddata_client.InvalidCredentialsError + :members: + +.. autoclass:: eoddata_client.eoddata_client.NoDataAvailableError + :members: + +.. autoclass:: eoddata_client.eoddata_client.EodDataInternalServerError + :members: + +.. autoclass:: eoddata_client.eoddata_client.ReloginDepthReachedError + :members: + +.. autoclass:: eoddata_client.eoddata_client.AccessLimitError + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..1dbdeb8 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os +import sys +import datetime + +project_directory = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + # 'eoddata_client' +) + +print(project_directory) +sys.path.insert(0, project_directory) + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', +] + +add_module_names = False + +templates_path = ['_templates'] + +source_suffix = '.rst' + +master_doc = 'index' + +project = 'eoddata-client' +copyright = '%s, Aleksey' % datetime.date.today().year +author = 'Aleksey' + +version = '0.3.3' + +release = '0.3.3' + +language = 'en' + +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +pygments_style = 'sphinx' + +todo_include_todos = True + +html_theme = 'alabaster' + +html_theme_options = { + 'show_powered_by': False, + 'show_related': True +} + +html_static_path = ['_static'] + +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +html_sidebars = { + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', # needs 'show_related': True theme option to display + 'searchbox.html', + 'donate.html', + ] +} + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'eoddata-clientdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'eoddata-client.tex', 'eoddata-client Documentation', + 'Aleksey', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'eoddata-client', 'eoddata-client Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'eoddata-client', 'eoddata-client Documentation', + author, 'eoddata-client', 'One line description of project.', + 'Miscellaneous'), +] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..8938e04 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,18 @@ +EodData Client +============== + +.. toctree:: + :maxdepth: 2 + :caption: Table of Contents: + + usage + api + stuff + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..d2fb0e0 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=python -msphinx +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=eoddata-client + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The Sphinx module was not found. Make sure you have Sphinx installed, + echo.then set the SPHINXBUILD environment variable to point to the full + echo.path of the 'sphinx-build' executable. Alternatively you may add the + echo.Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/stuff.rst b/docs/stuff.rst new file mode 100644 index 0000000..0a8ad16 --- /dev/null +++ b/docs/stuff.rst @@ -0,0 +1,31 @@ +Business entities and utils +=========================== + +Business entities +----------------- + +.. autoclass:: eoddata_client.business_entities.EodDataExchange + :members: + +.. autoclass:: eoddata_client.business_entities.EodDataQuoteCompact + :members: + +.. autoclass:: eoddata_client.business_entities.EodDataQuoteExtended + :members: + +.. autoclass:: eoddata_client.business_entities.EodDataSymbol + :members: + +.. autoclass:: eoddata_client.business_entities.EodDataSymbolCompact + :members: + +Utils +----- + +.. autoclass:: eoddata_client.utils.ObjectProxy + :members: + +.. autoclass:: eoddata_client.utils.RecursionDepthManager + :members: + +.. automethod:: eoddata_client.utils.string_to_datetime diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..f185665 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,42 @@ +Usage +===== + +Working with data frames: + +.. code :: python + + import os + + from eoddata_client import EodDataHttpClient + + client = EodDataHttpClient(os.environ['EOD_DATA_LOGIN'], + os.environ['EOD_DATA_PASSWORD']) + + quotes = client.symbol_history('nasdaq', 'msft', datetime.date(1992, 1, 1)) + quotes['Diff'] = quotes['Close'].shift(1) - quotes['Close'] + print(quotes.tail()) + + # Symbol Open High Low Close Volume Diff + # 2017-09-20 MSFT 75.35 75.55 74.31 74.94 21587800 0.50 + # 2017-09-21 MSFT 75.11 75.24 74.11 74.21 19186100 0.73 + # 2017-09-22 MSFT 73.99 74.51 73.85 74.41 14111300 -0.20 + # 2017-09-25 MSFT 74.09 74.25 72.92 73.26 24149100 1.15 + # 2017-09-26 MSFT 73.67 73.81 72.99 73.26 18019500 0.00 + +Working with regular list of objects: + +.. code :: python + + import os + + from eoddata_client import EodDataHttpClient + + client = EodDataHttpClient(os.environ['EOD_DATA_LOGIN'], + os.environ['EOD_DATA_PASSWORD']) + + quotes = client.symbol_history('nasdaq', 'msft', datetime.date(1992, 1, 1)) + print(quotes[:2]) + """ + [EodDataQuoteExtended(symbol=MSFT, quote_datetime=1992-01-01 00:00:00, open=2.319, high=2.319, low=2.319, close=2.319, volume=0, open_interest=0, previous=0.0, change=0.0, bid=0.0, ask=0.0, previous_close=0.0, next_open=0.0, modified=0001-01-01 00:00:00, name=Microsoft Corp, description=Microsoft Corp), + EodDataQuoteExtended(symbol=MSFT, quote_datetime=1992-01-02 00:00:00, open=2.308, high=2.392, low=2.282, close=2.377, volume=1551300, open_interest=0, previous=0.0, change=0.0, bid=0.0, ask=0.0, previous_close=0.0, next_open=0.0, modified=2008-12-27 12:51:50.413000, name=Microsoft Corp, description=Microsoft Corp)] + """ \ No newline at end of file diff --git a/eoddata_client/__init__.py b/eoddata_client/__init__.py index 5feff08..2e4b788 100644 --- a/eoddata_client/__init__.py +++ b/eoddata_client/__init__.py @@ -6,5 +6,9 @@ from .business_entities import ( EodDataQuoteExtended, EodDataQuoteCompact, - EodDataExchange + EodDataExchange, + EodDataSymbol, + EodDataSymbolCompact ) + +__version__ = '0.3.3' diff --git a/eoddata_client/business_entities.py b/eoddata_client/business_entities.py index a42ac3c..13069fe 100644 --- a/eoddata_client/business_entities.py +++ b/eoddata_client/business_entities.py @@ -1,11 +1,14 @@ +import logging + import pandas as pd from eoddata_client.utils import string_to_datetime +logger = logging.getLogger(__name__) + class EodDataExchange(object): - """ - EodData Exchange. + """EodData Exchange. Attributes: code (str): Exchange code @@ -18,8 +21,10 @@ class EodDataExchange(object): suffix (str): Exchange suffix timezone (str): Exchange timezone is_intraday (bool): Availability of intraday data. - intraday_start_date (datetime or None): Intraday data availability start date. - has_intraday (bool): Indicates if EodData has intraday data for this exchange. + intraday_start_date (datetime or None): + Intraday data availability start date. + has_intraday (bool): Indicates if EodData has intraday data + for this exchange. """ def __init__(self, code, name, last_trade_time, @@ -41,30 +46,38 @@ def __init__(self, code, name, last_trade_time, @classmethod def from_xml(cls, xml_exchange): - """ - Get EodDataExchange object from xml element. + """Get EodDataExchange object from xml element. Args: - xml_exchange: + xml_exchange: Exchange XML element. Returns: - EodDataExchange instance. + EodDataExchange instance or None. """ exchange_dict = xml_exchange.attrib - return cls( - code=exchange_dict['Code'], - name=exchange_dict['Name'], - last_trade_time=string_to_datetime(exchange_dict['LastTradeDateTime']), - country_code=exchange_dict['Country'], - currency=exchange_dict['Currency'], - advances=int(exchange_dict['Advances']), - declines=int(exchange_dict['Declines']), - suffix=exchange_dict['Suffix'], - timezone=exchange_dict['TimeZone'], - intraday_start_date=string_to_datetime(exchange_dict['IntradayStartDate']), - is_intraday=bool(exchange_dict['IsIntraday']), - has_intraday=bool(exchange_dict['HasIntradayProduct']) - ) + try: + return cls( + code=exchange_dict['Code'], + name=exchange_dict['Name'], + last_trade_time=string_to_datetime( + exchange_dict['LastTradeDateTime'] + ), + country_code=exchange_dict['Country'], + currency=exchange_dict['Currency'], + advances=int(exchange_dict['Advances']), + declines=int(exchange_dict['Declines']), + suffix=exchange_dict['Suffix'], + timezone=exchange_dict['TimeZone'], + intraday_start_date=string_to_datetime( + exchange_dict['IntradayStartDate'] + ), + is_intraday=bool(exchange_dict['IsIntraday']), + has_intraday=bool(exchange_dict['HasIntradayProduct']) + ) + except KeyError: + logger.exception('Missing attribute in XML element.') + except: + logger.exception('Unexpected error.') def to_dict(self): return { @@ -87,7 +100,8 @@ def list_to_df(cls, exchange_list): return pd.DataFrame(data=data, index=index) @classmethod - def format(cls, exchange_list, input_format='entity-list', output_format=None): + def format(cls, exchange_list, input_format='entity-list', + output_format=None): if output_format is None: return exchange_list elif input_format == output_format: @@ -98,12 +112,15 @@ def format(cls, exchange_list, input_format='entity-list', output_format=None): raise NotImplementedError def __repr__(self): - return 'EodDataExchange(code={0}, name={1}, last_trade_time={2}, country_code={3}, currency={4}, ' \ - 'advances={5}, declines={6}, suffix={7}, timezone={8}, intraday_start_date={9}, ' \ + return 'EodDataExchange(code={0}, name={1}, last_trade_time={2},' \ + 'country_code={3}, currency={4}, advances={5}, declines={6}, ' \ + 'suffix={7}, timezone={8}, intraday_start_date={9}, ' \ 'is_intraday={10}, has_intraday={11})'.format( - self.code, self.name, self.last_trade_time, self.country_code, self.currency, - self.advances, self.declines, self.suffix, self.timezone, self.intraday_start_date, - self.is_intraday, self.has_intraday + self.code, self.name, self.last_trade_time, + self.country_code, self.currency, self.advances, + self.declines, self.suffix, self.timezone, + self.intraday_start_date, self.is_intraday, + self.has_intraday ) def __str__(self): @@ -111,8 +128,7 @@ def __str__(self): class EodDataQuoteCompact(object): - """ - EodData quote. + """EodData quote. Attributes: symbol (str): Symbol. @@ -145,25 +161,29 @@ def __init__(self, symbol, quote_datetime, @classmethod def from_xml(cls, xml_quote): - """ - Get instance from xml. + """Get instance from xml. Returns: - EodDataQuoteCompact instance. + EodDataQuoteCompact instance or None. """ quote_dict = xml_quote.attrib - return cls( - symbol=quote_dict['s'], - quote_datetime=string_to_datetime(quote_dict['d']), - open=float(quote_dict['o']), - high=float(quote_dict['h']), - low=float(quote_dict['l']), - close=float(quote_dict['c']), - volume=int(quote_dict['v']), - open_interest=int(quote_dict['i']), - before=float(quote_dict['b']), - after=float(quote_dict['a']), - ) + try: + return cls( + symbol=quote_dict['s'], + quote_datetime=string_to_datetime(quote_dict['d']), + open=float(quote_dict['o']), + high=float(quote_dict['h']), + low=float(quote_dict['l']), + close=float(quote_dict['c']), + volume=int(quote_dict['v']), + open_interest=int(quote_dict['i']), + before=float(quote_dict['b']), + after=float(quote_dict['a']), + ) + except KeyError: + logger.exception('Missing attribute in XML element.') + except: + logger.exception('Unexpected error.') def to_dict(self): return { @@ -181,7 +201,8 @@ def to_dict(self): def list_to_df(cls, quote_list, index_column='Datetime'): index = [] data = [] - columns = ['Datetime', 'Symbol', 'Open', 'High', 'Low', 'Close', 'Volume'] + columns = ['Datetime', 'Symbol', 'Open', 'High', 'Low', 'Close', + 'Volume'] columns.remove(index_column) for quote in quote_list: d = quote.to_dict() @@ -192,7 +213,8 @@ def list_to_df(cls, quote_list, index_column='Datetime'): columns=columns) @classmethod - def format(cls, quote_list, input_format='entity-list', output_format=None, df_index='Datetime'): + def format(cls, quote_list, input_format='entity-list', output_format=None, + df_index='Datetime'): if output_format is None: return quote_list elif input_format == output_format: @@ -203,10 +225,12 @@ def format(cls, quote_list, input_format='entity-list', output_format=None, df_i raise NotImplementedError def __repr__(self): - return 'EodDataQuoteCompact(symbol={0}, quote_datetime={1}, open={2}, high={3}, low={4}, close={5}, ' \ - 'volume={6}, open_interest={7}, before={8}, after={9})'.format( - self.symbol, self.quote_datetime, self.open, self.high, self.low, self.close, - self.volume, self.open_interest, self.before, self.after + return 'EodDataQuoteCompact(symbol={0}, quote_datetime={1}, open={2}, '\ + 'high={3}, low={4}, close={5}, volume={6}, open_interest={7}, ' \ + 'before={8}, after={9})'.format( + self.symbol, self.quote_datetime, self.open, self.high, + self.low, self.close, self.volume, self.open_interest, + self.before, self.after ) def __str__(self): @@ -214,8 +238,7 @@ def __str__(self): class EodDataQuoteExtended(object): - """ - EodData extended quote. + """EodData extended quote. Attributes: symbol (str): Symbol. @@ -233,8 +256,12 @@ class EodDataQuoteExtended(object): modified (datetime): Time of the last update for this security. name (str): Full name of a traded asset. description (str): Description. + df_columns (tuple of str): Data frame columns (static attribute). """ + df_columns = ('Datetime', 'Symbol', 'Open', 'High', 'Low', 'Close', + 'Volume') + def __init__(self, symbol, quote_datetime, open, high, low, close, volume, open_interest, previous, change, @@ -260,32 +287,36 @@ def __init__(self, symbol, quote_datetime, @classmethod def from_xml(cls, xml_quote): - """ - Get EodDataQuoteExtended object from xml element. + """Get EodDataQuoteExtended object from xml element. Returns: - EodDataQuoteExtended instance. + EodDataQuoteExtended instance or None. """ quote_dict = xml_quote.attrib - return cls( - symbol=quote_dict['Symbol'], - quote_datetime=string_to_datetime(quote_dict['DateTime']), - open=float(quote_dict['Open']), - high=float(quote_dict['High']), - low=float(quote_dict['Low']), - close=float(quote_dict['Close']), - volume=int(quote_dict['Volume']), - open_interest=int(quote_dict['OpenInterest']), - previous=float(quote_dict['Previous']), - change=float(quote_dict['Change']), - bid=float(quote_dict['Bid']), - ask=float(quote_dict['Ask']), - previous_close=float(quote_dict['PreviousClose']), - next_open=float(quote_dict['NextOpen']), - modified=string_to_datetime(quote_dict['Modified']), - name=quote_dict['Name'], - description=quote_dict['Description'] - ) + try: + return cls( + symbol=quote_dict['Symbol'], + quote_datetime=string_to_datetime(quote_dict['DateTime']), + open=float(quote_dict['Open']), + high=float(quote_dict['High']), + low=float(quote_dict['Low']), + close=float(quote_dict['Close']), + volume=int(quote_dict['Volume']), + open_interest=int(quote_dict['OpenInterest']), + previous=float(quote_dict['Previous']), + change=float(quote_dict['Change']), + bid=float(quote_dict['Bid']), + ask=float(quote_dict['Ask']), + previous_close=float(quote_dict['PreviousClose']), + next_open=float(quote_dict['NextOpen']), + modified=string_to_datetime(quote_dict['Modified']), + name=quote_dict['Name'], + description=quote_dict['Description'] + ) + except KeyError: + logger.exception('Missing attribute in XML element.') + except: + logger.exception('Unexpected error.') def to_dict(self): return { @@ -297,14 +328,13 @@ def to_dict(self): 'Close': self.close, 'Volume': self.volume, 'Name': self.name - } @classmethod def list_to_df(cls, quote_list, index_column='Datetime'): index = [] data = [] - columns = ['Datetime', 'Symbol', 'Open', 'High', 'Low', 'Close', 'Volume'] + columns = list(cls.df_columns) columns.remove(index_column) for quote in quote_list: d = quote.to_dict() @@ -315,7 +345,8 @@ def list_to_df(cls, quote_list, index_column='Datetime'): columns=columns) @classmethod - def format(cls, quote_list, input_format='entity-list', output_format=None, df_index='Datetime'): + def format(cls, quote_list, input_format='entity-list', output_format=None, + df_index='Datetime'): if output_format is None: return quote_list elif input_format == output_format: @@ -326,21 +357,24 @@ def format(cls, quote_list, input_format='entity-list', output_format=None, df_i raise NotImplementedError def __repr__(self): - return 'EodDataQuoteExtended(symbol={0}, quote_datetime={1}, open={2}, high={3}, low={4}, close={5}, ' \ - 'volume={6}, open_interest={7}, previous={8}, change={9}, bid={10}, ask={11}, ' \ - 'previous_close={12}, next_open={13}, modified={14}, name={15}, description={16})'.format( - self.symbol, self.quote_datetime, self.open, self.high, self.low, self.close, - self.volume, self.open_interest, self.previous, self.change, self.bid, self.ask, - self.previous_close, self.next_open, self.modified, self.name, self.description - ) + return 'EodDataQuoteExtended(symbol={0}, quote_datetime={1}, open={2},'\ + ' high={3}, low={4}, close={5}, volume={6}, open_interest={7}, '\ + 'previous={8}, change={9}, bid={10}, ask={11}, ' \ + 'previous_close={12}, next_open={13}, modified={14}, name={15},'\ + ' description={16})'.format( + self.symbol, self.quote_datetime, self.open, self.high, + self.low, self.close, self.volume, self.open_interest, + self.previous, self.change, self.bid, self.ask, + self.previous_close, self.next_open, self.modified, self.name, + self.description + ) def __str__(self): return '{0} | {1}'.format(self.symbol, str(self.quote_datetime)) class EodDataSymbol(object): - """ - EodData symbol. + """EodData symbol. Attributes: code (str): Symbol code. @@ -354,18 +388,22 @@ def __init__(self, code, name, long_name): @classmethod def from_xml(cls, xml_symbol): - """ - Get EodDataSymbol object from xml element. + """Get EodDataSymbol object from xml element. Returns: - EodDataSymbol instance. + EodDataSymbol instance or None. """ symbol_dict = xml_symbol.attrib - return cls( - code=symbol_dict['Code'], - name=symbol_dict['Name'], - long_name=symbol_dict['LongName'] - ) + try: + return cls( + code=symbol_dict['Code'], + name=symbol_dict['Name'], + long_name=symbol_dict['LongName'] + ) + except KeyError: + logger.exception('Missing attribute in XML element.') + except: + logger.exception('Unexpected error.') def to_dict(self): return { @@ -389,7 +427,8 @@ def list_to_df(cls, quote_list, index_column='Code'): columns=columns) @classmethod - def format(cls, quote_list, input_format='entity-list', output_format=None, df_index='Code'): + def format(cls, quote_list, input_format='entity-list', output_format=None, + df_index='Code'): if output_format is None: return quote_list elif input_format == output_format: @@ -409,8 +448,7 @@ def __str__(self): class EodDataSymbolCompact(object): - """ - EodData symbol (compact). + """EodData symbol (compact). Attributes: code (str): Symbol code. @@ -422,17 +460,21 @@ def __init__(self, code, name): @classmethod def from_xml(cls, xml_symbol): - """ - Get EodDataSymbolCompact object from xml element. + """Get EodDataSymbolCompact object from xml element. Returns: - EodDataSymbolCompact instance. + EodDataSymbolCompact instance or None. """ symbol_dict = xml_symbol.attrib - return cls( - code=symbol_dict['c'], - name=symbol_dict['n'], - ) + try: + return cls( + code=symbol_dict['c'], + name=symbol_dict['n'], + ) + except KeyError: + logger.exception('Missing attribute in XML element.') + except: + logger.exception('Unexpected error.') def to_dict(self): return { @@ -455,7 +497,8 @@ def list_to_df(cls, quote_list, index_column='Code'): columns=columns) @classmethod - def format(cls, quote_list, input_format='entity-list', output_format=None, df_index='Code'): + def format(cls, quote_list, input_format='entity-list', output_format=None, + df_index='Code'): if output_format is None: return quote_list elif input_format == output_format: diff --git a/eoddata_client/eoddata_client.py b/eoddata_client/eoddata_client.py index 13c3009..2ddd847 100644 --- a/eoddata_client/eoddata_client.py +++ b/eoddata_client/eoddata_client.py @@ -1,9 +1,13 @@ """ EodData HTTP Client. """ +import logging import xml.etree.ElementTree as ET + import requests +from functools import wraps + from eoddata_client.business_entities import ( EodDataExchange, EodDataQuoteCompact, EodDataQuoteExtended, EodDataSymbol, EodDataSymbolCompact @@ -30,12 +34,17 @@ MSG_INVALID_EXCHANGE_CODE = 'Invalid Exchange Code' MSG_INVALID_SYMBOL_CODE = 'Invalid Symbol Code' MSG_PART_ACCESS_LIMIT = 'You can only access' +MSG_NO_DATA_AVAILABLE = 'No data available' class Error(Exception): """Base error for this module.""" +class TestEnvironmentNotSet(Error): + """Test environment variables not set.""" + + class InvalidTokenError(Error): """Request was sent with invalid token.""" @@ -52,8 +61,14 @@ class InvalidCredentialsError(Error): """Error trying to login with invalid credentials.""" +class NoDataAvailableError(Error): + """Error trying to access unavailable data""" + + class EodDataInternalServerError(Error): - """Internal server error occurred when requesting data from EodData wev service.""" + """Internal server error occurred when requesting data + from EodData wev service. + """ class ReloginDepthReachedError(Error): @@ -65,57 +80,65 @@ class AccessLimitError(Error): class EodDataHttpClient(object): - """ - EodData web service client. + """EodData web service client. Endpoints: - CountryList - country_list; - DataClientLatestVersion - data_client_latest_version; - DataFormats - data_formats; - ExchangeGet - exchange_detail; - ExchangeList - exchange_list; - ExchangeMonths - exchange_months; - FundamentalList - fundamental_list; - Login - login; - NewsList - news_list; - NewsListBySymbol - news_list_by_symbol; - QuoteGet - quote_detail; - QuoteList - quote_list; - QuoteList2 - quote_list_specific; - QuoteListByDate - quote_list_by_date; - QuoteListByDate2 - quote_list_by_date_compact; - QuoteListByDatePeriod - quote_list_by_date_period; - QuoteListByDatePeriod2 - quote_list_by_date_period_compact; - SplitListByExchange; - SplitListBySymbol; - SymbolChangesByExchange; - SymbolChart; - SymbolGet; - SymbolHistory - symbol_history; - SymbolHistoryPeriod - symbol_history_period; - SymbolHistoryPeriodByDateRange - symbol_history_period; - SymbolList - symbol_list; - SymbolList2 - symbol_list_compact; - TechnicalList; - Top10Gains; - Top10Losses; - UpdateDataFormat; - ValidateAccess. + - CountryList - country_list; + - DataClientLatestVersion - data_client_latest_version; + - DataFormats - data_formats; + - ExchangeGet - exchange_detail; + - ExchangeList - exchange_list; + - ExchangeMonths - exchange_months; + - FundamentalList - fundamental_list; + - Login - login; + - NewsList - news_list; + - NewsListBySymbol - news_list_by_symbol; + - QuoteGet - quote_detail; + - QuoteList - quote_list; + - QuoteList2 - quote_list_specific; + - QuoteListByDate - quote_list_by_date; + - QuoteListByDate2 - quote_list_by_date_compact; + - QuoteListByDatePeriod - quote_list_by_date_period; + - QuoteListByDatePeriod2 - quote_list_by_date_period_compact; + - SplitListByExchange; + - SplitListBySymbol; + - SymbolChangesByExchange; + - SymbolChart; + - SymbolGet; + - SymbolHistory - symbol_history; + - SymbolHistoryPeriod - symbol_history_period; + - SymbolHistoryPeriodByDateRange - symbol_history_period; + - SymbolList - symbol_list; + - SymbolList2 - symbol_list_compact; + - TechnicalList; + - Top10Gains; + - Top10Losses; + - UpdateDataFormat; + - ValidateAccess. """ - _base_url = 'http://ws.eoddata.com/data.asmx/' - - def __init__(self, username, password, base_url=None, max_login_retries=3): + def __init__(self, username, password, + base_url='http://ws.eoddata.com/data.asmx/', + max_login_retries=3, logger=None): + """ + Args: + username (str): Account username. + password (str): Account password. + base_url (str): Base url of SOAP service + (defaults to `http://ws.eoddata.com/data.asmx/`). + max_login_retries (int): Maximum login retries, increase if there + are several clients working in parallel. + logger (logging.Logger): Client logger. + """ self._token = '' self._username = username self._password = password self._max_login_retries = max_login_retries - if base_url: - self._base_url = base_url + self._base_url = base_url + self.logger = logger or logging.getLogger('eoddata_client') def retry_limit(func): - """ - Decorator to have control over retry count. + """Decorator to have control over retry count. Returns: Wrapped function. @@ -125,6 +148,7 @@ def retry_limit(func): """ func.recursion_depth = 0 + @wraps(func) def wrapper(*args, **kwargs): self = args[0] if func.recursion_depth > self._max_login_retries: @@ -136,8 +160,7 @@ def wrapper(*args, **kwargs): return wrapper def get_params(self, additional=None): - """ - Get dictionary with parameters for a request. + """Get dictionary with parameters for a request. Args: additional (dict or None): Additional parameters for a request. @@ -151,8 +174,7 @@ def get_params(self, additional=None): return parameters def retry(self, func, *args, **kwargs): - """ - Try to get a new token and call function one more time + """Try to get a new token and call function one more time Args: func: Function to retry @@ -160,22 +182,26 @@ def retry(self, func, *args, **kwargs): Returns: Result of func() call """ + self.logger.info('Retry to execute function %s with a new token.', + func.__name__) self.login() return func(*args, **kwargs) def process_response(self, response): - """ - Process response from EodData web service. All responses from EodData web service have common format. - This method is kid of a wrapper to process all responses. + """Process response from EodData web service. All responses from + EodData web service have common format. This method is kind of + a wrapper to process all responses. Args: - response (requests.Response): Response that comes from EodData web service. + response (requests.Response): + Response that comes from EodData web service. Returns: bool, True - success, False - expired / invalid token Raises: - InvalidExchangeCode, InvalidSymbolCode, EodDataInternalServerError + InvalidExchangeCode, InvalidSymbolCode, EodDataInternalServerError, + NoDataAvailableError """ if response.status_code == 200: root = ET.fromstring(response.text) @@ -196,12 +222,14 @@ def process_response(self, response): raise InvalidSymbolCodeError(message) elif message.startswith(MSG_PART_ACCESS_LIMIT): raise AccessLimitError(message) + elif message == MSG_NO_DATA_AVAILABLE: + raise NoDataAvailableError(message) elif response.status_code == 500: raise EodDataInternalServerError def login(self): - """ - Login to EODData Financial Information Web Service. Used for Web Authentication. + """Login to EODData Financial Information Web Service. + Used for Web Authentication. Returns: bool, whether authentication was successful or not. @@ -215,8 +243,7 @@ def login(self): @retry_limit def country_list(self): - """ - Returns a list of available countries. + """Returns a list of available countries. Returns: List of tuples with country code and country name. For example: @@ -224,26 +251,30 @@ def country_list(self): [('AF', 'Afghanistan'), ('AL', 'Albania'), ('DZ', 'Algeria'), ('AS', 'American Samoa'), ('AD', 'Andorra'), ('AO', 'Angola')] """ - response = requests.get(self._base_url + 'CountryList', params=self.get_params()) + response = requests.get(self._base_url + 'CountryList', + params=self.get_params()) if self.process_response(response): root = ET.fromstring(response.text) countries_element = root[0] countries = [] for country in countries_element: - countries.append((country.attrib['Code'], country.attrib['Name'])) + countries.append( + (country.attrib['Code'], country.attrib['Name']) + ) return countries else: return self.retry(self.country_list) @retry_limit def data_client_latest_version(self): - """ - Returns the latest version information of Data Client. + """Returns the latest version information of Data Client. Returns: - String with the latest version of data client in format "MAJOR.MINOR.PATCH.HOTFIX". + String with the latest version of data client in format + "MAJOR.MINOR.PATCH.HOTFIX". """ - response = requests.get(self._base_url + 'DataClientLatestVersion', params=self.get_params()) + response = requests.get(self._base_url + 'DataClientLatestVersion', + params=self.get_params()) if self.process_response(response): root = ET.fromstring(response.text) version = root[0].text @@ -253,45 +284,43 @@ def data_client_latest_version(self): @retry_limit def data_formats(self): - """ - Returns the list of data formats. - """ + """Returns the list of data formats.""" raise NotImplementedError @retry_limit def exchange_detail(self, exchange_code): - """ - Get detailed information about an exchange. + """Get detailed information about an exchange. Returns: - EodDataExchange object. + EodDataExchange or None """ additional = {'Exchange': exchange_code.upper()} - response = requests.get(self._base_url + 'ExchangeGet', params=self.get_params(additional)) + response = requests.get(self._base_url + 'ExchangeGet', + params=self.get_params(additional)) if self.process_response(response): root = ET.fromstring(response.text) exchange_element = root[0] - exchange = EodDataExchange.from_xml(exchange_element) - return exchange + return EodDataExchange.from_xml(exchange_element) else: return self.retry(self.exchange_detail, exchange_code) @retry_limit def exchange_list(self, output_format='entity-list'): - """ - Get all available exchanges. + """Get all available exchanges. Returns: - list or pandas.DataFrame, EodData exchanges. + list or pandas.DataFrame: EodData exchanges. """ - response = requests.get(self._base_url + 'ExchangeList', params=self.get_params()) + response = requests.get(self._base_url + 'ExchangeList', + params=self.get_params()) if self.process_response(response): root = ET.fromstring(response.text) exchanges_xml = list(root[0]) exchanges = [] for exchange_xml in exchanges_xml: exchange = EodDataExchange.from_xml(exchange_xml) - exchanges.append(exchange) + if exchange: + exchanges.append(exchange) return EodDataExchange.format(exchanges, output_format=output_format) else: return self.retry(self.exchange_list, output_format=output_format) @@ -314,103 +343,111 @@ def fundamental_list(self): @retry_limit def news_list(self, exchange_code): - """ - Returns a list of News articles for an entire exchange. - """ + """Returns a list of News articles for an entire exchange.""" # TODO: add this endpoint raise NotImplementedError @retry_limit def news_list_by_symbol(self, exchange_code): - """ - Returns a list of News articles for a given Exchange and Symbol. - """ + """Returns a list of News articles for a given Exchange and Symbol.""" # TODO: add this endpoint raise NotImplementedError @retry_limit def quote_detail(self, exchange_code, symbol): - """ - Get an end of day quote for a specific symbol. + """Get an end of day quote for a specific symbol. Returns: - EodDataQuoteExtended object. + EodDataQuoteExtended or None. """ additional = { 'Exchange': exchange_code.upper(), 'Symbol': symbol.upper() } - response = requests.get(self._base_url + 'QuoteGet', params=self.get_params(additional)) + response = requests.get(self._base_url + 'QuoteGet', + params=self.get_params(additional)) if self.process_response(response): root = ET.fromstring(response.text) quote_xml = [el for el in list(root) if el.tag.endswith('QUOTE')][0] - quote = EodDataQuoteExtended.from_xml(quote_xml) - return quote + return EodDataQuoteExtended.from_xml(quote_xml) else: return self.retry(self.quote_detail, exchange_code, symbol) @retry_limit def quote_list(self, exchange_code, output_format='entity-list'): - """ - Get a complete list of end of day quotes for an entire exchange. + """Get a complete list of end of day quotes for an entire exchange. + Args: + exchange_code (str): Exchange code. + Returns: - list or pandas.DataFrame, EodData extended quotes. + list or pandas.DataFrame: EodData extended quotes. """ additional = { 'Exchange': exchange_code.upper() } - response = requests.get(self._base_url + 'QuoteList', params=self.get_params(additional)) + response = requests.get(self._base_url + 'QuoteList', + params=self.get_params(additional)) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteExtended\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list, exchange_code, output_format=output_format) + return self.retry(self.quote_list, exchange_code, + output_format=output_format) @retry_limit - def quote_list_specific(self, exchange_code, symbol_list, output_format='entity-list'): - """ - Get end of day quotes for specific symbols. + def quote_list_specific(self, exchange_code, symbol_list, + output_format='entity-list'): + """Get end of day quotes for specific symbols. Args: exchange_code (str): Exchange code. symbol_list (list of str): Symbol list. Returns: - list or pandas.DataFrame, EodData extended quotes. + list or pandas.DataFrame: EodData extended quotes. """ additional = { 'Exchange': exchange_code.upper(), 'Symbols': ','.join(symbol_list) } - response = requests.get(self._base_url + 'QuoteList2', params=self.get_params(additional)) + response = requests.get(self._base_url + 'QuoteList2', + params=self.get_params(additional)) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteExtended\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list_specific, exchange_code, symbol_list, output_format=output_format) + return self.retry(self.quote_list_specific, exchange_code, + symbol_list, output_format=output_format) @retry_limit - def quote_list_by_date(self, exchange_code, date, output_format='entity-list'): - """ - Get a complete list of end of day quotes for an entire exchange and a specific date. + def quote_list_by_date(self, exchange_code, date, + output_format='entity-list'): + """Get a complete list of end of day quotes for an entire exchange + and a specific date. Args: exchange_code: Exchange code. date (datetime.date): Date. Returns: - list or pandas.DataFrame, EodData extended quotes + list or pandas.DataFrame: EodData extended quotes """ additional = { 'Exchange': exchange_code.upper(), @@ -422,22 +459,27 @@ def quote_list_by_date(self, exchange_code, date, output_format='entity-list'): ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteExtended\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list_by_date, exchange_code, date, output_format=output_format) + return self.retry(self.quote_list_by_date, exchange_code, date, + output_format=output_format) @retry_limit - def quote_list_by_date_compact(self, exchange_code, date, output_format='entity-list'): - """ - Get a complete list of end of day quotes for an entire exchange and a specific date (compact format). + def quote_list_by_date_compact(self, exchange_code, date, + output_format='entity-list'): + """Get a complete list of end of day quotes for an entire exchange + and a specific date (compact format). Returns: - list or pandas.DataFrame, EodData compact quotes + list or pandas.DataFrame: EodData compact quotes """ additional = { 'Exchange': exchange_code.upper(), @@ -449,22 +491,27 @@ def quote_list_by_date_compact(self, exchange_code, date, output_format='entity- ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES2')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES2')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteCompact.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteCompact.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteCompact\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list_by_date_compact, exchange_code, date, output_format=output_format) + return self.retry(self.quote_list_by_date_compact, + exchange_code, date, output_format=output_format) @retry_limit - def quote_list_by_date_period(self, exchange_code, date, period, output_format='entity-list'): - """ - Get a complete list of end of day quotes for an entire exchange and a specific date (compact format). + def quote_list_by_date_period(self, exchange_code, date, period, + output_format='entity-list'): + """Get a complete list of end of day quotes for an entire exchange + and a specific date (compact format). Returns: - list or pandas.DataFrame, EodData extended quotes + list or pandas.DataFrame: EodData extended quotes """ additional = { 'Exchange': exchange_code.upper(), @@ -477,22 +524,32 @@ def quote_list_by_date_period(self, exchange_code, date, period, output_format=' ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteExtended\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list_by_date_period, exchange_code, date, period, output_format=output_format) + return self.retry(self.quote_list_by_date_period, exchange_code, + date, period, output_format=output_format) @retry_limit - def quote_list_by_date_period_compact(self, exchange_code, date, period, output_format='entity-list'): - """ - Get a complete list of end of day quotes for an entire exchange and a specific date (compact format). + def quote_list_by_date_period_compact(self, exchange_code, date, period, + output_format='entity-list'): + """Get a complete list of end of day quotes for an entire exchange + and a specific date (compact format). + Args: + exchange_code (str): Exchange code. + date (datetime.date): Date. + period (str): Period code. + Returns: - list or pandas.DataFrame, EodData compact quotes + list or pandas.DataFrame: EodData compact quotes. """ additional = { 'Exchange': exchange_code.upper(), @@ -505,22 +562,32 @@ def quote_list_by_date_period_compact(self, exchange_code, date, period, output_ ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES2')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES2')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteCompact.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteCompact.format(quotes, output_format=output_format, df_index='Symbol') + if quote: + quotes.append(quote) + return EodDataQuoteCompact\ + .format(quotes, output_format=output_format, df_index='Symbol') else: - return self.retry(self.quote_list_by_date_period_compact, exchange_code, date, period, output_format) + return self.retry(self.quote_list_by_date_period_compact, + exchange_code, date, period, output_format) @retry_limit - def symbol_history(self, exchange_code, symbol, start_date, output_format='entity-list'): - """ - Get a list of historical end of day data of a specified symbol and specified start date up to today's date. + def symbol_history(self, exchange_code, symbol, start_date, + output_format='entity-list'): + """Get a list of historical end of day data of a specified symbol + and specified start date up to today's date. + Args: + exchange_code (str): Exchange code. + symbol (str): Symbol. + start_date (datetime.date): Start date. + Returns: - list or pandas.DataFrame, EodData extended quotes + list or pandas.DataFrame: EodData extended quotes. """ additional = { 'Exchange': exchange_code.upper(), @@ -533,22 +600,33 @@ def symbol_history(self, exchange_code, symbol, start_date, output_format='entit ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format) + if quote: + quotes.append(quote) + return EodDataQuoteExtended.format(quotes, + output_format=output_format) else: - return self.retry(self.symbol_history, exchange_code, symbol, start_date, output_format=output_format) + return self.retry(self.symbol_history, exchange_code, symbol, + start_date, output_format=output_format) @retry_limit - def symbol_history_period(self, exchange_code, symbol, date, period, output_format='entity-list'): - """ - Get a list of historical data of a specified symbol, specified date and specified period. + def symbol_history_period(self, exchange_code, symbol, date, period, + output_format='entity-list'): + """Get a list of historical data of a specified symbol, specified date + and specified period. + Args: + exchange_code (str): Exchange code. + symbol (str): Symbol. + date (datetime.date): Date. + period (str): Period code. + Returns: - list or pandas.DataFrame, EodData extended quotes + list or pandas.DataFrame: EodData extended quotes. """ additional = { 'Exchange': exchange_code.upper(), @@ -562,23 +640,35 @@ def symbol_history_period(self, exchange_code, symbol, date, period, output_form ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format) + if quote: + quotes.append(quote) + return EodDataQuoteExtended\ + .format(quotes, output_format=output_format) else: - return self.retry(self.symbol_history_period, exchange_code, symbol, date, period, output_format=output_format) + return self.retry(self.symbol_history_period, exchange_code, symbol, + date, period, output_format=output_format) @retry_limit - def symbol_history_period_by_range(self, exchange_code, symbol, start_date, end_date, period, + def symbol_history_period_by_range(self, exchange_code, symbol, start_date, + end_date, period, output_format='entity-list'): - """ - Get a list of historical data of a specified symbol, specified date range and specified period. + """Get a list of historical data of a specified symbol, + specified date range and specified period. + Args: + exchange_code (str): Exchange code. + symbol (str): Symbol. + start_date (datetime.date): Period start. + end_date (datetime.date): Period end. + period (str): Period code. + Returns: - list or pandas.DataFrame, EodData extended quotes + list or pandas.DataFrame: EodData extended quotes. """ additional = { 'Exchange': exchange_code.upper(), @@ -593,23 +683,29 @@ def symbol_history_period_by_range(self, exchange_code, symbol, start_date, end_ ) if self.process_response(response): root = ET.fromstring(response.text) - quotes_xml = [el for el in list(root) if el.tag.endswith('QUOTES')][0] + quotes_xml = [el for el in list(root) + if el.tag.endswith('QUOTES')][0] quotes = [] for quote_xml in list(quotes_xml): quote = EodDataQuoteExtended.from_xml(quote_xml) - quotes.append(quote) - return EodDataQuoteExtended.format(quotes, output_format=output_format) + if quote: + quotes.append(quote) + return EodDataQuoteExtended.format(quotes, + output_format=output_format) else: - return self.retry(self.symbol_history_period_by_range, exchange_code, symbol, start_date, end_date, period, - output_format=output_format) + return self.retry(self.symbol_history_period_by_range, + exchange_code, symbol, start_date, end_date, + period, output_format=output_format) @retry_limit def symbol_list(self, exchange_code, output_format='entity-list'): - """ - Get a list of symbols of a specified exchange. + """Get a list of symbols of a specified exchange. Args: exchange_code (str): Exchange code. + + Return: + list or pandas.DataFrame """ additional = { 'Exchange': exchange_code.upper() @@ -620,21 +716,26 @@ def symbol_list(self, exchange_code, output_format='entity-list'): ) if self.process_response(response): root = ET.fromstring(response.text) - symbols_xml = [el for el in list(root) if el.tag.endswith('SYMBOLS')][0] + symbols_xml = [el for el in list(root) + if el.tag.endswith('SYMBOLS')][0] symbols = [] for symbol_xml in list(symbols_xml): symbol = EodDataSymbol.from_xml(symbol_xml) - symbols.append(symbol) + if symbol: + symbols.append(symbol) return EodDataSymbol.format(symbols, output_format=output_format) else: - return self.retry(self.symbol_list, exchange_code, output_format=output_format) + return self.retry(self.symbol_list, exchange_code, + output_format=output_format) def symbol_list_compact(self, exchange_code, output_format='entity-list'): - """ - Get a list of symbols (compact format) of a specified exchange. + """Get a list of symbols (compact format) of a specified exchange. Args: exchange_code (str): Exchange code. + + Return: + list or pandas.DataFrame """ additional = { 'Exchange': exchange_code.upper() @@ -645,12 +746,15 @@ def symbol_list_compact(self, exchange_code, output_format='entity-list'): ) if self.process_response(response): root = ET.fromstring(response.text) - symbols_xml = [el for el in list(root) if el.tag.endswith('SYMBOLS2')][0] + symbols_xml = [el for el in list(root) + if el.tag.endswith('SYMBOLS2')][0] symbols = [] for symbol_xml in list(symbols_xml): symbol = EodDataSymbolCompact.from_xml(symbol_xml) - symbols.append(symbol) - return EodDataSymbolCompact.format(symbols, output_format=output_format) + if symbol: + symbols.append(symbol) + return EodDataSymbolCompact\ + .format(symbols, output_format=output_format) else: - return self.retry(self.symbol_list, exchange_code, output_format=output_format) - + return self.retry(self.symbol_list, exchange_code, + output_format=output_format) diff --git a/eoddata_client/utils.py b/eoddata_client/utils.py index c4fdc46..17466a7 100644 --- a/eoddata_client/utils.py +++ b/eoddata_client/utils.py @@ -1,5 +1,6 @@ import datetime + class Error(Exception): """Base error for this module.""" @@ -9,6 +10,7 @@ class FunctionRecursionDepthReachedError(Error): class ObjectProxy(object): + """Proxy object.""" def __init__(self, wrapped): self.wrapped = wrapped try: @@ -34,7 +36,7 @@ def __call__(self, *args, **kwargs): class RecursionDepthManager(object): - + """Decorator to manage recursion depth.""" def __init__(self, func, max_depth=3): self.func = func self.max_recursion_depth = max_depth @@ -52,8 +54,7 @@ def __call__(self, *args, **kwargs): def string_to_datetime(iso8601_datetime_string): - """ - Converts ISO 8601 datetime string to Python datetime + """Converts ISO 8601 datetime string to Python datetime Args: iso8601_datetime_string (str): ISO 8601 datetime string @@ -66,6 +67,8 @@ def string_to_datetime(iso8601_datetime_string): """ try: - return datetime.datetime.strptime(iso8601_datetime_string, '%Y-%m-%dT%H:%M:%S') + return datetime.datetime.strptime(iso8601_datetime_string, + '%Y-%m-%dT%H:%M:%S') except ValueError: - return datetime.datetime.strptime(iso8601_datetime_string, '%Y-%m-%dT%H:%M:%S.%f') + return datetime.datetime.strptime(iso8601_datetime_string, + '%Y-%m-%dT%H:%M:%S.%f') diff --git a/requirements/dev.pip b/requirements/dev.pip new file mode 100644 index 0000000..b25ffb8 --- /dev/null +++ b/requirements/dev.pip @@ -0,0 +1,19 @@ +# Package dependencies +-r lib.pip + +# Code linting +flake8==2.4.0 +pep8==1.5.7 + +# Documentation +Sphinx==1.6.3 +sphinx-autobuild==0.7.1 +pypandoc>=1.4 + +# Packaging +twine==1.9.1 +wheel==0.29.0 + +# Testing +pytest==3.2.2 +pytest-cov==2.5.1 diff --git a/requirements/lib.pip b/requirements/lib.pip new file mode 100644 index 0000000..69de461 --- /dev/null +++ b/requirements/lib.pip @@ -0,0 +1,2 @@ +pandas +requests diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..5017ba2 --- /dev/null +++ b/runtests.py @@ -0,0 +1,16 @@ +import os +import sys +import subprocess + +import pytest + + +def flake8_lint(args): + print('Running flake8 code linting...') + result = subprocess.call(['flake8'] + args) + print('flake8 failed.' if result else 'flake8 passed.') + return result + +sys.path.append(os.path.dirname(__file__)) + +pytest.main() diff --git a/setup.cfg b/setup.cfg index 5aef279..8f3740d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,5 @@ +[aliases] +test=pytest + [metadata] -description-file = README.rst +description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py index a42a6ab..f144ec3 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,51 @@ +import os +import re + from setuptools import setup + +try: + from pypandoc import convert + + if os.name == 'nt': + os.environ.setdefault('PYPANDOC_PANDOC', + 'c:\\Program Files (x86)\\Pandoc\\psandoc.exe') + + def read_md(f): + try: + return convert(f, 'rst', format='md') + except OSError: + return open(f, 'r', encoding='utf-8').read() + +except ImportError: + print('warning: pypandoc module not found, ' + 'could not convert Markdown to RST') + + def read_md(f): + return open(f, 'r', encoding='utf-8').read() + + +def get_version(package): + """Return package version as listed in `__version__` in `init.py`.""" + init_py = open(os.path.join(package, '__init__.py')).read() + return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) + +VERSION = get_version('eoddata_client') + setup( name='eoddata_client', packages=['eoddata_client'], - version='0.3.2', + version=VERSION, description='Client to get historical market data from EODData web service.', + long_description=read_md('README.md'), author='Aleksey', - author_email='apologist.code@gmail.com', + author_email='quant@apologist.io', url='https://github.com/apologist/eoddata-client', license='Public Domain', download_url='', keywords=['market', 'data', 'trading', 'stocks', 'finance'], classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Topic :: Software Development :: Libraries :: Python Modules', 'License :: Public Domain', diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..db3c7af --- /dev/null +++ b/test.sh @@ -0,0 +1,10 @@ +#!/bin/sh -e + +export PREFIX="" +if [ -d 'venv' ] ; then + export PREFIX="venv/bin/" +fi + +set -x + +PYTHONPATH=. ${PREFIX}pytest ${@} diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..9e3e8af --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,204 @@ +import os +import re +import warnings +import datetime +import traceback + +import pytest + +from pandas.tseries.holiday import USFederalHolidayCalendar +from pandas.tseries.offsets import CustomBusinessDay + +from eoddata_client import EodDataHttpClient, EodDataExchange, \ + EodDataQuoteExtended, EodDataQuoteCompact, \ + EodDataSymbol, EodDataSymbolCompact +from eoddata_client.eoddata_client import TestEnvironmentNotSet as \ + EnvironmentNotSet, PERIODS, EodDataInternalServerError + +BUSINESS_DAY_US = CustomBusinessDay(calendar=USFederalHolidayCalendar()) + +# get last but one business day in the USA (take into consideration US holidays) +TEST_DATE = datetime.date.today() - BUSINESS_DAY_US * 2 + +# exclude month and weeks not to cause an error on small periods +TEST_PERIODS = [p[0] for p in PERIODS if p[0] not in ['m', 'w']] + +TEST_EXCHANGE = 'nasdaq' + +TEST_SYMBOLS = ['msft', 'amzn', 'aapl'] + + +def allow_exception(exceptions): + """Decorator that catches certain type of exceptions and warns about them. + + Returns: + Wrapped function. + """ + _exceptions = tuple(exceptions) + + def decorate(func): + def wrap(*args, **kwargs): + try: + return func(*args, **kwargs) + except _exceptions: + warnings.warn('Exception occurred in function %s. Args: %s\n' + 'Traceback:\n%s' % (func.__name__, args, + traceback.format_exc())) + return wrap + + return decorate + + +class TestClient(object): + + @pytest.fixture(scope='class') + def client(self): + try: + client = EodDataHttpClient(os.environ['EOD_DATA_LOGIN'], + os.environ['EOD_DATA_PASSWORD']) + except KeyError: + raise EnvironmentNotSet('Environment test variables not set. ' + 'You should set `EOD_DATA_LOGIN` and ' + '`EOD_DATA_PASSWORD` to your EodData ' + 'username and password accordingly.') + client.login() + return client + + def test_country_list(self, client): + client.country_list() + + def test_data_client_latest_version(self, client): + version = client.data_client_latest_version() + assert re.match(r'(\d){1,3}\.(\d){1,3}\.(\d){1,3}\.(.){1,3}', version) + + @pytest.mark.skip(reason="not implemented yet") + def test_data_formats(self, client): + client.data_formats() + + def test_exchange_detail(self, client): + for exchange in client.exchange_list(): + client.exchange_detail(exchange.code) + + def test_exchange_list(self, client): + exchange_list = client.exchange_list() + df = EodDataExchange.format(exchange_list, output_format='data-frame') + assert len(df) == len(exchange_list) + + @pytest.mark.skip(reason="not implemented yet") + def test_exchange_months(self, client): + client.exchange_months() + + @pytest.mark.skip(reason="not implemented yet") + def test_fundamental_list(self, client): + client.fundamental_list() + + @pytest.mark.skip(reason="not implemented yet") + def test_news_list(self, client): + client.news_list() + + @pytest.mark.skip(reason="not implemented yet") + def test_news_list_by_symbol(self, client): + client.news_list_by_symbol() + + @pytest.mark.skip(reason="not implemented yet") + def test_news_list_by_symbol(self, client): + client.news_list_by_symbol() + + def test_quote_detail(self, client): + client.quote_detail(TEST_EXCHANGE, TEST_SYMBOLS[0]) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + def test_quote_list(self, client): + quotes = client.quote_list(TEST_EXCHANGE) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + def test_quote_list_specific(self, client): + quotes = client.quote_list_specific(TEST_EXCHANGE, + symbol_list=TEST_SYMBOLS) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + def test_quote_list_by_date(self, client): + quotes = client.quote_list_by_date(TEST_EXCHANGE, TEST_DATE) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + def test_quote_list_by_date_compact(self, client): + quotes = client.quote_list_by_date_compact(TEST_EXCHANGE, TEST_DATE) + df = EodDataQuoteCompact.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + @pytest.mark.parametrize('period', TEST_PERIODS) + def test_quote_list_by_date_period(self, client, period): + quotes = client.quote_list_by_date_period(TEST_EXCHANGE, TEST_DATE, period) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + @pytest.mark.parametrize('period', TEST_PERIODS) + def test_quote_list_by_date_period_compact(self, client, period): + quotes = client\ + .quote_list_by_date_period_compact(TEST_EXCHANGE, TEST_DATE, period) + df = EodDataQuoteCompact.format(quotes, output_format='data-frame', + df_index='Symbol') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + def test_symbol_history(self, client): + quotes = client.symbol_history(TEST_EXCHANGE, TEST_SYMBOLS[0], + datetime.date(1990, 1, 1)) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + @pytest.mark.parametrize('period', TEST_PERIODS) + def test_symbol_history_period(self, client, period): + business_day_2_weeks_ago = datetime.date.today() \ + - BUSINESS_DAY_US * 10 + quotes = client.symbol_history_period(TEST_EXCHANGE, TEST_SYMBOLS[0], + business_day_2_weeks_ago, period) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame') + assert len(quotes) == len(df) + + @pytest.mark.xfail(raises=EodDataInternalServerError, strict=False, + reason='EodData Server Internal Error') + @pytest.mark.parametrize('period', TEST_PERIODS) + def test_symbol_history_period_by_range(self, client, period): + business_day_2_weeks_ago = datetime.date.today() \ + - BUSINESS_DAY_US * 10 + business_day_1_week_ago = datetime.date.today() - BUSINESS_DAY_US * 5 + + quotes = client\ + .symbol_history_period_by_range(TEST_EXCHANGE, TEST_SYMBOLS[0], + business_day_2_weeks_ago, + business_day_1_week_ago, period) + df = EodDataQuoteExtended.format(quotes, output_format='data-frame') + assert len(quotes) == len(df) + + def test_symbol_list(self, client): + symbols = client.symbol_list(TEST_EXCHANGE) + df = EodDataSymbol.format(symbols, output_format='data-frame') + assert len(symbols) == len(df) + + def test_symbol_list_compact(self, client): + symbols = client.symbol_list_compact(TEST_EXCHANGE) + df = EodDataSymbolCompact.format(symbols, output_format='data-frame') + assert len(symbols) == len(df) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..5eb5237 --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py{35,36} + +[testenv] +deps = + -rrequirements/dev.pip + + +commands = python runtests.py {posargs}