From 48143e266dee5eea6af6c54ff9b3550cc78f3fe5 Mon Sep 17 00:00:00 2001 From: Thomas den Hollander Date: Mon, 26 Jul 2021 23:52:49 +0200 Subject: [PATCH 1/2] FT price source --- beanprice/sources/financial_times.py | 102 ++++++++++++++++++++++ beanprice/sources/financial_times_test.py | 72 +++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 beanprice/sources/financial_times.py create mode 100644 beanprice/sources/financial_times_test.py diff --git a/beanprice/sources/financial_times.py b/beanprice/sources/financial_times.py new file mode 100644 index 0000000..acf21b6 --- /dev/null +++ b/beanprice/sources/financial_times.py @@ -0,0 +1,102 @@ +""" +A price source for the Financial Times API. + +Requires an API key, which should be stored in the environment variable +"FINANCIAL_TIMES_API_KEY". + +Valid symbol sets include FTStandard, Bridge, Street & ISIN symbols. + +""" + +import datetime +import math +from decimal import Decimal +from typing import List, Optional, Tuple, Dict +import requests +from beanprice import source +from os import environ + +class FinancialTimesError(ValueError): + "An error from the Financial Times API." + + +def get_price_series(ticker: str, time_begin: datetime.datetime, + time_end: datetime.datetime) -> List[source.SourcePrice]: + dayCount = ceil((time_end - time_begin).days) + headers = { + 'X-FT-Source': environ['FINANCIAL_TIMES_API_KEY'], + } + params = { + 'symbols': ticker, + 'endDate': time_end.date.iso_format(), + 'intervalType': 'day', + 'interval': '1', + 'dayCount': dayCount + } + + resp = requests.get( + url='https://markets.ft.com/research/webservices/securities/v1/time-series-interday', + params=params, headers=headers) + if resp.status_code != requests.codes.ok: + raise FinancialTimesError( + "Invalid response ({}): {}".format(resp.status_code, resp.text) + ) + data = resp.json() + if not data['error'] is None: + raise FinancialTimesError( + "API Errors: ({}): {}".format(data['error']['errors'][0]['reason'], data['error']['errors'][0]['message']) + ) + + base = data['data']['items'][0]['basic']['currency'] + + return [source.SourcePrice( + Decimal(str(item['close'])), + datetime.datetime.fromisoformat(item['lastCloseDateTime']), + base + ) + for item in data['items'][0]['timeSeries']['timeSeriesData']] + +class Source(source.Source): + def get_latest_price(self, ticker) -> source.SourcePrice: + headers = { + 'X-FT-Source': environ['FINANCIAL_TIMES_API_KEY'], + } + params = { + 'symbols': ticker + } + + resp = requests.get( + url='https://markets.ft.com/research/webservices/securities/v1/quotes', + params=params, headers=headers) + if resp.status_code != requests.codes.ok: + raise FinancialTimesError( + "Invalid response ({}): {}".format(resp.status_code, resp.text) + ) + data = resp.json() + if not data['error'] is None: + raise FinancialTimesError( + "API Errors: ({}): {}".format(data['error']['errors'][0]['reason'], data['error']['errors'][0]['message']) + ) + + base = data['data']['items'][0]['basic']['currency'] + quote = data['data']['items'][0]['quote'] + price = Decimal(str(quote['lastPrice'])) + date = parse(quote['timeStamp']) + + return source.SourcePrice(price, date, base) + + def get_historical_price(self, ticker: str, + time: datetime.datetime) -> Optional[source.SourcePrice]: + for datapoint in self.get_prices_series(ticker, + time + + datetime.timedelta(days=-1), + time + datetime.timedelta(days=1)): + if not datapoint.time is None and datapoint.time.date() == time.date(): + return datapoint + return None + + def get_prices_series(self, ticker: str, + time_begin: datetime.datetime, + time_end: datetime.datetime + ) -> List[source.SourcePrice]: + return get_price_series(ticker, time_begin, time_end) diff --git a/beanprice/sources/financial_times_test.py b/beanprice/sources/financial_times_test.py new file mode 100644 index 0000000..8a8d0eb --- /dev/null +++ b/beanprice/sources/financial_times_test.py @@ -0,0 +1,72 @@ +import unittest +from decimal import Decimal +from os import environ + +from unittest import mock + +import requests + +from beanprice import source +from beanprice.sources import financial_times + +def response(contents, status_code=requests.codes.ok): + """Return a context manager to patch a JSON response.""" + response = mock.Mock() + response.status_code = status_code + response.text = "" + response.json.return_value = contents + return mock.patch('requests.get', return_value=response) + + +class CoinmarketcapPriceFetcher(unittest.TestCase): + def setUp(self): + environ['FINANCIAL_TIMES_API_KEY'] = 'foo' + + def tearDown(self): + del environ['FINANCIAL_TIMES_API_KEY'] + + def test_valid_response(self):contents = { + "data": { + "items": [ + { + "symbolInput": "pson:lse", + "basic": { + "symbol": "PSON:LSE", + "name": "Pearson", + "exchange": "London Stock Exchange", + "exhangeCode": "LSE", + "bridgeExchangeCode": "GBL", + "currency": "GBp" + }, + "quote": { + "lastPrice": 857.2, + "openPrice": 856.0, + "high": 859.2, + "low": 847.0, + "closePrice": 857.2, + "previousClosePrice": 848.6, + "change1Day": 8.6000000000000227, + "change1DayPercent": 1.01343389111478, + "change1Week": 45.200000000000045, + "change1WeekPercent": 5.5665024630541931, + "ask": 885.0, + "askSize": 2400.0, + "bid": 801.40000000000009, + "bidSize": 700.0, + "timeStamp": "2021-07-23T15:35:13", + "volume": 757939.0 + } + }, + { + "symbolInput": "mrkt", + "partialError": "No symbol match found" + } + ] + }, + "timeGenerated": "2021-07-24T12:27:24" + } + with response(contents): + srcprice = coinmarketcap.Source().get_latest_price('pson:lse') + self.assertIsInstance(srcprice, source.SourcePrice) + self.assertEqual(Decimal('857.2'), srcprice.price) + self.assertEqual('GBp', srcprice.quote_currency) From be192e607d7028bad8865d35933c54ae262025dd Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 3 Aug 2021 13:27:08 +0200 Subject: [PATCH 2/2] Add Financial Times price source --- beanprice/sources/financial_times.py | 89 ++++++----- beanprice/sources/financial_times_test.py | 175 +++++++++++++++++----- 2 files changed, 190 insertions(+), 74 deletions(-) diff --git a/beanprice/sources/financial_times.py b/beanprice/sources/financial_times.py index acf21b6..03da702 100644 --- a/beanprice/sources/financial_times.py +++ b/beanprice/sources/financial_times.py @@ -11,10 +11,14 @@ import datetime import math from decimal import Decimal -from typing import List, Optional, Tuple, Dict +from typing import List, Optional +from os import environ import requests +from dateutil.parser import parse + from beanprice import source -from os import environ + +BASE_URL = 'https://markets.ft.com/research/webservices/securities/v1/' class FinancialTimesError(ValueError): "An error from the Financial Times API." @@ -22,39 +26,46 @@ class FinancialTimesError(ValueError): def get_price_series(ticker: str, time_begin: datetime.datetime, time_end: datetime.datetime) -> List[source.SourcePrice]: - dayCount = ceil((time_end - time_begin).days) + day_count = math.ceil((time_end - time_begin).days) headers = { - 'X-FT-Source': environ['FINANCIAL_TIMES_API_KEY'], - } - params = { - 'symbols': ticker, - 'endDate': time_end.date.iso_format(), - 'intervalType': 'day', - 'interval': '1', - 'dayCount': dayCount - } - - resp = requests.get( - url='https://markets.ft.com/research/webservices/securities/v1/time-series-interday', - params=params, headers=headers) - if resp.status_code != requests.codes.ok: - raise FinancialTimesError( - "Invalid response ({}): {}".format(resp.status_code, resp.text) - ) - data = resp.json() - if not data['error'] is None: - raise FinancialTimesError( - "API Errors: ({}): {}".format(data['error']['errors'][0]['reason'], data['error']['errors'][0]['message']) - ) - - base = data['data']['items'][0]['basic']['currency'] - - return [source.SourcePrice( - Decimal(str(item['close'])), - datetime.datetime.fromisoformat(item['lastCloseDateTime']), - base - ) - for item in data['items'][0]['timeSeries']['timeSeriesData']] + 'X-FT-Source': environ['FINANCIAL_TIMES_API_KEY'], + } + params = { + 'symbols': ticker, + 'endDate': time_end.date().isoformat(), + 'intervalType': 'day', + 'interval': '1', + 'dayCount': str(day_count) + } + + resp = requests.get( + url= BASE_URL + 'time-series-interday', + params=params, headers=headers) + if resp.status_code != requests.codes.ok: + raise FinancialTimesError( + "Invalid response ({}): {}".format(resp.status_code, resp.text) + ) + data = resp.json() + if 'error' in data: + raise FinancialTimesError( + "API Errors: ({}): {}"\ + .format( + data['error']['errors'][0]['reason'], + data['error']['errors'][0]['message'] + ) + ) + + base = data['data']['items'][0]['basic']['currency'] + + prices = [] + + for item in data['data']['items'][0]['timeSeries']['timeSeriesData']: + date = datetime.datetime.fromisoformat(item['lastCloseDateTime']) + if time_begin.date() <= date.date() <= time_end.date(): + price = Decimal(str(item['close'])) + prices.append(source.SourcePrice(price, date, base)) + + return prices class Source(source.Source): def get_latest_price(self, ticker) -> source.SourcePrice: @@ -66,16 +77,20 @@ def get_latest_price(self, ticker) -> source.SourcePrice: } resp = requests.get( - url='https://markets.ft.com/research/webservices/securities/v1/quotes', + url= BASE_URL + '/quotes', params=params, headers=headers) if resp.status_code != requests.codes.ok: raise FinancialTimesError( "Invalid response ({}): {}".format(resp.status_code, resp.text) ) data = resp.json() - if not data['error'] is None: + if 'error' in data: raise FinancialTimesError( - "API Errors: ({}): {}".format(data['error']['errors'][0]['reason'], data['error']['errors'][0]['message']) + "API Errors: ({}): {}"\ + .format( + data['error']['errors'][0]['reason'], + data['error']['errors'][0]['message'] + ) ) base = data['data']['items'][0]['basic']['currency'] diff --git a/beanprice/sources/financial_times_test.py b/beanprice/sources/financial_times_test.py index 8a8d0eb..f2e2eae 100644 --- a/beanprice/sources/financial_times_test.py +++ b/beanprice/sources/financial_times_test.py @@ -1,3 +1,4 @@ +from datetime import datetime import unittest from decimal import Decimal from os import environ @@ -25,48 +26,148 @@ def setUp(self): def tearDown(self): del environ['FINANCIAL_TIMES_API_KEY'] - def test_valid_response(self):contents = { - "data": { - "items": [ - { - "symbolInput": "pson:lse", - "basic": { - "symbol": "PSON:LSE", - "name": "Pearson", - "exchange": "London Stock Exchange", - "exhangeCode": "LSE", - "bridgeExchangeCode": "GBL", - "currency": "GBp" + def test_valid_response(self): + contents = { + "data": { + "items": [ + { + "symbolInput": "pson:lse", + "basic": { + "symbol": "PSON:LSE", + "name": "Pearson", + "exchange": "London Stock Exchange", + "exhangeCode": "LSE", + "bridgeExchangeCode": "GBL", + "currency": "GBp" + }, + "quote": { + "lastPrice": 857.2, + "openPrice": 856.0, + "high": 859.2, + "low": 847.0, + "closePrice": 857.2, + "previousClosePrice": 848.6, + "change1Day": 8.6000000000000227, + "change1DayPercent": 1.01343389111478, + "change1Week": 45.200000000000045, + "change1WeekPercent": 5.5665024630541931, + "ask": 885.0, + "askSize": 2400.0, + "bid": 801.40000000000009, + "bidSize": 700.0, + "timeStamp": "2021-07-23T15:35:13", + "volume": 757939.0 + } }, - "quote": { - "lastPrice": 857.2, - "openPrice": 856.0, - "high": 859.2, - "low": 847.0, - "closePrice": 857.2, - "previousClosePrice": 848.6, - "change1Day": 8.6000000000000227, - "change1DayPercent": 1.01343389111478, - "change1Week": 45.200000000000045, - "change1WeekPercent": 5.5665024630541931, - "ask": 885.0, - "askSize": 2400.0, - "bid": 801.40000000000009, - "bidSize": 700.0, - "timeStamp": "2021-07-23T15:35:13", - "volume": 757939.0 + { + "symbolInput": "mrkt", + "partialError": "No symbol match found" } + ] }, - { - "symbolInput": "mrkt", - "partialError": "No symbol match found" + "timeGenerated": "2021-07-24T12:27:24" } - ] - }, - "timeGenerated": "2021-07-24T12:27:24" - } with response(contents): - srcprice = coinmarketcap.Source().get_latest_price('pson:lse') + srcprice = financial_times.Source().get_latest_price('pson:lse') self.assertIsInstance(srcprice, source.SourcePrice) self.assertEqual(Decimal('857.2'), srcprice.price) self.assertEqual('GBp', srcprice.quote_currency) + + def test_invalid_response(self): + contents = { + "error": { + "code": 400, + "message": "Missing or invalid parameters", + "errors": [ + { + "reason": "InvalidParameter", + "message": "There are no matches on any of the symbols provided." + } + ] + }, + "timeGenerated": "2021-07-27T10:12:59" + } + with response(contents): + with self.assertRaises(financial_times.FinancialTimesError): + financial_times.Source().get_latest_price('nonexistent') + + def test_get_price_series(self): + contents = { + "data": { + "items": [ + { + "symbolInput": "pson:lse", + "basic": { + "symbol": "PSON:LSE", + "name": "Pearson", + "exchange": "London Stock Exchange", + "exhangeCode": "LSE", + "bridgeExchangeCode": "GBL", + "currency": "GBp" + }, + "timeSeries": { + "timeSeriesData": [ + { + "open": 856.0, + "high": 859.2, + "low": 847.0, + "close": 857.2, + "lastClose": "2021-07-23T15:35:00", + "lastCloseDateTime": "2021-07-23T15:35:00.038", + "volume": 1163977.0 + }, + { + "open": 855.6, + "high": 860.6, + "low": 837.0, + "close": 838.6, + "lastClose": "2021-07-26T15:35:00", + "lastCloseDateTime": "2021-07-26T15:35:00.038", + "volume": 2319095.0 + }, + { + "open": 836.80000000000007, + "high": 840.2, + "low": 833.0, + "close": 837.2, + "lastClose": "2021-07-27T15:35:00", + "lastCloseDateTime": "2021-07-27T15:35:00.038", + "volume": 208775.0 + } + ], + "lastPrice": 837.2, + "lastPriceTimeStamp": "2021-07-27T09:43:47", + "lastSession": { + "timeOpen": "2021-07-27T07:00:00", + "timeClose": "2021-07-27T15:35:00", + "isInSession": True, + "isAfterOpen": True, + "isBeforeOpen": False, + "lastPrice": 837.2, + "previousClosePrice": 838.6, + "open": 836.80000000000007, + "high": 840.2, + "low": 833.0, + "lastCloseDateTime": "0001-01-01T00:00:00", + "volume": 184143.0 + }, + "boundaryData": {} + } + } + ] + }, + "timeGenerated": "2021-07-27T10:17:23" + } + with response(contents): + srcprice = financial_times.Source()\ + .get_prices_series('pson:lse', datetime(2021, 7, 23), datetime(2021, 7, 27)) + print(srcprice) + self.assertIsInstance(srcprice[0], source.SourcePrice) + self.assertEqual(Decimal('857.2'), srcprice[0].price) + self.assertEqual('GBp', srcprice[0].quote_currency) + self.assertIsInstance(srcprice[1], source.SourcePrice) + self.assertEqual(Decimal('838.6'), srcprice[1].price) + self.assertEqual('GBp', srcprice[1].quote_currency) + self.assertIsInstance(srcprice[2], source.SourcePrice) + self.assertEqual(Decimal('837.2'), srcprice[2].price) + self.assertEqual('GBp', srcprice[2].quote_currency)