diff --git a/app.py b/app.py index 147510e..3697f5b 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,7 @@ class ServerErrors: # TODO(nochiel) Replace these with HTTPException NO_DATA = 'Spotbit did not find any data.' EXCHANGE_NOT_SUPPORTED = 'Spotbit is not configured to support the exchange.' + BAD_DATE_FORMAT = 'Please use dates in YYYY-MM-DDTHH:mm:ss ISO8601 format or unix timestamps.' class Error(BaseModel): code : int @@ -53,7 +54,7 @@ class Config: # we can determine which ones shouldn't be supported i.e. populate # this list with exchanges that fail tests. _unsupported_exchanges = [] -_supported_exchanges: dict[str, ccxt.Exchange] = {} # TODO(nochiel) Load these when self.exchanges is loaded. +_supported_exchanges: dict[str, ccxt.Exchange] = {} def get_logger(): @@ -108,7 +109,7 @@ def get_logger(): assert _supported_exchanges -ExchangeName = Enum('ExchangeName', [(exchange.title(), exchange) for exchange in _supported_exchanges]) +ExchangeName = Enum('ExchangeName', [(id.upper(), id) for id in _supported_exchanges]) # Exchange data is sometimes returned as epoch milliseconds. def is_ms(timestamp): return timestamp % 1e3 == 0 @@ -137,13 +138,14 @@ def request_single(exchange: ccxt.Exchange, currency: CurrencyName) -> Candle | Make a single request, without having to loop through all exchanges and currency pairs. ''' assert exchange and isinstance(exchange, ccxt.Exchange) - assert str + assert currency result: Candle = None latest_candle = None - ticker = f'BTC/{currency}' + ticker = f'BTC/{currency.value}' dt = None + exchange.load_markets() if ticker not in exchange.markets: return None @@ -401,31 +403,31 @@ def get_candle(exchange: ccxt.Exchange, currency: CurrencyName) -> tuple[ccxt.Ex return result -@app.get('/api/now/{currency}/{exchange: str}') +@app.get('/api/now/{currency}/{exchange}', response_model = Candle) def now(currency: CurrencyName, exchange: ExchangeName): ''' parameters: - exchange(required): an exchange to use. - currency(required): the symbol for the base currency to use e.g. USD, GBP, UST. + exchange: an exchange to use. + currency: the symbol for the base currency to use e.g. USD, GBP, UST. ''' - if exchange not in _supported_exchanges: + if exchange.value not in _supported_exchanges: raise HTTPException( status_code = HTTPStatus.INTERNAL_SERVER_ERROR, - detail = f'Spotbit is not configured to use {exchange} exchange.') + detail = f'Spotbit is not configured to use {exchange.value} exchange.') result = None - exchange = _supported_exchanges[exchange] - assert exchange - exchange.load_markets() + ccxtExchange = _supported_exchanges[exchange.value] + assert ccxtExchange + ccxtExchange.load_markets() - if currency.value not in exchange.currencies: + if currency.value not in ccxtExchange.currencies: raise HTTPException( status_code = HTTPStatus.INTERNAL_SERVER_ERROR, - detail = f'Spotbit does not support {currency.value} on {exchange}.' ) + detail = f'Spotbit does not support {currency.value} on {ccxtExchange}.' ) - result = request_single(exchange, currency) + result = request_single(ccxtExchange, currency) if not result: raise HTTPException( status_code = HTTPStatus.INTERNAL_SERVER_ERROR, @@ -448,76 +450,61 @@ class OHLCV(IntEnum): # FIXME(nochiel) Use query parameters. # - exchange, currency, date_end are optional. -# FIXME(nochiel) Add parameter validation. +# FIXME(nochiel) Add date parameter validation. # TODO(nochiel) Write tests -@app.get('/api/history/{currency}/{exchange}/{date_start}/{date_end}') -async def get_candles_in_range(currency: CurrencyName, exchange: ExchangeName, date_start, date_end): +@app.get('/api/history/{currency}/{exchange}', response_model = list[Candle]) +async def get_candles_in_range( + currency: CurrencyName, + exchange: ExchangeName, + start: datetime, + end: datetime = datetime.now()): ''' parameters: exchange(required): an exchange to use. currency(required): the symbol for the base currency to use e.g. USD, GBP, UST. - date_start, date_end(required): datetime formatted as ISO8601 "YYYY-MM-DDTHH:mm:SS". + start, end(required): datetime formatted as ISO8601 "YYYY-MM-DDTHH:mm:SS" or unix timestamp. ''' - if exchange not in _supported_exchanges: - flask.abort(flask.Response(response = f'Spotbit is not configured to use {exchange} exchange.', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) + ccxtExchange = _supported_exchanges[exchange.value] + ccxtExchange.load_markets() + assert ccxtExchange.currencies + assert ccxtExchange.markets - exchange = _supported_exchanges[exchange] - exchange.load_markets() - assert exchange.currencies - assert exchange.markets - currency = currency.upper() - if currency not in exchange.currencies: - flask.abort(flask.Response(response = f'Spotbit does not support the {currency} on {exchange}', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) - - pair = f'BTC/{currency}' - if exchange.id == 'bitmex': + pair = f'BTC/{currency.value}' + if ccxtExchange.id == 'bitmex': pair = f'BTC/{currency}:{currency}' - if pair not in exchange.markets: - flask.abort(flask.Response(response = f'Spotbit does not support the {pair} pair on {exchange}', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) + if pair not in ccxtExchange.markets: + HTTPException( + detail = f'Spotbit does not support the {pair} pair on {ccxtExchange}', + status_code = HTTPStatus.INTERNAL_SERVER_ERROR) result = None - start, end = date_start, date_end - try: - try: - start = datetime.fromisoformat(date_start) - except ValueError: - start = datetime.fromtimestamp(int(date_start)) - try: - end = datetime.fromisoformat(date_end) - except ValueError: - end = datetime.fromtimestamp(int(date_end)) - except Exception as e: - flask.abort(flask.Response( - response = f'Error: {e}. Please use dates in YYYY-MM-DDTHH:mm:ss ISO8601 format.', - status = HTTPStatus.BAD_REQUEST)) + start = start.astimezone(start.tzinfo) + end = end.astimezone(end.tzinfo) (start, end) = (end, start) if end < start else (start, end) + logger.debug(f'start: {start}, end: {end}') limit = 100 - candles = None periods = [] # Consider enabling a backup historical candle feed e.g. coinmarketcap, coingecko, cryptowa.ch, graph or a dex? # Especially if there's a feed that allows you to filter by an exchange. - if exchange.has['fetchOHLCV'] is not True: - flask.abort(flask.Response( - response = f'{exchange} does not support pagination of historical candles. Please try to use a different exchange.', - status = HTTPStatus.BAD_REQUEST)) + if ccxtExchange.has['fetchOHLCV'] is not True: + HTTPException( + detail = f'{ccxtExchange} does not support pagination of historical candles. Please try to use a different exchange.', + status_code = HTTPStatus.BAD_REQUEST) dt = timedelta(0) params = None timeframe = '' - if '1h' in exchange.timeframes: + if '1h' in ccxtExchange.timeframes: timeframe = '1h' dt = timedelta(hours = 1) - elif '30m' in exchange.timeframes: + elif '30m' in ccxtExchange.timeframes: timeframe = '30m' dt = timedelta(minutes = 30) @@ -534,7 +521,7 @@ async def get_candles_in_range(currency: CurrencyName, exchange: ExchangeName, d logger.debug(f'requesting periods with {limit} limit: {periods}') def get_history(*, - exchange: ccxt.Exchange = exchange, + exchange: ccxt.Exchange = ccxtExchange, since: datetime, limit: int = limit, timeframe: str = timeframe) -> list: @@ -563,7 +550,7 @@ def get_history(*, wait = 0 - except ccxt.base.errors.RateLimitExceeded as e: + except ccxt.errors.RateLimitExceeded as e: logger.debug(f'{e}. Rate limit for {exchange} is {rateLimit}') time.sleep(wait) wait *= 2 @@ -576,7 +563,7 @@ def get_history(*, tasks = [] for p in periods: task = asyncio.to_thread(get_history, - exchange = exchange, + exchange = ccxtExchange, since = p) tasks.append(task) @@ -611,16 +598,17 @@ def get_history(*, expected_number_of_candles = (n_periods * limit) + remaining_frames received_number_of_candles = len(candles) if received_number_of_candles < expected_number_of_candles: - logger.info(f'{exchange} does not have data for the entire period. Expected {expected_number_of_candles} candles. Got {received_number_of_candles} candles') + logger.info(f'{ccxtExchange} does not have data for the entire period. Expected {expected_number_of_candles} candles. Got {received_number_of_candles} candles') if candles is None or len(candles) == 0: - flask.abort(flask.Response(response = f'Spotbit did not receive any candle history for the period {start} - {end} from {exchange}', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) + HTTPException( + detail = f'Spotbit did not receive any candle history for the period {start} - {end} from {ccxtExchange}', + status_code = HTTPStatus.INTERNAL_SERVER_ERROR) logger.debug(f'got: {len(candles)} candles') logger.debug(f'candles: {candles[:10]} ... {candles[-10:]}') + result = candles - result = flask.jsonify([candle.dict() for candle in candles]) return result tests_for_get_candles_at_dates = [ @@ -638,36 +626,9 @@ async def get_candles_at_dates(currency: CurrencyName, exchange: ExchangeName): logger.debug(f'{request.get_data()}') if exchange not in _supported_exchanges: - flask.abort(flask.Response( - response = ServerErrors.EXCHANGE_NOT_SUPPORTED, - status = HTTPStatus.INTERNAL_SERVER_ERROR - )) - exchange = _supported_exchanges[exchange] - - if exchange.has['fetchOHLCV'] is not True: - flask.abort(flask.Response( - response = f'{exchange} does not support pagination of historical candles. Please try to use a different exchange.', - status = HTTPStatus.BAD_REQUEST)) - - dates = None - try: - dates = request.get_json() - # TODO(nochiel) Check if isoformat or timestamps. Raise HTTPException if dates don't parse. - dates = [datetime.fromisoformat(date) for date in dates] if dates else [] - if len(dates) == 0: - raise Exception(f'received: ${dates}') - except Exception as e: - flask.abort(flask.Response( - response = f'error processing dates: {e}.\n' + - 'Dates should be provided in the body of the request as a json array of dates formatted as ISO8601 "YYYY-MM-DDTHH:mm:SS".', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) - - results = None - exchange.load_markets() - pair = f'BTC/{currency.upper()}' - if pair not in exchange.markets: - flask.abort(flask.Response(response = f'Spotbit does not support the {pair} pair on {exchange}', - status = HTTPStatus.INTERNAL_SERVER_ERROR)) + HTTPException( + detail = f'Spotbit does not support the {pair} pair on {exchange}', + status_code = HTTPStatus.INTERNAL_SERVER_ERROR) limit = 1 timeframe = '1h' @@ -727,11 +688,27 @@ def get_candle(*, return result +def tests(): + # Placeholder + + import requests + response = requests.get("http://[::1]:5000/api/now/FOOBAR") + response = requests.get("http://[::1]:5000/api/now/usd") + response = requests.get("http://[::1]:5000/api/now/USD") + response = requests.get("http://[::1]:5000/api/now/JPY") + + + response = requests.get("http://[::1]:5000/api/now/USD/Bitstamp") + response = requests.get("http://[::1]:5000/api/now/USD/bitstamp") + + response = requests.get( + "http://[::1]:5000/api/history/USD/bitstamp?start=2019-01-01T0000&end=1522641600" + ) + if __name__ == '__main__': import uvicorn assert logger - logger.setLevel(logging.DEBUG) # FIXME(nochiel) This won't actually change logging level. logger.debug('Running in debug mode') logger.debug(f'app.debug: {app.debug}') uvicorn.run('app:app',