From 9f6bba40af1a407f190a89f5c0c8b4e3f528ba46 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 18 Aug 2022 10:39:20 -0600 Subject: [PATCH 001/199] initial concept for replicate, basic leader and follower logic --- .gitignore | 2 + freqtrade/__init__.py | 2 +- freqtrade/constants.py | 28 + freqtrade/enums/__init__.py | 1 + freqtrade/enums/replicate.py | 11 + freqtrade/enums/rpcmessagetype.py | 2 + freqtrade/rpc/api_server/webserver.py | 8 +- freqtrade/rpc/replicate/__init__.py | 385 ++++++++++++++ freqtrade/rpc/replicate/channel.py | 106 ++++ freqtrade/rpc/replicate/proxy.py | 60 +++ freqtrade/rpc/replicate/serializer.py | 42 ++ freqtrade/rpc/replicate/thread_queue.py | 650 ++++++++++++++++++++++++ freqtrade/rpc/replicate/types.py | 9 + freqtrade/rpc/replicate/utils.py | 10 + freqtrade/rpc/rpc_manager.py | 13 + requirements-replicate.txt | 5 + 16 files changed, 1330 insertions(+), 4 deletions(-) create mode 100644 freqtrade/enums/replicate.py create mode 100644 freqtrade/rpc/replicate/__init__.py create mode 100644 freqtrade/rpc/replicate/channel.py create mode 100644 freqtrade/rpc/replicate/proxy.py create mode 100644 freqtrade/rpc/replicate/serializer.py create mode 100644 freqtrade/rpc/replicate/thread_queue.py create mode 100644 freqtrade/rpc/replicate/types.py create mode 100644 freqtrade/rpc/replicate/utils.py create mode 100644 requirements-replicate.txt diff --git a/.gitignore b/.gitignore index e400c01f5..df2121990 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,5 @@ target/ !config_examples/config_full.example.json !config_examples/config_kraken.example.json !config_examples/config_freqai.example.json + +*-config.json diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 2572c03f1..9e022b2d9 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2022.8.dev' +__version__ = '2022.8.1+pubsub' # Metadata 1.2 mandates PEP 440 version, but 'develop' is not if 'dev' in __version__: try: diff --git a/freqtrade/constants.py b/freqtrade/constants.py index ddbc84fa9..416b4646f 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -60,6 +60,8 @@ USERPATH_FREQAIMODELS = 'freqaimodels' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] +FOLLOWER_MODE_OPTIONS = ['follower', 'leader'] + ENV_VAR_PREFIX = 'FREQTRADE__' NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') @@ -242,6 +244,7 @@ CONF_SCHEMA = { 'exchange': {'$ref': '#/definitions/exchange'}, 'edge': {'$ref': '#/definitions/edge'}, 'freqai': {'$ref': '#/definitions/freqai'}, + 'replicate': {'$ref': '#/definitions/replicate'}, 'experimental': { 'type': 'object', 'properties': { @@ -483,6 +486,31 @@ CONF_SCHEMA = { }, 'required': ['process_throttle_secs', 'allowed_risk'] }, + 'replicate': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean', 'default': False}, + 'mode': { + 'type': 'string', + 'enum': FOLLOWER_MODE_OPTIONS + }, + 'api_key': {'type': 'string', 'default': ''}, + 'leaders': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string', 'default': ''}, + 'token': {'type': 'string', 'default': ''}, + } + } + }, + 'follower_reply_timeout': {'type': 'integer'}, + 'follower_sleep_time': {'type': 'integer'}, + 'follower_ping_timeout': {'type': 'integer'}, + }, + 'required': ['mode'] + }, "freqai": { "type": "object", "properties": { diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index e50ebc4a4..e1057208a 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -5,6 +5,7 @@ from freqtrade.enums.exitchecktuple import ExitCheckTuple from freqtrade.enums.exittype import ExitType from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues +from freqtrade.enums.replicate import LeaderMessageType, ReplicateModeType from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.signaltype import SignalDirection, SignalTagType, SignalType diff --git a/freqtrade/enums/replicate.py b/freqtrade/enums/replicate.py new file mode 100644 index 000000000..d55d45b45 --- /dev/null +++ b/freqtrade/enums/replicate.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class ReplicateModeType(str, Enum): + leader = "leader" + follower = "follower" + + +class LeaderMessageType(str, Enum): + Pairlist = "pairlist" + Dataframe = "dataframe" diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 415d8f18c..d5b3ce89c 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -19,6 +19,8 @@ class RPCMessageType(Enum): STRATEGY_MSG = 'strategy_msg' + EMIT_DATA = 'emit_data' + def __repr__(self): return self.value diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 0da129583..c98fb9fd4 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -54,7 +54,11 @@ class ApiServer(RPCHandler): ApiServer.__initialized = False return ApiServer.__instance - def __init__(self, config: Dict[str, Any], standalone: bool = False) -> None: + def __init__( + self, + config: Dict[str, Any], + standalone: bool = False, + ) -> None: ApiServer._config = config if self.__initialized and (standalone or self._standalone): return @@ -71,8 +75,6 @@ class ApiServer(RPCHandler): ) self.configure_app(self.app, self._config) - self.start_api() - def add_rpc_handler(self, rpc: RPC): """ Attach rpc handler diff --git a/freqtrade/rpc/replicate/__init__.py b/freqtrade/rpc/replicate/__init__.py new file mode 100644 index 000000000..d725a4a90 --- /dev/null +++ b/freqtrade/rpc/replicate/__init__.py @@ -0,0 +1,385 @@ +""" +This module manages replicate mode communication +""" +import asyncio +import logging +import secrets +import socket +from threading import Thread +from typing import Any, Coroutine, Dict, Union + +import websockets +from fastapi import Depends +from fastapi import WebSocket as FastAPIWebSocket +from fastapi import WebSocketDisconnect, status + +from freqtrade.enums import LeaderMessageType, ReplicateModeType, RPCMessageType +from freqtrade.rpc import RPC, RPCHandler +from freqtrade.rpc.replicate.channel import ChannelManager +from freqtrade.rpc.replicate.thread_queue import Queue as ThreadedQueue +from freqtrade.rpc.replicate.utils import is_websocket_alive + + +logger = logging.getLogger(__name__) + + +class ReplicateController(RPCHandler): + """ This class handles all websocket communication """ + + def __init__( + self, + rpc: RPC, + config: Dict[str, Any], + api_server: Union[Any, None] = None + ) -> None: + """ + Init the ReplicateRPC class, and init the super class RPCHandler + :param rpc: instance of RPC Helper class + :param config: Configuration object + :return: None + """ + super().__init__(rpc, config) + + self.api_server = api_server + + if not self.api_server: + raise RuntimeError("The API server must be enabled for replicate to work") + + self._loop = None + self._running = False + self._thread = None + self._queue = None + + self.channel_manager = ChannelManager() + + self.replicate_config = config.get('replicate', {}) + + # What the config should look like + # "replicate": { + # "enabled": true, + # "mode": "follower", + # "leaders": [ + # { + # "url": "ws://localhost:8080/replicate/ws", + # "token": "test" + # } + # ] + # } + + # "replicate": { + # "enabled": true, + # "mode": "leader", + # "api_key": "test" + # } + + self.mode = ReplicateModeType[self.replicate_config.get('mode', 'leader').lower()] + + self.leaders_list = self.replicate_config.get('leaders', []) + self.push_throttle_secs = self.replicate_config.get('push_throttle_secs', 1) + + self.reply_timeout = self.replicate_config.get('follower_reply_timeout', 10) + self.ping_timeout = self.replicate_config.get('follower_ping_timeout', 2) + self.sleep_time = self.replicate_config.get('follower_sleep_time', 1) + + if self.mode == ReplicateModeType.follower and len(self.leaders_list) == 0: + raise ValueError("You must specify at least 1 leader in follower mode.") + + # This is only used by the leader, the followers use the tokens specified + # in each of the leaders + # If you do not specify an API key in the config, one will be randomly + # generated and logged on startup + default_api_key = secrets.token_urlsafe(16) + self.secret_api_key = self.replicate_config.get('api_key', default_api_key) + + self.start_threaded_loop() + + if self.mode == ReplicateModeType.follower: + self.start_follower_mode() + elif self.mode == ReplicateModeType.leader: + self.start_leader_mode() + + def start_threaded_loop(self): + """ + Start the main internal loop in another thread to run coroutines + """ + self._loop = asyncio.new_event_loop() + + if not self._thread: + self._thread = Thread(target=self._loop.run_forever) + self._thread.start() + self._running = True + else: + raise RuntimeError("A loop is already running") + + def submit_coroutine(self, coroutine: Coroutine): + """ + Submit a coroutine to the threaded loop + """ + if not self._running: + raise RuntimeError("Cannot schedule new futures after shutdown") + + if not self._loop or not self._loop.is_running(): + raise RuntimeError("Loop must be started before any function can" + " be submitted") + + logger.debug(f"Running coroutine {repr(coroutine)} in loop") + try: + return asyncio.run_coroutine_threadsafe(coroutine, self._loop) + except Exception as e: + logger.error(f"Error running coroutine - {str(e)}") + return None + + def cleanup(self) -> None: + """ + Cleanup pending module resources. + """ + if self._thread: + if self._loop.is_running(): + + self._running = False + + # Tell all coroutines submitted to the loop they're cancelled + pending = asyncio.all_tasks(loop=self._loop) + for task in pending: + task.cancel() + + self._loop.call_soon_threadsafe(self.channel_manager.disconnect_all) + # This must be called threadsafe, otherwise would not work + self._loop.call_soon_threadsafe(self._loop.stop) + + self._thread.join() + + def send_msg(self, msg: Dict[str, Any]) -> None: + """ Push message through """ + + if msg["type"] == RPCMessageType.EMIT_DATA: + self._send_message( + { + "type": msg["data_type"], + "content": msg["data"] + } + ) + + # ----------------------- LEADER LOGIC ------------------------------ + + def start_leader_mode(self): + """ + Register the endpoint and start the leader loop + """ + + logger.info("Running rpc.replicate in Leader mode") + logger.info("-" * 15) + logger.info(f"API_KEY: {self.secret_api_key}") + logger.info("-" * 15) + + self.register_leader_endpoint() + self.submit_coroutine(self.leader_loop()) + + async def leader_loop(self): + """ + Main leader coroutine + At the moment this just broadcasts data that's in the queue to the followers + """ + try: + await self._broadcast_queue_data() + except Exception as e: + logger.error("Exception occurred in leader loop: ") + logger.exception(e) + + def _send_message(self, data: Dict[Any, Any]): + """ + Add data to the internal queue to be broadcasted. This func will block + if the queue is full. This is meant to be called in the main thread. + """ + + if self._queue: + self._queue.put(data) + else: + logger.warning("Can not send data, leader loop has not started yet!") + + async def _broadcast_queue_data(self): + """ + Loop over queue data and broadcast it + """ + # Instantiate the queue in this coroutine so it's attached to our loop + self._queue = ThreadedQueue() + async_queue = self._queue.async_q + + try: + while self._running: + # Get data from queue + data = await async_queue.get() + + # Broadcast it to everyone + await self.channel_manager.broadcast(data) + + # Sleep + await asyncio.sleep(self.push_throttle_secs) + except asyncio.CancelledError: + # Silently stop + pass + + async def get_api_token( + self, + websocket: FastAPIWebSocket, + token: Union[str, None] = None + ): + """ + Extract the API key from query param. Must match the + set secret_api_key or the websocket connection will be closed. + """ + if token == self.secret_api_key: + return token + else: + logger.info("Denying websocket request...") + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + + def register_leader_endpoint(self, path: str = "/replicate/ws"): + """ + Attach and start the main leader loop to the ApiServer + + :param path: The endpoint path + """ + + if not self.api_server: + raise RuntimeError("The leader needs the ApiServer to be active") + + # The endpoint function for running the main leader loop + @self.api_server.app.websocket(path) + async def leader_endpoint( + websocket: FastAPIWebSocket, + api_key: str = Depends(self.get_api_token) + ): + await self.leader_endpoint_loop(websocket) + + async def leader_endpoint_loop(self, websocket: FastAPIWebSocket): + """ + The WebSocket endpoint served by the ApiServer. This handles connections, + and adding them to the channel manager. + """ + try: + if is_websocket_alive(websocket): + logger.info(f"Follower connected - {websocket.client}") + channel = await self.channel_manager.on_connect(websocket) + + # Send initial data here + + # Keep connection open until explicitly closed, and sleep + try: + while not channel.is_closed(): + await channel.recv() + + except WebSocketDisconnect: + # Handle client disconnects + logger.info(f"Follower disconnected - {websocket.client}") + await self.channel_manager.on_disconnect(websocket) + except Exception as e: + logger.info(f"Follower connection failed - {websocket.client}") + logger.exception(e) + # Handle cases like - + # RuntimeError('Cannot call "send" once a closed message has been sent') + await self.channel_manager.on_disconnect(websocket) + + except Exception: + logger.error(f"Failed to serve - {websocket.client}") + await self.channel_manager.on_disconnect(websocket) + + # -------------------------------FOLLOWER LOGIC---------------------------- + + def start_follower_mode(self): + """ + Start the ReplicateController in Follower mode + """ + logger.info("Starting rpc.replicate in Follower mode") + + self.submit_coroutine(self.follower_loop()) + + async def follower_loop(self): + """ + Main follower coroutine + + This starts all of the leader connection coros + """ + try: + await self._connect_to_leaders() + except Exception as e: + logger.error("Exception occurred in follower loop: ") + logger.exception(e) + + async def _connect_to_leaders(self): + rpc_lock = asyncio.Lock() + + logger.info("Starting connections to Leaders...") + await asyncio.wait( + [ + self._handle_leader_connection(leader, rpc_lock) + for leader in self.leaders_list + ] + ) + + async def _handle_leader_connection(self, leader, lock): + """ + Given a leader, connect and wait on data. If connection is lost, + it will attempt to reconnect. + """ + url, token = leader["url"], leader["token"] + + websocket_url = f"{url}?token={token}" + + logger.info(f"Attempting to connect to leader at: {url}") + # TODO: limit the amount of connection retries + while True: + try: + async with websockets.connect(websocket_url) as ws: + channel = await self.channel_manager.on_connect(ws) + while True: + try: + data = await asyncio.wait_for( + channel.recv(), + timeout=self.reply_timeout + ) + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Just check the connection and continue. + try: + # ping + ping = await channel.ping() + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.info(f"Connection to {url} still alive...") + continue + except Exception: + logger.info(f"Ping error {url} - retrying in {self.sleep_time}s") + asyncio.sleep(self.sleep_time) + break + + with lock: + # Should we have a lock here? + await self._handle_leader_message(data) + + except socket.gaierror: + logger.info(f"Socket error - retrying connection in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + continue + except ConnectionRefusedError: + logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + continue + + async def _handle_leader_message(self, message): + type = message.get("type") + + message_type_handlers = { + LeaderMessageType.Pairlist.value: self._handle_pairlist_message, + LeaderMessageType.Dataframe.value: self._handle_dataframe_message + } + + handler = message_type_handlers.get(type, self._handle_default_message) + return await handler(message) + + async def _handle_default_message(self, message): + logger.info(f"Default message handled: {message}") + + async def _handle_pairlist_message(self, message): + logger.info(f"Pairlist message handled: {message}") + + async def _handle_dataframe_message(self, message): + logger.info(f"Dataframe message handled: {message}") diff --git a/freqtrade/rpc/replicate/channel.py b/freqtrade/rpc/replicate/channel.py new file mode 100644 index 000000000..9950742da --- /dev/null +++ b/freqtrade/rpc/replicate/channel.py @@ -0,0 +1,106 @@ +from typing import Type + +from freqtrade.rpc.replicate.proxy import WebSocketProxy +from freqtrade.rpc.replicate.serializer import JSONWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.replicate.types import WebSocketType + + +class WebSocketChannel: + """ + Object to help facilitate managing a websocket connection + """ + + def __init__( + self, + websocket: WebSocketType, + serializer_cls: Type[WebSocketSerializer] = JSONWebSocketSerializer + ): + # The WebSocket object + self._websocket = WebSocketProxy(websocket) + # The Serializing class for the WebSocket object + self._serializer_cls = serializer_cls + + # Internal event to signify a closed websocket + self._closed = False + + # Wrap the WebSocket in the Serializing class + self._wrapped_ws = self._serializer_cls(self._websocket) + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + await self._wrapped_ws.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + return await self._wrapped_ws.recv() + + async def ping(self): + """ + Ping the websocket + """ + return await self._websocket.ping() + + async def close(self): + """ + Close the WebSocketChannel + """ + + self._closed = True + + def is_closed(self): + return self._closed + + +class ChannelManager: + def __init__(self): + self.channels = dict() + + async def on_connect(self, websocket: WebSocketType): + """ + Wrap websocket connection into Channel and add to list + + :param websocket: The WebSocket object to attach to the Channel + """ + if hasattr(websocket, "accept"): + try: + await websocket.accept() + except RuntimeError: + # The connection was closed before we could accept it + return + + ws_channel = WebSocketChannel(websocket) + self.channels[websocket] = ws_channel + + return ws_channel + + async def on_disconnect(self, websocket: WebSocketType): + """ + Call close on the channel if it's not, and remove from channel list + + :param websocket: The WebSocket objet attached to the Channel + """ + if websocket in self.channels.keys(): + channel = self.channels[websocket] + if not channel.is_closed(): + await channel.close() + del channel + + async def disconnect_all(self): + """ + Disconnect all Channels + """ + for websocket in self.channels.keys(): + await self.on_disconnect(websocket) + + async def broadcast(self, data): + """ + Broadcast data on all Channels + + :param data: The data to send + """ + for channel in self.channels.values(): + await channel.send(data) diff --git a/freqtrade/rpc/replicate/proxy.py b/freqtrade/rpc/replicate/proxy.py new file mode 100644 index 000000000..b2173670b --- /dev/null +++ b/freqtrade/rpc/replicate/proxy.py @@ -0,0 +1,60 @@ +from typing import TYPE_CHECKING, Union + +from fastapi import WebSocket as FastAPIWebSocket +from websockets import WebSocketClientProtocol as WebSocket + + +if TYPE_CHECKING: + from freqtrade.rpc.replicate.types import WebSocketType + + +class WebSocketProxy: + """ + WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol + under the same API + """ + + def __init__(self, websocket: WebSocketType): + self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + if hasattr(self._websocket, "send_bytes"): + await self._websocket.send_bytes(data) + else: + await self._websocket.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + if hasattr(self._websocket, "receive_bytes"): + return await self._websocket.receive_bytes() + else: + return await self._websocket.recv() + + async def ping(self): + """ + Ping the websocket, not supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "ping"): + return await self._websocket.ping() + return False + + async def close(self, code: int = 1000): + """ + Close the websocket connection, only supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "close"): + return await self._websocket.close(code) + pass + + async def accept(self): + """ + Accept the WebSocket connection, only support by FastAPI WebSockets + """ + if hasattr(self._websocket, "accept"): + return await self._websocket.accept() + pass diff --git a/freqtrade/rpc/replicate/serializer.py b/freqtrade/rpc/replicate/serializer.py new file mode 100644 index 000000000..ae5e57b95 --- /dev/null +++ b/freqtrade/rpc/replicate/serializer.py @@ -0,0 +1,42 @@ +import json +from abc import ABC, abstractmethod + +from freqtrade.rpc.replicate.proxy import WebSocketProxy + + +class WebSocketSerializer(ABC): + def __init__(self, websocket: WebSocketProxy): + self._websocket: WebSocketProxy = websocket + + @abstractmethod + def _serialize(self, data): + raise NotImplementedError() + + @abstractmethod + def _deserialize(self, data): + raise NotImplementedError() + + async def send(self, data: bytes): + await self._websocket.send(self._serialize(data)) + + async def recv(self) -> bytes: + data = await self._websocket.recv() + + return self._deserialize(data) + + async def close(self, code: int = 1000): + await self._websocket.close(code) + +# Going to explore using MsgPack as the serialization, +# as that might be the best method for sending pandas +# dataframes over the wire + + +class JSONWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data: bytes) -> bytes: + # json expects string not bytes + return json.dumps(data.decode()).encode() + + def _deserialize(self, data: bytes) -> bytes: + # The WebSocketSerializer gives bytes not string + return json.loads(data).encode() diff --git a/freqtrade/rpc/replicate/thread_queue.py b/freqtrade/rpc/replicate/thread_queue.py new file mode 100644 index 000000000..88321321b --- /dev/null +++ b/freqtrade/rpc/replicate/thread_queue.py @@ -0,0 +1,650 @@ +import asyncio +import sys +import threading +from asyncio import QueueEmpty as AsyncQueueEmpty +from asyncio import QueueFull as AsyncQueueFull +from collections import deque +from heapq import heappop, heappush +from queue import Empty as SyncQueueEmpty +from queue import Full as SyncQueueFull +from typing import Any, Callable, Deque, Generic, List, Optional, Set, TypeVar + +from typing_extensions import Protocol + + +__version__ = "1.0.0" +__all__ = ( + "Queue", + "PriorityQueue", + "LifoQueue", + "SyncQueue", + "AsyncQueue", + "BaseQueue", +) + + +T = TypeVar("T") +OptFloat = Optional[float] + + +class BaseQueue(Protocol[T]): + @property + def maxsize(self) -> int: + ... + + @property + def closed(self) -> bool: + ... + + def task_done(self) -> None: + ... + + def qsize(self) -> int: + ... + + @property + def unfinished_tasks(self) -> int: + ... + + def empty(self) -> bool: + ... + + def full(self) -> bool: + ... + + def put_nowait(self, item: T) -> None: + ... + + def get_nowait(self) -> T: + ... + + +class SyncQueue(BaseQueue[T], Protocol[T]): + @property + def maxsize(self) -> int: + ... + + @property + def closed(self) -> bool: + ... + + def task_done(self) -> None: + ... + + def qsize(self) -> int: + ... + + @property + def unfinished_tasks(self) -> int: + ... + + def empty(self) -> bool: + ... + + def full(self) -> bool: + ... + + def put_nowait(self, item: T) -> None: + ... + + def get_nowait(self) -> T: + ... + + def put(self, item: T, block: bool = True, timeout: OptFloat = None) -> None: + ... + + def get(self, block: bool = True, timeout: OptFloat = None) -> T: + ... + + def join(self) -> None: + ... + + +class AsyncQueue(BaseQueue[T], Protocol[T]): + async def put(self, item: T) -> None: + ... + + async def get(self) -> T: + ... + + async def join(self) -> None: + ... + + +class Queue(Generic[T]): + def __init__(self, maxsize: int = 0) -> None: + self._loop = asyncio.get_running_loop() + self._maxsize = maxsize + + self._init(maxsize) + + self._unfinished_tasks = 0 + + self._sync_mutex = threading.Lock() + self._sync_not_empty = threading.Condition(self._sync_mutex) + self._sync_not_full = threading.Condition(self._sync_mutex) + self._all_tasks_done = threading.Condition(self._sync_mutex) + + self._async_mutex = asyncio.Lock() + if sys.version_info[:3] == (3, 10, 0): + # Workaround for Python 3.10 bug, see #358: + getattr(self._async_mutex, "_get_loop", lambda: None)() + self._async_not_empty = asyncio.Condition(self._async_mutex) + self._async_not_full = asyncio.Condition(self._async_mutex) + self._finished = asyncio.Event() + self._finished.set() + + self._closing = False + self._pending = set() # type: Set[asyncio.Future[Any]] + + def checked_call_soon_threadsafe( + callback: Callable[..., None], *args: Any + ) -> None: + try: + self._loop.call_soon_threadsafe(callback, *args) + except RuntimeError: + # swallowing agreed in #2 + pass + + self._call_soon_threadsafe = checked_call_soon_threadsafe + + def checked_call_soon(callback: Callable[..., None], *args: Any) -> None: + if not self._loop.is_closed(): + self._loop.call_soon(callback, *args) + + self._call_soon = checked_call_soon + + self._sync_queue = _SyncQueueProxy(self) + self._async_queue = _AsyncQueueProxy(self) + + def close(self) -> None: + with self._sync_mutex: + self._closing = True + for fut in self._pending: + fut.cancel() + self._finished.set() # unblocks all async_q.join() + self._all_tasks_done.notify_all() # unblocks all sync_q.join() + + async def wait_closed(self) -> None: + # should be called from loop after close(). + # Nobody should put/get at this point, + # so lock acquiring is not required + if not self._closing: + raise RuntimeError("Waiting for non-closed queue") + # give execution chances for the task-done callbacks + # of async tasks created inside + # _notify_async_not_empty, _notify_async_not_full + # methods. + await asyncio.sleep(0) + if not self._pending: + return + await asyncio.wait(self._pending) + + @property + def closed(self) -> bool: + return self._closing and not self._pending + + @property + def maxsize(self) -> int: + return self._maxsize + + @property + def sync_q(self) -> "_SyncQueueProxy[T]": + return self._sync_queue + + @property + def async_q(self) -> "_AsyncQueueProxy[T]": + return self._async_queue + + # Override these methods to implement other queue organizations + # (e.g. stack or priority queue). + # These will only be called with appropriate locks held + + def _init(self, maxsize: int) -> None: + self._queue = deque() # type: Deque[T] + + def _qsize(self) -> int: + return len(self._queue) + + # Put a new item in the queue + def _put(self, item: T) -> None: + self._queue.append(item) + + # Get an item from the queue + def _get(self) -> T: + return self._queue.popleft() + + def _put_internal(self, item: T) -> None: + self._put(item) + self._unfinished_tasks += 1 + self._finished.clear() + + def _notify_sync_not_empty(self) -> None: + def f() -> None: + with self._sync_mutex: + self._sync_not_empty.notify() + + self._loop.run_in_executor(None, f) + + def _notify_sync_not_full(self) -> None: + def f() -> None: + with self._sync_mutex: + self._sync_not_full.notify() + + fut = asyncio.ensure_future(self._loop.run_in_executor(None, f)) + fut.add_done_callback(self._pending.discard) + self._pending.add(fut) + + def _notify_async_not_empty(self, *, threadsafe: bool) -> None: + async def f() -> None: + async with self._async_mutex: + self._async_not_empty.notify() + + def task_maker() -> None: + task = self._loop.create_task(f()) + task.add_done_callback(self._pending.discard) + self._pending.add(task) + + if threadsafe: + self._call_soon_threadsafe(task_maker) + else: + self._call_soon(task_maker) + + def _notify_async_not_full(self, *, threadsafe: bool) -> None: + async def f() -> None: + async with self._async_mutex: + self._async_not_full.notify() + + def task_maker() -> None: + task = self._loop.create_task(f()) + task.add_done_callback(self._pending.discard) + self._pending.add(task) + + if threadsafe: + self._call_soon_threadsafe(task_maker) + else: + self._call_soon(task_maker) + + def _check_closing(self) -> None: + if self._closing: + raise RuntimeError("Operation on the closed queue is forbidden") + + +class _SyncQueueProxy(SyncQueue[T]): + """Create a queue object with a given maximum size. + + If maxsize is <= 0, the queue size is infinite. + """ + + def __init__(self, parent: Queue[T]): + self._parent = parent + + @property + def maxsize(self) -> int: + return self._parent._maxsize + + @property + def closed(self) -> bool: + return self._parent.closed + + def task_done(self) -> None: + """Indicate that a formerly enqueued task is complete. + + Used by Queue consumer threads. For each get() used to fetch a task, + a subsequent call to task_done() tells the queue that the processing + on the task is complete. + + If a join() is currently blocking, it will resume when all items + have been processed (meaning that a task_done() call was received + for every item that had been put() into the queue). + + Raises a ValueError if called more times than there were items + placed in the queue. + """ + self._parent._check_closing() + with self._parent._all_tasks_done: + unfinished = self._parent._unfinished_tasks - 1 + if unfinished <= 0: + if unfinished < 0: + raise ValueError("task_done() called too many times") + self._parent._all_tasks_done.notify_all() + self._parent._loop.call_soon_threadsafe(self._parent._finished.set) + self._parent._unfinished_tasks = unfinished + + def join(self) -> None: + """Blocks until all items in the Queue have been gotten and processed. + + The count of unfinished tasks goes up whenever an item is added to the + queue. The count goes down whenever a consumer thread calls task_done() + to indicate the item was retrieved and all work on it is complete. + + When the count of unfinished tasks drops to zero, join() unblocks. + """ + self._parent._check_closing() + with self._parent._all_tasks_done: + while self._parent._unfinished_tasks: + self._parent._all_tasks_done.wait() + self._parent._check_closing() + + def qsize(self) -> int: + """Return the approximate size of the queue (not reliable!).""" + return self._parent._qsize() + + @property + def unfinished_tasks(self) -> int: + """Return the number of unfinished tasks.""" + return self._parent._unfinished_tasks + + def empty(self) -> bool: + """Return True if the queue is empty, False otherwise (not reliable!). + + This method is likely to be removed at some point. Use qsize() == 0 + as a direct substitute, but be aware that either approach risks a race + condition where a queue can grow before the result of empty() or + qsize() can be used. + + To create code that needs to wait for all queued tasks to be + completed, the preferred technique is to use the join() method. + """ + return not self._parent._qsize() + + def full(self) -> bool: + """Return True if the queue is full, False otherwise (not reliable!). + + This method is likely to be removed at some point. Use qsize() >= n + as a direct substitute, but be aware that either approach risks a race + condition where a queue can shrink before the result of full() or + qsize() can be used. + """ + return 0 < self._parent._maxsize <= self._parent._qsize() + + def put(self, item: T, block: bool = True, timeout: OptFloat = None) -> None: + """Put an item into the queue. + + If optional args 'block' is true and 'timeout' is None (the default), + block if necessary until a free slot is available. If 'timeout' is + a non-negative number, it blocks at most 'timeout' seconds and raises + the Full exception if no free slot was available within that time. + Otherwise ('block' is false), put an item on the queue if a free slot + is immediately available, else raise the Full exception ('timeout' + is ignored in that case). + """ + self._parent._check_closing() + with self._parent._sync_not_full: + if self._parent._maxsize > 0: + if not block: + if self._parent._qsize() >= self._parent._maxsize: + raise SyncQueueFull + elif timeout is None: + while self._parent._qsize() >= self._parent._maxsize: + self._parent._sync_not_full.wait() + elif timeout < 0: + raise ValueError("'timeout' must be a non-negative number") + else: + time = self._parent._loop.time + endtime = time() + timeout + while self._parent._qsize() >= self._parent._maxsize: + remaining = endtime - time() + if remaining <= 0.0: + raise SyncQueueFull + self._parent._sync_not_full.wait(remaining) + self._parent._put_internal(item) + self._parent._sync_not_empty.notify() + self._parent._notify_async_not_empty(threadsafe=True) + + def get(self, block: bool = True, timeout: OptFloat = None) -> T: + """Remove and return an item from the queue. + + If optional args 'block' is true and 'timeout' is None (the default), + block if necessary until an item is available. If 'timeout' is + a non-negative number, it blocks at most 'timeout' seconds and raises + the Empty exception if no item was available within that time. + Otherwise ('block' is false), return an item if one is immediately + available, else raise the Empty exception ('timeout' is ignored + in that case). + """ + self._parent._check_closing() + with self._parent._sync_not_empty: + if not block: + if not self._parent._qsize(): + raise SyncQueueEmpty + elif timeout is None: + while not self._parent._qsize(): + self._parent._sync_not_empty.wait() + elif timeout < 0: + raise ValueError("'timeout' must be a non-negative number") + else: + time = self._parent._loop.time + endtime = time() + timeout + while not self._parent._qsize(): + remaining = endtime - time() + if remaining <= 0.0: + raise SyncQueueEmpty + self._parent._sync_not_empty.wait(remaining) + item = self._parent._get() + self._parent._sync_not_full.notify() + self._parent._notify_async_not_full(threadsafe=True) + return item + + def put_nowait(self, item: T) -> None: + """Put an item into the queue without blocking. + + Only enqueue the item if a free slot is immediately available. + Otherwise raise the Full exception. + """ + return self.put(item, block=False) + + def get_nowait(self) -> T: + """Remove and return an item from the queue without blocking. + + Only get an item if one is immediately available. Otherwise + raise the Empty exception. + """ + return self.get(block=False) + + +class _AsyncQueueProxy(AsyncQueue[T]): + """Create a queue object with a given maximum size. + + If maxsize is <= 0, the queue size is infinite. + """ + + def __init__(self, parent: Queue[T]): + self._parent = parent + + @property + def closed(self) -> bool: + return self._parent.closed + + def qsize(self) -> int: + """Number of items in the queue.""" + return self._parent._qsize() + + @property + def unfinished_tasks(self) -> int: + """Return the number of unfinished tasks.""" + return self._parent._unfinished_tasks + + @property + def maxsize(self) -> int: + """Number of items allowed in the queue.""" + return self._parent._maxsize + + def empty(self) -> bool: + """Return True if the queue is empty, False otherwise.""" + return self.qsize() == 0 + + def full(self) -> bool: + """Return True if there are maxsize items in the queue. + + Note: if the Queue was initialized with maxsize=0 (the default), + then full() is never True. + """ + if self._parent._maxsize <= 0: + return False + else: + return self.qsize() >= self._parent._maxsize + + async def put(self, item: T) -> None: + """Put an item into the queue. + + Put an item into the queue. If the queue is full, wait until a free + slot is available before adding item. + + This method is a coroutine. + """ + self._parent._check_closing() + async with self._parent._async_not_full: + self._parent._sync_mutex.acquire() + locked = True + try: + if self._parent._maxsize > 0: + do_wait = True + while do_wait: + do_wait = self._parent._qsize() >= self._parent._maxsize + if do_wait: + locked = False + self._parent._sync_mutex.release() + await self._parent._async_not_full.wait() + self._parent._sync_mutex.acquire() + locked = True + + self._parent._put_internal(item) + self._parent._async_not_empty.notify() + self._parent._notify_sync_not_empty() + finally: + if locked: + self._parent._sync_mutex.release() + + def put_nowait(self, item: T) -> None: + """Put an item into the queue without blocking. + + If no free slot is immediately available, raise QueueFull. + """ + self._parent._check_closing() + with self._parent._sync_mutex: + if self._parent._maxsize > 0: + if self._parent._qsize() >= self._parent._maxsize: + raise AsyncQueueFull + + self._parent._put_internal(item) + self._parent._notify_async_not_empty(threadsafe=False) + self._parent._notify_sync_not_empty() + + async def get(self) -> T: + """Remove and return an item from the queue. + + If queue is empty, wait until an item is available. + + This method is a coroutine. + """ + self._parent._check_closing() + async with self._parent._async_not_empty: + self._parent._sync_mutex.acquire() + locked = True + try: + do_wait = True + while do_wait: + do_wait = self._parent._qsize() == 0 + + if do_wait: + locked = False + self._parent._sync_mutex.release() + await self._parent._async_not_empty.wait() + self._parent._sync_mutex.acquire() + locked = True + + item = self._parent._get() + self._parent._async_not_full.notify() + self._parent._notify_sync_not_full() + return item + finally: + if locked: + self._parent._sync_mutex.release() + + def get_nowait(self) -> T: + """Remove and return an item from the queue. + + Return an item if one is immediately available, else raise QueueEmpty. + """ + self._parent._check_closing() + with self._parent._sync_mutex: + if self._parent._qsize() == 0: + raise AsyncQueueEmpty + + item = self._parent._get() + self._parent._notify_async_not_full(threadsafe=False) + self._parent._notify_sync_not_full() + return item + + def task_done(self) -> None: + """Indicate that a formerly enqueued task is complete. + + Used by queue consumers. For each get() used to fetch a task, + a subsequent call to task_done() tells the queue that the processing + on the task is complete. + + If a join() is currently blocking, it will resume when all items have + been processed (meaning that a task_done() call was received for every + item that had been put() into the queue). + + Raises ValueError if called more times than there were items placed in + the queue. + """ + self._parent._check_closing() + with self._parent._all_tasks_done: + if self._parent._unfinished_tasks <= 0: + raise ValueError("task_done() called too many times") + self._parent._unfinished_tasks -= 1 + if self._parent._unfinished_tasks == 0: + self._parent._finished.set() + self._parent._all_tasks_done.notify_all() + + async def join(self) -> None: + """Block until all items in the queue have been gotten and processed. + + The count of unfinished tasks goes up whenever an item is added to the + queue. The count goes down whenever a consumer calls task_done() to + indicate that the item was retrieved and all work on it is complete. + When the count of unfinished tasks drops to zero, join() unblocks. + """ + while True: + with self._parent._sync_mutex: + self._parent._check_closing() + if self._parent._unfinished_tasks == 0: + break + await self._parent._finished.wait() + + +class PriorityQueue(Queue[T]): + """Variant of Queue that retrieves open entries in priority order + (lowest first). + + Entries are typically tuples of the form: (priority number, data). + + """ + + def _init(self, maxsize: int) -> None: + self._heap_queue = [] # type: List[T] + + def _qsize(self) -> int: + return len(self._heap_queue) + + def _put(self, item: T) -> None: + heappush(self._heap_queue, item) + + def _get(self) -> T: + return heappop(self._heap_queue) + + +class LifoQueue(Queue[T]): + """Variant of Queue that retrieves most recently added entries first.""" + + def _qsize(self) -> int: + return len(self._queue) + + def _put(self, item: T) -> None: + self._queue.append(item) + + def _get(self) -> T: + return self._queue.pop() diff --git a/freqtrade/rpc/replicate/types.py b/freqtrade/rpc/replicate/types.py new file mode 100644 index 000000000..5d8c158bd --- /dev/null +++ b/freqtrade/rpc/replicate/types.py @@ -0,0 +1,9 @@ +from typing import TypeVar + +from fastapi import WebSocket as FastAPIWebSocket +from websockets import WebSocketClientProtocol as WebSocket + +from freqtrade.rpc.replicate.channel import WebSocketProxy + + +WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket, WebSocketProxy) diff --git a/freqtrade/rpc/replicate/utils.py b/freqtrade/rpc/replicate/utils.py new file mode 100644 index 000000000..7b703810e --- /dev/null +++ b/freqtrade/rpc/replicate/utils.py @@ -0,0 +1,10 @@ +from starlette.websockets import WebSocket, WebSocketState + + +async def is_websocket_alive(ws: WebSocket) -> bool: + if ( + ws.application_state == WebSocketState.CONNECTED and + ws.client_state == WebSocketState.CONNECTED + ): + return True + return False diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3ccf23228..140431586 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -44,10 +44,23 @@ class RPCManager: if config.get('api_server', {}).get('enabled', False): logger.info('Enabling rpc.api_server') from freqtrade.rpc.api_server import ApiServer + + # Pass replicate_rpc as param or defer starting api_server + # until we register the replicate rpc enpoint? apiserver = ApiServer(config) apiserver.add_rpc_handler(self._rpc) self.registered_modules.append(apiserver) + # Enable Replicate mode + # For this to be enabled, the API server must also be enabled + if config.get('replicate', {}).get('enabled', False): + logger.info('Enabling rpc.replicate') + from freqtrade.rpc.replicate import ReplicateController + replicate_rpc = ReplicateController(self._rpc, config, apiserver) + self.registered_modules.append(replicate_rpc) + + apiserver.start_api() + def cleanup(self) -> None: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') diff --git a/requirements-replicate.txt b/requirements-replicate.txt new file mode 100644 index 000000000..7ee351d9d --- /dev/null +++ b/requirements-replicate.txt @@ -0,0 +1,5 @@ +# Include all requirements to run the bot. +-r requirements.txt + +# Required for follower +websockets From 6834db11f3ec4d0b9d9a6540633e1b363c11c889 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 19 Aug 2022 00:06:19 -0600 Subject: [PATCH 002/199] minor improvements and pairlist data transmission --- freqtrade/enums/replicate.py | 3 +- freqtrade/freqtradebot.py | 13 + .../plugins/pairlist/ExternalPairList.py | 59 +++++ freqtrade/rpc/replicate/__init__.py | 236 ++++++++++-------- freqtrade/rpc/replicate/channel.py | 23 +- freqtrade/rpc/replicate/proxy.py | 9 +- freqtrade/rpc/replicate/serializer.py | 8 +- freqtrade/rpc/replicate/types.py | 4 +- freqtrade/rpc/rpc_manager.py | 3 + 9 files changed, 243 insertions(+), 115 deletions(-) create mode 100644 freqtrade/plugins/pairlist/ExternalPairList.py diff --git a/freqtrade/enums/replicate.py b/freqtrade/enums/replicate.py index d55d45b45..501d119f3 100644 --- a/freqtrade/enums/replicate.py +++ b/freqtrade/enums/replicate.py @@ -7,5 +7,4 @@ class ReplicateModeType(str, Enum): class LeaderMessageType(str, Enum): - Pairlist = "pairlist" - Dataframe = "dataframe" + whitelist = "whitelist" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 4e3af64ea..ac6a998c5 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -75,6 +75,8 @@ class FreqtradeBot(LoggingMixin): PairLocks.timeframe = self.config['timeframe'] + self.replicate_controller = None + # RPC runs in separate threads, can start handling external commands just after # initialization, even before Freqtradebot has a chance to start its throttling, # so anything in the Freqtradebot instance should be ready (initialized), including @@ -264,6 +266,17 @@ class FreqtradeBot(LoggingMixin): # Extend active-pair whitelist with pairs of open trades # It ensures that candle (OHLCV) data are downloaded for open trades as well _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) + + # If replicate leader, broadcast whitelist data + if self.replicate_controller: + if self.replicate_controller.is_leader(): + self.replicate_controller.send_message( + { + "data_type": "whitelist", + "data": _whitelist + } + ) + return _whitelist def get_free_open_trades(self) -> int: diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py new file mode 100644 index 000000000..832c3d5eb --- /dev/null +++ b/freqtrade/plugins/pairlist/ExternalPairList.py @@ -0,0 +1,59 @@ +""" +External Pair List provider + +Provides pair list from Leader data +""" +import logging +from typing import Any, Dict, List + +from freqtrade.plugins.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class ExternalPairList(IPairList): + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._num_assets = self._pairlistconfig.get('num_assets') + self._allow_inactive = self._pairlistconfig.get('allow_inactive', False) + + self._leader_pairs: List[str] = [] + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty Dict is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + -> Please overwrite in subclasses + """ + return f"{self.name}" + + def gen_pairlist(self, tickers: Dict) -> List[str]: + """ + Generate the pairlist + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: List of pairs + """ + pass + + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: + """ + Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary + :param pairlist: pairlist to filter or sort + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: new whitelist + """ + pass diff --git a/freqtrade/rpc/replicate/__init__.py b/freqtrade/rpc/replicate/__init__.py index d725a4a90..80ac0836c 100644 --- a/freqtrade/rpc/replicate/__init__.py +++ b/freqtrade/rpc/replicate/__init__.py @@ -5,7 +5,7 @@ import asyncio import logging import secrets import socket -from threading import Thread +from threading import Event, Thread from typing import Any, Coroutine, Dict, Union import websockets @@ -50,6 +50,9 @@ class ReplicateController(RPCHandler): self._thread = None self._queue = None + self._stop_event = Event() + self._follower_tasks = None + self.channel_manager = ChannelManager() self.replicate_config = config.get('replicate', {}) @@ -93,10 +96,7 @@ class ReplicateController(RPCHandler): self.start_threaded_loop() - if self.mode == ReplicateModeType.follower: - self.start_follower_mode() - elif self.mode == ReplicateModeType.leader: - self.start_leader_mode() + self.start() def start_threaded_loop(self): """ @@ -129,6 +129,29 @@ class ReplicateController(RPCHandler): logger.error(f"Error running coroutine - {str(e)}") return None + async def main_loop(self): + """ + Main loop coro + + Start the loop based on what mode we're in + """ + try: + if self.mode == ReplicateModeType.leader: + await self.leader_loop() + elif self.mode == ReplicateModeType.follower: + await self.follower_loop() + + except asyncio.CancelledError: + pass + finally: + self._loop.stop() + + def start(self): + """ + Start the controller main loop + """ + self.submit_coroutine(self.main_loop()) + def cleanup(self) -> None: """ Cleanup pending module resources. @@ -144,27 +167,62 @@ class ReplicateController(RPCHandler): task.cancel() self._loop.call_soon_threadsafe(self.channel_manager.disconnect_all) - # This must be called threadsafe, otherwise would not work - self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join() def send_msg(self, msg: Dict[str, Any]) -> None: - """ Push message through """ - + """ + Support RPC calls + """ if msg["type"] == RPCMessageType.EMIT_DATA: - self._send_message( + self.send_message( { - "type": msg["data_type"], - "content": msg["data"] + "data_type": msg.get("data_type"), + "data": msg.get("data") } ) + def send_message(self, msg: Dict[str, Any]) -> None: + """ Push message through """ + + if self.channel_manager.has_channels(): + self._send_message(msg) + else: + logger.debug("No listening followers, skipping...") + pass + + def _send_message(self, msg: Dict[Any, Any]): + """ + Add data to the internal queue to be broadcasted. This func will block + if the queue is full. This is meant to be called in the main thread. + """ + + if self._queue: + queue = self._queue.sync_q + queue.put(msg) + else: + logger.warning("Can not send data, leader loop has not started yet!") + + def is_leader(self): + """ + Leader flag + """ + return self.enabled() and self.mode == ReplicateModeType.leader + + def enabled(self): + """ + Enabled flag + """ + return self.replicate_config.get('enabled', False) + # ----------------------- LEADER LOGIC ------------------------------ - def start_leader_mode(self): + async def leader_loop(self): """ - Register the endpoint and start the leader loop + Main leader coroutine + + This starts all of the leader coros and registers the endpoint on + the ApiServer """ logger.info("Running rpc.replicate in Leader mode") @@ -173,30 +231,13 @@ class ReplicateController(RPCHandler): logger.info("-" * 15) self.register_leader_endpoint() - self.submit_coroutine(self.leader_loop()) - async def leader_loop(self): - """ - Main leader coroutine - At the moment this just broadcasts data that's in the queue to the followers - """ try: await self._broadcast_queue_data() except Exception as e: logger.error("Exception occurred in leader loop: ") logger.exception(e) - def _send_message(self, data: Dict[Any, Any]): - """ - Add data to the internal queue to be broadcasted. This func will block - if the queue is full. This is meant to be called in the main thread. - """ - - if self._queue: - self._queue.put(data) - else: - logger.warning("Can not send data, leader loop has not started yet!") - async def _broadcast_queue_data(self): """ Loop over queue data and broadcast it @@ -210,6 +251,8 @@ class ReplicateController(RPCHandler): # Get data from queue data = await async_queue.get() + logger.info(f"Found data - broadcasting: {data}") + # Broadcast it to everyone await self.channel_manager.broadcast(data) @@ -263,6 +306,9 @@ class ReplicateController(RPCHandler): channel = await self.channel_manager.on_connect(websocket) # Send initial data here + # Data is being broadcasted right away as soon as startup, + # we may not have to send initial data at all. Further testing + # required. # Keep connection open until explicitly closed, and sleep try: @@ -286,20 +332,15 @@ class ReplicateController(RPCHandler): # -------------------------------FOLLOWER LOGIC---------------------------- - def start_follower_mode(self): - """ - Start the ReplicateController in Follower mode - """ - logger.info("Starting rpc.replicate in Follower mode") - - self.submit_coroutine(self.follower_loop()) - async def follower_loop(self): """ Main follower coroutine - This starts all of the leader connection coros + This starts all of the follower connection coros """ + + logger.info("Starting rpc.replicate in Follower mode") + try: await self._connect_to_leaders() except Exception as e: @@ -307,79 +348,76 @@ class ReplicateController(RPCHandler): logger.exception(e) async def _connect_to_leaders(self): + """ + For each leader in `self.leaders_list` create a connection and + listen for data. + """ rpc_lock = asyncio.Lock() logger.info("Starting connections to Leaders...") - await asyncio.wait( - [ - self._handle_leader_connection(leader, rpc_lock) - for leader in self.leaders_list - ] - ) + + self.follower_tasks = [ + self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) + for leader in self.leaders_list + ] + return await asyncio.gather(*self.follower_tasks, return_exceptions=True) async def _handle_leader_connection(self, leader, lock): """ Given a leader, connect and wait on data. If connection is lost, it will attempt to reconnect. """ - url, token = leader["url"], leader["token"] + try: + url, token = leader["url"], leader["token"] - websocket_url = f"{url}?token={token}" + websocket_url = f"{url}?token={token}" - logger.info(f"Attempting to connect to leader at: {url}") - # TODO: limit the amount of connection retries - while True: - try: - async with websockets.connect(websocket_url) as ws: - channel = await self.channel_manager.on_connect(ws) - while True: - try: - data = await asyncio.wait_for( - channel.recv(), - timeout=self.reply_timeout - ) - except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): - # We haven't received data yet. Just check the connection and continue. + logger.info(f"Attempting to connect to leader at: {url}") + # TODO: limit the amount of connection retries + while True: + try: + async with websockets.connect(websocket_url) as ws: + channel = await self.channel_manager.on_connect(ws) + while True: try: - # ping - ping = await channel.ping() - await asyncio.wait_for(ping, timeout=self.ping_timeout) - logger.info(f"Connection to {url} still alive...") - continue - except Exception: - logger.info(f"Ping error {url} - retrying in {self.sleep_time}s") - asyncio.sleep(self.sleep_time) - break + data = await asyncio.wait_for( + channel.recv(), + timeout=self.reply_timeout + ) + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await channel.ping() + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.debug(f"Connection to {url} still alive...") + continue + except Exception: + logger.info( + f"Ping error {url} - retrying in {self.sleep_time}s") + asyncio.sleep(self.sleep_time) + break - with lock: - # Should we have a lock here? - await self._handle_leader_message(data) + async with lock: + # Acquire lock so only 1 coro handling at a time + # as we might call the RPC module in the main thread + await self._handle_leader_message(data) - except socket.gaierror: - logger.info(f"Socket error - retrying connection in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - continue - except ConnectionRefusedError: - logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - continue + except socket.gaierror: + logger.info(f"Socket error - retrying connection in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + continue + except ConnectionRefusedError: + logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + continue + + except asyncio.CancelledError: + pass async def _handle_leader_message(self, message): - type = message.get("type") + type = message.get('data_type') + data = message.get('data') - message_type_handlers = { - LeaderMessageType.Pairlist.value: self._handle_pairlist_message, - LeaderMessageType.Dataframe.value: self._handle_dataframe_message - } - - handler = message_type_handlers.get(type, self._handle_default_message) - return await handler(message) - - async def _handle_default_message(self, message): - logger.info(f"Default message handled: {message}") - - async def _handle_pairlist_message(self, message): - logger.info(f"Pairlist message handled: {message}") - - async def _handle_dataframe_message(self, message): - logger.info(f"Dataframe message handled: {message}") + if type == LeaderMessageType.whitelist: + logger.info(f"Received whitelist from Leader: {data}") diff --git a/freqtrade/rpc/replicate/channel.py b/freqtrade/rpc/replicate/channel.py index 9950742da..7aa316ff5 100644 --- a/freqtrade/rpc/replicate/channel.py +++ b/freqtrade/rpc/replicate/channel.py @@ -1,3 +1,4 @@ +import logging from typing import Type from freqtrade.rpc.replicate.proxy import WebSocketProxy @@ -5,6 +6,9 @@ from freqtrade.rpc.replicate.serializer import JSONWebSocketSerializer, WebSocke from freqtrade.rpc.replicate.types import WebSocketType +logger = logging.getLogger(__name__) + + class WebSocketChannel: """ Object to help facilitate managing a websocket connection @@ -85,9 +89,12 @@ class ChannelManager: """ if websocket in self.channels.keys(): channel = self.channels[websocket] + + logger.debug(f"Disconnecting channel - {channel}") + if not channel.is_closed(): await channel.close() - del channel + del self.channels[websocket] async def disconnect_all(self): """ @@ -102,5 +109,15 @@ class ChannelManager: :param data: The data to send """ - for channel in self.channels.values(): - await channel.send(data) + for websocket, channel in self.channels.items(): + try: + await channel.send(data) + except RuntimeError: + # Handle cannot send after close cases + await self.on_disconnect(websocket) + + def has_channels(self): + """ + Flag for more than 0 channels + """ + return len(self.channels) > 0 diff --git a/freqtrade/rpc/replicate/proxy.py b/freqtrade/rpc/replicate/proxy.py index b2173670b..aae536b6d 100644 --- a/freqtrade/rpc/replicate/proxy.py +++ b/freqtrade/rpc/replicate/proxy.py @@ -1,11 +1,9 @@ -from typing import TYPE_CHECKING, Union +from typing import Union from fastapi import WebSocket as FastAPIWebSocket from websockets import WebSocketClientProtocol as WebSocket - -if TYPE_CHECKING: - from freqtrade.rpc.replicate.types import WebSocketType +from freqtrade.rpc.replicate.types import WebSocketType class WebSocketProxy: @@ -21,6 +19,9 @@ class WebSocketProxy: """ Send data on the wrapped websocket """ + if isinstance(data, str): + data = data.encode() + if hasattr(self._websocket, "send_bytes"): await self._websocket.send_bytes(data) else: diff --git a/freqtrade/rpc/replicate/serializer.py b/freqtrade/rpc/replicate/serializer.py index ae5e57b95..717458f09 100644 --- a/freqtrade/rpc/replicate/serializer.py +++ b/freqtrade/rpc/replicate/serializer.py @@ -33,10 +33,10 @@ class WebSocketSerializer(ABC): class JSONWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data: bytes) -> bytes: + def _serialize(self, data): # json expects string not bytes - return json.dumps(data.decode()).encode() + return json.dumps(data) - def _deserialize(self, data: bytes) -> bytes: + def _deserialize(self, data): # The WebSocketSerializer gives bytes not string - return json.loads(data).encode() + return json.loads(data) diff --git a/freqtrade/rpc/replicate/types.py b/freqtrade/rpc/replicate/types.py index 5d8c158bd..763147196 100644 --- a/freqtrade/rpc/replicate/types.py +++ b/freqtrade/rpc/replicate/types.py @@ -3,7 +3,5 @@ from typing import TypeVar from fastapi import WebSocket as FastAPIWebSocket from websockets import WebSocketClientProtocol as WebSocket -from freqtrade.rpc.replicate.channel import WebSocketProxy - -WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket, WebSocketProxy) +WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 140431586..8eaec21ea 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -59,6 +59,9 @@ class RPCManager: replicate_rpc = ReplicateController(self._rpc, config, apiserver) self.registered_modules.append(replicate_rpc) + # Attach the controller to FreqTrade + freqtrade.replicate_controller = replicate_rpc + apiserver.start_api() def cleanup(self) -> None: From 739b68f8fd5c3ca5cc6eacc1f8a49a76fdedd620 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 19 Aug 2022 22:40:01 -0600 Subject: [PATCH 003/199] ExternalPairList plugin --- freqtrade/constants.py | 3 +- freqtrade/enums/replicate.py | 2 +- freqtrade/freqtradebot.py | 30 +++++---- .../plugins/pairlist/ExternalPairList.py | 61 +++++++++++++++++-- freqtrade/rpc/replicate/__init__.py | 27 +++++--- 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 416b4646f..55363cca1 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -33,7 +33,8 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', - 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] + 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter', + 'ExternalPairList'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] diff --git a/freqtrade/enums/replicate.py b/freqtrade/enums/replicate.py index 501d119f3..73be996c0 100644 --- a/freqtrade/enums/replicate.py +++ b/freqtrade/enums/replicate.py @@ -7,4 +7,4 @@ class ReplicateModeType(str, Enum): class LeaderMessageType(str, Enum): - whitelist = "whitelist" + pairlist = "pairlist" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index ac6a998c5..b2ec1448e 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -17,8 +17,8 @@ from freqtrade.constants import BuySell, LongShort from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.enums import (ExitCheckTuple, ExitType, RPCMessageType, RunMode, SignalDirection, - State, TradingMode) +from freqtrade.enums import (ExitCheckTuple, ExitType, LeaderMessageType, RPCMessageType, RunMode, + SignalDirection, State, TradingMode) from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, PricingError) from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds @@ -257,6 +257,22 @@ class FreqtradeBot(LoggingMixin): self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist + # If replicate leader, broadcast whitelist data + # Should we broadcast before trade pairs are added? What if + # the follower doesn't have trades with those pairs. They would be added for + # no reason. + + # Or should this class be made available to the PairListManager and ran + # when filter_pairlist is called? + if self.replicate_controller: + if self.replicate_controller.is_leader(): + self.replicate_controller.send_message( + { + "data_type": LeaderMessageType.pairlist, + "data": _whitelist + } + ) + # Calculating Edge positioning if self.edge: self.edge.calculate(_whitelist) @@ -267,16 +283,6 @@ class FreqtradeBot(LoggingMixin): # It ensures that candle (OHLCV) data are downloaded for open trades as well _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) - # If replicate leader, broadcast whitelist data - if self.replicate_controller: - if self.replicate_controller.is_leader(): - self.replicate_controller.send_message( - { - "data_type": "whitelist", - "data": _whitelist - } - ) - return _whitelist def get_free_open_trades(self) -> int: diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py index 832c3d5eb..82fc12ff9 100644 --- a/freqtrade/plugins/pairlist/ExternalPairList.py +++ b/freqtrade/plugins/pairlist/ExternalPairList.py @@ -4,6 +4,7 @@ External Pair List provider Provides pair list from Leader data """ import logging +from threading import Event from typing import Any, Dict, List from freqtrade.plugins.pairlist.IPairList import IPairList @@ -13,16 +14,41 @@ logger = logging.getLogger(__name__) class ExternalPairList(IPairList): + """ + PairList plugin for use with replicate follower mode. + Will use pairs given from leader data. + + Usage: + "pairlists": [ + { + "method": "ExternalPairList", + "number_assets": 5, # We can limit the amount of pairs to use from leader + } + ], + """ def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) - self._num_assets = self._pairlistconfig.get('num_assets') - self._allow_inactive = self._pairlistconfig.get('allow_inactive', False) + # Not sure how to enforce ExternalPairList as the only PairList + + self._num_assets = self._pairlistconfig.get('number_assets') self._leader_pairs: List[str] = [] + self._has_data = Event() + + def _clamped_pairlist(self): + """ + Return the self._leader_pairs pairlist limited to the maximum set num_assets + or the length of it. + """ + length = len(self._leader_pairs) + if self._num_assets: + return self._leader_pairs[:min(length, self._num_assets)] + else: + return self._leader_pairs @property def needstickers(self) -> bool: @@ -40,13 +66,40 @@ class ExternalPairList(IPairList): """ return f"{self.name}" + def add_pairlist_data(self, pairlist: List[str]): + """ + Add pairs from Leader + """ + + # If some pairs were removed on Leader, remove them here + for pair in self._leader_pairs: + if pair not in pairlist: + logger.debug(f"Leader removed pair: {pair}") + self._leader_pairs.remove(pair) + + # Only add new pairs + seen = set(self._leader_pairs) + for pair in pairlist: + if pair in seen: + logger.debug(f"Encountered already existing pair {pair}") + continue + self._leader_pairs.append(pair) + + if not self._has_data.is_set(): + self._has_data.set() + def gen_pairlist(self, tickers: Dict) -> List[str]: """ Generate the pairlist :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: List of pairs """ - pass + if not self._has_data.is_set(): + logger.info("Waiting on pairlists from Leaders...") + self._has_data.wait() + logger.info("Pairlist data received...") + + return self._clamped_pairlist() def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ @@ -56,4 +109,4 @@ class ExternalPairList(IPairList): :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: new whitelist """ - pass + return self._clamped_pairlist() diff --git a/freqtrade/rpc/replicate/__init__.py b/freqtrade/rpc/replicate/__init__.py index 80ac0836c..fd718197e 100644 --- a/freqtrade/rpc/replicate/__init__.py +++ b/freqtrade/rpc/replicate/__init__.py @@ -40,6 +40,7 @@ class ReplicateController(RPCHandler): """ super().__init__(rpc, config) + self.freqtrade = rpc._freqtrade self.api_server = api_server if not self.api_server: @@ -122,7 +123,6 @@ class ReplicateController(RPCHandler): raise RuntimeError("Loop must be started before any function can" " be submitted") - logger.debug(f"Running coroutine {repr(coroutine)} in loop") try: return asyncio.run_coroutine_threadsafe(coroutine, self._loop) except Exception as e: @@ -185,6 +185,8 @@ class ReplicateController(RPCHandler): def send_message(self, msg: Dict[str, Any]) -> None: """ Push message through """ + # We should probably do some type of schema validation here + if self.channel_manager.has_channels(): self._send_message(msg) else: @@ -199,7 +201,7 @@ class ReplicateController(RPCHandler): if self._queue: queue = self._queue.sync_q - queue.put(msg) + queue.put(msg) # This will block if the queue is full else: logger.warning("Can not send data, leader loop has not started yet!") @@ -235,7 +237,7 @@ class ReplicateController(RPCHandler): try: await self._broadcast_queue_data() except Exception as e: - logger.error("Exception occurred in leader loop: ") + logger.error("Exception occurred in Leader loop: ") logger.exception(e) async def _broadcast_queue_data(self): @@ -342,10 +344,14 @@ class ReplicateController(RPCHandler): logger.info("Starting rpc.replicate in Follower mode") try: - await self._connect_to_leaders() + results = await self._connect_to_leaders() except Exception as e: - logger.error("Exception occurred in follower loop: ") + logger.error("Exception occurred in Follower loop: ") logger.exception(e) + finally: + for result in results: + if isinstance(result, Exception): + logger.debug(f"Exception in Follower loop: {result}") async def _connect_to_leaders(self): """ @@ -372,7 +378,7 @@ class ReplicateController(RPCHandler): websocket_url = f"{url}?token={token}" - logger.info(f"Attempting to connect to leader at: {url}") + logger.info(f"Attempting to connect to Leader at: {url}") # TODO: limit the amount of connection retries while True: try: @@ -415,9 +421,12 @@ class ReplicateController(RPCHandler): except asyncio.CancelledError: pass - async def _handle_leader_message(self, message): + async def _handle_leader_message(self, message: Dict[str, Any]): type = message.get('data_type') data = message.get('data') - if type == LeaderMessageType.whitelist: - logger.info(f"Received whitelist from Leader: {data}") + logger.info(f"Received message from Leader: {type} - {data}") + + if type == LeaderMessageType.pairlist: + # Add the data to the ExternalPairlist + self.freqtrade.pairlists._pairlist_handlers[0].add_pairlist_data(data) From 6f5478cc029bc146e3980affa61dd7956c5cb416 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 21 Aug 2022 22:45:36 -0600 Subject: [PATCH 004/199] DataFrame transmission, strategy follower logic --- freqtrade/data/dataprovider.py | 57 ++++++++++- freqtrade/enums/replicate.py | 1 + freqtrade/freqtradebot.py | 43 +++++++-- freqtrade/misc.py | 22 +++++ .../plugins/pairlist/ExternalPairList.py | 3 +- freqtrade/rpc/replicate/__init__.py | 94 +++++++++++-------- freqtrade/rpc/replicate/channel.py | 16 +++- freqtrade/rpc/replicate/serializer.py | 27 +++++- freqtrade/rpc/replicate/types.py | 3 +- freqtrade/rpc/rpc.py | 39 +++++++- freqtrade/rpc/rpc_manager.py | 13 ++- freqtrade/strategy/interface.py | 92 +++++++++++++++--- requirements-replicate.txt | 1 + 13 files changed, 332 insertions(+), 79 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 21cead77f..3de73bb0d 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -7,6 +7,7 @@ Common Interface for bot and strategy to access data. import logging from collections import deque from datetime import datetime, timezone +from threading import Event from typing import Any, Dict, List, Optional, Tuple from pandas import DataFrame @@ -28,13 +29,16 @@ MAX_DATAFRAME_CANDLES = 1000 class DataProvider: - def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None: + def __init__(self, config: dict, exchange: Optional[Exchange], + pairlists=None, replicate_controller=None) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} + self.__external_pairs_df: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} + self.__external_pairs_event: Dict[str, Event] = {} self._msg_queue: deque = deque() self.__msg_cache = PeriodicCache( @@ -63,9 +67,58 @@ class DataProvider: :param dataframe: analyzed dataframe :param candle_type: Any of the enum CandleType (must match trading mode!) """ - self.__cached_pairs[(pair, timeframe, candle_type)] = ( + pair_key = (pair, timeframe, candle_type) + self.__cached_pairs[pair_key] = ( dataframe, datetime.now(timezone.utc)) + def add_external_df( + self, + pair: str, + timeframe: str, + dataframe: DataFrame, + candle_type: CandleType + ) -> None: + """ + Add the DataFrame to the __external_pairs_df. If a pair event exists, + set it to release the main thread from waiting. + """ + pair_key = (pair, timeframe, candle_type) + + # Delete stale data + if pair_key in self.__external_pairs_df: + del self.__external_pairs_df[pair_key] + + self.__external_pairs_df[pair_key] = (dataframe, datetime.now(timezone.utc)) + + pair_event = self.__external_pairs_event.get(pair) + if pair_event: + logger.debug(f"Leader data for pair {pair_key} has been added") + pair_event.set() + + def get_external_df( + self, + pair: str, + timeframe: str, + candle_type: CandleType + ) -> DataFrame: + """ + If the pair exists in __external_pairs_df, return it. If it doesn't, + create a new threading Event in __external_pairs_event and wait on it. + """ + pair_key = (pair, timeframe, candle_type) + if pair_key not in self.__external_pairs_df: + pair_event = Event() + self.__external_pairs_event[pair] = pair_event + + logger.debug(f"Waiting on Leader data for: {pair_key}") + self.__external_pairs_event[pair].wait() + + if pair_key in self.__external_pairs_df: + return self.__external_pairs_df[pair_key] + + # Because of the waiting mechanism, this should never return + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + def add_pairlisthandler(self, pairlists) -> None: """ Allow adding pairlisthandler after initialization diff --git a/freqtrade/enums/replicate.py b/freqtrade/enums/replicate.py index 73be996c0..8d036f0b9 100644 --- a/freqtrade/enums/replicate.py +++ b/freqtrade/enums/replicate.py @@ -8,3 +8,4 @@ class ReplicateModeType(str, Enum): class LeaderMessageType(str, Enum): pairlist = "pairlist" + analyzed_df = "analyzed_df" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index b2ec1448e..3b850dd4e 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -23,7 +23,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie InvalidOrderException, PricingError) from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange.exchange import timeframe_to_next_date -from freqtrade.misc import safe_value_fallback, safe_value_fallback2 +from freqtrade.misc import dataframe_to_json, safe_value_fallback, safe_value_fallback2 from freqtrade.mixins import LoggingMixin from freqtrade.persistence import Order, PairLocks, Trade, init_db from freqtrade.plugins.pairlistmanager import PairListManager @@ -77,6 +77,8 @@ class FreqtradeBot(LoggingMixin): self.replicate_controller = None + self.pairlists = PairListManager(self.exchange, self.config) + # RPC runs in separate threads, can start handling external commands just after # initialization, even before Freqtradebot has a chance to start its throttling, # so anything in the Freqtradebot instance should be ready (initialized), including @@ -84,8 +86,6 @@ class FreqtradeBot(LoggingMixin): # Keep this at the end of this initialization method. self.rpc: RPCManager = RPCManager(self) - self.pairlists = PairListManager(self.exchange, self.config) - self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) # Attach Dataprovider to strategy instance @@ -93,6 +93,9 @@ class FreqtradeBot(LoggingMixin): # Attach Wallets to strategy instance self.strategy.wallets = self.wallets + # Attach ReplicateController to the strategy + # self.strategy.replicate_controller = self.replicate_controller + # Initializing Edge only if enabled self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None @@ -194,7 +197,28 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - self.strategy.analyze(self.active_pair_whitelist) + if self.replicate_controller: + if not self.replicate_controller.is_leader(): + # Run Follower mode analyzing + leader_pairs = self.pairlists._whitelist + self.strategy.analyze_external(self.active_pair_whitelist, leader_pairs) + else: + # We are leader, make sure to pass callback func to emit data + def emit_on_finish(pair, dataframe, timeframe, candle_type): + logger.debug(f"Emitting dataframe for {pair}") + return self.rpc.emit_data( + { + "data_type": LeaderMessageType.analyzed_df, + "data": { + "key": (pair, timeframe, candle_type), + "value": dataframe_to_json(dataframe) + } + } + ) + + self.strategy.analyze(self.active_pair_whitelist, finish_callback=emit_on_finish) + else: + self.strategy.analyze(self.active_pair_whitelist) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace @@ -264,14 +288,13 @@ class FreqtradeBot(LoggingMixin): # Or should this class be made available to the PairListManager and ran # when filter_pairlist is called? + if self.replicate_controller: if self.replicate_controller.is_leader(): - self.replicate_controller.send_message( - { - "data_type": LeaderMessageType.pairlist, - "data": _whitelist - } - ) + self.rpc.emit_data({ + "data_type": LeaderMessageType.pairlist, + "data": _whitelist + }) # Calculating Edge positioning if self.edge: diff --git a/freqtrade/misc.py b/freqtrade/misc.py index c3968e61c..bc644a7ec 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -10,6 +10,7 @@ from typing import Any, Iterator, List from typing.io import IO from urllib.parse import urlparse +import pandas import rapidjson from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN @@ -249,3 +250,24 @@ def parse_db_uri_for_logging(uri: str): return uri pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0] return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@') + + +def dataframe_to_json(dataframe: pandas.DataFrame) -> str: + """ + Serialize a DataFrame for transmission over the wire using JSON + :param dataframe: A pandas DataFrame + :returns: A JSON string of the pandas DataFrame + """ + return dataframe.to_json(orient='records') + + +def json_to_dataframe(data: str) -> pandas.DataFrame: + """ + Deserialize JSON into a DataFrame + :param data: A JSON string + :returns: A pandas DataFrame from the JSON string + """ + dataframe = pandas.read_json(data) + dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) + + return dataframe diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py index 82fc12ff9..bd36c7cf3 100644 --- a/freqtrade/plugins/pairlist/ExternalPairList.py +++ b/freqtrade/plugins/pairlist/ExternalPairList.py @@ -81,11 +81,10 @@ class ExternalPairList(IPairList): seen = set(self._leader_pairs) for pair in pairlist: if pair in seen: - logger.debug(f"Encountered already existing pair {pair}") continue self._leader_pairs.append(pair) - if not self._has_data.is_set(): + if not self._has_data.is_set() and len(self._leader_pairs) > 0: self._has_data.set() def gen_pairlist(self, tickers: Dict) -> List[str]: diff --git a/freqtrade/rpc/replicate/__init__.py b/freqtrade/rpc/replicate/__init__.py index fd718197e..5cc2ae6a9 100644 --- a/freqtrade/rpc/replicate/__init__.py +++ b/freqtrade/rpc/replicate/__init__.py @@ -5,6 +5,7 @@ import asyncio import logging import secrets import socket +import traceback from threading import Event, Thread from typing import Any, Coroutine, Dict, Union @@ -17,6 +18,7 @@ from freqtrade.enums import LeaderMessageType, ReplicateModeType, RPCMessageType from freqtrade.rpc import RPC, RPCHandler from freqtrade.rpc.replicate.channel import ChannelManager from freqtrade.rpc.replicate.thread_queue import Queue as ThreadedQueue +from freqtrade.rpc.replicate.types import MessageType from freqtrade.rpc.replicate.utils import is_websocket_alive @@ -79,11 +81,11 @@ class ReplicateController(RPCHandler): self.mode = ReplicateModeType[self.replicate_config.get('mode', 'leader').lower()] self.leaders_list = self.replicate_config.get('leaders', []) - self.push_throttle_secs = self.replicate_config.get('push_throttle_secs', 1) + self.push_throttle_secs = self.replicate_config.get('push_throttle_secs', 0.1) self.reply_timeout = self.replicate_config.get('follower_reply_timeout', 10) self.ping_timeout = self.replicate_config.get('follower_ping_timeout', 2) - self.sleep_time = self.replicate_config.get('follower_sleep_time', 1) + self.sleep_time = self.replicate_config.get('follower_sleep_time', 5) if self.mode == ReplicateModeType.follower and len(self.leaders_list) == 0: raise ValueError("You must specify at least 1 leader in follower mode.") @@ -143,6 +145,8 @@ class ReplicateController(RPCHandler): except asyncio.CancelledError: pass + except Exception: + pass finally: self._loop.stop() @@ -170,22 +174,19 @@ class ReplicateController(RPCHandler): self._thread.join() - def send_msg(self, msg: Dict[str, Any]) -> None: + def send_msg(self, msg: MessageType) -> None: """ Support RPC calls """ if msg["type"] == RPCMessageType.EMIT_DATA: - self.send_message( - { - "data_type": msg.get("data_type"), - "data": msg.get("data") - } - ) + message = msg.get("message") + if message: + self.send_message(message) + else: + logger.error(f"Message is empty! {msg}") - def send_message(self, msg: Dict[str, Any]) -> None: - """ Push message through """ - - # We should probably do some type of schema validation here + def send_message(self, msg: MessageType) -> None: + """ Broadcast message over all channels if there are any """ if self.channel_manager.has_channels(): self._send_message(msg) @@ -193,12 +194,11 @@ class ReplicateController(RPCHandler): logger.debug("No listening followers, skipping...") pass - def _send_message(self, msg: Dict[Any, Any]): + def _send_message(self, msg: MessageType): """ Add data to the internal queue to be broadcasted. This func will block if the queue is full. This is meant to be called in the main thread. """ - if self._queue: queue = self._queue.sync_q queue.put(msg) # This will block if the queue is full @@ -226,7 +226,6 @@ class ReplicateController(RPCHandler): This starts all of the leader coros and registers the endpoint on the ApiServer """ - logger.info("Running rpc.replicate in Leader mode") logger.info("-" * 15) logger.info(f"API_KEY: {self.secret_api_key}") @@ -253,16 +252,17 @@ class ReplicateController(RPCHandler): # Get data from queue data = await async_queue.get() - logger.info(f"Found data - broadcasting: {data}") - # Broadcast it to everyone await self.channel_manager.broadcast(data) # Sleep await asyncio.sleep(self.push_throttle_secs) + except asyncio.CancelledError: # Silently stop pass + except Exception as e: + logger.exception(e) async def get_api_token( self, @@ -285,7 +285,6 @@ class ReplicateController(RPCHandler): :param path: The endpoint path """ - if not self.api_server: raise RuntimeError("The leader needs the ApiServer to be active") @@ -312,10 +311,13 @@ class ReplicateController(RPCHandler): # we may not have to send initial data at all. Further testing # required. + await self.send_initial_data(channel) + # Keep connection open until explicitly closed, and sleep try: while not channel.is_closed(): - await channel.recv() + request = await channel.recv() + logger.info(f"Follower request - {request}") except WebSocketDisconnect: # Handle client disconnects @@ -332,6 +334,17 @@ class ReplicateController(RPCHandler): logger.error(f"Failed to serve - {websocket.client}") await self.channel_manager.on_disconnect(websocket) + async def send_initial_data(self, channel): + logger.info("Sending initial data through channel") + + # We first send pairlist data + initial_data = { + "data_type": LeaderMessageType.pairlist, + "data": self.freqtrade.pairlists.whitelist + } + + await channel.send(initial_data) + # -------------------------------FOLLOWER LOGIC---------------------------- async def follower_loop(self): @@ -340,18 +353,27 @@ class ReplicateController(RPCHandler): This starts all of the follower connection coros """ - logger.info("Starting rpc.replicate in Follower mode") - try: - results = await self._connect_to_leaders() - except Exception as e: - logger.error("Exception occurred in Follower loop: ") - logger.exception(e) - finally: - for result in results: - if isinstance(result, Exception): - logger.debug(f"Exception in Follower loop: {result}") + responses = await self._connect_to_leaders() + + # Eventually add the ability to send requests to the Leader + # await self._send_requests() + + for result in responses: + if isinstance(result, Exception): + logger.debug(f"Exception in Follower loop: {result}") + traceback_message = ''.join(traceback.format_tb(result.__traceback__)) + logger.error(traceback_message) + + async def _handle_leader_message(self, message: MessageType): + """ + Handle message received from a Leader + """ + type = message.get("data_type") + data = message.get("data") + + self._rpc._handle_emitted_data(type, data) async def _connect_to_leaders(self): """ @@ -375,7 +397,6 @@ class ReplicateController(RPCHandler): """ try: url, token = leader["url"], leader["token"] - websocket_url = f"{url}?token={token}" logger.info(f"Attempting to connect to Leader at: {url}") @@ -384,6 +405,7 @@ class ReplicateController(RPCHandler): try: async with websockets.connect(websocket_url) as ws: channel = await self.channel_manager.on_connect(ws) + logger.info(f"Connection to Leader at {url} successful") while True: try: data = await asyncio.wait_for( @@ -420,13 +442,3 @@ class ReplicateController(RPCHandler): except asyncio.CancelledError: pass - - async def _handle_leader_message(self, message: Dict[str, Any]): - type = message.get('data_type') - data = message.get('data') - - logger.info(f"Received message from Leader: {type} - {data}") - - if type == LeaderMessageType.pairlist: - # Add the data to the ExternalPairlist - self.freqtrade.pairlists._pairlist_handlers[0].add_pairlist_data(data) diff --git a/freqtrade/rpc/replicate/channel.py b/freqtrade/rpc/replicate/channel.py index 7aa316ff5..62ed3e025 100644 --- a/freqtrade/rpc/replicate/channel.py +++ b/freqtrade/rpc/replicate/channel.py @@ -2,7 +2,7 @@ import logging from typing import Type from freqtrade.rpc.replicate.proxy import WebSocketProxy -from freqtrade.rpc.replicate.serializer import JSONWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.replicate.serializer import MsgPackWebSocketSerializer, WebSocketSerializer from freqtrade.rpc.replicate.types import WebSocketType @@ -17,7 +17,7 @@ class WebSocketChannel: def __init__( self, websocket: WebSocketType, - serializer_cls: Type[WebSocketSerializer] = JSONWebSocketSerializer + serializer_cls: Type[WebSocketSerializer] = MsgPackWebSocketSerializer ): # The WebSocket object self._websocket = WebSocketProxy(websocket) @@ -34,6 +34,7 @@ class WebSocketChannel: """ Send data on the wrapped websocket """ + # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") await self._wrapped_ws.send(data) async def recv(self): @@ -116,6 +117,17 @@ class ChannelManager: # Handle cannot send after close cases await self.on_disconnect(websocket) + async def send_direct(self, channel, data): + """ + Send data directly through direct_channel only + + :param direct_channel: The WebSocketChannel object to send data through + :param data: The data to send + """ + # We iterate over the channels to get reference to the websocket object + # so we can disconnect incase of failure + await channel.send(data) + def has_channels(self): """ Flag for more than 0 channels diff --git a/freqtrade/rpc/replicate/serializer.py b/freqtrade/rpc/replicate/serializer.py index 717458f09..98bdc8934 100644 --- a/freqtrade/rpc/replicate/serializer.py +++ b/freqtrade/rpc/replicate/serializer.py @@ -1,9 +1,16 @@ import json +import logging from abc import ABC, abstractmethod +import msgpack +import orjson + from freqtrade.rpc.replicate.proxy import WebSocketProxy +logger = logging.getLogger(__name__) + + class WebSocketSerializer(ABC): def __init__(self, websocket: WebSocketProxy): self._websocket: WebSocketProxy = websocket @@ -34,9 +41,25 @@ class WebSocketSerializer(ABC): class JSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data): - # json expects string not bytes return json.dumps(data) def _deserialize(self, data): - # The WebSocketSerializer gives bytes not string return json.loads(data) + + +class ORJSONWebSocketSerializer(WebSocketSerializer): + ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY + + def _serialize(self, data): + return orjson.dumps(data, option=self.ORJSON_OPTIONS) + + def _deserialize(self, data): + return orjson.loads(data, option=self.ORJSON_OPTIONS) + + +class MsgPackWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data): + return msgpack.packb(data, use_bin_type=True) + + def _deserialize(self, data): + return msgpack.unpackb(data, raw=False) diff --git a/freqtrade/rpc/replicate/types.py b/freqtrade/rpc/replicate/types.py index 763147196..814fe6649 100644 --- a/freqtrade/rpc/replicate/types.py +++ b/freqtrade/rpc/replicate/types.py @@ -1,7 +1,8 @@ -from typing import TypeVar +from typing import Any, Dict, TypeVar from fastapi import WebSocket as FastAPIWebSocket from websockets import WebSocketClientProtocol as WebSocket WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) +MessageType = Dict[str, Any] diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index ed7f13a96..2c7b2ec72 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,12 +19,12 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_max_drawdown -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, - TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, LeaderMessageType, + SignalDirection, State, TradingMode) from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import decimals_per_coin, shorten_date +from freqtrade.misc import decimals_per_coin, json_to_dataframe, shorten_date from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -1089,3 +1089,36 @@ class RPC: 'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), 'last_process_ts': int(last_p.timestamp()), } + + def _handle_emitted_data(self, type, data): + """ + Handles the emitted data from the Leaders + + :param type: The data_type of the data + :param data: The data + """ + logger.debug(f"Handling emitted data of type ({type})") + + if type == LeaderMessageType.pairlist: + pairlist = data + + logger.debug(pairlist) + + # Add the pairlist data to the ExternalPairList object + external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] + external_pairlist.add_pairlist_data(pairlist) + + elif type == LeaderMessageType.analyzed_df: + # Convert the dataframe back from json + key, value = data["key"], data["value"] + + pair, timeframe, candle_type = key + dataframe = json_to_dataframe(value) + + dataprovider = self._freqtrade.dataprovider + + logger.debug(f"Received analyzed dataframe for {pair}") + logger.debug(dataframe.tail()) + + # Add the dataframe to the dataprovider + dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 8eaec21ea..3d561cc8e 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -20,6 +20,7 @@ class RPCManager: def __init__(self, freqtrade) -> None: """ Initializes all enabled rpc modules """ self.registered_modules: List[RPCHandler] = [] + self._freqtrade = freqtrade self._rpc = RPC(freqtrade) config = freqtrade.config # Enable telegram @@ -82,7 +83,8 @@ class RPCManager: 'status': 'stopping bot' } """ - logger.info('Sending rpc message: %s', msg) + if msg.get("type") != RPCMessageType.EMIT_DATA: + logger.info('Sending rpc message: %s', msg) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) @@ -141,3 +143,12 @@ class RPCManager: 'type': RPCMessageType.STARTUP, 'status': f'Using Protections: \n{prots}' }) + + def emit_data(self, data: Dict[str, Any]): + """ + Send a message via RPC with type RPCMessageType.EMIT_DATA + """ + self.send_msg({ + "type": RPCMessageType.EMIT_DATA, + "message": data + }) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 79dbd4c69..ddd10dd8e 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -5,7 +5,7 @@ This module defines the interface to apply for strategies import logging from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union import arrow from pandas import DataFrame @@ -18,6 +18,7 @@ from freqtrade.enums.runmode import RunMode from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds from freqtrade.persistence import Order, PairLocks, Trade +from freqtrade.rpc.replicate import ReplicateController from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -110,6 +111,7 @@ class IStrategy(ABC, HyperStrategyMixin): # the dataprovider (dp) (access to other candles, historic data, ...) # and wallets - access to the current balance. dp: DataProvider + replicate_controller: Optional[ReplicateController] wallets: Optional[Wallets] = None # Filled from configuration stake_currency: str @@ -123,6 +125,7 @@ class IStrategy(ABC, HyperStrategyMixin): self.config = config # Dict to determine if analysis is necessary self._last_candle_seen_per_pair: Dict[str, datetime] = {} + self._last_candle_seen_external: Dict[str, datetime] = {} super().__init__(config) # Gather informative pairs from @informative-decorated methods. @@ -678,7 +681,12 @@ class IStrategy(ABC, HyperStrategyMixin): lock_time = timeframe_to_next_date(self.timeframe, candle_date) return PairLocks.is_pair_locked(pair, lock_time, side=side) - def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def analyze_ticker( + self, + dataframe: DataFrame, + metadata: dict, + populate_indicators: bool = True + ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame add several TA indicators and entry order signal to it @@ -687,12 +695,19 @@ class IStrategy(ABC, HyperStrategyMixin): :return: DataFrame of candle (OHLCV) data with indicator data and signals added """ logger.debug("TA Analysis Launched") - dataframe = self.advise_indicators(dataframe, metadata) + if populate_indicators: + dataframe = self.advise_indicators(dataframe, metadata) dataframe = self.advise_entry(dataframe, metadata) dataframe = self.advise_exit(dataframe, metadata) return dataframe - def _analyze_ticker_internal(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def _analyze_ticker_internal( + self, + dataframe: DataFrame, + metadata: dict, + external_data: bool = False, + finish_callback: Optional[Callable] = None, + ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame add several TA indicators and buy signal to it @@ -707,12 +722,19 @@ class IStrategy(ABC, HyperStrategyMixin): # always run if process_only_new_candles is set to false if (not self.process_only_new_candles or self._last_candle_seen_per_pair.get(pair, None) != dataframe.iloc[-1]['date']): + + populate_indicators = not external_data # Defs that only make change on new candle data. - dataframe = self.analyze_ticker(dataframe, metadata) + dataframe = self.analyze_ticker(dataframe, metadata, populate_indicators) + self._last_candle_seen_per_pair[pair] = dataframe.iloc[-1]['date'] - self.dp._set_cached_df( - pair, self.timeframe, dataframe, - candle_type=self.config.get('candle_type_def', CandleType.SPOT)) + + candle_type = self.config.get('candle_type_def', CandleType.SPOT) + self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) + + if finish_callback: + finish_callback(pair, dataframe, self.timeframe, candle_type) + else: logger.debug("Skipping TA Analysis for already analyzed candle") dataframe[SignalType.ENTER_LONG.value] = 0 @@ -726,16 +748,25 @@ class IStrategy(ABC, HyperStrategyMixin): return dataframe - def analyze_pair(self, pair: str) -> None: + def analyze_pair( + self, + pair: str, + external_data: bool = False, + finish_callback: Optional[Callable] = None, + ) -> None: """ Fetch data for this pair from dataprovider and analyze. Stores the dataframe into the dataprovider. The analyzed dataframe is then accessible via `dp.get_analyzed_dataframe()`. :param pair: Pair to analyze. """ - dataframe = self.dp.ohlcv( - pair, self.timeframe, candle_type=self.config.get('candle_type_def', CandleType.SPOT) - ) + candle_type = self.config.get('candle_type_def', CandleType.SPOT) + + if not external_data: + dataframe = self.dp.ohlcv(pair, self.timeframe, candle_type) + else: + dataframe, last_analyzed = self.dp.get_external_df(pair, self.timeframe, candle_type) + if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning('Empty candle (OHLCV) data for pair %s', pair) return @@ -745,7 +776,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = strategy_safe_wrapper( self._analyze_ticker_internal, message="" - )(dataframe, {'pair': pair}) + )(dataframe, {'pair': pair}, external_data, finish_callback) self.assert_df(dataframe, df_len, df_close, df_date) except StrategyError as error: @@ -756,15 +787,43 @@ class IStrategy(ABC, HyperStrategyMixin): logger.warning('Empty dataframe for pair %s', pair) return - def analyze(self, pairs: List[str]) -> None: + def analyze( + self, + pairs: List[str], + finish_callback: Optional[Callable] = None + ) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze """ for pair in pairs: + self.analyze_pair(pair, finish_callback=finish_callback) + + def analyze_external(self, pairs: List[str], leader_pairs: List[str]) -> None: + """ + Analyze the pre-populated dataframes from the Leader + + :param pairs: The active pair whitelist + :param leader_pairs: The list of pairs from the Leaders + """ + + # Get the extra pairs not listed in Leader pairs, and process + # them normally. + # List order is not preserved when doing this! + # We use ^ instead of - for symmetric difference + # What do we do with these? + extra_pairs = list(set(pairs) ^ set(leader_pairs)) + # These would be the pairs that we have trades in, which means + # we would have to analyze them normally + + for pair in leader_pairs: + # Analyze the pairs, but get the dataframe from the external data + self.analyze_pair(pair, external_data=True) + + for pair in extra_pairs: self.analyze_pair(pair) - @staticmethod + @ staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: """ keep some data for dataframes """ return len(dataframe), dataframe["close"].iloc[-1], dataframe["date"].iloc[-1] @@ -1185,6 +1244,9 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = _create_and_merge_informative_pair( self, dataframe, metadata, inf_data, populate_fn) + # If in follower mode, get analyzed dataframe from leader df's in dp + # otherise run populate_indicators + return self.populate_indicators(dataframe, metadata) def advise_entry(self, dataframe: DataFrame, metadata: dict) -> DataFrame: diff --git a/requirements-replicate.txt b/requirements-replicate.txt index 7ee351d9d..2c994ea2f 100644 --- a/requirements-replicate.txt +++ b/requirements-replicate.txt @@ -3,3 +3,4 @@ # Required for follower websockets +msgpack From 4fa01548f6c6ac49e63b7a4960ede268c0bf3aab Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 21 Aug 2022 22:49:42 -0600 Subject: [PATCH 005/199] Remove old var from strategy interface --- freqtrade/strategy/interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index ddd10dd8e..1084838ec 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -125,7 +125,6 @@ class IStrategy(ABC, HyperStrategyMixin): self.config = config # Dict to determine if analysis is necessary self._last_candle_seen_per_pair: Dict[str, datetime] = {} - self._last_candle_seen_external: Dict[str, datetime] = {} super().__init__(config) # Gather informative pairs from @informative-decorated methods. From 592373f096ce80c458673b92715b82b09e6b57eb Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 24 Aug 2022 18:30:30 -0600 Subject: [PATCH 006/199] Remove pairlist waiting, add .db files to .gitignore --- .gitignore | 1 + freqtrade/plugins/pairlist/ExternalPairList.py | 10 ---------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index df2121990..015c0a8d9 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,4 @@ target/ !config_examples/config_freqai.example.json *-config.json +*.db* diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py index bd36c7cf3..40e3f9a7f 100644 --- a/freqtrade/plugins/pairlist/ExternalPairList.py +++ b/freqtrade/plugins/pairlist/ExternalPairList.py @@ -4,7 +4,6 @@ External Pair List provider Provides pair list from Leader data """ import logging -from threading import Event from typing import Any, Dict, List from freqtrade.plugins.pairlist.IPairList import IPairList @@ -37,7 +36,6 @@ class ExternalPairList(IPairList): self._num_assets = self._pairlistconfig.get('number_assets') self._leader_pairs: List[str] = [] - self._has_data = Event() def _clamped_pairlist(self): """ @@ -84,20 +82,12 @@ class ExternalPairList(IPairList): continue self._leader_pairs.append(pair) - if not self._has_data.is_set() and len(self._leader_pairs) > 0: - self._has_data.set() - def gen_pairlist(self, tickers: Dict) -> List[str]: """ Generate the pairlist :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: List of pairs """ - if not self._has_data.is_set(): - logger.info("Waiting on pairlists from Leaders...") - self._has_data.wait() - logger.info("Pairlist data received...") - return self._clamped_pairlist() def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: From d474111a65a07c3133d7e2502be648b362fb72ce Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 24 Aug 2022 22:42:29 -0600 Subject: [PATCH 007/199] Renamed to external signals, controller class refactored --- freqtrade/constants.py | 8 +- freqtrade/data/dataprovider.py | 33 +- freqtrade/enums/__init__.py | 2 +- .../enums/{replicate.py => externalsignal.py} | 2 +- freqtrade/freqtradebot.py | 19 +- .../plugins/pairlist/ExternalPairList.py | 4 +- freqtrade/rpc/external_signal/__init__.py | 5 + .../{replicate => external_signal}/channel.py | 6 +- .../controller.py} | 288 +++++++++--------- .../{replicate => external_signal}/proxy.py | 2 +- .../serializer.py | 2 +- .../thread_queue.py | 0 .../{replicate => external_signal}/types.py | 0 .../{replicate => external_signal}/utils.py | 0 freqtrade/rpc/rpc.py | 10 +- freqtrade/rpc/rpc_manager.py | 12 +- freqtrade/strategy/interface.py | 4 +- 17 files changed, 203 insertions(+), 194 deletions(-) rename freqtrade/enums/{replicate.py => externalsignal.py} (80%) create mode 100644 freqtrade/rpc/external_signal/__init__.py rename freqtrade/rpc/{replicate => external_signal}/channel.py (94%) rename freqtrade/rpc/{replicate/__init__.py => external_signal/controller.py} (74%) rename freqtrade/rpc/{replicate => external_signal}/proxy.py (96%) rename freqtrade/rpc/{replicate => external_signal}/serializer.py (96%) rename freqtrade/rpc/{replicate => external_signal}/thread_queue.py (100%) rename freqtrade/rpc/{replicate => external_signal}/types.py (100%) rename freqtrade/rpc/{replicate => external_signal}/utils.py (100%) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 55363cca1..ad0758e22 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -245,7 +245,7 @@ CONF_SCHEMA = { 'exchange': {'$ref': '#/definitions/exchange'}, 'edge': {'$ref': '#/definitions/edge'}, 'freqai': {'$ref': '#/definitions/freqai'}, - 'replicate': {'$ref': '#/definitions/replicate'}, + 'external_signal': {'$ref': '#/definitions/external_signal'}, 'experimental': { 'type': 'object', 'properties': { @@ -487,7 +487,7 @@ CONF_SCHEMA = { }, 'required': ['process_throttle_secs', 'allowed_risk'] }, - 'replicate': { + 'external_signal': { 'type': 'object', 'properties': { 'enabled': {'type': 'boolean', 'default': False}, @@ -495,14 +495,14 @@ CONF_SCHEMA = { 'type': 'string', 'enum': FOLLOWER_MODE_OPTIONS }, - 'api_key': {'type': 'string', 'default': ''}, + 'api_token': {'type': 'string', 'default': ''}, 'leaders': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'url': {'type': 'string', 'default': ''}, - 'token': {'type': 'string', 'default': ''}, + 'api_token': {'type': 'string', 'default': ''}, } } }, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 3de73bb0d..036005c84 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -29,8 +29,7 @@ MAX_DATAFRAME_CANDLES = 1000 class DataProvider: - def __init__(self, config: dict, exchange: Optional[Exchange], - pairlists=None, replicate_controller=None) -> None: + def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists @@ -99,25 +98,33 @@ class DataProvider: self, pair: str, timeframe: str, - candle_type: CandleType + candle_type: CandleType, + wait: bool = True ) -> DataFrame: """ - If the pair exists in __external_pairs_df, return it. If it doesn't, - create a new threading Event in __external_pairs_event and wait on it. + If the pair exists in __external_pairs_df, return it. + If it doesn't, and wait is False, then return an empty df with the columns filled. + If it doesn't, and wait is True (default) create a new threading Event + in __external_pairs_event and wait on it. """ pair_key = (pair, timeframe, candle_type) + if pair_key not in self.__external_pairs_df: - pair_event = Event() - self.__external_pairs_event[pair] = pair_event + if wait: + pair_event = Event() + self.__external_pairs_event[pair] = pair_event - logger.debug(f"Waiting on Leader data for: {pair_key}") - self.__external_pairs_event[pair].wait() + logger.debug(f"Waiting on Leader data for: {pair_key}") + self.__external_pairs_event[pair].wait(timeout=5) - if pair_key in self.__external_pairs_df: - return self.__external_pairs_df[pair_key] + if pair_key not in self.__external_pairs_df: + # Return empty dataframe but with expected columns merged and filled with NaN + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + else: + # Return empty dataframe but with expected columns merged and filled with NaN + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) - # Because of the waiting mechanism, this should never return - return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + return self.__external_pairs_df[pair_key] def add_pairlisthandler(self, pairlists) -> None: """ diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index e1057208a..913ef82dd 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -3,9 +3,9 @@ from freqtrade.enums.backteststate import BacktestState from freqtrade.enums.candletype import CandleType from freqtrade.enums.exitchecktuple import ExitCheckTuple from freqtrade.enums.exittype import ExitType +from freqtrade.enums.externalsignal import ExternalSignalModeType, LeaderMessageType from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues -from freqtrade.enums.replicate import LeaderMessageType, ReplicateModeType from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.signaltype import SignalDirection, SignalTagType, SignalType diff --git a/freqtrade/enums/replicate.py b/freqtrade/enums/externalsignal.py similarity index 80% rename from freqtrade/enums/replicate.py rename to freqtrade/enums/externalsignal.py index 8d036f0b9..4695a4eab 100644 --- a/freqtrade/enums/replicate.py +++ b/freqtrade/enums/externalsignal.py @@ -1,7 +1,7 @@ from enum import Enum -class ReplicateModeType(str, Enum): +class ExternalSignalModeType(str, Enum): leader = "leader" follower = "follower" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 3b850dd4e..9704b7e08 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -75,7 +75,7 @@ class FreqtradeBot(LoggingMixin): PairLocks.timeframe = self.config['timeframe'] - self.replicate_controller = None + self.external_signal_controller = None self.pairlists = PairListManager(self.exchange, self.config) @@ -93,9 +93,6 @@ class FreqtradeBot(LoggingMixin): # Attach Wallets to strategy instance self.strategy.wallets = self.wallets - # Attach ReplicateController to the strategy - # self.strategy.replicate_controller = self.replicate_controller - # Initializing Edge only if enabled self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None @@ -197,8 +194,8 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - if self.replicate_controller: - if not self.replicate_controller.is_leader(): + if self.external_signal_controller: + if not self.external_signal_controller.is_leader(): # Run Follower mode analyzing leader_pairs = self.pairlists._whitelist self.strategy.analyze_external(self.active_pair_whitelist, leader_pairs) @@ -281,16 +278,14 @@ class FreqtradeBot(LoggingMixin): self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist - # If replicate leader, broadcast whitelist data - # Should we broadcast before trade pairs are added? What if - # the follower doesn't have trades with those pairs. They would be added for - # no reason. + # If external signal leader, broadcast whitelist data + # Should we broadcast before trade pairs are added? # Or should this class be made available to the PairListManager and ran # when filter_pairlist is called? - if self.replicate_controller: - if self.replicate_controller.is_leader(): + if self.external_signal_controller: + if self.external_signal_controller.is_leader(): self.rpc.emit_data({ "data_type": LeaderMessageType.pairlist, "data": _whitelist diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py index 40e3f9a7f..27a328060 100644 --- a/freqtrade/plugins/pairlist/ExternalPairList.py +++ b/freqtrade/plugins/pairlist/ExternalPairList.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) class ExternalPairList(IPairList): """ - PairList plugin for use with replicate follower mode. + PairList plugin for use with external signal follower mode. Will use pairs given from leader data. Usage: @@ -67,6 +67,8 @@ class ExternalPairList(IPairList): def add_pairlist_data(self, pairlist: List[str]): """ Add pairs from Leader + + :param pairlist: List of pairs """ # If some pairs were removed on Leader, remove them here diff --git a/freqtrade/rpc/external_signal/__init__.py b/freqtrade/rpc/external_signal/__init__.py new file mode 100644 index 000000000..c1b05b3f0 --- /dev/null +++ b/freqtrade/rpc/external_signal/__init__.py @@ -0,0 +1,5 @@ +# flake8: noqa: F401 +from freqtrade.rpc.external_signal.controller import ExternalSignalController + + +__all__ = ('ExternalSignalController') diff --git a/freqtrade/rpc/replicate/channel.py b/freqtrade/rpc/external_signal/channel.py similarity index 94% rename from freqtrade/rpc/replicate/channel.py rename to freqtrade/rpc/external_signal/channel.py index 62ed3e025..585b6bae5 100644 --- a/freqtrade/rpc/replicate/channel.py +++ b/freqtrade/rpc/external_signal/channel.py @@ -1,9 +1,9 @@ import logging from typing import Type -from freqtrade.rpc.replicate.proxy import WebSocketProxy -from freqtrade.rpc.replicate.serializer import MsgPackWebSocketSerializer, WebSocketSerializer -from freqtrade.rpc.replicate.types import WebSocketType +from freqtrade.rpc.external_signal.proxy import WebSocketProxy +from freqtrade.rpc.external_signal.serializer import MsgPackWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.external_signal.types import WebSocketType logger = logging.getLogger(__name__) diff --git a/freqtrade/rpc/replicate/__init__.py b/freqtrade/rpc/external_signal/controller.py similarity index 74% rename from freqtrade/rpc/replicate/__init__.py rename to freqtrade/rpc/external_signal/controller.py index 5cc2ae6a9..af91a67b7 100644 --- a/freqtrade/rpc/replicate/__init__.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -5,8 +5,7 @@ import asyncio import logging import secrets import socket -import traceback -from threading import Event, Thread +from threading import Thread from typing import Any, Coroutine, Dict, Union import websockets @@ -14,18 +13,18 @@ from fastapi import Depends from fastapi import WebSocket as FastAPIWebSocket from fastapi import WebSocketDisconnect, status -from freqtrade.enums import LeaderMessageType, ReplicateModeType, RPCMessageType +from freqtrade.enums import ExternalSignalModeType, LeaderMessageType, RPCMessageType from freqtrade.rpc import RPC, RPCHandler -from freqtrade.rpc.replicate.channel import ChannelManager -from freqtrade.rpc.replicate.thread_queue import Queue as ThreadedQueue -from freqtrade.rpc.replicate.types import MessageType -from freqtrade.rpc.replicate.utils import is_websocket_alive +from freqtrade.rpc.external_signal.channel import ChannelManager +from freqtrade.rpc.external_signal.thread_queue import Queue as ThreadedQueue +from freqtrade.rpc.external_signal.types import MessageType +from freqtrade.rpc.external_signal.utils import is_websocket_alive logger = logging.getLogger(__name__) -class ReplicateController(RPCHandler): +class ExternalSignalController(RPCHandler): """ This class handles all websocket communication """ def __init__( @@ -35,9 +34,10 @@ class ReplicateController(RPCHandler): api_server: Union[Any, None] = None ) -> None: """ - Init the ReplicateRPC class, and init the super class RPCHandler + Init the ExternalSignalController class, and init the super class RPCHandler :param rpc: instance of RPC Helper class :param config: Configuration object + :param api_server: The ApiServer object :return: None """ super().__init__(rpc, config) @@ -46,48 +46,50 @@ class ReplicateController(RPCHandler): self.api_server = api_server if not self.api_server: - raise RuntimeError("The API server must be enabled for replicate to work") + raise RuntimeError("The API server must be enabled for external signals to work") self._loop = None self._running = False self._thread = None self._queue = None - self._stop_event = Event() - self._follower_tasks = None + self._main_task = None + self._sub_tasks = None self.channel_manager = ChannelManager() - self.replicate_config = config.get('replicate', {}) + self.external_signal_config = config.get('external_signal', {}) # What the config should look like - # "replicate": { + # "external_signal": { # "enabled": true, # "mode": "follower", # "leaders": [ # { - # "url": "ws://localhost:8080/replicate/ws", - # "token": "test" + # "url": "ws://localhost:8080/signals/ws", + # "api_token": "test" # } # ] # } - # "replicate": { + # "external_signal": { # "enabled": true, # "mode": "leader", - # "api_key": "test" + # "api_token": "test" # } - self.mode = ReplicateModeType[self.replicate_config.get('mode', 'leader').lower()] + self.mode = ExternalSignalModeType[ + self.external_signal_config.get('mode', 'leader').lower() + ] - self.leaders_list = self.replicate_config.get('leaders', []) - self.push_throttle_secs = self.replicate_config.get('push_throttle_secs', 0.1) + self.leaders_list = self.external_signal_config.get('leaders', []) + self.push_throttle_secs = self.external_signal_config.get('push_throttle_secs', 0.1) - self.reply_timeout = self.replicate_config.get('follower_reply_timeout', 10) - self.ping_timeout = self.replicate_config.get('follower_ping_timeout', 2) - self.sleep_time = self.replicate_config.get('follower_sleep_time', 5) + self.reply_timeout = self.external_signal_config.get('follower_reply_timeout', 10) + self.ping_timeout = self.external_signal_config.get('follower_ping_timeout', 2) + self.sleep_time = self.external_signal_config.get('follower_sleep_time', 5) - if self.mode == ReplicateModeType.follower and len(self.leaders_list) == 0: + if self.mode == ExternalSignalModeType.follower and len(self.leaders_list) == 0: raise ValueError("You must specify at least 1 leader in follower mode.") # This is only used by the leader, the followers use the tokens specified @@ -95,12 +97,23 @@ class ReplicateController(RPCHandler): # If you do not specify an API key in the config, one will be randomly # generated and logged on startup default_api_key = secrets.token_urlsafe(16) - self.secret_api_key = self.replicate_config.get('api_key', default_api_key) + self.secret_api_key = self.external_signal_config.get('api_token', default_api_key) self.start_threaded_loop() - self.start() + def is_leader(self): + """ + Leader flag + """ + return self.enabled() and self.mode == ExternalSignalModeType.leader + + def enabled(self): + """ + Enabled flag + """ + return self.external_signal_config.get('enabled', False) + def start_threaded_loop(self): """ Start the main internal loop in another thread to run coroutines @@ -125,36 +138,29 @@ class ReplicateController(RPCHandler): raise RuntimeError("Loop must be started before any function can" " be submitted") - try: - return asyncio.run_coroutine_threadsafe(coroutine, self._loop) - except Exception as e: - logger.error(f"Error running coroutine - {str(e)}") - return None - - async def main_loop(self): - """ - Main loop coro - - Start the loop based on what mode we're in - """ - try: - if self.mode == ReplicateModeType.leader: - await self.leader_loop() - elif self.mode == ReplicateModeType.follower: - await self.follower_loop() - - except asyncio.CancelledError: - pass - except Exception: - pass - finally: - self._loop.stop() + return asyncio.run_coroutine_threadsafe(coroutine, self._loop) def start(self): """ Start the controller main loop """ - self.submit_coroutine(self.main_loop()) + self._main_task = self.submit_coroutine(self.main()) + + async def shutdown(self): + """ + Shutdown all tasks and close up + """ + logger.info("Stopping rpc.externalsignalcontroller") + + # Flip running flag + self._running = False + + # Cancel sub tasks + for task in self._sub_tasks: + task.cancel() + + # Then disconnect all channels + await self.channel_manager.disconnect_all() def cleanup(self) -> None: """ @@ -162,18 +168,44 @@ class ReplicateController(RPCHandler): """ if self._thread: if self._loop.is_running(): - - self._running = False - - # Tell all coroutines submitted to the loop they're cancelled - pending = asyncio.all_tasks(loop=self._loop) - for task in pending: - task.cancel() - - self._loop.call_soon_threadsafe(self.channel_manager.disconnect_all) - + self._main_task.cancel() self._thread.join() + async def main(self): + """ + Main coro + + Start the loop based on what mode we're in + """ + try: + if self.mode == ExternalSignalModeType.leader: + logger.info("Starting rpc.externalsignalcontroller in Leader mode") + + await self.run_leader_mode() + elif self.mode == ExternalSignalModeType.follower: + logger.info("Starting rpc.externalsignalcontroller in Follower mode") + + await self.run_follower_mode() + + except asyncio.CancelledError: + # We're cancelled + await self.shutdown() + except Exception as e: + # Log the error + logger.error(f"Exception occurred in main task: {e}") + logger.exception(e) + finally: + # This coroutine is the last thing to be ended, so it should stop the loop + self._loop.stop() + + def log_api_token(self): + """ + Log the API token + """ + logger.info("-" * 15) + logger.info(f"API_KEY: {self.secret_api_key}") + logger.info("-" * 15) + def send_msg(self, msg: MessageType) -> None: """ Support RPC calls @@ -186,7 +218,9 @@ class ReplicateController(RPCHandler): logger.error(f"Message is empty! {msg}") def send_message(self, msg: MessageType) -> None: - """ Broadcast message over all channels if there are any """ + """ + Broadcast message over all channels if there are any + """ if self.channel_manager.has_channels(): self._send_message(msg) @@ -205,39 +239,60 @@ class ReplicateController(RPCHandler): else: logger.warning("Can not send data, leader loop has not started yet!") - def is_leader(self): - """ - Leader flag - """ - return self.enabled() and self.mode == ReplicateModeType.leader + async def send_initial_data(self, channel): + logger.info("Sending initial data through channel") - def enabled(self): - """ - Enabled flag - """ - return self.replicate_config.get('enabled', False) + # We first send pairlist data + # We should move this to a func in the RPC object + initial_data = { + "data_type": LeaderMessageType.pairlist, + "data": self.freqtrade.pairlists.whitelist + } - # ----------------------- LEADER LOGIC ------------------------------ + await channel.send(initial_data) - async def leader_loop(self): + async def _handle_leader_message(self, message: MessageType): + """ + Handle message received from a Leader + """ + type = message.get("data_type") + data = message.get("data") + + self._rpc._handle_emitted_data(type, data) + + # ---------------------------------------------------------------------- + + async def run_leader_mode(self): """ Main leader coroutine This starts all of the leader coros and registers the endpoint on the ApiServer """ - logger.info("Running rpc.replicate in Leader mode") - logger.info("-" * 15) - logger.info(f"API_KEY: {self.secret_api_key}") - logger.info("-" * 15) - self.register_leader_endpoint() + self.log_api_token() - try: - await self._broadcast_queue_data() - except Exception as e: - logger.error("Exception occurred in Leader loop: ") - logger.exception(e) + self._sub_tasks = [ + self._loop.create_task(self._broadcast_queue_data()) + ] + + return await asyncio.gather(*self._sub_tasks) + + async def run_follower_mode(self): + """ + Main follower coroutine + + This starts all of the follower connection coros + """ + + rpc_lock = asyncio.Lock() + + self._sub_tasks = [ + self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) + for leader in self.leaders_list + ] + + return await asyncio.gather(*self._sub_tasks) async def _broadcast_queue_data(self): """ @@ -261,8 +316,6 @@ class ReplicateController(RPCHandler): except asyncio.CancelledError: # Silently stop pass - except Exception as e: - logger.exception(e) async def get_api_token( self, @@ -279,7 +332,7 @@ class ReplicateController(RPCHandler): logger.info("Denying websocket request...") await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - def register_leader_endpoint(self, path: str = "/replicate/ws"): + def register_leader_endpoint(self, path: str = "/signals/ws"): """ Attach and start the main leader loop to the ApiServer @@ -334,73 +387,16 @@ class ReplicateController(RPCHandler): logger.error(f"Failed to serve - {websocket.client}") await self.channel_manager.on_disconnect(websocket) - async def send_initial_data(self, channel): - logger.info("Sending initial data through channel") - - # We first send pairlist data - initial_data = { - "data_type": LeaderMessageType.pairlist, - "data": self.freqtrade.pairlists.whitelist - } - - await channel.send(initial_data) - - # -------------------------------FOLLOWER LOGIC---------------------------- - - async def follower_loop(self): - """ - Main follower coroutine - - This starts all of the follower connection coros - """ - logger.info("Starting rpc.replicate in Follower mode") - - responses = await self._connect_to_leaders() - - # Eventually add the ability to send requests to the Leader - # await self._send_requests() - - for result in responses: - if isinstance(result, Exception): - logger.debug(f"Exception in Follower loop: {result}") - traceback_message = ''.join(traceback.format_tb(result.__traceback__)) - logger.error(traceback_message) - - async def _handle_leader_message(self, message: MessageType): - """ - Handle message received from a Leader - """ - type = message.get("data_type") - data = message.get("data") - - self._rpc._handle_emitted_data(type, data) - - async def _connect_to_leaders(self): - """ - For each leader in `self.leaders_list` create a connection and - listen for data. - """ - rpc_lock = asyncio.Lock() - - logger.info("Starting connections to Leaders...") - - self.follower_tasks = [ - self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) - for leader in self.leaders_list - ] - return await asyncio.gather(*self.follower_tasks, return_exceptions=True) - async def _handle_leader_connection(self, leader, lock): """ Given a leader, connect and wait on data. If connection is lost, it will attempt to reconnect. """ try: - url, token = leader["url"], leader["token"] + url, token = leader["url"], leader["api_token"] websocket_url = f"{url}?token={token}" logger.info(f"Attempting to connect to Leader at: {url}") - # TODO: limit the amount of connection retries while True: try: async with websockets.connect(websocket_url) as ws: diff --git a/freqtrade/rpc/replicate/proxy.py b/freqtrade/rpc/external_signal/proxy.py similarity index 96% rename from freqtrade/rpc/replicate/proxy.py rename to freqtrade/rpc/external_signal/proxy.py index aae536b6d..36ff4a74e 100644 --- a/freqtrade/rpc/replicate/proxy.py +++ b/freqtrade/rpc/external_signal/proxy.py @@ -3,7 +3,7 @@ from typing import Union from fastapi import WebSocket as FastAPIWebSocket from websockets import WebSocketClientProtocol as WebSocket -from freqtrade.rpc.replicate.types import WebSocketType +from freqtrade.rpc.external_signal.types import WebSocketType class WebSocketProxy: diff --git a/freqtrade/rpc/replicate/serializer.py b/freqtrade/rpc/external_signal/serializer.py similarity index 96% rename from freqtrade/rpc/replicate/serializer.py rename to freqtrade/rpc/external_signal/serializer.py index 98bdc8934..2a0f53037 100644 --- a/freqtrade/rpc/replicate/serializer.py +++ b/freqtrade/rpc/external_signal/serializer.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import msgpack import orjson -from freqtrade.rpc.replicate.proxy import WebSocketProxy +from freqtrade.rpc.external_signal.proxy import WebSocketProxy logger = logging.getLogger(__name__) diff --git a/freqtrade/rpc/replicate/thread_queue.py b/freqtrade/rpc/external_signal/thread_queue.py similarity index 100% rename from freqtrade/rpc/replicate/thread_queue.py rename to freqtrade/rpc/external_signal/thread_queue.py diff --git a/freqtrade/rpc/replicate/types.py b/freqtrade/rpc/external_signal/types.py similarity index 100% rename from freqtrade/rpc/replicate/types.py rename to freqtrade/rpc/external_signal/types.py diff --git a/freqtrade/rpc/replicate/utils.py b/freqtrade/rpc/external_signal/utils.py similarity index 100% rename from freqtrade/rpc/replicate/utils.py rename to freqtrade/rpc/external_signal/utils.py diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 2c7b2ec72..68871a15a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1109,16 +1109,22 @@ class RPC: external_pairlist.add_pairlist_data(pairlist) elif type == LeaderMessageType.analyzed_df: + # Convert the dataframe back from json key, value = data["key"], data["value"] pair, timeframe, candle_type = key - dataframe = json_to_dataframe(value) - dataprovider = self._freqtrade.dataprovider + # Skip any pairs that we don't have in the pairlist? + # leader_pairlist = self._freqtrade.pairlists._whitelist + # if pair not in leader_pairlist: + # return + + dataframe = json_to_dataframe(value) logger.debug(f"Received analyzed dataframe for {pair}") logger.debug(dataframe.tail()) # Add the dataframe to the dataprovider + dataprovider = self._freqtrade.dataprovider dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3d561cc8e..0a0e285a4 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -54,14 +54,14 @@ class RPCManager: # Enable Replicate mode # For this to be enabled, the API server must also be enabled - if config.get('replicate', {}).get('enabled', False): - logger.info('Enabling rpc.replicate') - from freqtrade.rpc.replicate import ReplicateController - replicate_rpc = ReplicateController(self._rpc, config, apiserver) - self.registered_modules.append(replicate_rpc) + if config.get('external_signal', {}).get('enabled', False): + logger.info('Enabling RPC.ExternalSignalController') + from freqtrade.rpc.external_signal import ExternalSignalController + external_signal_rpc = ExternalSignalController(self._rpc, config, apiserver) + self.registered_modules.append(external_signal_rpc) # Attach the controller to FreqTrade - freqtrade.replicate_controller = replicate_rpc + freqtrade.external_signal_controller = external_signal_rpc apiserver.start_api() diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 1084838ec..22a10b4d3 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -18,7 +18,6 @@ from freqtrade.enums.runmode import RunMode from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds from freqtrade.persistence import Order, PairLocks, Trade -from freqtrade.rpc.replicate import ReplicateController from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -111,7 +110,6 @@ class IStrategy(ABC, HyperStrategyMixin): # the dataprovider (dp) (access to other candles, historic data, ...) # and wallets - access to the current balance. dp: DataProvider - replicate_controller: Optional[ReplicateController] wallets: Optional[Wallets] = None # Filled from configuration stake_currency: str @@ -764,7 +762,7 @@ class IStrategy(ABC, HyperStrategyMixin): if not external_data: dataframe = self.dp.ohlcv(pair, self.timeframe, candle_type) else: - dataframe, last_analyzed = self.dp.get_external_df(pair, self.timeframe, candle_type) + dataframe, _ = self.dp.get_external_df(pair, self.timeframe, candle_type) if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning('Empty candle (OHLCV) data for pair %s', pair) From 3e786a9b8b791f06c1cf24e7af738ea29f23dc75 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 24 Aug 2022 22:44:22 -0600 Subject: [PATCH 008/199] added example configs --- .gitignore | 2 + config_examples/config_follower.example.json | 85 +++++++++++++++++ config_examples/config_leader.example.json | 97 ++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 config_examples/config_follower.example.json create mode 100644 config_examples/config_leader.example.json diff --git a/.gitignore b/.gitignore index 015c0a8d9..b8c4c3846 100644 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,8 @@ target/ !config_examples/config_full.example.json !config_examples/config_kraken.example.json !config_examples/config_freqai.example.json +!config_examples/config_leader.example.json +!config_examples/config_follower.example.json *-config.json *.db* diff --git a/config_examples/config_follower.example.json b/config_examples/config_follower.example.json new file mode 100644 index 000000000..5317c8df2 --- /dev/null +++ b/config_examples/config_follower.example.json @@ -0,0 +1,85 @@ + +{ + "db_url": "sqlite:///follower.db", + "strategy": "SampleStrategy", + "max_open_trades": 3, + "stake_currency": "USDT", + "stake_amount": 100, + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "cancel_open_orders_on_exit": false, + "trading_mode": "spot", + "margin_mode": "", + "unfilledtimeout": { + "entry": 10, + "exit": 10, + "exit_timeout_count": 0, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing":{ + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "kucoin", + "key": "", + "secret": "", + "password": "", + "ccxt_config": {}, + "ccxt_async_config": {}, + "pair_whitelist": [ + ], + "pair_blacklist": [ + ] + }, + "pairlists": [ + { + "method": "ExternalPairList", + "number_assets": 5, + } + ], + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8081, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "fcc24d31d6581ad2c90c3fc438c8a8b2ccce1393126959934568707f0bd2d647", + "CORS_origins": [], + "username": "freqtrader", + "password": "testing123" + }, + "external_signal": { + "enabled": true, + "mode": "follower", + "leaders": [ + { + "url": "ws://localhost:8080/signals/ws", + "api_token": "testtoken" + } + ] + }, + "bot_name": "freqtrade", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5, + } +} diff --git a/config_examples/config_leader.example.json b/config_examples/config_leader.example.json new file mode 100644 index 000000000..5103fdbd4 --- /dev/null +++ b/config_examples/config_leader.example.json @@ -0,0 +1,97 @@ + +{ + "db_url": "sqlite:///leader.db", + "strategy": "SampleStrategy", + "max_open_trades": 3, + "stake_currency": "USDT", + "stake_amount": 100, + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "USD", + "dry_run": true, + "cancel_open_orders_on_exit": false, + "trading_mode": "spot", + "margin_mode": "", + "unfilledtimeout": { + "entry": 10, + "exit": 10, + "exit_timeout_count": 0, + "unit": "minutes" + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing":{ + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "exchange": { + "name": "kucoin", + "key": "", + "secret": "", + "password": "", + "ccxt_config": {}, + "ccxt_async_config": {}, + "pair_whitelist": [ + ], + "pair_blacklist": [ + ] + }, + "pairlists": [ + { + "method": "VolumePairList", + "number_assets": 20, + "sort_key": "quoteVolume", + "min_value": 0, + "refresh_period": 1800 + } + ], + "edge": { + "enabled": false, + "process_throttle_secs": 3600, + "calculate_since_number_of_days": 7, + "allowed_risk": 0.01, + "stoploss_range_min": -0.01, + "stoploss_range_max": -0.1, + "stoploss_range_step": -0.01, + "minimum_winrate": 0.60, + "minimum_expectancy": 0.20, + "min_trade_number": 10, + "max_trade_duration_minute": 1440, + "remove_pumps": false + }, + "telegram": { + "enabled": false, + "token": "", + "chat_id": "" + }, + "api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "fcc24d31d6581ad2c90c3fc438c8a8b2ccce1393126959934568707f0bd2d647", + "CORS_origins": [], + "username": "freqtrader", + "password": "testing123" + }, + "external_signal": { + "enabled": true, + "mode": "leader", + "api_token": "testtoken", + }, + "bot_name": "freqtrade", + "initial_state": "running", + "force_entry_enable": false, + "internals": { + "process_throttle_secs": 5, + } +} From a998d6d7735a37b37259e988bf2ed17152891338 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 26 Aug 2022 14:52:15 -0600 Subject: [PATCH 009/199] fix tests --- tests/rpc/test_rpc_apiserver.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 9aa965da2..9a7bdfef6 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -52,6 +52,7 @@ def botclient(default_conf, mocker): try: apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(rpc) + apiserver.start_api() yield ftbot, TestClient(apiserver.app) # Cleanup ... ? finally: @@ -332,6 +333,7 @@ def test_api_run(default_conf, mocker, caplog): apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.start_api() assert server_mock.call_count == 1 assert apiserver._config == default_conf apiserver.start_api() @@ -406,6 +408,7 @@ def test_api_cleanup(default_conf, mocker, caplog): apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.start_api() apiserver.cleanup() assert apiserver._server.cleanup.call_count == 1 From 2b5f0678772bea0abaf4abe93efc55de43ea3e0e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 26 Aug 2022 23:40:13 -0600 Subject: [PATCH 010/199] Refactoring, minor improvements, data provider improvements --- freqtrade/constants.py | 6 + freqtrade/data/dataprovider.py | 106 ++++++++++++------ freqtrade/enums/__init__.py | 2 +- freqtrade/enums/externalsignal.py | 7 ++ freqtrade/freqtradebot.py | 3 - freqtrade/misc.py | 17 +++ freqtrade/rpc/api_server/webserver.py | 1 + freqtrade/rpc/external_signal/channel.py | 40 ++++--- freqtrade/rpc/external_signal/controller.py | 34 ++++-- freqtrade/rpc/external_signal/utils.py | 12 ++ freqtrade/rpc/rpc.py | 72 ++++++++---- freqtrade/rpc/rpc_manager.py | 13 +-- ...te.txt => requirements-externalsignals.txt | 1 + tests/rpc/test_rpc_apiserver.py | 2 - 14 files changed, 218 insertions(+), 98 deletions(-) rename requirements-replicate.txt => requirements-externalsignals.txt (94%) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index ad0758e22..b1f189093 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -62,6 +62,7 @@ TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] FOLLOWER_MODE_OPTIONS = ['follower', 'leader'] +WAIT_DATA_POLICY_OPTIONS = ['none', 'first', 'all'] ENV_VAR_PREFIX = 'FREQTRADE__' @@ -509,6 +510,11 @@ CONF_SCHEMA = { 'follower_reply_timeout': {'type': 'integer'}, 'follower_sleep_time': {'type': 'integer'}, 'follower_ping_timeout': {'type': 'integer'}, + 'wait_data_policy': { + 'type': 'string', + 'enum': WAIT_DATA_POLICY_OPTIONS + }, + 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False} }, 'required': ['mode'] }, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 036005c84..cd70db9a3 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -15,7 +15,7 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history -from freqtrade.enums import CandleType, RunMode +from freqtrade.enums import CandleType, RunMode, WaitDataPolicy from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange, timeframe_to_seconds from freqtrade.util import PeriodicCache @@ -29,7 +29,12 @@ MAX_DATAFRAME_CANDLES = 1000 class DataProvider: - def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None: + def __init__( + self, + config: dict, + exchange: Optional[Exchange], + pairlists=None + ) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists @@ -37,12 +42,18 @@ class DataProvider: self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} self.__external_pairs_df: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} - self.__external_pairs_event: Dict[str, Event] = {} + self.__external_pairs_event: Dict[PairWithTimeframe, Tuple[int, Event]] = {} self._msg_queue: deque = deque() self.__msg_cache = PeriodicCache( maxsize=1000, ttl=timeframe_to_seconds(self._config.get('timeframe', '1h'))) + self._num_sources = len(self._config.get('external_signal', {}).get('leader_list', [])) + self._wait_data_policy = self._config.get('external_signal', {}).get( + 'wait_data_policy', WaitDataPolicy.all) + self._wait_data_timeout = self._config.get( + 'external_signal', {}).get('wait_data_timeout', 5) + def _set_dataframe_max_index(self, limit_index: int): """ Limit analyzed dataframe to max specified index. @@ -75,57 +86,88 @@ class DataProvider: pair: str, timeframe: str, dataframe: DataFrame, - candle_type: CandleType + candle_type: CandleType, ) -> None: """ - Add the DataFrame to the __external_pairs_df. If a pair event exists, - set it to release the main thread from waiting. + Add the pair data to this class from an external source. + + :param pair: pair to get the data for + :param timeframe: Timeframe to get data for + :param candle_type: Any of the enum CandleType (must match trading mode!) """ pair_key = (pair, timeframe, candle_type) - # Delete stale data - if pair_key in self.__external_pairs_df: - del self.__external_pairs_df[pair_key] - + # For multiple leaders, if the data already exists, we'd merge self.__external_pairs_df[pair_key] = (dataframe, datetime.now(timezone.utc)) - - pair_event = self.__external_pairs_event.get(pair) - if pair_event: - logger.debug(f"Leader data for pair {pair_key} has been added") - pair_event.set() + self._set_data_event(pair_key) def get_external_df( self, pair: str, timeframe: str, - candle_type: CandleType, - wait: bool = True + candle_type: CandleType ) -> DataFrame: """ - If the pair exists in __external_pairs_df, return it. - If it doesn't, and wait is False, then return an empty df with the columns filled. - If it doesn't, and wait is True (default) create a new threading Event - in __external_pairs_event and wait on it. + Get the pair data from the external sources. Will wait if the policy is + set to, and data is not available. + + :param pair: pair to get the data for + :param timeframe: Timeframe to get data for + :param candle_type: Any of the enum CandleType (must match trading mode!) """ pair_key = (pair, timeframe, candle_type) if pair_key not in self.__external_pairs_df: - if wait: - pair_event = Event() - self.__external_pairs_event[pair] = pair_event + self._wait_on_data(pair_key) - logger.debug(f"Waiting on Leader data for: {pair_key}") - self.__external_pairs_event[pair].wait(timeout=5) - - if pair_key not in self.__external_pairs_df: - # Return empty dataframe but with expected columns merged and filled with NaN - return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) - else: - # Return empty dataframe but with expected columns merged and filled with NaN + if pair_key not in self.__external_pairs_df: return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) return self.__external_pairs_df[pair_key] + def _set_data_event(self, key: PairWithTimeframe): + """ + Depending on the WaitDataPolicy, if an event exists for this PairWithTimeframe + then set the event to release main thread from waiting. + + :param key: PairWithTimeframe + """ + pair_event = self.__external_pairs_event.get(key) + + if pair_event: + num_concat, event = pair_event + self.__external_pairs_event[key] = (num_concat + 1, event) + + if self._wait_data_policy == WaitDataPolicy.one: + logger.debug("Setting Data as policy is One") + event.set() + elif self._wait_data_policy == WaitDataPolicy.all and num_concat == self._num_sources: + logger.debug("Setting Data as policy is all, and is complete") + event.set() + + del self.__external_pairs_event[key] + + def _wait_on_data(self, key: PairWithTimeframe): + """ + Depending on the WaitDataPolicy, we will create and wait on an event until + set that determines the full amount of data is available + + :param key: PairWithTimeframe + """ + if self._wait_data_policy is not WaitDataPolicy.none: + pair, timeframe, candle_type = key + + pair_event = Event() + self.__external_pairs_event[key] = (0, pair_event) + + timeout = self._wait_data_timeout \ + if self._wait_data_policy is not WaitDataPolicy.all else 0 + + timeout_str = f"for {timeout} seconds" if timeout > 0 else "indefinitely" + logger.debug(f"Waiting for external data on {pair} for {timeout_str}") + + pair_event.wait(timeout=timeout) + def add_pairlisthandler(self, pairlists) -> None: """ Allow adding pairlisthandler after initialization diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index 913ef82dd..ffeb8cc12 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -3,7 +3,7 @@ from freqtrade.enums.backteststate import BacktestState from freqtrade.enums.candletype import CandleType from freqtrade.enums.exitchecktuple import ExitCheckTuple from freqtrade.enums.exittype import ExitType -from freqtrade.enums.externalsignal import ExternalSignalModeType, LeaderMessageType +from freqtrade.enums.externalsignal import ExternalSignalModeType, LeaderMessageType, WaitDataPolicy from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues from freqtrade.enums.rpcmessagetype import RPCMessageType diff --git a/freqtrade/enums/externalsignal.py b/freqtrade/enums/externalsignal.py index 4695a4eab..05dc604a2 100644 --- a/freqtrade/enums/externalsignal.py +++ b/freqtrade/enums/externalsignal.py @@ -7,5 +7,12 @@ class ExternalSignalModeType(str, Enum): class LeaderMessageType(str, Enum): + default = "default" pairlist = "pairlist" analyzed_df = "analyzed_df" + + +class WaitDataPolicy(str, Enum): + none = "none" + one = "one" + all = "all" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 9704b7e08..6aee3d104 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -281,9 +281,6 @@ class FreqtradeBot(LoggingMixin): # If external signal leader, broadcast whitelist data # Should we broadcast before trade pairs are added? - # Or should this class be made available to the PairListManager and ran - # when filter_pairlist is called? - if self.external_signal_controller: if self.external_signal_controller.is_leader(): self.rpc.emit_data({ diff --git a/freqtrade/misc.py b/freqtrade/misc.py index bc644a7ec..ceace4ed8 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -14,6 +14,7 @@ import pandas import rapidjson from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN +from freqtrade.enums.signaltype import SignalTagType, SignalType logger = logging.getLogger(__name__) @@ -271,3 +272,19 @@ def json_to_dataframe(data: str) -> pandas.DataFrame: dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) return dataframe + + +def remove_entry_exit_signals(dataframe: pandas.DataFrame): + """ + Remove Entry and Exit signals from a DataFrame + + :param dataframe: The DataFrame to remove signals from + """ + dataframe[SignalType.ENTER_LONG.value] = 0 + dataframe[SignalType.EXIT_LONG.value] = 0 + dataframe[SignalType.ENTER_SHORT.value] = 0 + dataframe[SignalType.EXIT_SHORT.value] = 0 + dataframe[SignalTagType.ENTER_TAG.value] = None + dataframe[SignalTagType.EXIT_TAG.value] = None + + return dataframe diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index c98fb9fd4..049e7dbc2 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -74,6 +74,7 @@ class ApiServer(RPCHandler): default_response_class=FTJSONResponse, ) self.configure_app(self.app, self._config) + self.start_api() def add_rpc_handler(self, rpc: RPC): """ diff --git a/freqtrade/rpc/external_signal/channel.py b/freqtrade/rpc/external_signal/channel.py index 585b6bae5..4ccb2d864 100644 --- a/freqtrade/rpc/external_signal/channel.py +++ b/freqtrade/rpc/external_signal/channel.py @@ -1,4 +1,5 @@ import logging +from threading import RLock from typing import Type from freqtrade.rpc.external_signal.proxy import WebSocketProxy @@ -63,6 +64,7 @@ class WebSocketChannel: class ChannelManager: def __init__(self): self.channels = dict() + self._lock = RLock() # Re-entrant Lock async def on_connect(self, websocket: WebSocketType): """ @@ -78,7 +80,9 @@ class ChannelManager: return ws_channel = WebSocketChannel(websocket) - self.channels[websocket] = ws_channel + + with self._lock: + self.channels[websocket] = ws_channel return ws_channel @@ -88,21 +92,26 @@ class ChannelManager: :param websocket: The WebSocket objet attached to the Channel """ - if websocket in self.channels.keys(): - channel = self.channels[websocket] + with self._lock: + channel = self.channels.get(websocket) + if channel: + logger.debug(f"Disconnecting channel - {channel}") - logger.debug(f"Disconnecting channel - {channel}") + if not channel.is_closed(): + await channel.close() - if not channel.is_closed(): - await channel.close() - del self.channels[websocket] + del self.channels[websocket] async def disconnect_all(self): """ Disconnect all Channels """ - for websocket in self.channels.keys(): - await self.on_disconnect(websocket) + with self._lock: + for websocket, channel in self.channels.items(): + if not channel.is_closed(): + await channel.close() + + self.channels = dict() async def broadcast(self, data): """ @@ -110,12 +119,13 @@ class ChannelManager: :param data: The data to send """ - for websocket, channel in self.channels.items(): - try: - await channel.send(data) - except RuntimeError: - # Handle cannot send after close cases - await self.on_disconnect(websocket) + with self._lock: + for websocket, channel in self.channels.items(): + try: + await channel.send(data) + except RuntimeError: + # Handle cannot send after close cases + await self.on_disconnect(websocket) async def send_direct(self, channel, data): """ diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py index af91a67b7..01c15fc15 100644 --- a/freqtrade/rpc/external_signal/controller.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -6,7 +6,7 @@ import logging import secrets import socket from threading import Thread -from typing import Any, Coroutine, Dict, Union +from typing import Any, Callable, Coroutine, Dict, Union import websockets from fastapi import Depends @@ -56,8 +56,13 @@ class ExternalSignalController(RPCHandler): self._main_task = None self._sub_tasks = None - self.channel_manager = ChannelManager() + self._message_handlers = { + LeaderMessageType.pairlist: self._rpc._handle_pairlist_message, + LeaderMessageType.analyzed_df: self._rpc._handle_analyzed_df_message, + LeaderMessageType.default: self._rpc._handle_default_message + } + self.channel_manager = ChannelManager() self.external_signal_config = config.get('external_signal', {}) # What the config should look like @@ -89,6 +94,8 @@ class ExternalSignalController(RPCHandler): self.ping_timeout = self.external_signal_config.get('follower_ping_timeout', 2) self.sleep_time = self.external_signal_config.get('follower_sleep_time', 5) + # Validate external_signal_config here? + if self.mode == ExternalSignalModeType.follower and len(self.leaders_list) == 0: raise ValueError("You must specify at least 1 leader in follower mode.") @@ -99,7 +106,6 @@ class ExternalSignalController(RPCHandler): default_api_key = secrets.token_urlsafe(16) self.secret_api_key = self.external_signal_config.get('api_token', default_api_key) - self.start_threaded_loop() self.start() def is_leader(self): @@ -114,6 +120,12 @@ class ExternalSignalController(RPCHandler): """ return self.external_signal_config.get('enabled', False) + def num_leaders(self): + """ + The number of leaders we should be connected to + """ + return len(self.leaders_list) + def start_threaded_loop(self): """ Start the main internal loop in another thread to run coroutines @@ -144,6 +156,7 @@ class ExternalSignalController(RPCHandler): """ Start the controller main loop """ + self.start_threaded_loop() self._main_task = self.submit_coroutine(self.main()) async def shutdown(self): @@ -242,23 +255,20 @@ class ExternalSignalController(RPCHandler): async def send_initial_data(self, channel): logger.info("Sending initial data through channel") - # We first send pairlist data - # We should move this to a func in the RPC object - initial_data = { - "data_type": LeaderMessageType.pairlist, - "data": self.freqtrade.pairlists.whitelist - } + data = self._rpc._initial_leader_data() - await channel.send(initial_data) + for message in data: + await channel.send(message) async def _handle_leader_message(self, message: MessageType): """ Handle message received from a Leader """ - type = message.get("data_type") + type = message.get("data_type", LeaderMessageType.default) data = message.get("data") - self._rpc._handle_emitted_data(type, data) + handler: Callable = self._message_handlers[type] + handler(type, data) # ---------------------------------------------------------------------- diff --git a/freqtrade/rpc/external_signal/utils.py b/freqtrade/rpc/external_signal/utils.py index 7b703810e..e5469dce3 100644 --- a/freqtrade/rpc/external_signal/utils.py +++ b/freqtrade/rpc/external_signal/utils.py @@ -1,5 +1,8 @@ +from pandas import DataFrame from starlette.websockets import WebSocket, WebSocketState +from freqtrade.enums.signaltype import SignalTagType, SignalType + async def is_websocket_alive(ws: WebSocket) -> bool: if ( @@ -8,3 +11,12 @@ async def is_websocket_alive(ws: WebSocket) -> bool: ): return True return False + + +def remove_entry_exit_signals(dataframe: DataFrame): + dataframe[SignalType.ENTER_LONG.value] = 0 + dataframe[SignalType.EXIT_LONG.value] = 0 + dataframe[SignalType.ENTER_SHORT.value] = 0 + dataframe[SignalType.EXIT_SHORT.value] = 0 + dataframe[SignalTagType.ENTER_TAG.value] = None + dataframe[SignalTagType.EXIT_TAG.value] = None diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 68871a15a..82d50f33c 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -24,7 +24,8 @@ from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, LeaderMessage from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import decimals_per_coin, json_to_dataframe, shorten_date +from freqtrade.misc import (decimals_per_coin, json_to_dataframe, remove_entry_exit_signals, + shorten_date) from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -1090,41 +1091,64 @@ class RPC: 'last_process_ts': int(last_p.timestamp()), } - def _handle_emitted_data(self, type, data): + # ------------------------------ EXTERNAL SIGNALS ----------------------- + + def _initial_leader_data(self): + # We create a list of Messages to send to the follower on connect + data = [] + + # Send Pairlist data + data.append({ + "data_type": LeaderMessageType.pairlist, + "data": self._freqtrade.pairlists._whitelist + }) + + return data + + def _handle_pairlist_message(self, type, data): """ - Handles the emitted data from the Leaders + Handles the emitted pairlists from the Leaders :param type: The data_type of the data :param data: The data """ - logger.debug(f"Handling emitted data of type ({type})") + pairlist = data - if type == LeaderMessageType.pairlist: - pairlist = data + logger.debug(f"Handling Pairlist message: {pairlist}") - logger.debug(pairlist) + external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] + external_pairlist.add_pairlist_data(pairlist) - # Add the pairlist data to the ExternalPairList object - external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] - external_pairlist.add_pairlist_data(pairlist) + def _handle_analyzed_df_message(self, type, data): + """ + Handles the analyzed dataframes from the Leaders - elif type == LeaderMessageType.analyzed_df: + :param type: The data_type of the data + :param data: The data + """ + key, value = data["key"], data["value"] + pair, timeframe, candle_type = key - # Convert the dataframe back from json - key, value = data["key"], data["value"] + # Skip any pairs that we don't have in the pairlist? + # leader_pairlist = self._freqtrade.pairlists._whitelist + # if pair not in leader_pairlist: + # return - pair, timeframe, candle_type = key + dataframe = json_to_dataframe(value) - # Skip any pairs that we don't have in the pairlist? - # leader_pairlist = self._freqtrade.pairlists._whitelist - # if pair not in leader_pairlist: - # return + if self._config.get('external_signal', {}).get('remove_signals_analyzed_df', False): + dataframe = remove_entry_exit_signals(dataframe) - dataframe = json_to_dataframe(value) + logger.debug(f"Handling analyzed dataframe for {pair}") + logger.debug(dataframe.tail()) - logger.debug(f"Received analyzed dataframe for {pair}") - logger.debug(dataframe.tail()) + # Add the dataframe to the dataprovider + dataprovider = self._freqtrade.dataprovider + dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) - # Add the dataframe to the dataprovider - dataprovider = self._freqtrade.dataprovider - dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) + def _handle_default_message(self, type, data): + """ + Default leader message handler, just logs it. We should never have to + run this unless the leader sends us some weird message. + """ + logger.debug(f"Received message from Leader of type {type}: {data}") diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 0a0e285a4..11e21da6f 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -45,25 +45,20 @@ class RPCManager: if config.get('api_server', {}).get('enabled', False): logger.info('Enabling rpc.api_server') from freqtrade.rpc.api_server import ApiServer - - # Pass replicate_rpc as param or defer starting api_server - # until we register the replicate rpc enpoint? apiserver = ApiServer(config) apiserver.add_rpc_handler(self._rpc) self.registered_modules.append(apiserver) - # Enable Replicate mode + # Enable External Signals mode # For this to be enabled, the API server must also be enabled if config.get('external_signal', {}).get('enabled', False): logger.info('Enabling RPC.ExternalSignalController') from freqtrade.rpc.external_signal import ExternalSignalController - external_signal_rpc = ExternalSignalController(self._rpc, config, apiserver) - self.registered_modules.append(external_signal_rpc) + external_signals = ExternalSignalController(self._rpc, config, apiserver) + self.registered_modules.append(external_signals) # Attach the controller to FreqTrade - freqtrade.external_signal_controller = external_signal_rpc - - apiserver.start_api() + freqtrade.external_signal_controller = external_signals def cleanup(self) -> None: """ Stops all enabled rpc modules """ diff --git a/requirements-replicate.txt b/requirements-externalsignals.txt similarity index 94% rename from requirements-replicate.txt rename to requirements-externalsignals.txt index 2c994ea2f..7920b34f6 100644 --- a/requirements-replicate.txt +++ b/requirements-externalsignals.txt @@ -4,3 +4,4 @@ # Required for follower websockets msgpack +janus diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 9a7bdfef6..af9f9d248 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -52,7 +52,6 @@ def botclient(default_conf, mocker): try: apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(rpc) - apiserver.start_api() yield ftbot, TestClient(apiserver.app) # Cleanup ... ? finally: @@ -333,7 +332,6 @@ def test_api_run(default_conf, mocker, caplog): apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) - apiserver.start_api() assert server_mock.call_count == 1 assert apiserver._config == default_conf apiserver.start_api() From fcceb744c5b0362464b59c13fb462291f4a977bb Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 26 Aug 2022 23:43:05 -0600 Subject: [PATCH 011/199] Add janus to requirements.txt --- freqtrade/rpc/external_signal/controller.py | 2 +- freqtrade/rpc/external_signal/thread_queue.py | 650 ------------------ 2 files changed, 1 insertion(+), 651 deletions(-) delete mode 100644 freqtrade/rpc/external_signal/thread_queue.py diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py index 01c15fc15..0d43b0b2d 100644 --- a/freqtrade/rpc/external_signal/controller.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -12,11 +12,11 @@ import websockets from fastapi import Depends from fastapi import WebSocket as FastAPIWebSocket from fastapi import WebSocketDisconnect, status +from janus import Queue as ThreadedQueue from freqtrade.enums import ExternalSignalModeType, LeaderMessageType, RPCMessageType from freqtrade.rpc import RPC, RPCHandler from freqtrade.rpc.external_signal.channel import ChannelManager -from freqtrade.rpc.external_signal.thread_queue import Queue as ThreadedQueue from freqtrade.rpc.external_signal.types import MessageType from freqtrade.rpc.external_signal.utils import is_websocket_alive diff --git a/freqtrade/rpc/external_signal/thread_queue.py b/freqtrade/rpc/external_signal/thread_queue.py deleted file mode 100644 index 88321321b..000000000 --- a/freqtrade/rpc/external_signal/thread_queue.py +++ /dev/null @@ -1,650 +0,0 @@ -import asyncio -import sys -import threading -from asyncio import QueueEmpty as AsyncQueueEmpty -from asyncio import QueueFull as AsyncQueueFull -from collections import deque -from heapq import heappop, heappush -from queue import Empty as SyncQueueEmpty -from queue import Full as SyncQueueFull -from typing import Any, Callable, Deque, Generic, List, Optional, Set, TypeVar - -from typing_extensions import Protocol - - -__version__ = "1.0.0" -__all__ = ( - "Queue", - "PriorityQueue", - "LifoQueue", - "SyncQueue", - "AsyncQueue", - "BaseQueue", -) - - -T = TypeVar("T") -OptFloat = Optional[float] - - -class BaseQueue(Protocol[T]): - @property - def maxsize(self) -> int: - ... - - @property - def closed(self) -> bool: - ... - - def task_done(self) -> None: - ... - - def qsize(self) -> int: - ... - - @property - def unfinished_tasks(self) -> int: - ... - - def empty(self) -> bool: - ... - - def full(self) -> bool: - ... - - def put_nowait(self, item: T) -> None: - ... - - def get_nowait(self) -> T: - ... - - -class SyncQueue(BaseQueue[T], Protocol[T]): - @property - def maxsize(self) -> int: - ... - - @property - def closed(self) -> bool: - ... - - def task_done(self) -> None: - ... - - def qsize(self) -> int: - ... - - @property - def unfinished_tasks(self) -> int: - ... - - def empty(self) -> bool: - ... - - def full(self) -> bool: - ... - - def put_nowait(self, item: T) -> None: - ... - - def get_nowait(self) -> T: - ... - - def put(self, item: T, block: bool = True, timeout: OptFloat = None) -> None: - ... - - def get(self, block: bool = True, timeout: OptFloat = None) -> T: - ... - - def join(self) -> None: - ... - - -class AsyncQueue(BaseQueue[T], Protocol[T]): - async def put(self, item: T) -> None: - ... - - async def get(self) -> T: - ... - - async def join(self) -> None: - ... - - -class Queue(Generic[T]): - def __init__(self, maxsize: int = 0) -> None: - self._loop = asyncio.get_running_loop() - self._maxsize = maxsize - - self._init(maxsize) - - self._unfinished_tasks = 0 - - self._sync_mutex = threading.Lock() - self._sync_not_empty = threading.Condition(self._sync_mutex) - self._sync_not_full = threading.Condition(self._sync_mutex) - self._all_tasks_done = threading.Condition(self._sync_mutex) - - self._async_mutex = asyncio.Lock() - if sys.version_info[:3] == (3, 10, 0): - # Workaround for Python 3.10 bug, see #358: - getattr(self._async_mutex, "_get_loop", lambda: None)() - self._async_not_empty = asyncio.Condition(self._async_mutex) - self._async_not_full = asyncio.Condition(self._async_mutex) - self._finished = asyncio.Event() - self._finished.set() - - self._closing = False - self._pending = set() # type: Set[asyncio.Future[Any]] - - def checked_call_soon_threadsafe( - callback: Callable[..., None], *args: Any - ) -> None: - try: - self._loop.call_soon_threadsafe(callback, *args) - except RuntimeError: - # swallowing agreed in #2 - pass - - self._call_soon_threadsafe = checked_call_soon_threadsafe - - def checked_call_soon(callback: Callable[..., None], *args: Any) -> None: - if not self._loop.is_closed(): - self._loop.call_soon(callback, *args) - - self._call_soon = checked_call_soon - - self._sync_queue = _SyncQueueProxy(self) - self._async_queue = _AsyncQueueProxy(self) - - def close(self) -> None: - with self._sync_mutex: - self._closing = True - for fut in self._pending: - fut.cancel() - self._finished.set() # unblocks all async_q.join() - self._all_tasks_done.notify_all() # unblocks all sync_q.join() - - async def wait_closed(self) -> None: - # should be called from loop after close(). - # Nobody should put/get at this point, - # so lock acquiring is not required - if not self._closing: - raise RuntimeError("Waiting for non-closed queue") - # give execution chances for the task-done callbacks - # of async tasks created inside - # _notify_async_not_empty, _notify_async_not_full - # methods. - await asyncio.sleep(0) - if not self._pending: - return - await asyncio.wait(self._pending) - - @property - def closed(self) -> bool: - return self._closing and not self._pending - - @property - def maxsize(self) -> int: - return self._maxsize - - @property - def sync_q(self) -> "_SyncQueueProxy[T]": - return self._sync_queue - - @property - def async_q(self) -> "_AsyncQueueProxy[T]": - return self._async_queue - - # Override these methods to implement other queue organizations - # (e.g. stack or priority queue). - # These will only be called with appropriate locks held - - def _init(self, maxsize: int) -> None: - self._queue = deque() # type: Deque[T] - - def _qsize(self) -> int: - return len(self._queue) - - # Put a new item in the queue - def _put(self, item: T) -> None: - self._queue.append(item) - - # Get an item from the queue - def _get(self) -> T: - return self._queue.popleft() - - def _put_internal(self, item: T) -> None: - self._put(item) - self._unfinished_tasks += 1 - self._finished.clear() - - def _notify_sync_not_empty(self) -> None: - def f() -> None: - with self._sync_mutex: - self._sync_not_empty.notify() - - self._loop.run_in_executor(None, f) - - def _notify_sync_not_full(self) -> None: - def f() -> None: - with self._sync_mutex: - self._sync_not_full.notify() - - fut = asyncio.ensure_future(self._loop.run_in_executor(None, f)) - fut.add_done_callback(self._pending.discard) - self._pending.add(fut) - - def _notify_async_not_empty(self, *, threadsafe: bool) -> None: - async def f() -> None: - async with self._async_mutex: - self._async_not_empty.notify() - - def task_maker() -> None: - task = self._loop.create_task(f()) - task.add_done_callback(self._pending.discard) - self._pending.add(task) - - if threadsafe: - self._call_soon_threadsafe(task_maker) - else: - self._call_soon(task_maker) - - def _notify_async_not_full(self, *, threadsafe: bool) -> None: - async def f() -> None: - async with self._async_mutex: - self._async_not_full.notify() - - def task_maker() -> None: - task = self._loop.create_task(f()) - task.add_done_callback(self._pending.discard) - self._pending.add(task) - - if threadsafe: - self._call_soon_threadsafe(task_maker) - else: - self._call_soon(task_maker) - - def _check_closing(self) -> None: - if self._closing: - raise RuntimeError("Operation on the closed queue is forbidden") - - -class _SyncQueueProxy(SyncQueue[T]): - """Create a queue object with a given maximum size. - - If maxsize is <= 0, the queue size is infinite. - """ - - def __init__(self, parent: Queue[T]): - self._parent = parent - - @property - def maxsize(self) -> int: - return self._parent._maxsize - - @property - def closed(self) -> bool: - return self._parent.closed - - def task_done(self) -> None: - """Indicate that a formerly enqueued task is complete. - - Used by Queue consumer threads. For each get() used to fetch a task, - a subsequent call to task_done() tells the queue that the processing - on the task is complete. - - If a join() is currently blocking, it will resume when all items - have been processed (meaning that a task_done() call was received - for every item that had been put() into the queue). - - Raises a ValueError if called more times than there were items - placed in the queue. - """ - self._parent._check_closing() - with self._parent._all_tasks_done: - unfinished = self._parent._unfinished_tasks - 1 - if unfinished <= 0: - if unfinished < 0: - raise ValueError("task_done() called too many times") - self._parent._all_tasks_done.notify_all() - self._parent._loop.call_soon_threadsafe(self._parent._finished.set) - self._parent._unfinished_tasks = unfinished - - def join(self) -> None: - """Blocks until all items in the Queue have been gotten and processed. - - The count of unfinished tasks goes up whenever an item is added to the - queue. The count goes down whenever a consumer thread calls task_done() - to indicate the item was retrieved and all work on it is complete. - - When the count of unfinished tasks drops to zero, join() unblocks. - """ - self._parent._check_closing() - with self._parent._all_tasks_done: - while self._parent._unfinished_tasks: - self._parent._all_tasks_done.wait() - self._parent._check_closing() - - def qsize(self) -> int: - """Return the approximate size of the queue (not reliable!).""" - return self._parent._qsize() - - @property - def unfinished_tasks(self) -> int: - """Return the number of unfinished tasks.""" - return self._parent._unfinished_tasks - - def empty(self) -> bool: - """Return True if the queue is empty, False otherwise (not reliable!). - - This method is likely to be removed at some point. Use qsize() == 0 - as a direct substitute, but be aware that either approach risks a race - condition where a queue can grow before the result of empty() or - qsize() can be used. - - To create code that needs to wait for all queued tasks to be - completed, the preferred technique is to use the join() method. - """ - return not self._parent._qsize() - - def full(self) -> bool: - """Return True if the queue is full, False otherwise (not reliable!). - - This method is likely to be removed at some point. Use qsize() >= n - as a direct substitute, but be aware that either approach risks a race - condition where a queue can shrink before the result of full() or - qsize() can be used. - """ - return 0 < self._parent._maxsize <= self._parent._qsize() - - def put(self, item: T, block: bool = True, timeout: OptFloat = None) -> None: - """Put an item into the queue. - - If optional args 'block' is true and 'timeout' is None (the default), - block if necessary until a free slot is available. If 'timeout' is - a non-negative number, it blocks at most 'timeout' seconds and raises - the Full exception if no free slot was available within that time. - Otherwise ('block' is false), put an item on the queue if a free slot - is immediately available, else raise the Full exception ('timeout' - is ignored in that case). - """ - self._parent._check_closing() - with self._parent._sync_not_full: - if self._parent._maxsize > 0: - if not block: - if self._parent._qsize() >= self._parent._maxsize: - raise SyncQueueFull - elif timeout is None: - while self._parent._qsize() >= self._parent._maxsize: - self._parent._sync_not_full.wait() - elif timeout < 0: - raise ValueError("'timeout' must be a non-negative number") - else: - time = self._parent._loop.time - endtime = time() + timeout - while self._parent._qsize() >= self._parent._maxsize: - remaining = endtime - time() - if remaining <= 0.0: - raise SyncQueueFull - self._parent._sync_not_full.wait(remaining) - self._parent._put_internal(item) - self._parent._sync_not_empty.notify() - self._parent._notify_async_not_empty(threadsafe=True) - - def get(self, block: bool = True, timeout: OptFloat = None) -> T: - """Remove and return an item from the queue. - - If optional args 'block' is true and 'timeout' is None (the default), - block if necessary until an item is available. If 'timeout' is - a non-negative number, it blocks at most 'timeout' seconds and raises - the Empty exception if no item was available within that time. - Otherwise ('block' is false), return an item if one is immediately - available, else raise the Empty exception ('timeout' is ignored - in that case). - """ - self._parent._check_closing() - with self._parent._sync_not_empty: - if not block: - if not self._parent._qsize(): - raise SyncQueueEmpty - elif timeout is None: - while not self._parent._qsize(): - self._parent._sync_not_empty.wait() - elif timeout < 0: - raise ValueError("'timeout' must be a non-negative number") - else: - time = self._parent._loop.time - endtime = time() + timeout - while not self._parent._qsize(): - remaining = endtime - time() - if remaining <= 0.0: - raise SyncQueueEmpty - self._parent._sync_not_empty.wait(remaining) - item = self._parent._get() - self._parent._sync_not_full.notify() - self._parent._notify_async_not_full(threadsafe=True) - return item - - def put_nowait(self, item: T) -> None: - """Put an item into the queue without blocking. - - Only enqueue the item if a free slot is immediately available. - Otherwise raise the Full exception. - """ - return self.put(item, block=False) - - def get_nowait(self) -> T: - """Remove and return an item from the queue without blocking. - - Only get an item if one is immediately available. Otherwise - raise the Empty exception. - """ - return self.get(block=False) - - -class _AsyncQueueProxy(AsyncQueue[T]): - """Create a queue object with a given maximum size. - - If maxsize is <= 0, the queue size is infinite. - """ - - def __init__(self, parent: Queue[T]): - self._parent = parent - - @property - def closed(self) -> bool: - return self._parent.closed - - def qsize(self) -> int: - """Number of items in the queue.""" - return self._parent._qsize() - - @property - def unfinished_tasks(self) -> int: - """Return the number of unfinished tasks.""" - return self._parent._unfinished_tasks - - @property - def maxsize(self) -> int: - """Number of items allowed in the queue.""" - return self._parent._maxsize - - def empty(self) -> bool: - """Return True if the queue is empty, False otherwise.""" - return self.qsize() == 0 - - def full(self) -> bool: - """Return True if there are maxsize items in the queue. - - Note: if the Queue was initialized with maxsize=0 (the default), - then full() is never True. - """ - if self._parent._maxsize <= 0: - return False - else: - return self.qsize() >= self._parent._maxsize - - async def put(self, item: T) -> None: - """Put an item into the queue. - - Put an item into the queue. If the queue is full, wait until a free - slot is available before adding item. - - This method is a coroutine. - """ - self._parent._check_closing() - async with self._parent._async_not_full: - self._parent._sync_mutex.acquire() - locked = True - try: - if self._parent._maxsize > 0: - do_wait = True - while do_wait: - do_wait = self._parent._qsize() >= self._parent._maxsize - if do_wait: - locked = False - self._parent._sync_mutex.release() - await self._parent._async_not_full.wait() - self._parent._sync_mutex.acquire() - locked = True - - self._parent._put_internal(item) - self._parent._async_not_empty.notify() - self._parent._notify_sync_not_empty() - finally: - if locked: - self._parent._sync_mutex.release() - - def put_nowait(self, item: T) -> None: - """Put an item into the queue without blocking. - - If no free slot is immediately available, raise QueueFull. - """ - self._parent._check_closing() - with self._parent._sync_mutex: - if self._parent._maxsize > 0: - if self._parent._qsize() >= self._parent._maxsize: - raise AsyncQueueFull - - self._parent._put_internal(item) - self._parent._notify_async_not_empty(threadsafe=False) - self._parent._notify_sync_not_empty() - - async def get(self) -> T: - """Remove and return an item from the queue. - - If queue is empty, wait until an item is available. - - This method is a coroutine. - """ - self._parent._check_closing() - async with self._parent._async_not_empty: - self._parent._sync_mutex.acquire() - locked = True - try: - do_wait = True - while do_wait: - do_wait = self._parent._qsize() == 0 - - if do_wait: - locked = False - self._parent._sync_mutex.release() - await self._parent._async_not_empty.wait() - self._parent._sync_mutex.acquire() - locked = True - - item = self._parent._get() - self._parent._async_not_full.notify() - self._parent._notify_sync_not_full() - return item - finally: - if locked: - self._parent._sync_mutex.release() - - def get_nowait(self) -> T: - """Remove and return an item from the queue. - - Return an item if one is immediately available, else raise QueueEmpty. - """ - self._parent._check_closing() - with self._parent._sync_mutex: - if self._parent._qsize() == 0: - raise AsyncQueueEmpty - - item = self._parent._get() - self._parent._notify_async_not_full(threadsafe=False) - self._parent._notify_sync_not_full() - return item - - def task_done(self) -> None: - """Indicate that a formerly enqueued task is complete. - - Used by queue consumers. For each get() used to fetch a task, - a subsequent call to task_done() tells the queue that the processing - on the task is complete. - - If a join() is currently blocking, it will resume when all items have - been processed (meaning that a task_done() call was received for every - item that had been put() into the queue). - - Raises ValueError if called more times than there were items placed in - the queue. - """ - self._parent._check_closing() - with self._parent._all_tasks_done: - if self._parent._unfinished_tasks <= 0: - raise ValueError("task_done() called too many times") - self._parent._unfinished_tasks -= 1 - if self._parent._unfinished_tasks == 0: - self._parent._finished.set() - self._parent._all_tasks_done.notify_all() - - async def join(self) -> None: - """Block until all items in the queue have been gotten and processed. - - The count of unfinished tasks goes up whenever an item is added to the - queue. The count goes down whenever a consumer calls task_done() to - indicate that the item was retrieved and all work on it is complete. - When the count of unfinished tasks drops to zero, join() unblocks. - """ - while True: - with self._parent._sync_mutex: - self._parent._check_closing() - if self._parent._unfinished_tasks == 0: - break - await self._parent._finished.wait() - - -class PriorityQueue(Queue[T]): - """Variant of Queue that retrieves open entries in priority order - (lowest first). - - Entries are typically tuples of the form: (priority number, data). - - """ - - def _init(self, maxsize: int) -> None: - self._heap_queue = [] # type: List[T] - - def _qsize(self) -> int: - return len(self._heap_queue) - - def _put(self, item: T) -> None: - heappush(self._heap_queue, item) - - def _get(self) -> T: - return heappop(self._heap_queue) - - -class LifoQueue(Queue[T]): - """Variant of Queue that retrieves most recently added entries first.""" - - def _qsize(self) -> int: - return len(self._queue) - - def _put(self, item: T) -> None: - self._queue.append(item) - - def _get(self) -> T: - return self._queue.pop() From 05ca673883146a543bfb3d739a1ad05368dd28bc Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 27 Aug 2022 00:06:03 -0600 Subject: [PATCH 012/199] Catch status code errors --- freqtrade/rpc/external_signal/controller.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py index 0d43b0b2d..29a318b53 100644 --- a/freqtrade/rpc/external_signal/controller.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -437,12 +437,12 @@ class ExternalSignalController(RPCHandler): # as we might call the RPC module in the main thread await self._handle_leader_message(data) - except socket.gaierror: - logger.info(f"Socket error - retrying connection in {self.sleep_time}s") + except (socket.gaierror, ConnectionRefusedError): + logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) continue - except ConnectionRefusedError: - logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") + except websockets.exceptions.InvalidStatusCode as e: + logger.error(f"Connection Refused - {e}") await asyncio.sleep(self.sleep_time) continue From 8c4e68b8eb21e6bb0e04e47befb15a9d2ad40913 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 28 Aug 2022 13:00:52 -0600 Subject: [PATCH 013/199] updated example configs --- config_examples/config_follower.example.json | 8 +++++--- freqtrade/rpc/external_signal/controller.py | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/config_examples/config_follower.example.json b/config_examples/config_follower.example.json index 5317c8df2..646310d9a 100644 --- a/config_examples/config_follower.example.json +++ b/config_examples/config_follower.example.json @@ -46,8 +46,8 @@ }, "pairlists": [ { - "method": "ExternalPairList", - "number_assets": 5, + "method": "ExternalPairList", // ExternalPairList is required in follower mode + "number_assets": 5, // We can limit the amount of pairs to use from the leaders } ], "telegram": { @@ -74,7 +74,9 @@ "url": "ws://localhost:8080/signals/ws", "api_token": "testtoken" } - ] + ], + "wait_data_policy": "all", // ['all', 'first', none] defaults to all + "remove_signals_analyzed_df": true, // Remove entry/exit signals from Leader df, Defaults to false }, "bot_name": "freqtrade", "initial_state": "running", diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py index 29a318b53..2b29cde6f 100644 --- a/freqtrade/rpc/external_signal/controller.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -373,7 +373,6 @@ class ExternalSignalController(RPCHandler): # Data is being broadcasted right away as soon as startup, # we may not have to send initial data at all. Further testing # required. - await self.send_initial_data(channel) # Keep connection open until explicitly closed, and sleep @@ -434,7 +433,7 @@ class ExternalSignalController(RPCHandler): async with lock: # Acquire lock so only 1 coro handling at a time - # as we might call the RPC module in the main thread + # as we call the RPC module in the main thread await self._handle_leader_message(data) except (socket.gaierror, ConnectionRefusedError): From 7952e0df25914bef04bc0f8acda729e2b9a685d6 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 29 Aug 2022 13:41:15 -0600 Subject: [PATCH 014/199] initial rework separating server and client impl --- .gitignore | 3 - config_examples/config_follower.example.json | 87 -- config_examples/config_leader.example.json | 97 -- freqtrade/constants.py | 1 + freqtrade/enums/rpcmessagetype.py | 5 +- freqtrade/freqtradebot.py | 41 +- freqtrade/rpc/api_server/api_auth.py | 24 +- freqtrade/rpc/api_server/api_ws.py | 52 ++ freqtrade/rpc/api_server/deps.py | 4 + freqtrade/rpc/api_server/webserver.py | 76 +- freqtrade/rpc/api_server/ws/channel.py | 146 +++ freqtrade/rpc/api_server/ws/proxy.py | 61 ++ freqtrade/rpc/api_server/ws/serializer.py | 65 ++ freqtrade/rpc/api_server/ws/types.py | 8 + freqtrade/rpc/api_server/ws/utils.py | 12 + freqtrade/rpc/external_signal/__init__.py | 10 +- freqtrade/rpc/external_signal/channel.py | 290 +++--- freqtrade/rpc/external_signal/controller.py | 898 +++++++++---------- freqtrade/rpc/external_signal/proxy.py | 122 +-- freqtrade/rpc/external_signal/serializer.py | 130 +-- freqtrade/rpc/external_signal/types.py | 16 +- freqtrade/rpc/external_signal/utils.py | 32 +- freqtrade/rpc/rpc.py | 131 ++- freqtrade/rpc/rpc_manager.py | 28 +- scripts/test_ws_client.py | 58 ++ 25 files changed, 1329 insertions(+), 1068 deletions(-) delete mode 100644 config_examples/config_follower.example.json delete mode 100644 config_examples/config_leader.example.json create mode 100644 freqtrade/rpc/api_server/api_ws.py create mode 100644 freqtrade/rpc/api_server/ws/channel.py create mode 100644 freqtrade/rpc/api_server/ws/proxy.py create mode 100644 freqtrade/rpc/api_server/ws/serializer.py create mode 100644 freqtrade/rpc/api_server/ws/types.py create mode 100644 freqtrade/rpc/api_server/ws/utils.py create mode 100644 scripts/test_ws_client.py diff --git a/.gitignore b/.gitignore index b8c4c3846..6a47a7f81 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,3 @@ target/ !config_examples/config_freqai.example.json !config_examples/config_leader.example.json !config_examples/config_follower.example.json - -*-config.json -*.db* diff --git a/config_examples/config_follower.example.json b/config_examples/config_follower.example.json deleted file mode 100644 index 646310d9a..000000000 --- a/config_examples/config_follower.example.json +++ /dev/null @@ -1,87 +0,0 @@ - -{ - "db_url": "sqlite:///follower.db", - "strategy": "SampleStrategy", - "max_open_trades": 3, - "stake_currency": "USDT", - "stake_amount": 100, - "tradable_balance_ratio": 0.99, - "fiat_display_currency": "USD", - "dry_run": true, - "cancel_open_orders_on_exit": false, - "trading_mode": "spot", - "margin_mode": "", - "unfilledtimeout": { - "entry": 10, - "exit": 10, - "exit_timeout_count": 0, - "unit": "minutes" - }, - "entry_pricing": { - "price_side": "same", - "use_order_book": true, - "order_book_top": 1, - "price_last_balance": 0.0, - "check_depth_of_market": { - "enabled": false, - "bids_to_ask_delta": 1 - } - }, - "exit_pricing":{ - "price_side": "same", - "use_order_book": true, - "order_book_top": 1 - }, - "exchange": { - "name": "kucoin", - "key": "", - "secret": "", - "password": "", - "ccxt_config": {}, - "ccxt_async_config": {}, - "pair_whitelist": [ - ], - "pair_blacklist": [ - ] - }, - "pairlists": [ - { - "method": "ExternalPairList", // ExternalPairList is required in follower mode - "number_assets": 5, // We can limit the amount of pairs to use from the leaders - } - ], - "telegram": { - "enabled": false, - "token": "", - "chat_id": "" - }, - "api_server": { - "enabled": true, - "listen_ip_address": "127.0.0.1", - "listen_port": 8081, - "verbosity": "error", - "enable_openapi": false, - "jwt_secret_key": "fcc24d31d6581ad2c90c3fc438c8a8b2ccce1393126959934568707f0bd2d647", - "CORS_origins": [], - "username": "freqtrader", - "password": "testing123" - }, - "external_signal": { - "enabled": true, - "mode": "follower", - "leaders": [ - { - "url": "ws://localhost:8080/signals/ws", - "api_token": "testtoken" - } - ], - "wait_data_policy": "all", // ['all', 'first', none] defaults to all - "remove_signals_analyzed_df": true, // Remove entry/exit signals from Leader df, Defaults to false - }, - "bot_name": "freqtrade", - "initial_state": "running", - "force_entry_enable": false, - "internals": { - "process_throttle_secs": 5, - } -} diff --git a/config_examples/config_leader.example.json b/config_examples/config_leader.example.json deleted file mode 100644 index 5103fdbd4..000000000 --- a/config_examples/config_leader.example.json +++ /dev/null @@ -1,97 +0,0 @@ - -{ - "db_url": "sqlite:///leader.db", - "strategy": "SampleStrategy", - "max_open_trades": 3, - "stake_currency": "USDT", - "stake_amount": 100, - "tradable_balance_ratio": 0.99, - "fiat_display_currency": "USD", - "dry_run": true, - "cancel_open_orders_on_exit": false, - "trading_mode": "spot", - "margin_mode": "", - "unfilledtimeout": { - "entry": 10, - "exit": 10, - "exit_timeout_count": 0, - "unit": "minutes" - }, - "entry_pricing": { - "price_side": "same", - "use_order_book": true, - "order_book_top": 1, - "price_last_balance": 0.0, - "check_depth_of_market": { - "enabled": false, - "bids_to_ask_delta": 1 - } - }, - "exit_pricing":{ - "price_side": "same", - "use_order_book": true, - "order_book_top": 1 - }, - "exchange": { - "name": "kucoin", - "key": "", - "secret": "", - "password": "", - "ccxt_config": {}, - "ccxt_async_config": {}, - "pair_whitelist": [ - ], - "pair_blacklist": [ - ] - }, - "pairlists": [ - { - "method": "VolumePairList", - "number_assets": 20, - "sort_key": "quoteVolume", - "min_value": 0, - "refresh_period": 1800 - } - ], - "edge": { - "enabled": false, - "process_throttle_secs": 3600, - "calculate_since_number_of_days": 7, - "allowed_risk": 0.01, - "stoploss_range_min": -0.01, - "stoploss_range_max": -0.1, - "stoploss_range_step": -0.01, - "minimum_winrate": 0.60, - "minimum_expectancy": 0.20, - "min_trade_number": 10, - "max_trade_duration_minute": 1440, - "remove_pumps": false - }, - "telegram": { - "enabled": false, - "token": "", - "chat_id": "" - }, - "api_server": { - "enabled": true, - "listen_ip_address": "127.0.0.1", - "listen_port": 8080, - "verbosity": "error", - "enable_openapi": false, - "jwt_secret_key": "fcc24d31d6581ad2c90c3fc438c8a8b2ccce1393126959934568707f0bd2d647", - "CORS_origins": [], - "username": "freqtrader", - "password": "testing123" - }, - "external_signal": { - "enabled": true, - "mode": "leader", - "api_token": "testtoken", - }, - "bot_name": "freqtrade", - "initial_state": "running", - "force_entry_enable": false, - "internals": { - "process_throttle_secs": 5, - } -} diff --git a/freqtrade/constants.py b/freqtrade/constants.py index b1f189093..96f8413b0 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -404,6 +404,7 @@ CONF_SCHEMA = { }, 'username': {'type': 'string'}, 'password': {'type': 'string'}, + 'api_token': {'type': 'string'}, 'jwt_secret_key': {'type': 'string'}, 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index d5b3ce89c..8e4182b33 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -1,7 +1,8 @@ from enum import Enum -class RPCMessageType(Enum): +# We need to inherit from str so we can use as a str +class RPCMessageType(str, Enum): STATUS = 'status' WARNING = 'warning' STARTUP = 'startup' @@ -19,7 +20,7 @@ class RPCMessageType(Enum): STRATEGY_MSG = 'strategy_msg' - EMIT_DATA = 'emit_data' + WHITELIST = 'whitelist' def __repr__(self): return self.value diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 6aee3d104..c9caaace6 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -17,13 +17,13 @@ from freqtrade.constants import BuySell, LongShort from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.enums import (ExitCheckTuple, ExitType, LeaderMessageType, RPCMessageType, RunMode, - SignalDirection, State, TradingMode) +from freqtrade.enums import (ExitCheckTuple, ExitType, RPCMessageType, RunMode, SignalDirection, + State, TradingMode) from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, PricingError) from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange.exchange import timeframe_to_next_date -from freqtrade.misc import dataframe_to_json, safe_value_fallback, safe_value_fallback2 +from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.mixins import LoggingMixin from freqtrade.persistence import Order, PairLocks, Trade, init_db from freqtrade.plugins.pairlistmanager import PairListManager @@ -75,8 +75,6 @@ class FreqtradeBot(LoggingMixin): PairLocks.timeframe = self.config['timeframe'] - self.external_signal_controller = None - self.pairlists = PairListManager(self.exchange, self.config) # RPC runs in separate threads, can start handling external commands just after @@ -194,28 +192,7 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - if self.external_signal_controller: - if not self.external_signal_controller.is_leader(): - # Run Follower mode analyzing - leader_pairs = self.pairlists._whitelist - self.strategy.analyze_external(self.active_pair_whitelist, leader_pairs) - else: - # We are leader, make sure to pass callback func to emit data - def emit_on_finish(pair, dataframe, timeframe, candle_type): - logger.debug(f"Emitting dataframe for {pair}") - return self.rpc.emit_data( - { - "data_type": LeaderMessageType.analyzed_df, - "data": { - "key": (pair, timeframe, candle_type), - "value": dataframe_to_json(dataframe) - } - } - ) - - self.strategy.analyze(self.active_pair_whitelist, finish_callback=emit_on_finish) - else: - self.strategy.analyze(self.active_pair_whitelist) + self.strategy.analyze(self.active_pair_whitelist) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace @@ -278,15 +255,7 @@ class FreqtradeBot(LoggingMixin): self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist - # If external signal leader, broadcast whitelist data - # Should we broadcast before trade pairs are added? - - if self.external_signal_controller: - if self.external_signal_controller.is_leader(): - self.rpc.emit_data({ - "data_type": LeaderMessageType.pairlist, - "data": _whitelist - }) + self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'msg': _whitelist}) # Calculating Edge positioning if self.edge: diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index a39e31b85..fd90918e1 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -1,8 +1,10 @@ +import logging import secrets from datetime import datetime, timedelta +from typing import Any, Dict, Union import jwt -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, WebSocket, status from fastapi.security import OAuth2PasswordBearer from fastapi.security.http import HTTPBasic, HTTPBasicCredentials @@ -10,6 +12,8 @@ from freqtrade.rpc.api_server.api_schemas import AccessAndRefreshToken, AccessTo from freqtrade.rpc.api_server.deps import get_api_config +logger = logging.getLogger(__name__) + ALGORITHM = "HS256" router_login = APIRouter() @@ -44,6 +48,24 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access"): return username +# This should be reimplemented to better realign with the existing tools provided +# by FastAPI regarding API Tokens +async def get_ws_token( + ws: WebSocket, + token: Union[str, None] = None, + api_config: Dict[str, Any] = Depends(get_api_config) +): + secret_ws_token = api_config['ws_token'] + + if token == secret_ws_token: + # Just return the token if it matches + return token + else: + logger.debug("Denying websocket request") + # If it doesn't match, close the websocket connection + await ws.close(code=status.WS_1008_POLICY_VIOLATION) + + def create_token(data: dict, secret_key: str, token_type: str = "access") -> str: to_encode = data.copy() if token_type == "access": diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py new file mode 100644 index 000000000..464ea22b2 --- /dev/null +++ b/freqtrade/rpc/api_server/api_ws.py @@ -0,0 +1,52 @@ +import logging + +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect + +from freqtrade.rpc.api_server.deps import get_channel_manager +from freqtrade.rpc.api_server.ws.utils import is_websocket_alive + + +logger = logging.getLogger(__name__) + +# Private router, protected by API Key authentication +router = APIRouter() + + +@router.websocket("/message/ws") +async def message_endpoint( + ws: WebSocket, + channel_manager=Depends(get_channel_manager) +): + try: + if is_websocket_alive(ws): + logger.info(f"Consumer connected - {ws.client}") + + # TODO: + # Return a channel ID, pass that instead of ws to the rest of the methods + channel = await channel_manager.on_connect(ws) + + # Keep connection open until explicitly closed, and sleep + try: + while not channel.is_closed(): + request = await channel.recv() + + # This is where we'd parse the request. For now this should only + # be a list of topics to subscribe too. List[str] + # Maybe allow the consumer to update the topics subscribed + # during runtime? + logger.info(f"Consumer request - {request}") + + except WebSocketDisconnect: + # Handle client disconnects + logger.info(f"Consumer disconnected - {ws.client}") + await channel_manager.on_disconnect(ws) + except Exception as e: + logger.info(f"Consumer connection failed - {ws.client}") + logger.exception(e) + # Handle cases like - + # RuntimeError('Cannot call "send" once a closed message has been sent') + await channel_manager.on_disconnect(ws) + + except Exception: + logger.error(f"Failed to serve - {ws.client}") + await channel_manager.on_disconnect(ws) diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py index 66654c0b1..360771d77 100644 --- a/freqtrade/rpc/api_server/deps.py +++ b/freqtrade/rpc/api_server/deps.py @@ -41,6 +41,10 @@ def get_exchange(config=Depends(get_config)): return ApiServer._exchange +def get_channel_manager(): + return ApiServer._channel_manager + + def is_webserver_mode(config=Depends(get_config)): if config['runmode'] != RunMode.WEBSERVER: raise RPCException('Bot is not in the correct state') diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 049e7dbc2..94cb8cd45 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -1,15 +1,20 @@ +import asyncio import logging from ipaddress import IPv4Address +from threading import Thread from typing import Any, Dict import orjson import uvicorn from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +# Look into alternatives +from janus import Queue as ThreadedQueue from starlette.responses import JSONResponse from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer +from freqtrade.rpc.api_server.ws.channel import ChannelManager from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler @@ -43,6 +48,10 @@ class ApiServer(RPCHandler): _config: Dict[str, Any] = {} # Exchange - only available in webserver mode. _exchange = None + # websocket message queue stuff + _channel_manager = None + _thread = None + _loop = None def __new__(cls, *args, **kwargs): """ @@ -64,10 +73,15 @@ class ApiServer(RPCHandler): return self._standalone: bool = standalone self._server = None + self._queue = None + self._background_task = None + ApiServer.__initialized = True api_config = self._config['api_server'] + ApiServer._channel_manager = ChannelManager() + self.app = FastAPI(title="Freqtrade API", docs_url='/docs' if api_config.get('enable_openapi', False) else None, redoc_url=None, @@ -95,6 +109,18 @@ class ApiServer(RPCHandler): logger.info("Stopping API Server") self._server.cleanup() + if self._thread and self._loop: + logger.info("Stopping API Server background tasks") + + if self._background_task: + # Cancel the queue task + self._background_task.cancel() + + # Finally stop the loop + self._loop.call_soon_threadsafe(self._loop.stop) + + self._thread.join() + @classmethod def shutdown(cls): cls.__initialized = False @@ -104,7 +130,10 @@ class ApiServer(RPCHandler): cls._rpc = None def send_msg(self, msg: Dict[str, str]) -> None: - pass + if self._queue: + logger.info(f"Adding message to queue: {msg}") + sync_q = self._queue.sync_q + sync_q.put(msg) def handle_rpc_exception(self, request, exc): logger.exception(f"API Error calling: {exc}") @@ -114,10 +143,12 @@ class ApiServer(RPCHandler): ) def configure_app(self, app: FastAPI, config): - from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login + from freqtrade.rpc.api_server.api_auth import (get_ws_token, http_basic_or_jwt_token, + router_login) from freqtrade.rpc.api_server.api_backtest import router as api_backtest from freqtrade.rpc.api_server.api_v1 import router as api_v1 from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public + from freqtrade.rpc.api_server.api_ws import router as ws_router from freqtrade.rpc.api_server.web_ui import router_ui app.include_router(api_v1_public, prefix="/api/v1") @@ -128,6 +159,9 @@ class ApiServer(RPCHandler): app.include_router(api_backtest, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) + app.include_router(ws_router, prefix="/api/v1", + dependencies=[Depends(get_ws_token)] + ) app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') @@ -142,6 +176,43 @@ class ApiServer(RPCHandler): app.add_exception_handler(RPCException, self.handle_rpc_exception) + def start_message_queue(self): + # Create a new loop, as it'll be just for the background thread + self._loop = asyncio.new_event_loop() + + # Start the thread + if not self._thread: + self._thread = Thread(target=self._loop.run_forever) + self._thread.start() + else: + raise RuntimeError("Threaded loop is already running") + + # Finally, submit the coro to the thread + self._background_task = asyncio.run_coroutine_threadsafe( + self._broadcast_queue_data(), loop=self._loop) + + async def _broadcast_queue_data(self): + # Instantiate the queue in this coroutine so it's attached to our loop + self._queue = ThreadedQueue() + async_queue = self._queue.async_q + + try: + while True: + logger.debug("Getting queue data...") + # Get data from queue + data = await async_queue.get() + logger.debug(f"Found data: {data}") + # Broadcast it + await self._channel_manager.broadcast(data) + # Sleep, make this configurable? + await asyncio.sleep(0.1) + except asyncio.CancelledError: + # Silently stop + pass + # For testing, shouldn't happen when stable + except Exception as e: + logger.info(f"Exception happened in background task: {e}") + def start_api(self): """ Start API ... should be run in thread. @@ -179,6 +250,7 @@ class ApiServer(RPCHandler): if self._standalone: self._server.run() else: + self.start_message_queue() self._server.run_in_thread() except Exception: logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py new file mode 100644 index 000000000..486e8657b --- /dev/null +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -0,0 +1,146 @@ +import logging +from threading import RLock +from typing import Type + +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy +from freqtrade.rpc.api_server.ws.serializer import ORJSONWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.api_server.ws.types import WebSocketType + + +logger = logging.getLogger(__name__) + + +class WebSocketChannel: + """ + Object to help facilitate managing a websocket connection + """ + + def __init__( + self, + websocket: WebSocketType, + serializer_cls: Type[WebSocketSerializer] = ORJSONWebSocketSerializer + ): + # The WebSocket object + self._websocket = WebSocketProxy(websocket) + # The Serializing class for the WebSocket object + self._serializer_cls = serializer_cls + + # Internal event to signify a closed websocket + self._closed = False + + # Wrap the WebSocket in the Serializing class + self._wrapped_ws = self._serializer_cls(self._websocket) + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") + await self._wrapped_ws.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + return await self._wrapped_ws.recv() + + async def ping(self): + """ + Ping the websocket + """ + return await self._websocket.ping() + + async def close(self): + """ + Close the WebSocketChannel + """ + + self._closed = True + + def is_closed(self): + return self._closed + + +class ChannelManager: + def __init__(self): + self.channels = dict() + self._lock = RLock() # Re-entrant Lock + + async def on_connect(self, websocket: WebSocketType): + """ + Wrap websocket connection into Channel and add to list + + :param websocket: The WebSocket object to attach to the Channel + """ + if hasattr(websocket, "accept"): + try: + await websocket.accept() + except RuntimeError: + # The connection was closed before we could accept it + return + + ws_channel = WebSocketChannel(websocket) + + with self._lock: + self.channels[websocket] = ws_channel + + return ws_channel + + async def on_disconnect(self, websocket: WebSocketType): + """ + Call close on the channel if it's not, and remove from channel list + + :param websocket: The WebSocket objet attached to the Channel + """ + with self._lock: + channel = self.channels.get(websocket) + if channel: + logger.debug(f"Disconnecting channel - {channel}") + + if not channel.is_closed(): + await channel.close() + + del self.channels[websocket] + + async def disconnect_all(self): + """ + Disconnect all Channels + """ + with self._lock: + for websocket, channel in self.channels.items(): + if not channel.is_closed(): + await channel.close() + + self.channels = dict() + + async def broadcast(self, data): + """ + Broadcast data on all Channels + + :param data: The data to send + """ + with self._lock: + logger.debug(f"Broadcasting data: {data}") + for websocket, channel in self.channels.items(): + try: + await channel.send(data) + except RuntimeError: + # Handle cannot send after close cases + await self.on_disconnect(websocket) + + async def send_direct(self, channel, data): + """ + Send data directly through direct_channel only + + :param direct_channel: The WebSocketChannel object to send data through + :param data: The data to send + """ + # We iterate over the channels to get reference to the websocket object + # so we can disconnect incase of failure + await channel.send(data) + + def has_channels(self): + """ + Flag for more than 0 channels + """ + return len(self.channels) > 0 diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py new file mode 100644 index 000000000..6acc1d363 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -0,0 +1,61 @@ +from typing import Union + +from fastapi import WebSocket as FastAPIWebSocket +from websockets import WebSocketClientProtocol as WebSocket + +from freqtrade.rpc.api_server.ws.types import WebSocketType + + +class WebSocketProxy: + """ + WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol + under the same API + """ + + def __init__(self, websocket: WebSocketType): + self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + if isinstance(data, str): + data = data.encode() + + if hasattr(self._websocket, "send_bytes"): + await self._websocket.send_bytes(data) + else: + await self._websocket.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + if hasattr(self._websocket, "receive_bytes"): + return await self._websocket.receive_bytes() + else: + return await self._websocket.recv() + + async def ping(self): + """ + Ping the websocket, not supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "ping"): + return await self._websocket.ping() + return False + + async def close(self, code: int = 1000): + """ + Close the websocket connection, only supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "close"): + return await self._websocket.close(code) + pass + + async def accept(self): + """ + Accept the WebSocket connection, only support by FastAPI WebSockets + """ + if hasattr(self._websocket, "accept"): + return await self._websocket.accept() + pass diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py new file mode 100644 index 000000000..40cbbfad7 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -0,0 +1,65 @@ +import json +import logging +from abc import ABC, abstractmethod + +import msgpack +import orjson + +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy + + +logger = logging.getLogger(__name__) + + +class WebSocketSerializer(ABC): + def __init__(self, websocket: WebSocketProxy): + self._websocket: WebSocketProxy = websocket + + @abstractmethod + def _serialize(self, data): + raise NotImplementedError() + + @abstractmethod + def _deserialize(self, data): + raise NotImplementedError() + + async def send(self, data: bytes): + await self._websocket.send(self._serialize(data)) + + async def recv(self) -> bytes: + data = await self._websocket.recv() + + return self._deserialize(data) + + async def close(self, code: int = 1000): + await self._websocket.close(code) + +# Going to explore using MsgPack as the serialization, +# as that might be the best method for sending pandas +# dataframes over the wire + + +class JSONWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data): + return json.dumps(data) + + def _deserialize(self, data): + return json.loads(data) + + +class ORJSONWebSocketSerializer(WebSocketSerializer): + ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY + + def _serialize(self, data): + return orjson.dumps(data, option=self.ORJSON_OPTIONS) + + def _deserialize(self, data): + return orjson.loads(data, option=self.ORJSON_OPTIONS) + + +class MsgPackWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data): + return msgpack.packb(data, use_bin_type=True) + + def _deserialize(self, data): + return msgpack.unpackb(data, raw=False) diff --git a/freqtrade/rpc/api_server/ws/types.py b/freqtrade/rpc/api_server/ws/types.py new file mode 100644 index 000000000..814fe6649 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/types.py @@ -0,0 +1,8 @@ +from typing import Any, Dict, TypeVar + +from fastapi import WebSocket as FastAPIWebSocket +from websockets import WebSocketClientProtocol as WebSocket + + +WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) +MessageType = Dict[str, Any] diff --git a/freqtrade/rpc/api_server/ws/utils.py b/freqtrade/rpc/api_server/ws/utils.py new file mode 100644 index 000000000..1ceecab88 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/utils.py @@ -0,0 +1,12 @@ +from fastapi import WebSocket +# fastapi does not make this available through it, so import directly from starlette +from starlette.websockets import WebSocketState + + +async def is_websocket_alive(ws: WebSocket) -> bool: + if ( + ws.application_state == WebSocketState.CONNECTED and + ws.client_state == WebSocketState.CONNECTED + ): + return True + return False diff --git a/freqtrade/rpc/external_signal/__init__.py b/freqtrade/rpc/external_signal/__init__.py index c1b05b3f0..decc51551 100644 --- a/freqtrade/rpc/external_signal/__init__.py +++ b/freqtrade/rpc/external_signal/__init__.py @@ -1,5 +1,5 @@ -# flake8: noqa: F401 -from freqtrade.rpc.external_signal.controller import ExternalSignalController - - -__all__ = ('ExternalSignalController') +# # flake8: noqa: F401 +# from freqtrade.rpc.external_signal.controller import ExternalSignalController +# +# +# __all__ = ('ExternalSignalController') diff --git a/freqtrade/rpc/external_signal/channel.py b/freqtrade/rpc/external_signal/channel.py index 4ccb2d864..5b278dfed 100644 --- a/freqtrade/rpc/external_signal/channel.py +++ b/freqtrade/rpc/external_signal/channel.py @@ -1,145 +1,145 @@ -import logging -from threading import RLock -from typing import Type - -from freqtrade.rpc.external_signal.proxy import WebSocketProxy -from freqtrade.rpc.external_signal.serializer import MsgPackWebSocketSerializer, WebSocketSerializer -from freqtrade.rpc.external_signal.types import WebSocketType - - -logger = logging.getLogger(__name__) - - -class WebSocketChannel: - """ - Object to help facilitate managing a websocket connection - """ - - def __init__( - self, - websocket: WebSocketType, - serializer_cls: Type[WebSocketSerializer] = MsgPackWebSocketSerializer - ): - # The WebSocket object - self._websocket = WebSocketProxy(websocket) - # The Serializing class for the WebSocket object - self._serializer_cls = serializer_cls - - # Internal event to signify a closed websocket - self._closed = False - - # Wrap the WebSocket in the Serializing class - self._wrapped_ws = self._serializer_cls(self._websocket) - - async def send(self, data): - """ - Send data on the wrapped websocket - """ - # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") - await self._wrapped_ws.send(data) - - async def recv(self): - """ - Receive data on the wrapped websocket - """ - return await self._wrapped_ws.recv() - - async def ping(self): - """ - Ping the websocket - """ - return await self._websocket.ping() - - async def close(self): - """ - Close the WebSocketChannel - """ - - self._closed = True - - def is_closed(self): - return self._closed - - -class ChannelManager: - def __init__(self): - self.channels = dict() - self._lock = RLock() # Re-entrant Lock - - async def on_connect(self, websocket: WebSocketType): - """ - Wrap websocket connection into Channel and add to list - - :param websocket: The WebSocket object to attach to the Channel - """ - if hasattr(websocket, "accept"): - try: - await websocket.accept() - except RuntimeError: - # The connection was closed before we could accept it - return - - ws_channel = WebSocketChannel(websocket) - - with self._lock: - self.channels[websocket] = ws_channel - - return ws_channel - - async def on_disconnect(self, websocket: WebSocketType): - """ - Call close on the channel if it's not, and remove from channel list - - :param websocket: The WebSocket objet attached to the Channel - """ - with self._lock: - channel = self.channels.get(websocket) - if channel: - logger.debug(f"Disconnecting channel - {channel}") - - if not channel.is_closed(): - await channel.close() - - del self.channels[websocket] - - async def disconnect_all(self): - """ - Disconnect all Channels - """ - with self._lock: - for websocket, channel in self.channels.items(): - if not channel.is_closed(): - await channel.close() - - self.channels = dict() - - async def broadcast(self, data): - """ - Broadcast data on all Channels - - :param data: The data to send - """ - with self._lock: - for websocket, channel in self.channels.items(): - try: - await channel.send(data) - except RuntimeError: - # Handle cannot send after close cases - await self.on_disconnect(websocket) - - async def send_direct(self, channel, data): - """ - Send data directly through direct_channel only - - :param direct_channel: The WebSocketChannel object to send data through - :param data: The data to send - """ - # We iterate over the channels to get reference to the websocket object - # so we can disconnect incase of failure - await channel.send(data) - - def has_channels(self): - """ - Flag for more than 0 channels - """ - return len(self.channels) > 0 +# import logging +# from threading import RLock +# from typing import Type +# +# from freqtrade.rpc.external_signal.proxy import WebSocketProxy +# from freqtrade.rpc.external_signal.serializer import MsgPackWebSocketSerializer +# from freqtrade.rpc.external_signal.types import WebSocketType +# +# +# logger = logging.getLogger(__name__) +# +# +# class WebSocketChannel: +# """ +# Object to help facilitate managing a websocket connection +# """ +# +# def __init__( +# self, +# websocket: WebSocketType, +# serializer_cls: Type[WebSocketSerializer] = MsgPackWebSocketSerializer +# ): +# # The WebSocket object +# self._websocket = WebSocketProxy(websocket) +# # The Serializing class for the WebSocket object +# self._serializer_cls = serializer_cls +# +# # Internal event to signify a closed websocket +# self._closed = False +# +# # Wrap the WebSocket in the Serializing class +# self._wrapped_ws = self._serializer_cls(self._websocket) +# +# async def send(self, data): +# """ +# Send data on the wrapped websocket +# """ +# # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") +# await self._wrapped_ws.send(data) +# +# async def recv(self): +# """ +# Receive data on the wrapped websocket +# """ +# return await self._wrapped_ws.recv() +# +# async def ping(self): +# """ +# Ping the websocket +# """ +# return await self._websocket.ping() +# +# async def close(self): +# """ +# Close the WebSocketChannel +# """ +# +# self._closed = True +# +# def is_closed(self): +# return self._closed +# +# +# class ChannelManager: +# def __init__(self): +# self.channels = dict() +# self._lock = RLock() # Re-entrant Lock +# +# async def on_connect(self, websocket: WebSocketType): +# """ +# Wrap websocket connection into Channel and add to list +# +# :param websocket: The WebSocket object to attach to the Channel +# """ +# if hasattr(websocket, "accept"): +# try: +# await websocket.accept() +# except RuntimeError: +# # The connection was closed before we could accept it +# return +# +# ws_channel = WebSocketChannel(websocket) +# +# with self._lock: +# self.channels[websocket] = ws_channel +# +# return ws_channel +# +# async def on_disconnect(self, websocket: WebSocketType): +# """ +# Call close on the channel if it's not, and remove from channel list +# +# :param websocket: The WebSocket objet attached to the Channel +# """ +# with self._lock: +# channel = self.channels.get(websocket) +# if channel: +# logger.debug(f"Disconnecting channel - {channel}") +# +# if not channel.is_closed(): +# await channel.close() +# +# del self.channels[websocket] +# +# async def disconnect_all(self): +# """ +# Disconnect all Channels +# """ +# with self._lock: +# for websocket, channel in self.channels.items(): +# if not channel.is_closed(): +# await channel.close() +# +# self.channels = dict() +# +# async def broadcast(self, data): +# """ +# Broadcast data on all Channels +# +# :param data: The data to send +# """ +# with self._lock: +# for websocket, channel in self.channels.items(): +# try: +# await channel.send(data) +# except RuntimeError: +# # Handle cannot send after close cases +# await self.on_disconnect(websocket) +# +# async def send_direct(self, channel, data): +# """ +# Send data directly through direct_channel only +# +# :param direct_channel: The WebSocketChannel object to send data through +# :param data: The data to send +# """ +# # We iterate over the channels to get reference to the websocket object +# # so we can disconnect incase of failure +# await channel.send(data) +# +# def has_channels(self): +# """ +# Flag for more than 0 channels +# """ +# return len(self.channels) > 0 diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py index 2b29cde6f..616ea7801 100644 --- a/freqtrade/rpc/external_signal/controller.py +++ b/freqtrade/rpc/external_signal/controller.py @@ -1,449 +1,449 @@ -""" -This module manages replicate mode communication -""" -import asyncio -import logging -import secrets -import socket -from threading import Thread -from typing import Any, Callable, Coroutine, Dict, Union - -import websockets -from fastapi import Depends -from fastapi import WebSocket as FastAPIWebSocket -from fastapi import WebSocketDisconnect, status -from janus import Queue as ThreadedQueue - -from freqtrade.enums import ExternalSignalModeType, LeaderMessageType, RPCMessageType -from freqtrade.rpc import RPC, RPCHandler -from freqtrade.rpc.external_signal.channel import ChannelManager -from freqtrade.rpc.external_signal.types import MessageType -from freqtrade.rpc.external_signal.utils import is_websocket_alive - - -logger = logging.getLogger(__name__) - - -class ExternalSignalController(RPCHandler): - """ This class handles all websocket communication """ - - def __init__( - self, - rpc: RPC, - config: Dict[str, Any], - api_server: Union[Any, None] = None - ) -> None: - """ - Init the ExternalSignalController class, and init the super class RPCHandler - :param rpc: instance of RPC Helper class - :param config: Configuration object - :param api_server: The ApiServer object - :return: None - """ - super().__init__(rpc, config) - - self.freqtrade = rpc._freqtrade - self.api_server = api_server - - if not self.api_server: - raise RuntimeError("The API server must be enabled for external signals to work") - - self._loop = None - self._running = False - self._thread = None - self._queue = None - - self._main_task = None - self._sub_tasks = None - - self._message_handlers = { - LeaderMessageType.pairlist: self._rpc._handle_pairlist_message, - LeaderMessageType.analyzed_df: self._rpc._handle_analyzed_df_message, - LeaderMessageType.default: self._rpc._handle_default_message - } - - self.channel_manager = ChannelManager() - self.external_signal_config = config.get('external_signal', {}) - - # What the config should look like - # "external_signal": { - # "enabled": true, - # "mode": "follower", - # "leaders": [ - # { - # "url": "ws://localhost:8080/signals/ws", - # "api_token": "test" - # } - # ] - # } - - # "external_signal": { - # "enabled": true, - # "mode": "leader", - # "api_token": "test" - # } - - self.mode = ExternalSignalModeType[ - self.external_signal_config.get('mode', 'leader').lower() - ] - - self.leaders_list = self.external_signal_config.get('leaders', []) - self.push_throttle_secs = self.external_signal_config.get('push_throttle_secs', 0.1) - - self.reply_timeout = self.external_signal_config.get('follower_reply_timeout', 10) - self.ping_timeout = self.external_signal_config.get('follower_ping_timeout', 2) - self.sleep_time = self.external_signal_config.get('follower_sleep_time', 5) - - # Validate external_signal_config here? - - if self.mode == ExternalSignalModeType.follower and len(self.leaders_list) == 0: - raise ValueError("You must specify at least 1 leader in follower mode.") - - # This is only used by the leader, the followers use the tokens specified - # in each of the leaders - # If you do not specify an API key in the config, one will be randomly - # generated and logged on startup - default_api_key = secrets.token_urlsafe(16) - self.secret_api_key = self.external_signal_config.get('api_token', default_api_key) - - self.start() - - def is_leader(self): - """ - Leader flag - """ - return self.enabled() and self.mode == ExternalSignalModeType.leader - - def enabled(self): - """ - Enabled flag - """ - return self.external_signal_config.get('enabled', False) - - def num_leaders(self): - """ - The number of leaders we should be connected to - """ - return len(self.leaders_list) - - def start_threaded_loop(self): - """ - Start the main internal loop in another thread to run coroutines - """ - self._loop = asyncio.new_event_loop() - - if not self._thread: - self._thread = Thread(target=self._loop.run_forever) - self._thread.start() - self._running = True - else: - raise RuntimeError("A loop is already running") - - def submit_coroutine(self, coroutine: Coroutine): - """ - Submit a coroutine to the threaded loop - """ - if not self._running: - raise RuntimeError("Cannot schedule new futures after shutdown") - - if not self._loop or not self._loop.is_running(): - raise RuntimeError("Loop must be started before any function can" - " be submitted") - - return asyncio.run_coroutine_threadsafe(coroutine, self._loop) - - def start(self): - """ - Start the controller main loop - """ - self.start_threaded_loop() - self._main_task = self.submit_coroutine(self.main()) - - async def shutdown(self): - """ - Shutdown all tasks and close up - """ - logger.info("Stopping rpc.externalsignalcontroller") - - # Flip running flag - self._running = False - - # Cancel sub tasks - for task in self._sub_tasks: - task.cancel() - - # Then disconnect all channels - await self.channel_manager.disconnect_all() - - def cleanup(self) -> None: - """ - Cleanup pending module resources. - """ - if self._thread: - if self._loop.is_running(): - self._main_task.cancel() - self._thread.join() - - async def main(self): - """ - Main coro - - Start the loop based on what mode we're in - """ - try: - if self.mode == ExternalSignalModeType.leader: - logger.info("Starting rpc.externalsignalcontroller in Leader mode") - - await self.run_leader_mode() - elif self.mode == ExternalSignalModeType.follower: - logger.info("Starting rpc.externalsignalcontroller in Follower mode") - - await self.run_follower_mode() - - except asyncio.CancelledError: - # We're cancelled - await self.shutdown() - except Exception as e: - # Log the error - logger.error(f"Exception occurred in main task: {e}") - logger.exception(e) - finally: - # This coroutine is the last thing to be ended, so it should stop the loop - self._loop.stop() - - def log_api_token(self): - """ - Log the API token - """ - logger.info("-" * 15) - logger.info(f"API_KEY: {self.secret_api_key}") - logger.info("-" * 15) - - def send_msg(self, msg: MessageType) -> None: - """ - Support RPC calls - """ - if msg["type"] == RPCMessageType.EMIT_DATA: - message = msg.get("message") - if message: - self.send_message(message) - else: - logger.error(f"Message is empty! {msg}") - - def send_message(self, msg: MessageType) -> None: - """ - Broadcast message over all channels if there are any - """ - - if self.channel_manager.has_channels(): - self._send_message(msg) - else: - logger.debug("No listening followers, skipping...") - pass - - def _send_message(self, msg: MessageType): - """ - Add data to the internal queue to be broadcasted. This func will block - if the queue is full. This is meant to be called in the main thread. - """ - if self._queue: - queue = self._queue.sync_q - queue.put(msg) # This will block if the queue is full - else: - logger.warning("Can not send data, leader loop has not started yet!") - - async def send_initial_data(self, channel): - logger.info("Sending initial data through channel") - - data = self._rpc._initial_leader_data() - - for message in data: - await channel.send(message) - - async def _handle_leader_message(self, message: MessageType): - """ - Handle message received from a Leader - """ - type = message.get("data_type", LeaderMessageType.default) - data = message.get("data") - - handler: Callable = self._message_handlers[type] - handler(type, data) - - # ---------------------------------------------------------------------- - - async def run_leader_mode(self): - """ - Main leader coroutine - - This starts all of the leader coros and registers the endpoint on - the ApiServer - """ - self.register_leader_endpoint() - self.log_api_token() - - self._sub_tasks = [ - self._loop.create_task(self._broadcast_queue_data()) - ] - - return await asyncio.gather(*self._sub_tasks) - - async def run_follower_mode(self): - """ - Main follower coroutine - - This starts all of the follower connection coros - """ - - rpc_lock = asyncio.Lock() - - self._sub_tasks = [ - self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) - for leader in self.leaders_list - ] - - return await asyncio.gather(*self._sub_tasks) - - async def _broadcast_queue_data(self): - """ - Loop over queue data and broadcast it - """ - # Instantiate the queue in this coroutine so it's attached to our loop - self._queue = ThreadedQueue() - async_queue = self._queue.async_q - - try: - while self._running: - # Get data from queue - data = await async_queue.get() - - # Broadcast it to everyone - await self.channel_manager.broadcast(data) - - # Sleep - await asyncio.sleep(self.push_throttle_secs) - - except asyncio.CancelledError: - # Silently stop - pass - - async def get_api_token( - self, - websocket: FastAPIWebSocket, - token: Union[str, None] = None - ): - """ - Extract the API key from query param. Must match the - set secret_api_key or the websocket connection will be closed. - """ - if token == self.secret_api_key: - return token - else: - logger.info("Denying websocket request...") - await websocket.close(code=status.WS_1008_POLICY_VIOLATION) - - def register_leader_endpoint(self, path: str = "/signals/ws"): - """ - Attach and start the main leader loop to the ApiServer - - :param path: The endpoint path - """ - if not self.api_server: - raise RuntimeError("The leader needs the ApiServer to be active") - - # The endpoint function for running the main leader loop - @self.api_server.app.websocket(path) - async def leader_endpoint( - websocket: FastAPIWebSocket, - api_key: str = Depends(self.get_api_token) - ): - await self.leader_endpoint_loop(websocket) - - async def leader_endpoint_loop(self, websocket: FastAPIWebSocket): - """ - The WebSocket endpoint served by the ApiServer. This handles connections, - and adding them to the channel manager. - """ - try: - if is_websocket_alive(websocket): - logger.info(f"Follower connected - {websocket.client}") - channel = await self.channel_manager.on_connect(websocket) - - # Send initial data here - # Data is being broadcasted right away as soon as startup, - # we may not have to send initial data at all. Further testing - # required. - await self.send_initial_data(channel) - - # Keep connection open until explicitly closed, and sleep - try: - while not channel.is_closed(): - request = await channel.recv() - logger.info(f"Follower request - {request}") - - except WebSocketDisconnect: - # Handle client disconnects - logger.info(f"Follower disconnected - {websocket.client}") - await self.channel_manager.on_disconnect(websocket) - except Exception as e: - logger.info(f"Follower connection failed - {websocket.client}") - logger.exception(e) - # Handle cases like - - # RuntimeError('Cannot call "send" once a closed message has been sent') - await self.channel_manager.on_disconnect(websocket) - - except Exception: - logger.error(f"Failed to serve - {websocket.client}") - await self.channel_manager.on_disconnect(websocket) - - async def _handle_leader_connection(self, leader, lock): - """ - Given a leader, connect and wait on data. If connection is lost, - it will attempt to reconnect. - """ - try: - url, token = leader["url"], leader["api_token"] - websocket_url = f"{url}?token={token}" - - logger.info(f"Attempting to connect to Leader at: {url}") - while True: - try: - async with websockets.connect(websocket_url) as ws: - channel = await self.channel_manager.on_connect(ws) - logger.info(f"Connection to Leader at {url} successful") - while True: - try: - data = await asyncio.wait_for( - channel.recv(), - timeout=self.reply_timeout - ) - except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): - # We haven't received data yet. Check the connection and continue. - try: - # ping - ping = await channel.ping() - await asyncio.wait_for(ping, timeout=self.ping_timeout) - logger.debug(f"Connection to {url} still alive...") - continue - except Exception: - logger.info( - f"Ping error {url} - retrying in {self.sleep_time}s") - asyncio.sleep(self.sleep_time) - break - - async with lock: - # Acquire lock so only 1 coro handling at a time - # as we call the RPC module in the main thread - await self._handle_leader_message(data) - - except (socket.gaierror, ConnectionRefusedError): - logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - continue - except websockets.exceptions.InvalidStatusCode as e: - logger.error(f"Connection Refused - {e}") - await asyncio.sleep(self.sleep_time) - continue - - except asyncio.CancelledError: - pass +# """ +# This module manages replicate mode communication +# """ +# import asyncio +# import logging +# import secrets +# import socket +# from threading import Thread +# from typing import Any, Callable, Coroutine, Dict, Union +# +# import websockets +# from fastapi import Depends +# from fastapi import WebSocket as FastAPIWebSocket +# from fastapi import WebSocketDisconnect, status +# from janus import Queue as ThreadedQueue +# +# from freqtrade.enums import ExternalSignalModeType, LeaderMessageType, RPCMessageType +# from freqtrade.rpc import RPC, RPCHandler +# from freqtrade.rpc.external_signal.channel import ChannelManager +# from freqtrade.rpc.external_signal.types import MessageType +# from freqtrade.rpc.external_signal.utils import is_websocket_alive +# +# +# logger = logging.getLogger(__name__) +# +# +# class ExternalSignalController(RPCHandler): +# """ This class handles all websocket communication """ +# +# def __init__( +# self, +# rpc: RPC, +# config: Dict[str, Any], +# api_server: Union[Any, None] = None +# ) -> None: +# """ +# Init the ExternalSignalController class, and init the super class RPCHandler +# :param rpc: instance of RPC Helper class +# :param config: Configuration object +# :param api_server: The ApiServer object +# :return: None +# """ +# super().__init__(rpc, config) +# +# self.freqtrade = rpc._freqtrade +# self.api_server = api_server +# +# if not self.api_server: +# raise RuntimeError("The API server must be enabled for external signals to work") +# +# self._loop = None +# self._running = False +# self._thread = None +# self._queue = None +# +# self._main_task = None +# self._sub_tasks = None +# +# self._message_handlers = { +# LeaderMessageType.pairlist: self._rpc._handle_pairlist_message, +# LeaderMessageType.analyzed_df: self._rpc._handle_analyzed_df_message, +# LeaderMessageType.default: self._rpc._handle_default_message +# } +# +# self.channel_manager = ChannelManager() +# self.external_signal_config = config.get('external_signal', {}) +# +# # What the config should look like +# # "external_signal": { +# # "enabled": true, +# # "mode": "follower", +# # "leaders": [ +# # { +# # "url": "ws://localhost:8080/signals/ws", +# # "api_token": "test" +# # } +# # ] +# # } +# +# # "external_signal": { +# # "enabled": true, +# # "mode": "leader", +# # "api_token": "test" +# # } +# +# self.mode = ExternalSignalModeType[ +# self.external_signal_config.get('mode', 'leader').lower() +# ] +# +# self.leaders_list = self.external_signal_config.get('leaders', []) +# self.push_throttle_secs = self.external_signal_config.get('push_throttle_secs', 0.1) +# +# self.reply_timeout = self.external_signal_config.get('follower_reply_timeout', 10) +# self.ping_timeout = self.external_signal_config.get('follower_ping_timeout', 2) +# self.sleep_time = self.external_signal_config.get('follower_sleep_time', 5) +# +# # Validate external_signal_config here? +# +# if self.mode == ExternalSignalModeType.follower and len(self.leaders_list) == 0: +# raise ValueError("You must specify at least 1 leader in follower mode.") +# +# # This is only used by the leader, the followers use the tokens specified +# # in each of the leaders +# # If you do not specify an API key in the config, one will be randomly +# # generated and logged on startup +# default_api_key = secrets.token_urlsafe(16) +# self.secret_api_key = self.external_signal_config.get('api_token', default_api_key) +# +# self.start() +# +# def is_leader(self): +# """ +# Leader flag +# """ +# return self.enabled() and self.mode == ExternalSignalModeType.leader +# +# def enabled(self): +# """ +# Enabled flag +# """ +# return self.external_signal_config.get('enabled', False) +# +# def num_leaders(self): +# """ +# The number of leaders we should be connected to +# """ +# return len(self.leaders_list) +# +# def start_threaded_loop(self): +# """ +# Start the main internal loop in another thread to run coroutines +# """ +# self._loop = asyncio.new_event_loop() +# +# if not self._thread: +# self._thread = Thread(target=self._loop.run_forever) +# self._thread.start() +# self._running = True +# else: +# raise RuntimeError("A loop is already running") +# +# def submit_coroutine(self, coroutine: Coroutine): +# """ +# Submit a coroutine to the threaded loop +# """ +# if not self._running: +# raise RuntimeError("Cannot schedule new futures after shutdown") +# +# if not self._loop or not self._loop.is_running(): +# raise RuntimeError("Loop must be started before any function can" +# " be submitted") +# +# return asyncio.run_coroutine_threadsafe(coroutine, self._loop) +# +# def start(self): +# """ +# Start the controller main loop +# """ +# self.start_threaded_loop() +# self._main_task = self.submit_coroutine(self.main()) +# +# async def shutdown(self): +# """ +# Shutdown all tasks and close up +# """ +# logger.info("Stopping rpc.externalsignalcontroller") +# +# # Flip running flag +# self._running = False +# +# # Cancel sub tasks +# for task in self._sub_tasks: +# task.cancel() +# +# # Then disconnect all channels +# await self.channel_manager.disconnect_all() +# +# def cleanup(self) -> None: +# """ +# Cleanup pending module resources. +# """ +# if self._thread: +# if self._loop.is_running(): +# self._main_task.cancel() +# self._thread.join() +# +# async def main(self): +# """ +# Main coro +# +# Start the loop based on what mode we're in +# """ +# try: +# if self.mode == ExternalSignalModeType.leader: +# logger.info("Starting rpc.externalsignalcontroller in Leader mode") +# +# await self.run_leader_mode() +# elif self.mode == ExternalSignalModeType.follower: +# logger.info("Starting rpc.externalsignalcontroller in Follower mode") +# +# await self.run_follower_mode() +# +# except asyncio.CancelledError: +# # We're cancelled +# await self.shutdown() +# except Exception as e: +# # Log the error +# logger.error(f"Exception occurred in main task: {e}") +# logger.exception(e) +# finally: +# # This coroutine is the last thing to be ended, so it should stop the loop +# self._loop.stop() +# +# def log_api_token(self): +# """ +# Log the API token +# """ +# logger.info("-" * 15) +# logger.info(f"API_KEY: {self.secret_api_key}") +# logger.info("-" * 15) +# +# def send_msg(self, msg: MessageType) -> None: +# """ +# Support RPC calls +# """ +# if msg["type"] == RPCMessageType.EMIT_DATA: +# message = msg.get("message") +# if message: +# self.send_message(message) +# else: +# logger.error(f"Message is empty! {msg}") +# +# def send_message(self, msg: MessageType) -> None: +# """ +# Broadcast message over all channels if there are any +# """ +# +# if self.channel_manager.has_channels(): +# self._send_message(msg) +# else: +# logger.debug("No listening followers, skipping...") +# pass +# +# def _send_message(self, msg: MessageType): +# """ +# Add data to the internal queue to be broadcasted. This func will block +# if the queue is full. This is meant to be called in the main thread. +# """ +# if self._queue: +# queue = self._queue.sync_q +# queue.put(msg) # This will block if the queue is full +# else: +# logger.warning("Can not send data, leader loop has not started yet!") +# +# async def send_initial_data(self, channel): +# logger.info("Sending initial data through channel") +# +# data = self._rpc._initial_leader_data() +# +# for message in data: +# await channel.send(message) +# +# async def _handle_leader_message(self, message: MessageType): +# """ +# Handle message received from a Leader +# """ +# type = message.get("data_type", LeaderMessageType.default) +# data = message.get("data") +# +# handler: Callable = self._message_handlers[type] +# handler(type, data) +# +# # ---------------------------------------------------------------------- +# +# async def run_leader_mode(self): +# """ +# Main leader coroutine +# +# This starts all of the leader coros and registers the endpoint on +# the ApiServer +# """ +# self.register_leader_endpoint() +# self.log_api_token() +# +# self._sub_tasks = [ +# self._loop.create_task(self._broadcast_queue_data()) +# ] +# +# return await asyncio.gather(*self._sub_tasks) +# +# async def run_follower_mode(self): +# """ +# Main follower coroutine +# +# This starts all of the follower connection coros +# """ +# +# rpc_lock = asyncio.Lock() +# +# self._sub_tasks = [ +# self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) +# for leader in self.leaders_list +# ] +# +# return await asyncio.gather(*self._sub_tasks) +# +# async def _broadcast_queue_data(self): +# """ +# Loop over queue data and broadcast it +# """ +# # Instantiate the queue in this coroutine so it's attached to our loop +# self._queue = ThreadedQueue() +# async_queue = self._queue.async_q +# +# try: +# while self._running: +# # Get data from queue +# data = await async_queue.get() +# +# # Broadcast it to everyone +# await self.channel_manager.broadcast(data) +# +# # Sleep +# await asyncio.sleep(self.push_throttle_secs) +# +# except asyncio.CancelledError: +# # Silently stop +# pass +# +# async def get_api_token( +# self, +# websocket: FastAPIWebSocket, +# token: Union[str, None] = None +# ): +# """ +# Extract the API key from query param. Must match the +# set secret_api_key or the websocket connection will be closed. +# """ +# if token == self.secret_api_key: +# return token +# else: +# logger.info("Denying websocket request...") +# await websocket.close(code=status.WS_1008_POLICY_VIOLATION) +# +# def register_leader_endpoint(self, path: str = "/signals/ws"): +# """ +# Attach and start the main leader loop to the ApiServer +# +# :param path: The endpoint path +# """ +# if not self.api_server: +# raise RuntimeError("The leader needs the ApiServer to be active") +# +# # The endpoint function for running the main leader loop +# @self.api_server.app.websocket(path) +# async def leader_endpoint( +# websocket: FastAPIWebSocket, +# api_key: str = Depends(self.get_api_token) +# ): +# await self.leader_endpoint_loop(websocket) +# +# async def leader_endpoint_loop(self, websocket: FastAPIWebSocket): +# """ +# The WebSocket endpoint served by the ApiServer. This handles connections, +# and adding them to the channel manager. +# """ +# try: +# if is_websocket_alive(websocket): +# logger.info(f"Follower connected - {websocket.client}") +# channel = await self.channel_manager.on_connect(websocket) +# +# # Send initial data here +# # Data is being broadcasted right away as soon as startup, +# # we may not have to send initial data at all. Further testing +# # required. +# await self.send_initial_data(channel) +# +# # Keep connection open until explicitly closed, and sleep +# try: +# while not channel.is_closed(): +# request = await channel.recv() +# logger.info(f"Follower request - {request}") +# +# except WebSocketDisconnect: +# # Handle client disconnects +# logger.info(f"Follower disconnected - {websocket.client}") +# await self.channel_manager.on_disconnect(websocket) +# except Exception as e: +# logger.info(f"Follower connection failed - {websocket.client}") +# logger.exception(e) +# # Handle cases like - +# # RuntimeError('Cannot call "send" once a closed message has been sent') +# await self.channel_manager.on_disconnect(websocket) +# +# except Exception: +# logger.error(f"Failed to serve - {websocket.client}") +# await self.channel_manager.on_disconnect(websocket) +# +# async def _handle_leader_connection(self, leader, lock): +# """ +# Given a leader, connect and wait on data. If connection is lost, +# it will attempt to reconnect. +# """ +# try: +# url, token = leader["url"], leader["api_token"] +# websocket_url = f"{url}?token={token}" +# +# logger.info(f"Attempting to connect to Leader at: {url}") +# while True: +# try: +# async with websockets.connect(websocket_url) as ws: +# channel = await self.channel_manager.on_connect(ws) +# logger.info(f"Connection to Leader at {url} successful") +# while True: +# try: +# data = await asyncio.wait_for( +# channel.recv(), +# timeout=self.reply_timeout +# ) +# except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): +# # We haven't received data yet. Check the connection and continue. +# try: +# # ping +# ping = await channel.ping() +# await asyncio.wait_for(ping, timeout=self.ping_timeout) +# logger.debug(f"Connection to {url} still alive...") +# continue +# except Exception: +# logger.info( +# f"Ping error {url} - retrying in {self.sleep_time}s") +# asyncio.sleep(self.sleep_time) +# break +# +# async with lock: +# # Acquire lock so only 1 coro handling at a time +# # as we call the RPC module in the main thread +# await self._handle_leader_message(data) +# +# except (socket.gaierror, ConnectionRefusedError): +# logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") +# await asyncio.sleep(self.sleep_time) +# continue +# except websockets.exceptions.InvalidStatusCode as e: +# logger.error(f"Connection Refused - {e}") +# await asyncio.sleep(self.sleep_time) +# continue +# +# except asyncio.CancelledError: +# pass diff --git a/freqtrade/rpc/external_signal/proxy.py b/freqtrade/rpc/external_signal/proxy.py index 36ff4a74e..df2a07da0 100644 --- a/freqtrade/rpc/external_signal/proxy.py +++ b/freqtrade/rpc/external_signal/proxy.py @@ -1,61 +1,61 @@ -from typing import Union - -from fastapi import WebSocket as FastAPIWebSocket -from websockets import WebSocketClientProtocol as WebSocket - -from freqtrade.rpc.external_signal.types import WebSocketType - - -class WebSocketProxy: - """ - WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol - under the same API - """ - - def __init__(self, websocket: WebSocketType): - self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket - - async def send(self, data): - """ - Send data on the wrapped websocket - """ - if isinstance(data, str): - data = data.encode() - - if hasattr(self._websocket, "send_bytes"): - await self._websocket.send_bytes(data) - else: - await self._websocket.send(data) - - async def recv(self): - """ - Receive data on the wrapped websocket - """ - if hasattr(self._websocket, "receive_bytes"): - return await self._websocket.receive_bytes() - else: - return await self._websocket.recv() - - async def ping(self): - """ - Ping the websocket, not supported by FastAPI WebSockets - """ - if hasattr(self._websocket, "ping"): - return await self._websocket.ping() - return False - - async def close(self, code: int = 1000): - """ - Close the websocket connection, only supported by FastAPI WebSockets - """ - if hasattr(self._websocket, "close"): - return await self._websocket.close(code) - pass - - async def accept(self): - """ - Accept the WebSocket connection, only support by FastAPI WebSockets - """ - if hasattr(self._websocket, "accept"): - return await self._websocket.accept() - pass +# from typing import Union +# +# from fastapi import WebSocket as FastAPIWebSocket +# from websockets import WebSocketClientProtocol as WebSocket +# +# from freqtrade.rpc.external_signal.types import WebSocketType +# +# +# class WebSocketProxy: +# """ +# WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol +# under the same API +# """ +# +# def __init__(self, websocket: WebSocketType): +# self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket +# +# async def send(self, data): +# """ +# Send data on the wrapped websocket +# """ +# if isinstance(data, str): +# data = data.encode() +# +# if hasattr(self._websocket, "send_bytes"): +# await self._websocket.send_bytes(data) +# else: +# await self._websocket.send(data) +# +# async def recv(self): +# """ +# Receive data on the wrapped websocket +# """ +# if hasattr(self._websocket, "receive_bytes"): +# return await self._websocket.receive_bytes() +# else: +# return await self._websocket.recv() +# +# async def ping(self): +# """ +# Ping the websocket, not supported by FastAPI WebSockets +# """ +# if hasattr(self._websocket, "ping"): +# return await self._websocket.ping() +# return False +# +# async def close(self, code: int = 1000): +# """ +# Close the websocket connection, only supported by FastAPI WebSockets +# """ +# if hasattr(self._websocket, "close"): +# return await self._websocket.close(code) +# pass +# +# async def accept(self): +# """ +# Accept the WebSocket connection, only support by FastAPI WebSockets +# """ +# if hasattr(self._websocket, "accept"): +# return await self._websocket.accept() +# pass diff --git a/freqtrade/rpc/external_signal/serializer.py b/freqtrade/rpc/external_signal/serializer.py index 2a0f53037..a23469ef4 100644 --- a/freqtrade/rpc/external_signal/serializer.py +++ b/freqtrade/rpc/external_signal/serializer.py @@ -1,65 +1,65 @@ -import json -import logging -from abc import ABC, abstractmethod - -import msgpack -import orjson - -from freqtrade.rpc.external_signal.proxy import WebSocketProxy - - -logger = logging.getLogger(__name__) - - -class WebSocketSerializer(ABC): - def __init__(self, websocket: WebSocketProxy): - self._websocket: WebSocketProxy = websocket - - @abstractmethod - def _serialize(self, data): - raise NotImplementedError() - - @abstractmethod - def _deserialize(self, data): - raise NotImplementedError() - - async def send(self, data: bytes): - await self._websocket.send(self._serialize(data)) - - async def recv(self) -> bytes: - data = await self._websocket.recv() - - return self._deserialize(data) - - async def close(self, code: int = 1000): - await self._websocket.close(code) - -# Going to explore using MsgPack as the serialization, -# as that might be the best method for sending pandas -# dataframes over the wire - - -class JSONWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - return json.dumps(data) - - def _deserialize(self, data): - return json.loads(data) - - -class ORJSONWebSocketSerializer(WebSocketSerializer): - ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY - - def _serialize(self, data): - return orjson.dumps(data, option=self.ORJSON_OPTIONS) - - def _deserialize(self, data): - return orjson.loads(data, option=self.ORJSON_OPTIONS) - - -class MsgPackWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - return msgpack.packb(data, use_bin_type=True) - - def _deserialize(self, data): - return msgpack.unpackb(data, raw=False) +# import json +# import logging +# from abc import ABC, abstractmethod +# +# import msgpack +# import orjson +# +# from freqtrade.rpc.external_signal.proxy import WebSocketProxy +# +# +# logger = logging.getLogger(__name__) +# +# +# class WebSocketSerializer(ABC): +# def __init__(self, websocket: WebSocketProxy): +# self._websocket: WebSocketProxy = websocket +# +# @abstractmethod +# def _serialize(self, data): +# raise NotImplementedError() +# +# @abstractmethod +# def _deserialize(self, data): +# raise NotImplementedError() +# +# async def send(self, data: bytes): +# await self._websocket.send(self._serialize(data)) +# +# async def recv(self) -> bytes: +# data = await self._websocket.recv() +# +# return self._deserialize(data) +# +# async def close(self, code: int = 1000): +# await self._websocket.close(code) +# +# # Going to explore using MsgPack as the serialization, +# # as that might be the best method for sending pandas +# # dataframes over the wire +# +# +# class JSONWebSocketSerializer(WebSocketSerializer): +# def _serialize(self, data): +# return json.dumps(data) +# +# def _deserialize(self, data): +# return json.loads(data) +# +# +# class ORJSONWebSocketSerializer(WebSocketSerializer): +# ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY +# +# def _serialize(self, data): +# return orjson.dumps(data, option=self.ORJSON_OPTIONS) +# +# def _deserialize(self, data): +# return orjson.loads(data, option=self.ORJSON_OPTIONS) +# +# +# class MsgPackWebSocketSerializer(WebSocketSerializer): +# def _serialize(self, data): +# return msgpack.packb(data, use_bin_type=True) +# +# def _deserialize(self, data): +# return msgpack.unpackb(data, raw=False) diff --git a/freqtrade/rpc/external_signal/types.py b/freqtrade/rpc/external_signal/types.py index 814fe6649..38e43f667 100644 --- a/freqtrade/rpc/external_signal/types.py +++ b/freqtrade/rpc/external_signal/types.py @@ -1,8 +1,8 @@ -from typing import Any, Dict, TypeVar - -from fastapi import WebSocket as FastAPIWebSocket -from websockets import WebSocketClientProtocol as WebSocket - - -WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) -MessageType = Dict[str, Any] +# from typing import Any, Dict, TypeVar +# +# from fastapi import WebSocket as FastAPIWebSocket +# from websockets import WebSocketClientProtocol as WebSocket +# +# +# WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) +# MessageType = Dict[str, Any] diff --git a/freqtrade/rpc/external_signal/utils.py b/freqtrade/rpc/external_signal/utils.py index e5469dce3..72c8d2ef8 100644 --- a/freqtrade/rpc/external_signal/utils.py +++ b/freqtrade/rpc/external_signal/utils.py @@ -1,22 +1,10 @@ -from pandas import DataFrame -from starlette.websockets import WebSocket, WebSocketState - -from freqtrade.enums.signaltype import SignalTagType, SignalType - - -async def is_websocket_alive(ws: WebSocket) -> bool: - if ( - ws.application_state == WebSocketState.CONNECTED and - ws.client_state == WebSocketState.CONNECTED - ): - return True - return False - - -def remove_entry_exit_signals(dataframe: DataFrame): - dataframe[SignalType.ENTER_LONG.value] = 0 - dataframe[SignalType.EXIT_LONG.value] = 0 - dataframe[SignalType.ENTER_SHORT.value] = 0 - dataframe[SignalType.EXIT_SHORT.value] = 0 - dataframe[SignalTagType.ENTER_TAG.value] = None - dataframe[SignalTagType.EXIT_TAG.value] = None +# from starlette.websockets import WebSocket, WebSocketState +# +# +# async def is_websocket_alive(ws: WebSocket) -> bool: +# if ( +# ws.application_state == WebSocketState.CONNECTED and +# ws.client_state == WebSocketState.CONNECTED +# ): +# return True +# return False diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 82d50f33c..3c7558158 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,13 +19,12 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_max_drawdown -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, LeaderMessageType, - SignalDirection, State, TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, + TradingMode) from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import (decimals_per_coin, json_to_dataframe, remove_entry_exit_signals, - shorten_date) +from freqtrade.misc import decimals_per_coin, shorten_date from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -1090,65 +1089,65 @@ class RPC: 'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), 'last_process_ts': int(last_p.timestamp()), } - - # ------------------------------ EXTERNAL SIGNALS ----------------------- - - def _initial_leader_data(self): - # We create a list of Messages to send to the follower on connect - data = [] - - # Send Pairlist data - data.append({ - "data_type": LeaderMessageType.pairlist, - "data": self._freqtrade.pairlists._whitelist - }) - - return data - - def _handle_pairlist_message(self, type, data): - """ - Handles the emitted pairlists from the Leaders - - :param type: The data_type of the data - :param data: The data - """ - pairlist = data - - logger.debug(f"Handling Pairlist message: {pairlist}") - - external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] - external_pairlist.add_pairlist_data(pairlist) - - def _handle_analyzed_df_message(self, type, data): - """ - Handles the analyzed dataframes from the Leaders - - :param type: The data_type of the data - :param data: The data - """ - key, value = data["key"], data["value"] - pair, timeframe, candle_type = key - - # Skip any pairs that we don't have in the pairlist? - # leader_pairlist = self._freqtrade.pairlists._whitelist - # if pair not in leader_pairlist: - # return - - dataframe = json_to_dataframe(value) - - if self._config.get('external_signal', {}).get('remove_signals_analyzed_df', False): - dataframe = remove_entry_exit_signals(dataframe) - - logger.debug(f"Handling analyzed dataframe for {pair}") - logger.debug(dataframe.tail()) - - # Add the dataframe to the dataprovider - dataprovider = self._freqtrade.dataprovider - dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) - - def _handle_default_message(self, type, data): - """ - Default leader message handler, just logs it. We should never have to - run this unless the leader sends us some weird message. - """ - logger.debug(f"Received message from Leader of type {type}: {data}") + # + # # ------------------------------ EXTERNAL SIGNALS ----------------------- + # + # def _initial_leader_data(self): + # # We create a list of Messages to send to the follower on connect + # data = [] + # + # # Send Pairlist data + # data.append({ + # "data_type": LeaderMessageType.pairlist, + # "data": self._freqtrade.pairlists._whitelist + # }) + # + # return data + # + # def _handle_pairlist_message(self, type, data): + # """ + # Handles the emitted pairlists from the Leaders + # + # :param type: The data_type of the data + # :param data: The data + # """ + # pairlist = data + # + # logger.debug(f"Handling Pairlist message: {pairlist}") + # + # external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] + # external_pairlist.add_pairlist_data(pairlist) + # + # def _handle_analyzed_df_message(self, type, data): + # """ + # Handles the analyzed dataframes from the Leaders + # + # :param type: The data_type of the data + # :param data: The data + # """ + # key, value = data["key"], data["value"] + # pair, timeframe, candle_type = key + # + # # Skip any pairs that we don't have in the pairlist? + # # leader_pairlist = self._freqtrade.pairlists._whitelist + # # if pair not in leader_pairlist: + # # return + # + # dataframe = json_to_dataframe(value) + # + # if self._config.get('external_signal', {}).get('remove_signals_analyzed_df', False): + # dataframe = remove_entry_exit_signals(dataframe) + # + # logger.debug(f"Handling analyzed dataframe for {pair}") + # logger.debug(dataframe.tail()) + # + # # Add the dataframe to the dataprovider + # dataprovider = self._freqtrade.dataprovider + # dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) + # + # def _handle_default_message(self, type, data): + # """ + # Default leader message handler, just logs it. We should never have to + # run this unless the leader sends us some weird message. + # """ + # logger.debug(f"Received message from Leader of type {type}: {data}") diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 11e21da6f..c5e93e3b4 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -51,14 +51,14 @@ class RPCManager: # Enable External Signals mode # For this to be enabled, the API server must also be enabled - if config.get('external_signal', {}).get('enabled', False): - logger.info('Enabling RPC.ExternalSignalController') - from freqtrade.rpc.external_signal import ExternalSignalController - external_signals = ExternalSignalController(self._rpc, config, apiserver) - self.registered_modules.append(external_signals) - - # Attach the controller to FreqTrade - freqtrade.external_signal_controller = external_signals + # if config.get('external_signal', {}).get('enabled', False): + # logger.info('Enabling RPC.ExternalSignalController') + # from freqtrade.rpc.external_signal import ExternalSignalController + # external_signals = ExternalSignalController(self._rpc, config, apiserver) + # self.registered_modules.append(external_signals) + # + # # Attach the controller to FreqTrade + # freqtrade.external_signal_controller = external_signals def cleanup(self) -> None: """ Stops all enabled rpc modules """ @@ -78,8 +78,7 @@ class RPCManager: 'status': 'stopping bot' } """ - if msg.get("type") != RPCMessageType.EMIT_DATA: - logger.info('Sending rpc message: %s', msg) + logger.info('Sending rpc message: %s', msg) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) @@ -138,12 +137,3 @@ class RPCManager: 'type': RPCMessageType.STARTUP, 'status': f'Using Protections: \n{prots}' }) - - def emit_data(self, data: Dict[str, Any]): - """ - Send a message via RPC with type RPCMessageType.EMIT_DATA - """ - self.send_msg({ - "type": RPCMessageType.EMIT_DATA, - "message": data - }) diff --git a/scripts/test_ws_client.py b/scripts/test_ws_client.py new file mode 100644 index 000000000..caa495a19 --- /dev/null +++ b/scripts/test_ws_client.py @@ -0,0 +1,58 @@ +import asyncio +import logging +import socket + +import websockets + + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +async def _client(): + try: + while True: + try: + url = "ws://localhost:8080/api/v1/message/ws?token=testtoken" + async with websockets.connect(url) as ws: + logger.info("Connection successful") + while True: + try: + data = await asyncio.wait_for( + ws.recv(), + timeout=5 + ) + logger.info(f"Data received - {data}") + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await ws.ping() + await asyncio.wait_for(ping, timeout=2) + logger.debug(f"Connection to {url} still alive...") + continue + except Exception: + logger.info( + f"Ping error {url} - retrying in 5s") + asyncio.sleep(2) + break + + except (socket.gaierror, ConnectionRefusedError): + logger.info("Connection Refused - retrying connection in 5s") + await asyncio.sleep(2) + continue + except websockets.exceptions.InvalidStatusCode as e: + logger.error(f"Connection Refused - {e}") + await asyncio.sleep(2) + continue + + except (asyncio.CancelledError, KeyboardInterrupt): + pass + + +def main(): + asyncio.run(_client()) + + +if __name__ == "__main__": + main() From 47f7c384fbea381926a3b9aad0c6c99935b5e8d2 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 29 Aug 2022 15:48:29 -0600 Subject: [PATCH 015/199] consumer subscriptions, fix serializer bug --- freqtrade/rpc/api_server/api_ws.py | 11 +++++++- freqtrade/rpc/api_server/ws/__init__.py | 0 freqtrade/rpc/api_server/ws/channel.py | 31 ++++++++++++++++++++--- freqtrade/rpc/api_server/ws/serializer.py | 2 +- scripts/test_ws_client.py | 15 ++++++++--- 5 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 freqtrade/rpc/api_server/ws/__init__.py diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 464ea22b2..405beed79 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from freqtrade.enums import RPCMessageType from freqtrade.rpc.api_server.deps import get_channel_manager from freqtrade.rpc.api_server.ws.utils import is_websocket_alive @@ -34,7 +35,15 @@ async def message_endpoint( # be a list of topics to subscribe too. List[str] # Maybe allow the consumer to update the topics subscribed # during runtime? - logger.info(f"Consumer request - {request}") + + # If the request isn't a list then skip it + if not isinstance(request, list): + continue + + # Check if all topics listed are an RPCMessageType + if all([any(x.value == topic for x in RPCMessageType) for topic in request]): + logger.debug(f"{ws.client} subscribed to topics: {request}") + channel.set_subscriptions(request) except WebSocketDisconnect: # Handle client disconnects diff --git a/freqtrade/rpc/api_server/ws/__init__.py b/freqtrade/rpc/api_server/ws/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index 486e8657b..f24713a77 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -1,6 +1,6 @@ import logging from threading import RLock -from typing import Type +from typing import List, Type from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy from freqtrade.rpc.api_server.ws.serializer import ORJSONWebSocketSerializer, WebSocketSerializer @@ -25,6 +25,8 @@ class WebSocketChannel: # The Serializing class for the WebSocket object self._serializer_cls = serializer_cls + self._subscriptions: List[str] = [] + # Internal event to signify a closed websocket self._closed = False @@ -57,9 +59,28 @@ class WebSocketChannel: self._closed = True - def is_closed(self): + def is_closed(self) -> bool: + """ + Closed flag + """ return self._closed + def set_subscriptions(self, subscriptions: List[str] = []) -> None: + """ + Set which subscriptions this channel is subscribed to + + :param subscriptions: List of subscriptions, List[str] + """ + self._subscriptions = subscriptions + + def subscribed_to(self, message_type: str) -> bool: + """ + Check if this channel is subscribed to the message_type + + :param message_type: The message type to check + """ + return message_type in self._subscriptions + class ChannelManager: def __init__(self): @@ -120,10 +141,12 @@ class ChannelManager: :param data: The data to send """ with self._lock: - logger.debug(f"Broadcasting data: {data}") + message_type = data.get('type') + logger.debug(f"Broadcasting data: {message_type} - {data}") for websocket, channel in self.channels.items(): try: - await channel.send(data) + if channel.subscribed_to(message_type): + await channel.send(data) except RuntimeError: # Handle cannot send after close cases await self.on_disconnect(websocket) diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index 40cbbfad7..ae2857f0b 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -54,7 +54,7 @@ class ORJSONWebSocketSerializer(WebSocketSerializer): return orjson.dumps(data, option=self.ORJSON_OPTIONS) def _deserialize(self, data): - return orjson.loads(data, option=self.ORJSON_OPTIONS) + return orjson.loads(data) class MsgPackWebSocketSerializer(WebSocketSerializer): diff --git a/scripts/test_ws_client.py b/scripts/test_ws_client.py index caa495a19..2c64ae867 100644 --- a/scripts/test_ws_client.py +++ b/scripts/test_ws_client.py @@ -4,22 +4,31 @@ import socket import websockets +from freqtrade.enums import RPCMessageType +from freqtrade.rpc.api_server.ws.channel import WebSocketChannel + logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) async def _client(): + subscribe_topics = [RPCMessageType.WHITELIST] try: while True: try: url = "ws://localhost:8080/api/v1/message/ws?token=testtoken" async with websockets.connect(url) as ws: + channel = WebSocketChannel(ws) + logger.info("Connection successful") + # Tell the producer we only want these topics + await channel.send(subscribe_topics) + while True: try: data = await asyncio.wait_for( - ws.recv(), + channel.recv(), timeout=5 ) logger.info(f"Data received - {data}") @@ -27,14 +36,14 @@ async def _client(): # We haven't received data yet. Check the connection and continue. try: # ping - ping = await ws.ping() + ping = await channel.ping() await asyncio.wait_for(ping, timeout=2) logger.debug(f"Connection to {url} still alive...") continue except Exception: logger.info( f"Ping error {url} - retrying in 5s") - asyncio.sleep(2) + await asyncio.sleep(2) break except (socket.gaierror, ConnectionRefusedError): From 418bd26a8097008ccb5ef747cc63ea087fadfea4 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 30 Aug 2022 11:04:16 -0600 Subject: [PATCH 016/199] minor fixes, rework consumer request, update requirements.txt --- freqtrade/enums/__init__.py | 2 +- freqtrade/enums/rpcmessagetype.py | 6 ++++++ freqtrade/rpc/api_server/api_ws.py | 23 +++++++---------------- freqtrade/rpc/api_server/webserver.py | 6 +----- freqtrade/rpc/rpc.py | 14 ++++++++++++-- freqtrade/rpc/rpc_manager.py | 2 +- freqtrade/rpc/telegram.py | 10 +++++++--- requirements-externalsignals.txt | 7 ------- requirements.txt | 5 +++++ scripts/test_ws_client.py | 11 +++++++++-- setup.py | 5 ++++- 11 files changed, 53 insertions(+), 38 deletions(-) delete mode 100644 requirements-externalsignals.txt diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index ffeb8cc12..406d847e6 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -6,7 +6,7 @@ from freqtrade.enums.exittype import ExitType from freqtrade.enums.externalsignal import ExternalSignalModeType, LeaderMessageType, WaitDataPolicy from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues -from freqtrade.enums.rpcmessagetype import RPCMessageType +from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.signaltype import SignalDirection, SignalTagType, SignalType from freqtrade.enums.state import State diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 8e4182b33..6283fb7cc 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -21,9 +21,15 @@ class RPCMessageType(str, Enum): STRATEGY_MSG = 'strategy_msg' WHITELIST = 'whitelist' + ANALYZED_DF = 'analyzed_df' def __repr__(self): return self.value def __str__(self): return self.value + + +# Enum for parsing requests from ws consumers +class RPCRequestType(str, Enum): + SUBSCRIBE = 'subscribe' diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 405beed79..c8d1b70fa 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -2,8 +2,7 @@ import logging from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect -from freqtrade.enums import RPCMessageType -from freqtrade.rpc.api_server.deps import get_channel_manager +from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc_optional from freqtrade.rpc.api_server.ws.utils import is_websocket_alive @@ -16,7 +15,8 @@ router = APIRouter() @router.websocket("/message/ws") async def message_endpoint( ws: WebSocket, - channel_manager=Depends(get_channel_manager) + channel_manager=Depends(get_channel_manager), + rpc=Depends(get_rpc_optional) ): try: if is_websocket_alive(ws): @@ -31,19 +31,10 @@ async def message_endpoint( while not channel.is_closed(): request = await channel.recv() - # This is where we'd parse the request. For now this should only - # be a list of topics to subscribe too. List[str] - # Maybe allow the consumer to update the topics subscribed - # during runtime? - - # If the request isn't a list then skip it - if not isinstance(request, list): - continue - - # Check if all topics listed are an RPCMessageType - if all([any(x.value == topic for x in RPCMessageType) for topic in request]): - logger.debug(f"{ws.client} subscribed to topics: {request}") - channel.set_subscriptions(request) + # Process the request here. Should this be a method of RPC? + if rpc: + logger.info(f"Request: {request}") + rpc._process_consumer_request(request, channel) except WebSocketDisconnect: # Handle client disconnects diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 94cb8cd45..f4af8c8ed 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -63,11 +63,7 @@ class ApiServer(RPCHandler): ApiServer.__initialized = False return ApiServer.__instance - def __init__( - self, - config: Dict[str, Any], - standalone: bool = False, - ) -> None: + def __init__(self, config: Dict[str, Any], standalone: bool = False) -> None: ApiServer._config = config if self.__initialized and (standalone or self._standalone): return diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 3c7558158..f684c7783 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,8 +19,8 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_max_drawdown -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, - TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RPCMessageType, RPCRequestType, + SignalDirection, State, TradingMode) from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler @@ -1089,6 +1089,16 @@ class RPC: 'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), 'last_process_ts': int(last_p.timestamp()), } + + # We are passed a Channel object, we can only do sync functions on that channel object + def _process_consumer_request(self, request, channel): + # Should we ensure that request is Dict[str, Any]? + type, data = request.get('type'), request.get('data') + + if type == RPCRequestType.SUBSCRIBE: + if all([any(x.value == topic for x in RPCMessageType) for topic in data]): + logger.debug(f"{channel} subscribed to topics: {data}") + channel.set_subscriptions(data) # # # ------------------------------ EXTERNAL SIGNALS ----------------------- # diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index c5e93e3b4..b3cd5604c 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -64,7 +64,7 @@ class RPCManager: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') while self.registered_modules: - mod = self.registered_modules.pop() + mod = self.registered_modules.pop() # popleft to cleanup API server last? logger.info('Cleaning up rpc.%s ...', mod.name) mod.cleanup() del mod diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 8ce2fa2e4..141368769 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -436,9 +436,13 @@ class Telegram(RPCHandler): # Notification disabled return - message = self.compose_message(msg, msg_type) - - self._send_msg(message, disable_notification=(noti == 'silent')) + # Would this be better than adding un-needed if statements to compose_message? + try: + message = self.compose_message(msg, msg_type) + self._send_msg(message, disable_notification=(noti == 'silent')) + except NotImplementedError: + # just skip it + return def _get_sell_emoji(self, msg): """ diff --git a/requirements-externalsignals.txt b/requirements-externalsignals.txt deleted file mode 100644 index 7920b34f6..000000000 --- a/requirements-externalsignals.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Include all requirements to run the bot. --r requirements.txt - -# Required for follower -websockets -msgpack -janus diff --git a/requirements.txt b/requirements.txt index 77925f98b..4d97f500a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,3 +50,8 @@ python-dateutil==2.8.2 #Futures schedule==1.1.0 + +#WS Messages +websockets~=10.3 +msgpack~=1.0.4 +janus==1.0.0 diff --git a/scripts/test_ws_client.py b/scripts/test_ws_client.py index 2c64ae867..872ff3ccf 100644 --- a/scripts/test_ws_client.py +++ b/scripts/test_ws_client.py @@ -1,6 +1,7 @@ import asyncio import logging import socket +from typing import Any import websockets @@ -12,8 +13,14 @@ logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) +def compose_consumer_request(type_: str, data: Any): + return {"type": type_, "data": data} + + async def _client(): - subscribe_topics = [RPCMessageType.WHITELIST] + # Trying to recreate multiple topic issue. Wait until first whitelist message, + # then CTRL-C to get the status message. + topics = [RPCMessageType.WHITELIST, RPCMessageType.STATUS] try: while True: try: @@ -23,7 +30,7 @@ async def _client(): logger.info("Connection successful") # Tell the producer we only want these topics - await channel.send(subscribe_topics) + await channel.send(compose_consumer_request("subscribe", topics)) while True: try: diff --git a/setup.py b/setup.py index 8f04e75f7..c7b1f1c7c 100644 --- a/setup.py +++ b/setup.py @@ -79,7 +79,10 @@ setup( 'psutil', 'pyjwt', 'aiofiles', - 'schedule' + 'schedule', + 'websockets', + 'msgpack', + 'janus' ], extras_require={ 'dev': all_extra, From 346e73dd75503f9170d6f6759a517d0f421e6fc6 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 30 Aug 2022 19:21:34 -0600 Subject: [PATCH 017/199] client implementation, minor fixes --- config_examples/config_full.example.json | 13 +- freqtrade/constants.py | 26 +- freqtrade/data/dataprovider.py | 10 +- freqtrade/enums/__init__.py | 2 +- freqtrade/enums/externalmessages.py | 7 + freqtrade/enums/externalsignal.py | 18 - freqtrade/freqtradebot.py | 17 +- freqtrade/rpc/api_server/api_ws.py | 2 +- freqtrade/rpc/api_server/webserver.py | 29 +- freqtrade/rpc/api_server/ws/channel.py | 3 - freqtrade/rpc/emc.py | 229 ++++++++++ freqtrade/rpc/external_signal/__init__.py | 5 - freqtrade/rpc/external_signal/channel.py | 145 ------- freqtrade/rpc/external_signal/controller.py | 449 -------------------- freqtrade/rpc/external_signal/proxy.py | 61 --- freqtrade/rpc/external_signal/serializer.py | 65 --- freqtrade/rpc/external_signal/types.py | 8 - freqtrade/rpc/external_signal/utils.py | 10 - freqtrade/rpc/rpc.py | 62 --- freqtrade/rpc/rpc_manager.py | 4 +- freqtrade/strategy/interface.py | 43 +- scripts/test_ws_client.py | 74 ---- 22 files changed, 323 insertions(+), 959 deletions(-) create mode 100644 freqtrade/enums/externalmessages.py delete mode 100644 freqtrade/enums/externalsignal.py create mode 100644 freqtrade/rpc/emc.py delete mode 100644 freqtrade/rpc/external_signal/__init__.py delete mode 100644 freqtrade/rpc/external_signal/channel.py delete mode 100644 freqtrade/rpc/external_signal/controller.py delete mode 100644 freqtrade/rpc/external_signal/proxy.py delete mode 100644 freqtrade/rpc/external_signal/serializer.py delete mode 100644 freqtrade/rpc/external_signal/types.py delete mode 100644 freqtrade/rpc/external_signal/utils.py delete mode 100644 scripts/test_ws_client.py diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 74457d2b6..ec988687f 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -172,7 +172,18 @@ "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "freqtrader", - "password": "SuperSecurePassword" + "password": "SuperSecurePassword", + "ws_token": "a_secret_ws_token", + "enable_message_ws": false + }, + "external_message_consumer": { + "enabled": false, + "producers": [ + { + "url": "ws://some.freqtrade.bot/api/v1/message/ws", + "ws_token": "a_secret_ws_token" + } + ] }, "bot_name": "freqtrade", "db_url": "sqlite:///tradesv3.sqlite", diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 96f8413b0..c7f2acc84 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -61,7 +61,6 @@ USERPATH_FREQAIMODELS = 'freqaimodels' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] -FOLLOWER_MODE_OPTIONS = ['follower', 'leader'] WAIT_DATA_POLICY_OPTIONS = ['none', 'first', 'all'] ENV_VAR_PREFIX = 'FREQTRADE__' @@ -246,7 +245,7 @@ CONF_SCHEMA = { 'exchange': {'$ref': '#/definitions/exchange'}, 'edge': {'$ref': '#/definitions/edge'}, 'freqai': {'$ref': '#/definitions/freqai'}, - 'external_signal': {'$ref': '#/definitions/external_signal'}, + 'external_message_consumer': {'$ref': '#/definitions/external_message_consumer'}, 'experimental': { 'type': 'object', 'properties': { @@ -404,7 +403,8 @@ CONF_SCHEMA = { }, 'username': {'type': 'string'}, 'password': {'type': 'string'}, - 'api_token': {'type': 'string'}, + 'ws_token': {'type': 'string'}, + 'enable_message_ws': {'type': 'boolean', 'default': False}, 'jwt_secret_key': {'type': 'string'}, 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, @@ -489,35 +489,31 @@ CONF_SCHEMA = { }, 'required': ['process_throttle_secs', 'allowed_risk'] }, - 'external_signal': { + 'external_message_consumer': { 'type': 'object', 'properties': { 'enabled': {'type': 'boolean', 'default': False}, - 'mode': { - 'type': 'string', - 'enum': FOLLOWER_MODE_OPTIONS - }, - 'api_token': {'type': 'string', 'default': ''}, - 'leaders': { + 'producers': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'url': {'type': 'string', 'default': ''}, - 'api_token': {'type': 'string', 'default': ''}, + 'ws_token': {'type': 'string', 'default': ''}, } } }, - 'follower_reply_timeout': {'type': 'integer'}, - 'follower_sleep_time': {'type': 'integer'}, - 'follower_ping_timeout': {'type': 'integer'}, + 'reply_timeout': {'type': 'integer'}, + 'sleep_time': {'type': 'integer'}, + 'ping_timeout': {'type': 'integer'}, 'wait_data_policy': { 'type': 'string', 'enum': WAIT_DATA_POLICY_OPTIONS }, + 'wait_data_timeout': {'type': 'integer', 'default': 5}, 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False} }, - 'required': ['mode'] + 'required': ['producers'] }, "freqai": { "type": "object", diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index cd70db9a3..430ee0932 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -48,11 +48,13 @@ class DataProvider: self.__msg_cache = PeriodicCache( maxsize=1000, ttl=timeframe_to_seconds(self._config.get('timeframe', '1h'))) - self._num_sources = len(self._config.get('external_signal', {}).get('leader_list', [])) - self._wait_data_policy = self._config.get('external_signal', {}).get( + self._num_sources = len( + self._config.get('external_message_consumer', {}).get('producers', []) + ) + self._wait_data_policy = self._config.get('external_message_consumer', {}).get( 'wait_data_policy', WaitDataPolicy.all) - self._wait_data_timeout = self._config.get( - 'external_signal', {}).get('wait_data_timeout', 5) + self._wait_data_timeout = self._config.get('external_message_consumer', {}).get( + 'wait_data_timeout', 5) def _set_dataframe_max_index(self, limit_index: int): """ diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index 406d847e6..229d770ce 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -3,7 +3,7 @@ from freqtrade.enums.backteststate import BacktestState from freqtrade.enums.candletype import CandleType from freqtrade.enums.exitchecktuple import ExitCheckTuple from freqtrade.enums.exittype import ExitType -from freqtrade.enums.externalsignal import ExternalSignalModeType, LeaderMessageType, WaitDataPolicy +from freqtrade.enums.externalmessages import WaitDataPolicy from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType diff --git a/freqtrade/enums/externalmessages.py b/freqtrade/enums/externalmessages.py new file mode 100644 index 000000000..e43899ab5 --- /dev/null +++ b/freqtrade/enums/externalmessages.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class WaitDataPolicy(str, Enum): + none = "none" + one = "one" + all = "all" diff --git a/freqtrade/enums/externalsignal.py b/freqtrade/enums/externalsignal.py deleted file mode 100644 index 05dc604a2..000000000 --- a/freqtrade/enums/externalsignal.py +++ /dev/null @@ -1,18 +0,0 @@ -from enum import Enum - - -class ExternalSignalModeType(str, Enum): - leader = "leader" - follower = "follower" - - -class LeaderMessageType(str, Enum): - default = "default" - pairlist = "pairlist" - analyzed_df = "analyzed_df" - - -class WaitDataPolicy(str, Enum): - none = "none" - one = "one" - all = "all" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index c9caaace6..c0d658c61 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -30,6 +30,7 @@ from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.protectionmanager import ProtectionManager from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.rpc import RPCManager +from freqtrade.rpc.emc import ExternalMessageConsumer from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.util import FtPrecise @@ -90,11 +91,17 @@ class FreqtradeBot(LoggingMixin): self.strategy.dp = self.dataprovider # Attach Wallets to strategy instance self.strategy.wallets = self.wallets + # Attach rpc to strategy instance + self.strategy.rpc = self.rpc # Initializing Edge only if enabled self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None + # Init ExternalMessageConsumer if enabled + self.emc = ExternalMessageConsumer(self.rpc._rpc, self.config) if \ + self.config.get('external_message_consumer', {}).get('enabled', False) else None + self.active_pair_whitelist = self._refresh_active_whitelist() # Set initial bot state from config @@ -150,6 +157,8 @@ class FreqtradeBot(LoggingMixin): self.check_for_open_trades() self.rpc.cleanup() + if self.emc: + self.emc.shutdown() Trade.commit() self.exchange.close() @@ -192,7 +201,11 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - self.strategy.analyze(self.active_pair_whitelist) + if self.emc: + leader_pairs = self.pairlists._whitelist + self.strategy.analyze_external(self.active_pair_whitelist, leader_pairs) + else: + self.strategy.analyze(self.active_pair_whitelist) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace @@ -255,7 +268,7 @@ class FreqtradeBot(LoggingMixin): self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist - self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'msg': _whitelist}) + self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'data': _whitelist}) # Calculating Edge positioning if self.edge: diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index c8d1b70fa..88bae099a 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -26,7 +26,7 @@ async def message_endpoint( # Return a channel ID, pass that instead of ws to the rest of the methods channel = await channel_manager.on_connect(ws) - # Keep connection open until explicitly closed, and sleep + # Keep connection open until explicitly closed, and process requests try: while not channel.is_closed(): request = await channel.recv() diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index f4af8c8ed..e391e66af 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -112,9 +112,6 @@ class ApiServer(RPCHandler): # Cancel the queue task self._background_task.cancel() - # Finally stop the loop - self._loop.call_soon_threadsafe(self._loop.stop) - self._thread.join() @classmethod @@ -127,7 +124,6 @@ class ApiServer(RPCHandler): def send_msg(self, msg: Dict[str, str]) -> None: if self._queue: - logger.info(f"Adding message to queue: {msg}") sync_q = self._queue.sync_q sync_q.put(msg) @@ -155,9 +151,11 @@ class ApiServer(RPCHandler): app.include_router(api_backtest, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) - app.include_router(ws_router, prefix="/api/v1", - dependencies=[Depends(get_ws_token)] - ) + if self._config.get('api_server', {}).get('enable_message_ws', False): + logger.info("Enabling Message WebSocket") + app.include_router(ws_router, prefix="/api/v1", + dependencies=[Depends(get_ws_token)] + ) app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') @@ -194,17 +192,19 @@ class ApiServer(RPCHandler): try: while True: - logger.debug("Getting queue data...") + logger.debug("Getting queue messages...") # Get data from queue - data = await async_queue.get() - logger.debug(f"Found data: {data}") + message = await async_queue.get() + logger.debug(f"Found message of type: {message.get('type')}") # Broadcast it - await self._channel_manager.broadcast(data) + await self._channel_manager.broadcast(message) # Sleep, make this configurable? await asyncio.sleep(0.1) except asyncio.CancelledError: - # Silently stop - pass + # Disconnect channels and stop the loop on cancel + await self._channel_manager.disconnect_all() + self._loop.stop() + # For testing, shouldn't happen when stable except Exception as e: logger.info(f"Exception happened in background task: {e}") @@ -246,7 +246,8 @@ class ApiServer(RPCHandler): if self._standalone: self._server.run() else: - self.start_message_queue() + if self._config.get('api_server', {}).get('enable_message_ws', False): + self.start_message_queue() self._server.run_in_thread() except Exception: logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index f24713a77..6bc5b9d6b 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -116,8 +116,6 @@ class ChannelManager: with self._lock: channel = self.channels.get(websocket) if channel: - logger.debug(f"Disconnecting channel - {channel}") - if not channel.is_closed(): await channel.close() @@ -142,7 +140,6 @@ class ChannelManager: """ with self._lock: message_type = data.get('type') - logger.debug(f"Broadcasting data: {message_type} - {data}") for websocket, channel in self.channels.items(): try: if channel.subscribed_to(message_type): diff --git a/freqtrade/rpc/emc.py b/freqtrade/rpc/emc.py new file mode 100644 index 000000000..48ad78266 --- /dev/null +++ b/freqtrade/rpc/emc.py @@ -0,0 +1,229 @@ +""" +ExternalMessageConsumer module + +Main purpose is to connect to external bot's message websocket to consume data +from it +""" +import asyncio +import logging +import socket +from threading import Thread +from typing import Any, Dict + +import websockets + +from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.misc import json_to_dataframe, remove_entry_exit_signals +from freqtrade.rpc import RPC +from freqtrade.rpc.api_server.ws.channel import WebSocketChannel + + +logger = logging.getLogger(__name__) + + +class ExternalMessageConsumer: + """ + The main controller class for consuming external messages from + other FreqTrade bot's + """ + + def __init__( + self, + rpc: RPC, + config: Dict[str, Any], + ): + self._rpc = rpc + self._config = config + + self._running = False + self._thread = None + self._loop = None + self._main_task = None + self._sub_tasks = None + + self._emc_config = self._config.get('external_message_consumer', {}) + + self.enabled = self._emc_config.get('enabled', False) + self.producers = self._emc_config.get('producers', []) + + if self.enabled and len(self.producers) < 1: + raise ValueError("You must specify at least 1 Producer to connect to.") + + self.reply_timeout = self._emc_config.get('reply_timeout', 10) + self.ping_timeout = self._emc_config.get('ping_timeout', 2) + self.sleep_time = self._emc_config.get('sleep_time', 5) + + # Setting these explicitly as they probably shouldn't be changed by a user + # Unless we somehow integrate this with the strategy to allow creating + # callbacks for the messages + self.topics = [RPCMessageType.WHITELIST, RPCMessageType.ANALYZED_DF] + + self.start() + + def start(self): + """ + Start the main internal loop in another thread to run coroutines + """ + self._loop = asyncio.new_event_loop() + + if not self._thread: + logger.info("Starting ExternalMessageConsumer") + + self._thread = Thread(target=self._loop.run_forever) + self._thread.start() + self._running = True + else: + raise RuntimeError("A loop is already running") + + self._main_task = asyncio.run_coroutine_threadsafe(self._main(), loop=self._loop) + + def shutdown(self): + """ + Shutdown the loop, thread, and tasks + """ + if self._thread and self._loop: + logger.info("Stopping ExternalMessageConsumer") + + if self._sub_tasks: + # Cancel sub tasks + for task in self._sub_tasks: + task.cancel() + + if self._main_task: + # Cancel the main task + self._main_task.cancel() + + self._thread.join() + + async def _main(self): + """ + The main task coroutine + """ + rpc_lock = asyncio.Lock() + + try: + # Create a connection to each producer + self._sub_tasks = [ + self._loop.create_task(self._handle_producer_connection(producer, rpc_lock)) + for producer in self.producers + ] + + await asyncio.gather(*self._sub_tasks) + except asyncio.CancelledError: + pass + finally: + # Stop the loop once we are done + self._loop.stop() + + async def _handle_producer_connection(self, producer, lock): + """ + Main connection loop for the consumer + """ + try: + while True: + try: + url, token = producer['url'], producer['ws_token'] + ws_url = f"{url}?token={token}" + + async with websockets.connect(ws_url) as ws: + logger.info("Connection successful") + channel = WebSocketChannel(ws) + + # Tell the producer we only want these topics + # Should always be the first thing we send + await channel.send( + self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) + ) + + # Now receive data, if none is within the time limit, ping + while True: + try: + message = await asyncio.wait_for( + channel.recv(), + timeout=5 + ) + + async with lock: + # Handle the data here + # We use a lock because it will call RPC methods + self.handle_producer_message(message) + + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await channel.ping() + + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.debug(f"Connection to {url} still alive...") + + continue + except Exception: + logger.info( + f"Ping error {url} - retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + + break + except ( + socket.gaierror, + ConnectionRefusedError, + websockets.exceptions.InvalidStatusCode + ) as e: + logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + + continue + + except asyncio.CancelledError: + # Exit silently + pass + + def compose_consumer_request(self, type_: str, data: Any) -> Dict[str, Any]: + """ + Create a request for sending to a producer + + :param type_: The RPCRequestType + :param data: The data to send + :returns: Dict[str, Any] + """ + return {'type': type_, 'data': data} + + # How we do things here isn't set in stone. There seems to be some interest + # in figuring out a better way, but we shall do this for now. + def handle_producer_message(self, message: Dict[str, Any]): + """ + Handles external messages from a Producer + """ + # Should we have a default message type? + message_type = message.get('type', RPCMessageType.STATUS) + message_data = message.get('data') + + logger.debug(f"Received message of type {message_type}") + + # Handle Whitelists + if message_type == RPCMessageType.WHITELIST: + pairlist = message_data + + # Add the pairlist data to the ExternalPairlist plugin + external_pairlist = self._rpc._freqtrade.pairlists._pairlist_handlers[0] + external_pairlist.add_pairlist_data(pairlist) + + # Handle analyzed dataframes + elif message_type == RPCMessageType.ANALYZED_DF: + # This shouldn't happen + if message_data is None: + return + + key, value = message_data.get('key'), message_data.get('data') + pair, timeframe, candle_type = key + + # Convert the JSON to a pandas DataFrame + dataframe = json_to_dataframe(value) + + # If set, remove the Entry and Exit signals from the Producer + if self._emc_config.get('remove_entry_exit_signals', False): + dataframe = remove_entry_exit_signals(dataframe) + + # Add the dataframe to the dataprovider + dataprovider = self._rpc._freqtrade.dataprovider + dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) diff --git a/freqtrade/rpc/external_signal/__init__.py b/freqtrade/rpc/external_signal/__init__.py deleted file mode 100644 index decc51551..000000000 --- a/freqtrade/rpc/external_signal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# # flake8: noqa: F401 -# from freqtrade.rpc.external_signal.controller import ExternalSignalController -# -# -# __all__ = ('ExternalSignalController') diff --git a/freqtrade/rpc/external_signal/channel.py b/freqtrade/rpc/external_signal/channel.py deleted file mode 100644 index 5b278dfed..000000000 --- a/freqtrade/rpc/external_signal/channel.py +++ /dev/null @@ -1,145 +0,0 @@ -# import logging -# from threading import RLock -# from typing import Type -# -# from freqtrade.rpc.external_signal.proxy import WebSocketProxy -# from freqtrade.rpc.external_signal.serializer import MsgPackWebSocketSerializer -# from freqtrade.rpc.external_signal.types import WebSocketType -# -# -# logger = logging.getLogger(__name__) -# -# -# class WebSocketChannel: -# """ -# Object to help facilitate managing a websocket connection -# """ -# -# def __init__( -# self, -# websocket: WebSocketType, -# serializer_cls: Type[WebSocketSerializer] = MsgPackWebSocketSerializer -# ): -# # The WebSocket object -# self._websocket = WebSocketProxy(websocket) -# # The Serializing class for the WebSocket object -# self._serializer_cls = serializer_cls -# -# # Internal event to signify a closed websocket -# self._closed = False -# -# # Wrap the WebSocket in the Serializing class -# self._wrapped_ws = self._serializer_cls(self._websocket) -# -# async def send(self, data): -# """ -# Send data on the wrapped websocket -# """ -# # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") -# await self._wrapped_ws.send(data) -# -# async def recv(self): -# """ -# Receive data on the wrapped websocket -# """ -# return await self._wrapped_ws.recv() -# -# async def ping(self): -# """ -# Ping the websocket -# """ -# return await self._websocket.ping() -# -# async def close(self): -# """ -# Close the WebSocketChannel -# """ -# -# self._closed = True -# -# def is_closed(self): -# return self._closed -# -# -# class ChannelManager: -# def __init__(self): -# self.channels = dict() -# self._lock = RLock() # Re-entrant Lock -# -# async def on_connect(self, websocket: WebSocketType): -# """ -# Wrap websocket connection into Channel and add to list -# -# :param websocket: The WebSocket object to attach to the Channel -# """ -# if hasattr(websocket, "accept"): -# try: -# await websocket.accept() -# except RuntimeError: -# # The connection was closed before we could accept it -# return -# -# ws_channel = WebSocketChannel(websocket) -# -# with self._lock: -# self.channels[websocket] = ws_channel -# -# return ws_channel -# -# async def on_disconnect(self, websocket: WebSocketType): -# """ -# Call close on the channel if it's not, and remove from channel list -# -# :param websocket: The WebSocket objet attached to the Channel -# """ -# with self._lock: -# channel = self.channels.get(websocket) -# if channel: -# logger.debug(f"Disconnecting channel - {channel}") -# -# if not channel.is_closed(): -# await channel.close() -# -# del self.channels[websocket] -# -# async def disconnect_all(self): -# """ -# Disconnect all Channels -# """ -# with self._lock: -# for websocket, channel in self.channels.items(): -# if not channel.is_closed(): -# await channel.close() -# -# self.channels = dict() -# -# async def broadcast(self, data): -# """ -# Broadcast data on all Channels -# -# :param data: The data to send -# """ -# with self._lock: -# for websocket, channel in self.channels.items(): -# try: -# await channel.send(data) -# except RuntimeError: -# # Handle cannot send after close cases -# await self.on_disconnect(websocket) -# -# async def send_direct(self, channel, data): -# """ -# Send data directly through direct_channel only -# -# :param direct_channel: The WebSocketChannel object to send data through -# :param data: The data to send -# """ -# # We iterate over the channels to get reference to the websocket object -# # so we can disconnect incase of failure -# await channel.send(data) -# -# def has_channels(self): -# """ -# Flag for more than 0 channels -# """ -# return len(self.channels) > 0 diff --git a/freqtrade/rpc/external_signal/controller.py b/freqtrade/rpc/external_signal/controller.py deleted file mode 100644 index 616ea7801..000000000 --- a/freqtrade/rpc/external_signal/controller.py +++ /dev/null @@ -1,449 +0,0 @@ -# """ -# This module manages replicate mode communication -# """ -# import asyncio -# import logging -# import secrets -# import socket -# from threading import Thread -# from typing import Any, Callable, Coroutine, Dict, Union -# -# import websockets -# from fastapi import Depends -# from fastapi import WebSocket as FastAPIWebSocket -# from fastapi import WebSocketDisconnect, status -# from janus import Queue as ThreadedQueue -# -# from freqtrade.enums import ExternalSignalModeType, LeaderMessageType, RPCMessageType -# from freqtrade.rpc import RPC, RPCHandler -# from freqtrade.rpc.external_signal.channel import ChannelManager -# from freqtrade.rpc.external_signal.types import MessageType -# from freqtrade.rpc.external_signal.utils import is_websocket_alive -# -# -# logger = logging.getLogger(__name__) -# -# -# class ExternalSignalController(RPCHandler): -# """ This class handles all websocket communication """ -# -# def __init__( -# self, -# rpc: RPC, -# config: Dict[str, Any], -# api_server: Union[Any, None] = None -# ) -> None: -# """ -# Init the ExternalSignalController class, and init the super class RPCHandler -# :param rpc: instance of RPC Helper class -# :param config: Configuration object -# :param api_server: The ApiServer object -# :return: None -# """ -# super().__init__(rpc, config) -# -# self.freqtrade = rpc._freqtrade -# self.api_server = api_server -# -# if not self.api_server: -# raise RuntimeError("The API server must be enabled for external signals to work") -# -# self._loop = None -# self._running = False -# self._thread = None -# self._queue = None -# -# self._main_task = None -# self._sub_tasks = None -# -# self._message_handlers = { -# LeaderMessageType.pairlist: self._rpc._handle_pairlist_message, -# LeaderMessageType.analyzed_df: self._rpc._handle_analyzed_df_message, -# LeaderMessageType.default: self._rpc._handle_default_message -# } -# -# self.channel_manager = ChannelManager() -# self.external_signal_config = config.get('external_signal', {}) -# -# # What the config should look like -# # "external_signal": { -# # "enabled": true, -# # "mode": "follower", -# # "leaders": [ -# # { -# # "url": "ws://localhost:8080/signals/ws", -# # "api_token": "test" -# # } -# # ] -# # } -# -# # "external_signal": { -# # "enabled": true, -# # "mode": "leader", -# # "api_token": "test" -# # } -# -# self.mode = ExternalSignalModeType[ -# self.external_signal_config.get('mode', 'leader').lower() -# ] -# -# self.leaders_list = self.external_signal_config.get('leaders', []) -# self.push_throttle_secs = self.external_signal_config.get('push_throttle_secs', 0.1) -# -# self.reply_timeout = self.external_signal_config.get('follower_reply_timeout', 10) -# self.ping_timeout = self.external_signal_config.get('follower_ping_timeout', 2) -# self.sleep_time = self.external_signal_config.get('follower_sleep_time', 5) -# -# # Validate external_signal_config here? -# -# if self.mode == ExternalSignalModeType.follower and len(self.leaders_list) == 0: -# raise ValueError("You must specify at least 1 leader in follower mode.") -# -# # This is only used by the leader, the followers use the tokens specified -# # in each of the leaders -# # If you do not specify an API key in the config, one will be randomly -# # generated and logged on startup -# default_api_key = secrets.token_urlsafe(16) -# self.secret_api_key = self.external_signal_config.get('api_token', default_api_key) -# -# self.start() -# -# def is_leader(self): -# """ -# Leader flag -# """ -# return self.enabled() and self.mode == ExternalSignalModeType.leader -# -# def enabled(self): -# """ -# Enabled flag -# """ -# return self.external_signal_config.get('enabled', False) -# -# def num_leaders(self): -# """ -# The number of leaders we should be connected to -# """ -# return len(self.leaders_list) -# -# def start_threaded_loop(self): -# """ -# Start the main internal loop in another thread to run coroutines -# """ -# self._loop = asyncio.new_event_loop() -# -# if not self._thread: -# self._thread = Thread(target=self._loop.run_forever) -# self._thread.start() -# self._running = True -# else: -# raise RuntimeError("A loop is already running") -# -# def submit_coroutine(self, coroutine: Coroutine): -# """ -# Submit a coroutine to the threaded loop -# """ -# if not self._running: -# raise RuntimeError("Cannot schedule new futures after shutdown") -# -# if not self._loop or not self._loop.is_running(): -# raise RuntimeError("Loop must be started before any function can" -# " be submitted") -# -# return asyncio.run_coroutine_threadsafe(coroutine, self._loop) -# -# def start(self): -# """ -# Start the controller main loop -# """ -# self.start_threaded_loop() -# self._main_task = self.submit_coroutine(self.main()) -# -# async def shutdown(self): -# """ -# Shutdown all tasks and close up -# """ -# logger.info("Stopping rpc.externalsignalcontroller") -# -# # Flip running flag -# self._running = False -# -# # Cancel sub tasks -# for task in self._sub_tasks: -# task.cancel() -# -# # Then disconnect all channels -# await self.channel_manager.disconnect_all() -# -# def cleanup(self) -> None: -# """ -# Cleanup pending module resources. -# """ -# if self._thread: -# if self._loop.is_running(): -# self._main_task.cancel() -# self._thread.join() -# -# async def main(self): -# """ -# Main coro -# -# Start the loop based on what mode we're in -# """ -# try: -# if self.mode == ExternalSignalModeType.leader: -# logger.info("Starting rpc.externalsignalcontroller in Leader mode") -# -# await self.run_leader_mode() -# elif self.mode == ExternalSignalModeType.follower: -# logger.info("Starting rpc.externalsignalcontroller in Follower mode") -# -# await self.run_follower_mode() -# -# except asyncio.CancelledError: -# # We're cancelled -# await self.shutdown() -# except Exception as e: -# # Log the error -# logger.error(f"Exception occurred in main task: {e}") -# logger.exception(e) -# finally: -# # This coroutine is the last thing to be ended, so it should stop the loop -# self._loop.stop() -# -# def log_api_token(self): -# """ -# Log the API token -# """ -# logger.info("-" * 15) -# logger.info(f"API_KEY: {self.secret_api_key}") -# logger.info("-" * 15) -# -# def send_msg(self, msg: MessageType) -> None: -# """ -# Support RPC calls -# """ -# if msg["type"] == RPCMessageType.EMIT_DATA: -# message = msg.get("message") -# if message: -# self.send_message(message) -# else: -# logger.error(f"Message is empty! {msg}") -# -# def send_message(self, msg: MessageType) -> None: -# """ -# Broadcast message over all channels if there are any -# """ -# -# if self.channel_manager.has_channels(): -# self._send_message(msg) -# else: -# logger.debug("No listening followers, skipping...") -# pass -# -# def _send_message(self, msg: MessageType): -# """ -# Add data to the internal queue to be broadcasted. This func will block -# if the queue is full. This is meant to be called in the main thread. -# """ -# if self._queue: -# queue = self._queue.sync_q -# queue.put(msg) # This will block if the queue is full -# else: -# logger.warning("Can not send data, leader loop has not started yet!") -# -# async def send_initial_data(self, channel): -# logger.info("Sending initial data through channel") -# -# data = self._rpc._initial_leader_data() -# -# for message in data: -# await channel.send(message) -# -# async def _handle_leader_message(self, message: MessageType): -# """ -# Handle message received from a Leader -# """ -# type = message.get("data_type", LeaderMessageType.default) -# data = message.get("data") -# -# handler: Callable = self._message_handlers[type] -# handler(type, data) -# -# # ---------------------------------------------------------------------- -# -# async def run_leader_mode(self): -# """ -# Main leader coroutine -# -# This starts all of the leader coros and registers the endpoint on -# the ApiServer -# """ -# self.register_leader_endpoint() -# self.log_api_token() -# -# self._sub_tasks = [ -# self._loop.create_task(self._broadcast_queue_data()) -# ] -# -# return await asyncio.gather(*self._sub_tasks) -# -# async def run_follower_mode(self): -# """ -# Main follower coroutine -# -# This starts all of the follower connection coros -# """ -# -# rpc_lock = asyncio.Lock() -# -# self._sub_tasks = [ -# self._loop.create_task(self._handle_leader_connection(leader, rpc_lock)) -# for leader in self.leaders_list -# ] -# -# return await asyncio.gather(*self._sub_tasks) -# -# async def _broadcast_queue_data(self): -# """ -# Loop over queue data and broadcast it -# """ -# # Instantiate the queue in this coroutine so it's attached to our loop -# self._queue = ThreadedQueue() -# async_queue = self._queue.async_q -# -# try: -# while self._running: -# # Get data from queue -# data = await async_queue.get() -# -# # Broadcast it to everyone -# await self.channel_manager.broadcast(data) -# -# # Sleep -# await asyncio.sleep(self.push_throttle_secs) -# -# except asyncio.CancelledError: -# # Silently stop -# pass -# -# async def get_api_token( -# self, -# websocket: FastAPIWebSocket, -# token: Union[str, None] = None -# ): -# """ -# Extract the API key from query param. Must match the -# set secret_api_key or the websocket connection will be closed. -# """ -# if token == self.secret_api_key: -# return token -# else: -# logger.info("Denying websocket request...") -# await websocket.close(code=status.WS_1008_POLICY_VIOLATION) -# -# def register_leader_endpoint(self, path: str = "/signals/ws"): -# """ -# Attach and start the main leader loop to the ApiServer -# -# :param path: The endpoint path -# """ -# if not self.api_server: -# raise RuntimeError("The leader needs the ApiServer to be active") -# -# # The endpoint function for running the main leader loop -# @self.api_server.app.websocket(path) -# async def leader_endpoint( -# websocket: FastAPIWebSocket, -# api_key: str = Depends(self.get_api_token) -# ): -# await self.leader_endpoint_loop(websocket) -# -# async def leader_endpoint_loop(self, websocket: FastAPIWebSocket): -# """ -# The WebSocket endpoint served by the ApiServer. This handles connections, -# and adding them to the channel manager. -# """ -# try: -# if is_websocket_alive(websocket): -# logger.info(f"Follower connected - {websocket.client}") -# channel = await self.channel_manager.on_connect(websocket) -# -# # Send initial data here -# # Data is being broadcasted right away as soon as startup, -# # we may not have to send initial data at all. Further testing -# # required. -# await self.send_initial_data(channel) -# -# # Keep connection open until explicitly closed, and sleep -# try: -# while not channel.is_closed(): -# request = await channel.recv() -# logger.info(f"Follower request - {request}") -# -# except WebSocketDisconnect: -# # Handle client disconnects -# logger.info(f"Follower disconnected - {websocket.client}") -# await self.channel_manager.on_disconnect(websocket) -# except Exception as e: -# logger.info(f"Follower connection failed - {websocket.client}") -# logger.exception(e) -# # Handle cases like - -# # RuntimeError('Cannot call "send" once a closed message has been sent') -# await self.channel_manager.on_disconnect(websocket) -# -# except Exception: -# logger.error(f"Failed to serve - {websocket.client}") -# await self.channel_manager.on_disconnect(websocket) -# -# async def _handle_leader_connection(self, leader, lock): -# """ -# Given a leader, connect and wait on data. If connection is lost, -# it will attempt to reconnect. -# """ -# try: -# url, token = leader["url"], leader["api_token"] -# websocket_url = f"{url}?token={token}" -# -# logger.info(f"Attempting to connect to Leader at: {url}") -# while True: -# try: -# async with websockets.connect(websocket_url) as ws: -# channel = await self.channel_manager.on_connect(ws) -# logger.info(f"Connection to Leader at {url} successful") -# while True: -# try: -# data = await asyncio.wait_for( -# channel.recv(), -# timeout=self.reply_timeout -# ) -# except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): -# # We haven't received data yet. Check the connection and continue. -# try: -# # ping -# ping = await channel.ping() -# await asyncio.wait_for(ping, timeout=self.ping_timeout) -# logger.debug(f"Connection to {url} still alive...") -# continue -# except Exception: -# logger.info( -# f"Ping error {url} - retrying in {self.sleep_time}s") -# asyncio.sleep(self.sleep_time) -# break -# -# async with lock: -# # Acquire lock so only 1 coro handling at a time -# # as we call the RPC module in the main thread -# await self._handle_leader_message(data) -# -# except (socket.gaierror, ConnectionRefusedError): -# logger.info(f"Connection Refused - retrying connection in {self.sleep_time}s") -# await asyncio.sleep(self.sleep_time) -# continue -# except websockets.exceptions.InvalidStatusCode as e: -# logger.error(f"Connection Refused - {e}") -# await asyncio.sleep(self.sleep_time) -# continue -# -# except asyncio.CancelledError: -# pass diff --git a/freqtrade/rpc/external_signal/proxy.py b/freqtrade/rpc/external_signal/proxy.py deleted file mode 100644 index df2a07da0..000000000 --- a/freqtrade/rpc/external_signal/proxy.py +++ /dev/null @@ -1,61 +0,0 @@ -# from typing import Union -# -# from fastapi import WebSocket as FastAPIWebSocket -# from websockets import WebSocketClientProtocol as WebSocket -# -# from freqtrade.rpc.external_signal.types import WebSocketType -# -# -# class WebSocketProxy: -# """ -# WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol -# under the same API -# """ -# -# def __init__(self, websocket: WebSocketType): -# self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket -# -# async def send(self, data): -# """ -# Send data on the wrapped websocket -# """ -# if isinstance(data, str): -# data = data.encode() -# -# if hasattr(self._websocket, "send_bytes"): -# await self._websocket.send_bytes(data) -# else: -# await self._websocket.send(data) -# -# async def recv(self): -# """ -# Receive data on the wrapped websocket -# """ -# if hasattr(self._websocket, "receive_bytes"): -# return await self._websocket.receive_bytes() -# else: -# return await self._websocket.recv() -# -# async def ping(self): -# """ -# Ping the websocket, not supported by FastAPI WebSockets -# """ -# if hasattr(self._websocket, "ping"): -# return await self._websocket.ping() -# return False -# -# async def close(self, code: int = 1000): -# """ -# Close the websocket connection, only supported by FastAPI WebSockets -# """ -# if hasattr(self._websocket, "close"): -# return await self._websocket.close(code) -# pass -# -# async def accept(self): -# """ -# Accept the WebSocket connection, only support by FastAPI WebSockets -# """ -# if hasattr(self._websocket, "accept"): -# return await self._websocket.accept() -# pass diff --git a/freqtrade/rpc/external_signal/serializer.py b/freqtrade/rpc/external_signal/serializer.py deleted file mode 100644 index a23469ef4..000000000 --- a/freqtrade/rpc/external_signal/serializer.py +++ /dev/null @@ -1,65 +0,0 @@ -# import json -# import logging -# from abc import ABC, abstractmethod -# -# import msgpack -# import orjson -# -# from freqtrade.rpc.external_signal.proxy import WebSocketProxy -# -# -# logger = logging.getLogger(__name__) -# -# -# class WebSocketSerializer(ABC): -# def __init__(self, websocket: WebSocketProxy): -# self._websocket: WebSocketProxy = websocket -# -# @abstractmethod -# def _serialize(self, data): -# raise NotImplementedError() -# -# @abstractmethod -# def _deserialize(self, data): -# raise NotImplementedError() -# -# async def send(self, data: bytes): -# await self._websocket.send(self._serialize(data)) -# -# async def recv(self) -> bytes: -# data = await self._websocket.recv() -# -# return self._deserialize(data) -# -# async def close(self, code: int = 1000): -# await self._websocket.close(code) -# -# # Going to explore using MsgPack as the serialization, -# # as that might be the best method for sending pandas -# # dataframes over the wire -# -# -# class JSONWebSocketSerializer(WebSocketSerializer): -# def _serialize(self, data): -# return json.dumps(data) -# -# def _deserialize(self, data): -# return json.loads(data) -# -# -# class ORJSONWebSocketSerializer(WebSocketSerializer): -# ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY -# -# def _serialize(self, data): -# return orjson.dumps(data, option=self.ORJSON_OPTIONS) -# -# def _deserialize(self, data): -# return orjson.loads(data, option=self.ORJSON_OPTIONS) -# -# -# class MsgPackWebSocketSerializer(WebSocketSerializer): -# def _serialize(self, data): -# return msgpack.packb(data, use_bin_type=True) -# -# def _deserialize(self, data): -# return msgpack.unpackb(data, raw=False) diff --git a/freqtrade/rpc/external_signal/types.py b/freqtrade/rpc/external_signal/types.py deleted file mode 100644 index 38e43f667..000000000 --- a/freqtrade/rpc/external_signal/types.py +++ /dev/null @@ -1,8 +0,0 @@ -# from typing import Any, Dict, TypeVar -# -# from fastapi import WebSocket as FastAPIWebSocket -# from websockets import WebSocketClientProtocol as WebSocket -# -# -# WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) -# MessageType = Dict[str, Any] diff --git a/freqtrade/rpc/external_signal/utils.py b/freqtrade/rpc/external_signal/utils.py deleted file mode 100644 index 72c8d2ef8..000000000 --- a/freqtrade/rpc/external_signal/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -# from starlette.websockets import WebSocket, WebSocketState -# -# -# async def is_websocket_alive(ws: WebSocket) -> bool: -# if ( -# ws.application_state == WebSocketState.CONNECTED and -# ws.client_state == WebSocketState.CONNECTED -# ): -# return True -# return False diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index f684c7783..a41d08d55 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1099,65 +1099,3 @@ class RPC: if all([any(x.value == topic for x in RPCMessageType) for topic in data]): logger.debug(f"{channel} subscribed to topics: {data}") channel.set_subscriptions(data) - # - # # ------------------------------ EXTERNAL SIGNALS ----------------------- - # - # def _initial_leader_data(self): - # # We create a list of Messages to send to the follower on connect - # data = [] - # - # # Send Pairlist data - # data.append({ - # "data_type": LeaderMessageType.pairlist, - # "data": self._freqtrade.pairlists._whitelist - # }) - # - # return data - # - # def _handle_pairlist_message(self, type, data): - # """ - # Handles the emitted pairlists from the Leaders - # - # :param type: The data_type of the data - # :param data: The data - # """ - # pairlist = data - # - # logger.debug(f"Handling Pairlist message: {pairlist}") - # - # external_pairlist = self._freqtrade.pairlists._pairlist_handlers[0] - # external_pairlist.add_pairlist_data(pairlist) - # - # def _handle_analyzed_df_message(self, type, data): - # """ - # Handles the analyzed dataframes from the Leaders - # - # :param type: The data_type of the data - # :param data: The data - # """ - # key, value = data["key"], data["value"] - # pair, timeframe, candle_type = key - # - # # Skip any pairs that we don't have in the pairlist? - # # leader_pairlist = self._freqtrade.pairlists._whitelist - # # if pair not in leader_pairlist: - # # return - # - # dataframe = json_to_dataframe(value) - # - # if self._config.get('external_signal', {}).get('remove_signals_analyzed_df', False): - # dataframe = remove_entry_exit_signals(dataframe) - # - # logger.debug(f"Handling analyzed dataframe for {pair}") - # logger.debug(dataframe.tail()) - # - # # Add the dataframe to the dataprovider - # dataprovider = self._freqtrade.dataprovider - # dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) - # - # def _handle_default_message(self, type, data): - # """ - # Default leader message handler, just logs it. We should never have to - # run this unless the leader sends us some weird message. - # """ - # logger.debug(f"Received message from Leader of type {type}: {data}") diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index b3cd5604c..3488a6e3c 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -78,7 +78,9 @@ class RPCManager: 'status': 'stopping bot' } """ - logger.info('Sending rpc message: %s', msg) + # Removed actually showing the message because the logs would be + # completely spammed of the json dataframe + logger.info('Sending rpc message of type: %s', msg.get('type')) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 22a10b4d3..7120928ff 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -5,19 +5,21 @@ This module defines the interface to apply for strategies import logging from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import arrow from pandas import DataFrame from freqtrade.constants import ListPairsWithTimeframes from freqtrade.data.dataprovider import DataProvider -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, SignalTagType, - SignalType, TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RPCMessageType, SignalDirection, + SignalTagType, SignalType, TradingMode) from freqtrade.enums.runmode import RunMode from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds +from freqtrade.misc import dataframe_to_json, remove_entry_exit_signals from freqtrade.persistence import Order, PairLocks, Trade +from freqtrade.rpc import RPCManager from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -111,6 +113,7 @@ class IStrategy(ABC, HyperStrategyMixin): # and wallets - access to the current balance. dp: DataProvider wallets: Optional[Wallets] = None + rpc: RPCManager # Filled from configuration stake_currency: str # container variable for strategy source code @@ -702,8 +705,7 @@ class IStrategy(ABC, HyperStrategyMixin): self, dataframe: DataFrame, metadata: dict, - external_data: bool = False, - finish_callback: Optional[Callable] = None, + external_data: bool = False ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame @@ -729,17 +731,20 @@ class IStrategy(ABC, HyperStrategyMixin): candle_type = self.config.get('candle_type_def', CandleType.SPOT) self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) - if finish_callback: - finish_callback(pair, dataframe, self.timeframe, candle_type) + if not external_data: + self.rpc.send_msg( + { + 'type': RPCMessageType.ANALYZED_DF, + 'data': { + 'key': (pair, self.timeframe, candle_type), + 'value': dataframe_to_json(dataframe) + } + } + ) else: logger.debug("Skipping TA Analysis for already analyzed candle") - dataframe[SignalType.ENTER_LONG.value] = 0 - dataframe[SignalType.EXIT_LONG.value] = 0 - dataframe[SignalType.ENTER_SHORT.value] = 0 - dataframe[SignalType.EXIT_SHORT.value] = 0 - dataframe[SignalTagType.ENTER_TAG.value] = None - dataframe[SignalTagType.EXIT_TAG.value] = None + dataframe = remove_entry_exit_signals(dataframe) logger.debug("Loop Analysis Launched") @@ -748,8 +753,7 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze_pair( self, pair: str, - external_data: bool = False, - finish_callback: Optional[Callable] = None, + external_data: bool = False ) -> None: """ Fetch data for this pair from dataprovider and analyze. @@ -773,7 +777,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = strategy_safe_wrapper( self._analyze_ticker_internal, message="" - )(dataframe, {'pair': pair}, external_data, finish_callback) + )(dataframe, {'pair': pair}, external_data) self.assert_df(dataframe, df_len, df_close, df_date) except StrategyError as error: @@ -786,15 +790,14 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze( self, - pairs: List[str], - finish_callback: Optional[Callable] = None + pairs: List[str] ) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze """ for pair in pairs: - self.analyze_pair(pair, finish_callback=finish_callback) + self.analyze_pair(pair) def analyze_external(self, pairs: List[str], leader_pairs: List[str]) -> None: """ @@ -808,10 +811,10 @@ class IStrategy(ABC, HyperStrategyMixin): # them normally. # List order is not preserved when doing this! # We use ^ instead of - for symmetric difference - # What do we do with these? extra_pairs = list(set(pairs) ^ set(leader_pairs)) # These would be the pairs that we have trades in, which means # we would have to analyze them normally + # Eventually maybe request data from the Leader if we don't have it? for pair in leader_pairs: # Analyze the pairs, but get the dataframe from the external data diff --git a/scripts/test_ws_client.py b/scripts/test_ws_client.py deleted file mode 100644 index 872ff3ccf..000000000 --- a/scripts/test_ws_client.py +++ /dev/null @@ -1,74 +0,0 @@ -import asyncio -import logging -import socket -from typing import Any - -import websockets - -from freqtrade.enums import RPCMessageType -from freqtrade.rpc.api_server.ws.channel import WebSocketChannel - - -logging.basicConfig(level=logging.DEBUG) -logger = logging.getLogger(__name__) - - -def compose_consumer_request(type_: str, data: Any): - return {"type": type_, "data": data} - - -async def _client(): - # Trying to recreate multiple topic issue. Wait until first whitelist message, - # then CTRL-C to get the status message. - topics = [RPCMessageType.WHITELIST, RPCMessageType.STATUS] - try: - while True: - try: - url = "ws://localhost:8080/api/v1/message/ws?token=testtoken" - async with websockets.connect(url) as ws: - channel = WebSocketChannel(ws) - - logger.info("Connection successful") - # Tell the producer we only want these topics - await channel.send(compose_consumer_request("subscribe", topics)) - - while True: - try: - data = await asyncio.wait_for( - channel.recv(), - timeout=5 - ) - logger.info(f"Data received - {data}") - except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): - # We haven't received data yet. Check the connection and continue. - try: - # ping - ping = await channel.ping() - await asyncio.wait_for(ping, timeout=2) - logger.debug(f"Connection to {url} still alive...") - continue - except Exception: - logger.info( - f"Ping error {url} - retrying in 5s") - await asyncio.sleep(2) - break - - except (socket.gaierror, ConnectionRefusedError): - logger.info("Connection Refused - retrying connection in 5s") - await asyncio.sleep(2) - continue - except websockets.exceptions.InvalidStatusCode as e: - logger.error(f"Connection Refused - {e}") - await asyncio.sleep(2) - continue - - except (asyncio.CancelledError, KeyboardInterrupt): - pass - - -def main(): - asyncio.run(_client()) - - -if __name__ == "__main__": - main() From ddc45ce2ebe57c670a6419a724cc10b7b127e7ad Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 30 Aug 2022 19:30:14 -0600 Subject: [PATCH 018/199] message handling fix, data waiting fix --- freqtrade/data/dataprovider.py | 5 ++++- freqtrade/rpc/emc.py | 25 +++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 430ee0932..2d473683c 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -168,7 +168,10 @@ class DataProvider: timeout_str = f"for {timeout} seconds" if timeout > 0 else "indefinitely" logger.debug(f"Waiting for external data on {pair} for {timeout_str}") - pair_event.wait(timeout=timeout) + if timeout > 0: + pair_event.wait(timeout=timeout) + else: + pair_event.wait() def add_pairlisthandler(self, pairlists) -> None: """ diff --git a/freqtrade/rpc/emc.py b/freqtrade/rpc/emc.py index 48ad78266..ee4d9e6b8 100644 --- a/freqtrade/rpc/emc.py +++ b/freqtrade/rpc/emc.py @@ -164,6 +164,9 @@ class ExternalMessageConsumer: await asyncio.sleep(self.sleep_time) break + except Exception as e: + logger.exception(e) + continue except ( socket.gaierror, ConnectionRefusedError, @@ -214,16 +217,18 @@ class ExternalMessageConsumer: if message_data is None: return - key, value = message_data.get('key'), message_data.get('data') - pair, timeframe, candle_type = key + key, value = message_data.get('key'), message_data.get('value') - # Convert the JSON to a pandas DataFrame - dataframe = json_to_dataframe(value) + if key and value: + pair, timeframe, candle_type = key - # If set, remove the Entry and Exit signals from the Producer - if self._emc_config.get('remove_entry_exit_signals', False): - dataframe = remove_entry_exit_signals(dataframe) + # Convert the JSON to a pandas DataFrame + dataframe = json_to_dataframe(value) - # Add the dataframe to the dataprovider - dataprovider = self._rpc._freqtrade.dataprovider - dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) + # If set, remove the Entry and Exit signals from the Producer + if self._emc_config.get('remove_entry_exit_signals', False): + dataframe = remove_entry_exit_signals(dataframe) + + # Add the dataframe to the dataprovider + dataprovider = self._rpc._freqtrade.dataprovider + dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) From 115a901773c63cb4d207e6039e9bed05f260ef7f Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 30 Aug 2022 19:34:43 -0600 Subject: [PATCH 019/199] minor fix for conditional in handle func --- freqtrade/rpc/emc.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/emc.py b/freqtrade/rpc/emc.py index ee4d9e6b8..42061079a 100644 --- a/freqtrade/rpc/emc.py +++ b/freqtrade/rpc/emc.py @@ -201,6 +201,10 @@ class ExternalMessageConsumer: message_type = message.get('type', RPCMessageType.STATUS) message_data = message.get('data') + # We shouldn't get empty messages + if message_data is None: + return + logger.debug(f"Received message of type {message_type}") # Handle Whitelists @@ -213,10 +217,6 @@ class ExternalMessageConsumer: # Handle analyzed dataframes elif message_type == RPCMessageType.ANALYZED_DF: - # This shouldn't happen - if message_data is None: - return - key, value = message_data.get('key'), message_data.get('value') if key and value: From 510cf4f30507ed4763d13e12a41e12ceb59a6748 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 31 Aug 2022 10:40:26 -0600 Subject: [PATCH 020/199] remove data waiting, remove explicit analyzing of external df --- freqtrade/data/dataprovider.py | 107 +++++++++---------- freqtrade/enums/__init__.py | 1 - freqtrade/enums/externalmessages.py | 7 -- freqtrade/freqtradebot.py | 16 ++- freqtrade/rpc/api_server/api_ws.py | 37 +++++-- freqtrade/rpc/emc.py | 153 +++++++++++++++------------- freqtrade/rpc/rpc.py | 14 +-- freqtrade/strategy/interface.py | 54 ++-------- 8 files changed, 182 insertions(+), 207 deletions(-) delete mode 100644 freqtrade/enums/externalmessages.py diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 2d473683c..9376c0b33 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -7,7 +7,6 @@ Common Interface for bot and strategy to access data. import logging from collections import deque from datetime import datetime, timezone -from threading import Event from typing import Any, Dict, List, Optional, Tuple from pandas import DataFrame @@ -15,9 +14,11 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history -from freqtrade.enums import CandleType, RunMode, WaitDataPolicy +from freqtrade.enums import CandleType, RPCMessageType, RunMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange, timeframe_to_seconds +from freqtrade.misc import dataframe_to_json +from freqtrade.rpc import RPCManager from freqtrade.util import PeriodicCache @@ -33,16 +34,18 @@ class DataProvider: self, config: dict, exchange: Optional[Exchange], + rpc: Optional[RPCManager] = None, pairlists=None ) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists + self.__rpc = rpc self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} self.__external_pairs_df: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} - self.__external_pairs_event: Dict[PairWithTimeframe, Tuple[int, Event]] = {} + self.__producer_pairs: List[str] = [] self._msg_queue: deque = deque() self.__msg_cache = PeriodicCache( @@ -51,10 +54,7 @@ class DataProvider: self._num_sources = len( self._config.get('external_message_consumer', {}).get('producers', []) ) - self._wait_data_policy = self._config.get('external_message_consumer', {}).get( - 'wait_data_policy', WaitDataPolicy.all) - self._wait_data_timeout = self._config.get('external_message_consumer', {}).get( - 'wait_data_timeout', 5) + self.external_data_enabled = self._num_sources > 0 def _set_dataframe_max_index(self, limit_index: int): """ @@ -83,6 +83,46 @@ class DataProvider: self.__cached_pairs[pair_key] = ( dataframe, datetime.now(timezone.utc)) + # For multiple producers we will want to merge the pairlists instead of overwriting + def set_producer_pairs(self, pairlist: List[str]): + """ + Set the pairs received to later be used. + This only supports 1 Producer right now. + + :param pairlist: List of pairs + """ + self.__producer_pairs = pairlist.copy() + + def get_producer_pairs(self) -> List[str]: + """ + Get the pairs cached from the producer + + :returns: List of pairs + """ + return self.__producer_pairs + + def emit_df( + self, + pair_key: PairWithTimeframe, + dataframe: DataFrame + ) -> None: + """ + Send this dataframe as an ANALYZED_DF message to RPC + + :param pair_key: PairWithTimeframe tuple + :param data: Tuple containing the DataFrame and the datetime it was cached + """ + if self.__rpc: + self.__rpc.send_msg( + { + 'type': RPCMessageType.ANALYZED_DF, + 'data': { + 'key': pair_key, + 'value': dataframe_to_json(dataframe) + } + } + ) + def add_external_df( self, pair: str, @@ -101,7 +141,6 @@ class DataProvider: # For multiple leaders, if the data already exists, we'd merge self.__external_pairs_df[pair_key] = (dataframe, datetime.now(timezone.utc)) - self._set_data_event(pair_key) def get_external_df( self, @@ -120,59 +159,11 @@ class DataProvider: pair_key = (pair, timeframe, candle_type) if pair_key not in self.__external_pairs_df: - self._wait_on_data(pair_key) - - if pair_key not in self.__external_pairs_df: - return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) return self.__external_pairs_df[pair_key] - def _set_data_event(self, key: PairWithTimeframe): - """ - Depending on the WaitDataPolicy, if an event exists for this PairWithTimeframe - then set the event to release main thread from waiting. - - :param key: PairWithTimeframe - """ - pair_event = self.__external_pairs_event.get(key) - - if pair_event: - num_concat, event = pair_event - self.__external_pairs_event[key] = (num_concat + 1, event) - - if self._wait_data_policy == WaitDataPolicy.one: - logger.debug("Setting Data as policy is One") - event.set() - elif self._wait_data_policy == WaitDataPolicy.all and num_concat == self._num_sources: - logger.debug("Setting Data as policy is all, and is complete") - event.set() - - del self.__external_pairs_event[key] - - def _wait_on_data(self, key: PairWithTimeframe): - """ - Depending on the WaitDataPolicy, we will create and wait on an event until - set that determines the full amount of data is available - - :param key: PairWithTimeframe - """ - if self._wait_data_policy is not WaitDataPolicy.none: - pair, timeframe, candle_type = key - - pair_event = Event() - self.__external_pairs_event[key] = (0, pair_event) - - timeout = self._wait_data_timeout \ - if self._wait_data_policy is not WaitDataPolicy.all else 0 - - timeout_str = f"for {timeout} seconds" if timeout > 0 else "indefinitely" - logger.debug(f"Waiting for external data on {pair} for {timeout_str}") - - if timeout > 0: - pair_event.wait(timeout=timeout) - else: - pair_event.wait() - def add_pairlisthandler(self, pairlists) -> None: """ Allow adding pairlisthandler after initialization diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index 229d770ce..d32e04e17 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -3,7 +3,6 @@ from freqtrade.enums.backteststate import BacktestState from freqtrade.enums.candletype import CandleType from freqtrade.enums.exitchecktuple import ExitCheckTuple from freqtrade.enums.exittype import ExitType -from freqtrade.enums.externalmessages import WaitDataPolicy from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType diff --git a/freqtrade/enums/externalmessages.py b/freqtrade/enums/externalmessages.py deleted file mode 100644 index e43899ab5..000000000 --- a/freqtrade/enums/externalmessages.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class WaitDataPolicy(str, Enum): - none = "none" - one = "one" - all = "all" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index c0d658c61..f7b7ad80b 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -85,21 +85,19 @@ class FreqtradeBot(LoggingMixin): # Keep this at the end of this initialization method. self.rpc: RPCManager = RPCManager(self) - self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) + self.dataprovider = DataProvider(self.config, self.exchange, self.rpc, self.pairlists) # Attach Dataprovider to strategy instance self.strategy.dp = self.dataprovider # Attach Wallets to strategy instance self.strategy.wallets = self.wallets - # Attach rpc to strategy instance - self.strategy.rpc = self.rpc # Initializing Edge only if enabled self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None # Init ExternalMessageConsumer if enabled - self.emc = ExternalMessageConsumer(self.rpc._rpc, self.config) if \ + self.emc = ExternalMessageConsumer(self.config, self.dataprovider) if \ self.config.get('external_message_consumer', {}).get('enabled', False) else None self.active_pair_whitelist = self._refresh_active_whitelist() @@ -201,11 +199,11 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - if self.emc: - leader_pairs = self.pairlists._whitelist - self.strategy.analyze_external(self.active_pair_whitelist, leader_pairs) - else: - self.strategy.analyze(self.active_pair_whitelist) + # This just means we won't broadcast dataframes if we're listening to a producer + # Doesn't necessarily NEED to be this way, as maybe we'd like to broadcast + # even if we are using external dataframes in the future. + self.strategy.analyze(self.active_pair_whitelist, + external_data=self.dataprovider.external_data_enabled) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 88bae099a..d7c7239d1 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -1,22 +1,48 @@ import logging +from typing import Any, Dict from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect -from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc_optional +from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.rpc.api_server.deps import get_channel_manager +from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws.utils import is_websocket_alive +# from typing import Any, Dict + + logger = logging.getLogger(__name__) # Private router, protected by API Key authentication router = APIRouter() +# We are passed a Channel object, we can only do sync functions on that channel object +def _process_consumer_request(request: Dict[str, Any], channel: WebSocketChannel): + type, data = request.get('type'), request.get('data') + + # If the request is empty, do nothing + if not data: + return + + # If we have a request of type SUBSCRIBE, set the topics in this channel + if type == RPCRequestType.SUBSCRIBE: + if isinstance(data, list): + logger.error(f"Improper request from channel: {channel} - {request}") + return + + # If all topics passed are a valid RPCMessageType, set subscriptions on channel + if all([any(x.value == topic for x in RPCMessageType) for topic in data]): + + logger.debug(f"{channel} subscribed to topics: {data}") + channel.set_subscriptions(data) + + @router.websocket("/message/ws") async def message_endpoint( ws: WebSocket, - channel_manager=Depends(get_channel_manager), - rpc=Depends(get_rpc_optional) + channel_manager=Depends(get_channel_manager) ): try: if is_websocket_alive(ws): @@ -32,9 +58,8 @@ async def message_endpoint( request = await channel.recv() # Process the request here. Should this be a method of RPC? - if rpc: - logger.info(f"Request: {request}") - rpc._process_consumer_request(request, channel) + logger.info(f"Request: {request}") + _process_consumer_request(request, channel) except WebSocketDisconnect: # Handle client disconnects diff --git a/freqtrade/rpc/emc.py b/freqtrade/rpc/emc.py index 42061079a..3d78bc257 100644 --- a/freqtrade/rpc/emc.py +++ b/freqtrade/rpc/emc.py @@ -12,9 +12,9 @@ from typing import Any, Dict import websockets +from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.misc import json_to_dataframe, remove_entry_exit_signals -from freqtrade.rpc import RPC from freqtrade.rpc.api_server.ws.channel import WebSocketChannel @@ -29,11 +29,11 @@ class ExternalMessageConsumer: def __init__( self, - rpc: RPC, config: Dict[str, Any], + dataprovider: DataProvider ): - self._rpc = rpc self._config = config + self._dp = dataprovider self._running = False self._thread = None @@ -99,12 +99,12 @@ class ExternalMessageConsumer: """ The main task coroutine """ - rpc_lock = asyncio.Lock() + lock = asyncio.Lock() try: # Create a connection to each producer self._sub_tasks = [ - self._loop.create_task(self._handle_producer_connection(producer, rpc_lock)) + self._loop.create_task(self._handle_producer_connection(producer, lock)) for producer in self.producers ] @@ -115,73 +115,90 @@ class ExternalMessageConsumer: # Stop the loop once we are done self._loop.stop() - async def _handle_producer_connection(self, producer, lock): + async def _handle_producer_connection(self, producer: Dict[str, Any], lock: asyncio.Lock): """ Main connection loop for the consumer + + :param producer: Dictionary containing producer info: {'url': '', 'ws_token': ''} + :param lock: An asyncio Lock """ try: - while True: - try: - url, token = producer['url'], producer['ws_token'] - ws_url = f"{url}?token={token}" - - async with websockets.connect(ws_url) as ws: - logger.info("Connection successful") - channel = WebSocketChannel(ws) - - # Tell the producer we only want these topics - # Should always be the first thing we send - await channel.send( - self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) - ) - - # Now receive data, if none is within the time limit, ping - while True: - try: - message = await asyncio.wait_for( - channel.recv(), - timeout=5 - ) - - async with lock: - # Handle the data here - # We use a lock because it will call RPC methods - self.handle_producer_message(message) - - except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): - # We haven't received data yet. Check the connection and continue. - try: - # ping - ping = await channel.ping() - - await asyncio.wait_for(ping, timeout=self.ping_timeout) - logger.debug(f"Connection to {url} still alive...") - - continue - except Exception: - logger.info( - f"Ping error {url} - retrying in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - - break - except Exception as e: - logger.exception(e) - continue - except ( - socket.gaierror, - ConnectionRefusedError, - websockets.exceptions.InvalidStatusCode - ) as e: - logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - - continue - + await self._create_connection(producer, lock) except asyncio.CancelledError: # Exit silently pass - def compose_consumer_request(self, type_: str, data: Any) -> Dict[str, Any]: + async def _create_connection(self, producer: Dict[str, Any], lock: asyncio.Lock): + """ + Actually creates and handles the websocket connection, pinging on timeout + and handling connection errors. + + :param producer: Dictionary containing producer info: {'url': '', 'ws_token': ''} + :param lock: An asyncio Lock + """ + while self._running: + try: + url, token = producer['url'], producer['ws_token'] + ws_url = f"{url}?token={token}" + + # This will raise InvalidURI if the url is bad + async with websockets.connect(ws_url) as ws: + logger.info("Connection successful") + channel = WebSocketChannel(ws) + + # Tell the producer we only want these topics + # Should always be the first thing we send + await channel.send( + self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) + ) + + # Now receive data, if none is within the time limit, ping + while True: + try: + message = await asyncio.wait_for( + channel.recv(), + timeout=self.reply_timeout + ) + + async with lock: + # Handle the message + self.handle_producer_message(message) + + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await channel.ping() + + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.debug(f"Connection to {url} still alive...") + + continue + except Exception: + logger.info( + f"Ping error {url} - retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + + break + except Exception as e: + logger.exception(e) + continue + except ( + socket.gaierror, + ConnectionRefusedError, + websockets.exceptions.InvalidStatusCode + ) as e: + logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + + continue + + # Catch invalid ws_url, and break the loop + except websockets.exceptions.InvalidURI as e: + logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") + break + + def compose_consumer_request(self, type_: RPCRequestType, data: Any) -> Dict[str, Any]: """ Create a request for sending to a producer @@ -211,9 +228,8 @@ class ExternalMessageConsumer: if message_type == RPCMessageType.WHITELIST: pairlist = message_data - # Add the pairlist data to the ExternalPairlist plugin - external_pairlist = self._rpc._freqtrade.pairlists._pairlist_handlers[0] - external_pairlist.add_pairlist_data(pairlist) + # Add the pairlist data to the DataProvider + self._dp.set_producer_pairs(pairlist) # Handle analyzed dataframes elif message_type == RPCMessageType.ANALYZED_DF: @@ -230,5 +246,4 @@ class ExternalMessageConsumer: dataframe = remove_entry_exit_signals(dataframe) # Add the dataframe to the dataprovider - dataprovider = self._rpc._freqtrade.dataprovider - dataprovider.add_external_df(pair, timeframe, dataframe, candle_type) + self._dp.add_external_df(pair, timeframe, dataframe, candle_type) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index a41d08d55..ed7f13a96 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -19,8 +19,8 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_max_drawdown -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RPCMessageType, RPCRequestType, - SignalDirection, State, TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, + TradingMode) from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler @@ -1089,13 +1089,3 @@ class RPC: 'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), 'last_process_ts': int(last_p.timestamp()), } - - # We are passed a Channel object, we can only do sync functions on that channel object - def _process_consumer_request(self, request, channel): - # Should we ensure that request is Dict[str, Any]? - type, data = request.get('type'), request.get('data') - - if type == RPCRequestType.SUBSCRIBE: - if all([any(x.value == topic for x in RPCMessageType) for topic in data]): - logger.debug(f"{channel} subscribed to topics: {data}") - channel.set_subscriptions(data) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 7120928ff..a06b6506e 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -12,14 +12,13 @@ from pandas import DataFrame from freqtrade.constants import ListPairsWithTimeframes from freqtrade.data.dataprovider import DataProvider -from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RPCMessageType, SignalDirection, - SignalTagType, SignalType, TradingMode) +from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, SignalTagType, + SignalType, TradingMode) from freqtrade.enums.runmode import RunMode from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds -from freqtrade.misc import dataframe_to_json, remove_entry_exit_signals +from freqtrade.misc import remove_entry_exit_signals from freqtrade.persistence import Order, PairLocks, Trade -from freqtrade.rpc import RPCManager from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -113,7 +112,6 @@ class IStrategy(ABC, HyperStrategyMixin): # and wallets - access to the current balance. dp: DataProvider wallets: Optional[Wallets] = None - rpc: RPCManager # Filled from configuration stake_currency: str # container variable for strategy source code @@ -731,16 +729,8 @@ class IStrategy(ABC, HyperStrategyMixin): candle_type = self.config.get('candle_type_def', CandleType.SPOT) self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) - if not external_data: - self.rpc.send_msg( - { - 'type': RPCMessageType.ANALYZED_DF, - 'data': { - 'key': (pair, self.timeframe, candle_type), - 'value': dataframe_to_json(dataframe) - } - } - ) + if populate_indicators: + self.dp.emit_df((pair, self.timeframe, candle_type), dataframe) else: logger.debug("Skipping TA Analysis for already analyzed candle") @@ -763,10 +753,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ candle_type = self.config.get('candle_type_def', CandleType.SPOT) - if not external_data: - dataframe = self.dp.ohlcv(pair, self.timeframe, candle_type) - else: - dataframe, _ = self.dp.get_external_df(pair, self.timeframe, candle_type) + dataframe = self.dp.ohlcv(pair, self.timeframe, candle_type) if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning('Empty candle (OHLCV) data for pair %s', pair) @@ -790,38 +777,15 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze( self, - pairs: List[str] + pairs: List[str], + external_data: bool = False ) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze """ for pair in pairs: - self.analyze_pair(pair) - - def analyze_external(self, pairs: List[str], leader_pairs: List[str]) -> None: - """ - Analyze the pre-populated dataframes from the Leader - - :param pairs: The active pair whitelist - :param leader_pairs: The list of pairs from the Leaders - """ - - # Get the extra pairs not listed in Leader pairs, and process - # them normally. - # List order is not preserved when doing this! - # We use ^ instead of - for symmetric difference - extra_pairs = list(set(pairs) ^ set(leader_pairs)) - # These would be the pairs that we have trades in, which means - # we would have to analyze them normally - # Eventually maybe request data from the Leader if we don't have it? - - for pair in leader_pairs: - # Analyze the pairs, but get the dataframe from the external data - self.analyze_pair(pair, external_data=True) - - for pair in extra_pairs: - self.analyze_pair(pair) + self.analyze_pair(pair, external_data) @ staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: From 865b34cd6f91972cb252fff97d7d3ca1248976bd Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 31 Aug 2022 11:43:02 -0600 Subject: [PATCH 021/199] add producer names --- freqtrade/constants.py | 1 + freqtrade/data/dataprovider.py | 36 ++++++++++++------- freqtrade/freqtradebot.py | 7 ++-- .../{emc.py => external_message_consumer.py} | 10 +++--- freqtrade/strategy/interface.py | 21 +++++------ tests/rpc/test_rpc_telegram.py | 7 ++-- 6 files changed, 46 insertions(+), 36 deletions(-) rename freqtrade/rpc/{emc.py => external_message_consumer.py} (95%) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index c7f2acc84..2e580acf5 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -498,6 +498,7 @@ CONF_SCHEMA = { 'items': { 'type': 'object', 'properties': { + 'name': {'type': 'string'}, 'url': {'type': 'string', 'default': ''}, 'ws_token': {'type': 'string', 'default': ''}, } diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 9376c0b33..947387f75 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -34,8 +34,8 @@ class DataProvider: self, config: dict, exchange: Optional[Exchange], - rpc: Optional[RPCManager] = None, - pairlists=None + pairlists=None, + rpc: Optional[RPCManager] = None ) -> None: self._config = config self._exchange = exchange @@ -44,8 +44,9 @@ class DataProvider: self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} - self.__external_pairs_df: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} - self.__producer_pairs: List[str] = [] + self.__producer_pairs_df: Dict[str, + Dict[PairWithTimeframe, Tuple[DataFrame, datetime]]] = {} + self.__producer_pairs: Dict[str, List[str]] = {} self._msg_queue: deque = deque() self.__msg_cache = PeriodicCache( @@ -84,22 +85,22 @@ class DataProvider: dataframe, datetime.now(timezone.utc)) # For multiple producers we will want to merge the pairlists instead of overwriting - def set_producer_pairs(self, pairlist: List[str]): + def set_producer_pairs(self, pairlist: List[str], producer_name: str = "default"): """ Set the pairs received to later be used. This only supports 1 Producer right now. :param pairlist: List of pairs """ - self.__producer_pairs = pairlist.copy() + self.__producer_pairs[producer_name] = pairlist.copy() - def get_producer_pairs(self) -> List[str]: + def get_producer_pairs(self, producer_name: str = "default") -> List[str]: """ Get the pairs cached from the producer :returns: List of pairs """ - return self.__producer_pairs + return self.__producer_pairs.get(producer_name, []) def emit_df( self, @@ -129,6 +130,7 @@ class DataProvider: timeframe: str, dataframe: DataFrame, candle_type: CandleType, + producer_name: str = "default" ) -> None: """ Add the pair data to this class from an external source. @@ -139,15 +141,19 @@ class DataProvider: """ pair_key = (pair, timeframe, candle_type) + if producer_name not in self.__producer_pairs_df: + self.__producer_pairs_df[producer_name] = {} + # For multiple leaders, if the data already exists, we'd merge - self.__external_pairs_df[pair_key] = (dataframe, datetime.now(timezone.utc)) + self.__producer_pairs_df[producer_name][pair_key] = (dataframe, datetime.now(timezone.utc)) def get_external_df( self, pair: str, timeframe: str, - candle_type: CandleType - ) -> DataFrame: + candle_type: CandleType, + producer_name: str = "default" + ) -> Tuple[DataFrame, datetime]: """ Get the pair data from the external sources. Will wait if the policy is set to, and data is not available. @@ -158,11 +164,15 @@ class DataProvider: """ pair_key = (pair, timeframe, candle_type) - if pair_key not in self.__external_pairs_df: + if producer_name not in self.__producer_pairs_df: # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) - return self.__external_pairs_df[pair_key] + if pair_key not in self.__producer_pairs_df: + # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + + return self.__producer_pairs_df[producer_name][pair_key] def add_pairlisthandler(self, pairlists) -> None: """ diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index f7b7ad80b..19c77d403 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -30,7 +30,7 @@ from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.protectionmanager import ProtectionManager from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.rpc import RPCManager -from freqtrade.rpc.emc import ExternalMessageConsumer +from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.util import FtPrecise @@ -85,7 +85,7 @@ class FreqtradeBot(LoggingMixin): # Keep this at the end of this initialization method. self.rpc: RPCManager = RPCManager(self) - self.dataprovider = DataProvider(self.config, self.exchange, self.rpc, self.pairlists) + self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists, self.rpc) # Attach Dataprovider to strategy instance self.strategy.dp = self.dataprovider @@ -202,8 +202,9 @@ class FreqtradeBot(LoggingMixin): # This just means we won't broadcast dataframes if we're listening to a producer # Doesn't necessarily NEED to be this way, as maybe we'd like to broadcast # even if we are using external dataframes in the future. + self.strategy.analyze(self.active_pair_whitelist, - external_data=self.dataprovider.external_data_enabled) + emit_df=self.dataprovider.external_data_enabled) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace diff --git a/freqtrade/rpc/emc.py b/freqtrade/rpc/external_message_consumer.py similarity index 95% rename from freqtrade/rpc/emc.py rename to freqtrade/rpc/external_message_consumer.py index 3d78bc257..ae72089b5 100644 --- a/freqtrade/rpc/emc.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -162,7 +162,7 @@ class ExternalMessageConsumer: async with lock: # Handle the message - self.handle_producer_message(message) + self.handle_producer_message(producer, message) except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): # We haven't received data yet. Check the connection and continue. @@ -210,10 +210,11 @@ class ExternalMessageConsumer: # How we do things here isn't set in stone. There seems to be some interest # in figuring out a better way, but we shall do this for now. - def handle_producer_message(self, message: Dict[str, Any]): + def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): """ Handles external messages from a Producer """ + producer_name = producer.get('name', 'default') # Should we have a default message type? message_type = message.get('type', RPCMessageType.STATUS) message_data = message.get('data') @@ -229,7 +230,7 @@ class ExternalMessageConsumer: pairlist = message_data # Add the pairlist data to the DataProvider - self._dp.set_producer_pairs(pairlist) + self._dp.set_producer_pairs(pairlist, producer_name=producer_name) # Handle analyzed dataframes elif message_type == RPCMessageType.ANALYZED_DF: @@ -246,4 +247,5 @@ class ExternalMessageConsumer: dataframe = remove_entry_exit_signals(dataframe) # Add the dataframe to the dataprovider - self._dp.add_external_df(pair, timeframe, dataframe, candle_type) + self._dp.add_external_df(pair, timeframe, dataframe, + candle_type, producer_name=producer_name) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index a06b6506e..34e475ed7 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -682,8 +682,7 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze_ticker( self, dataframe: DataFrame, - metadata: dict, - populate_indicators: bool = True + metadata: dict ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame @@ -693,8 +692,7 @@ class IStrategy(ABC, HyperStrategyMixin): :return: DataFrame of candle (OHLCV) data with indicator data and signals added """ logger.debug("TA Analysis Launched") - if populate_indicators: - dataframe = self.advise_indicators(dataframe, metadata) + dataframe = self.advise_indicators(dataframe, metadata) dataframe = self.advise_entry(dataframe, metadata) dataframe = self.advise_exit(dataframe, metadata) return dataframe @@ -703,7 +701,7 @@ class IStrategy(ABC, HyperStrategyMixin): self, dataframe: DataFrame, metadata: dict, - external_data: bool = False + emit_df: bool = False ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame @@ -720,16 +718,15 @@ class IStrategy(ABC, HyperStrategyMixin): if (not self.process_only_new_candles or self._last_candle_seen_per_pair.get(pair, None) != dataframe.iloc[-1]['date']): - populate_indicators = not external_data # Defs that only make change on new candle data. - dataframe = self.analyze_ticker(dataframe, metadata, populate_indicators) + dataframe = self.analyze_ticker(dataframe, metadata) self._last_candle_seen_per_pair[pair] = dataframe.iloc[-1]['date'] candle_type = self.config.get('candle_type_def', CandleType.SPOT) self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) - if populate_indicators: + if emit_df: self.dp.emit_df((pair, self.timeframe, candle_type), dataframe) else: @@ -743,7 +740,7 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze_pair( self, pair: str, - external_data: bool = False + emit_df: bool = False ) -> None: """ Fetch data for this pair from dataprovider and analyze. @@ -764,7 +761,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = strategy_safe_wrapper( self._analyze_ticker_internal, message="" - )(dataframe, {'pair': pair}, external_data) + )(dataframe, {'pair': pair}, emit_df) self.assert_df(dataframe, df_len, df_close, df_date) except StrategyError as error: @@ -778,14 +775,14 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze( self, pairs: List[str], - external_data: bool = False + emit_df: bool = False ) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze """ for pair in pairs: - self.analyze_pair(pair, external_data) + self.analyze_pair(pair, emit_df) @ staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index a30115bd9..c7ae7cb74 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -2138,10 +2138,9 @@ def test_send_msg_strategy_msg_notification(default_conf, mocker) -> None: def test_send_msg_unknown_type(default_conf, mocker) -> None: telegram, _, _ = get_telegram_testobject(mocker, default_conf) - with pytest.raises(NotImplementedError, match=r'Unknown message type: None'): - telegram.send_msg({ - 'type': None, - }) + telegram.send_msg({ + 'type': None, + }) @pytest.mark.parametrize('message_type,enter,enter_signal,leverage', [ From 6e8abf8674b143fcee50e38c8ecb44081f31d347 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 31 Aug 2022 11:58:58 -0600 Subject: [PATCH 022/199] add producer name to required fields in config --- freqtrade/constants.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 2e580acf5..6bacaf961 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -501,17 +501,13 @@ CONF_SCHEMA = { 'name': {'type': 'string'}, 'url': {'type': 'string', 'default': ''}, 'ws_token': {'type': 'string', 'default': ''}, - } + }, + 'required': ['name', 'url', 'ws_token'] } }, 'reply_timeout': {'type': 'integer'}, 'sleep_time': {'type': 'integer'}, 'ping_timeout': {'type': 'integer'}, - 'wait_data_policy': { - 'type': 'string', - 'enum': WAIT_DATA_POLICY_OPTIONS - }, - 'wait_data_timeout': {'type': 'integer', 'default': 5}, 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False} }, 'required': ['producers'] From c72a2c26c7c3be0f9da2582886cd8bcca87efb5b Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 31 Aug 2022 12:06:24 -0600 Subject: [PATCH 023/199] remove external pairlist --- .../plugins/pairlist/ExternalPairList.py | 103 ------------------ 1 file changed, 103 deletions(-) delete mode 100644 freqtrade/plugins/pairlist/ExternalPairList.py diff --git a/freqtrade/plugins/pairlist/ExternalPairList.py b/freqtrade/plugins/pairlist/ExternalPairList.py deleted file mode 100644 index 27a328060..000000000 --- a/freqtrade/plugins/pairlist/ExternalPairList.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -External Pair List provider - -Provides pair list from Leader data -""" -import logging -from typing import Any, Dict, List - -from freqtrade.plugins.pairlist.IPairList import IPairList - - -logger = logging.getLogger(__name__) - - -class ExternalPairList(IPairList): - """ - PairList plugin for use with external signal follower mode. - Will use pairs given from leader data. - - Usage: - "pairlists": [ - { - "method": "ExternalPairList", - "number_assets": 5, # We can limit the amount of pairs to use from leader - } - ], - """ - - def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], - pairlist_pos: int) -> None: - super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) - - # Not sure how to enforce ExternalPairList as the only PairList - - self._num_assets = self._pairlistconfig.get('number_assets') - - self._leader_pairs: List[str] = [] - - def _clamped_pairlist(self): - """ - Return the self._leader_pairs pairlist limited to the maximum set num_assets - or the length of it. - """ - length = len(self._leader_pairs) - if self._num_assets: - return self._leader_pairs[:min(length, self._num_assets)] - else: - return self._leader_pairs - - @property - def needstickers(self) -> bool: - """ - Boolean property defining if tickers are necessary. - If no Pairlist requires tickers, an empty Dict is passed - as tickers argument to filter_pairlist - """ - return False - - def short_desc(self) -> str: - """ - Short whitelist method description - used for startup-messages - -> Please overwrite in subclasses - """ - return f"{self.name}" - - def add_pairlist_data(self, pairlist: List[str]): - """ - Add pairs from Leader - - :param pairlist: List of pairs - """ - - # If some pairs were removed on Leader, remove them here - for pair in self._leader_pairs: - if pair not in pairlist: - logger.debug(f"Leader removed pair: {pair}") - self._leader_pairs.remove(pair) - - # Only add new pairs - seen = set(self._leader_pairs) - for pair in pairlist: - if pair in seen: - continue - self._leader_pairs.append(pair) - - def gen_pairlist(self, tickers: Dict) -> List[str]: - """ - Generate the pairlist - :param tickers: Tickers (from exchange.get_tickers()). May be cached. - :return: List of pairs - """ - return self._clamped_pairlist() - - def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: - """ - Filters and sorts pairlist and returns the whitelist again. - Called on each bot iteration - please use internal caching if necessary - :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. - :return: new whitelist - """ - return self._clamped_pairlist() From 57e9078727fe03ca682fee3ee38ff1c8c93a29b1 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 31 Aug 2022 14:44:52 -0600 Subject: [PATCH 024/199] update example config --- config_examples/config_full.example.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index ec988687f..d0efa5bb6 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -180,10 +180,15 @@ "enabled": false, "producers": [ { + "name": "default", "url": "ws://some.freqtrade.bot/api/v1/message/ws", "ws_token": "a_secret_ws_token" } - ] + ], + "reply_timeout": 10, + "ping_timeout": 5, + "sleep_time": 5, + "remove_entry_exit_signals": false }, "bot_name": "freqtrade", "db_url": "sqlite:///tradesv3.sqlite", From 00f35f48707a600fb13fd723d376502fd1e4ddd8 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 1 Sep 2022 20:06:36 -0600 Subject: [PATCH 025/199] remove old constant, add initial_data requesting, minor changes --- freqtrade/constants.py | 3 - freqtrade/data/dataprovider.py | 1 - freqtrade/enums/rpcmessagetype.py | 1 + freqtrade/freqtradebot.py | 10 +-- freqtrade/rpc/api_server/api_ws.py | 39 +++++++++--- freqtrade/rpc/api_server/webserver.py | 8 +-- freqtrade/rpc/external_message_consumer.py | 73 +++++++++++++++------- freqtrade/rpc/rpc.py | 51 ++++++++++++--- freqtrade/rpc/rpc_manager.py | 2 + freqtrade/strategy/interface.py | 4 +- 10 files changed, 136 insertions(+), 56 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 6bacaf961..63222f2ff 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -61,8 +61,6 @@ USERPATH_FREQAIMODELS = 'freqaimodels' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] -WAIT_DATA_POLICY_OPTIONS = ['none', 'first', 'all'] - ENV_VAR_PREFIX = 'FREQTRADE__' NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') @@ -404,7 +402,6 @@ CONF_SCHEMA = { 'username': {'type': 'string'}, 'password': {'type': 'string'}, 'ws_token': {'type': 'string'}, - 'enable_message_ws': {'type': 'boolean', 'default': False}, 'jwt_secret_key': {'type': 'string'}, 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 947387f75..90302f88e 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -144,7 +144,6 @@ class DataProvider: if producer_name not in self.__producer_pairs_df: self.__producer_pairs_df[producer_name] = {} - # For multiple leaders, if the data already exists, we'd merge self.__producer_pairs_df[producer_name][pair_key] = (dataframe, datetime.now(timezone.utc)) def get_external_df( diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 6283fb7cc..c213826ae 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -33,3 +33,4 @@ class RPCMessageType(str, Enum): # Enum for parsing requests from ws consumers class RPCRequestType(str, Enum): SUBSCRIBE = 'subscribe' + INITIAL_DATA = 'initial_data' diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 19c77d403..888994ffb 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -203,8 +203,7 @@ class FreqtradeBot(LoggingMixin): # Doesn't necessarily NEED to be this way, as maybe we'd like to broadcast # even if we are using external dataframes in the future. - self.strategy.analyze(self.active_pair_whitelist, - emit_df=self.dataprovider.external_data_enabled) + self.strategy.analyze(self.active_pair_whitelist) with self._exit_lock: # Check for exchange cancelations, timeouts and user requested replace @@ -264,11 +263,10 @@ class FreqtradeBot(LoggingMixin): pairs that have open trades. """ # Refresh whitelist + _prev_whitelist = self.pairlists.whitelist self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist - self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'data': _whitelist}) - # Calculating Edge positioning if self.edge: self.edge.calculate(_whitelist) @@ -279,6 +277,10 @@ class FreqtradeBot(LoggingMixin): # It ensures that candle (OHLCV) data are downloaded for open trades as well _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) + # Called last to include the included pairs + if _prev_whitelist != _whitelist: + self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'data': _whitelist}) + return _whitelist def get_free_open_trades(self) -> int: diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index d7c7239d1..52507106d 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -4,9 +4,10 @@ from typing import Any, Dict from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from freqtrade.enums import RPCMessageType, RPCRequestType -from freqtrade.rpc.api_server.deps import get_channel_manager +from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws.utils import is_websocket_alive +from freqtrade.rpc.rpc import RPC # from typing import Any, Dict @@ -18,17 +19,20 @@ logger = logging.getLogger(__name__) router = APIRouter() -# We are passed a Channel object, we can only do sync functions on that channel object -def _process_consumer_request(request: Dict[str, Any], channel: WebSocketChannel): +async def _process_consumer_request( + request: Dict[str, Any], + channel: WebSocketChannel, + rpc: RPC +): type, data = request.get('type'), request.get('data') - # If the request is empty, do nothing - if not data: - return - # If we have a request of type SUBSCRIBE, set the topics in this channel if type == RPCRequestType.SUBSCRIBE: - if isinstance(data, list): + # If the request is empty, do nothing + if not data: + return + + if not isinstance(data, list): logger.error(f"Improper request from channel: {channel} - {request}") return @@ -38,11 +42,26 @@ def _process_consumer_request(request: Dict[str, Any], channel: WebSocketChannel logger.debug(f"{channel} subscribed to topics: {data}") channel.set_subscriptions(data) + elif type == RPCRequestType.INITIAL_DATA: + # Acquire the data + initial_data = rpc._ws_initial_data() + + # We now loop over it sending it in pieces + whitelist_data, analyzed_df = initial_data.get('whitelist'), initial_data.get('analyzed_df') + + if whitelist_data: + await channel.send({"type": RPCMessageType.WHITELIST, "data": whitelist_data}) + + if analyzed_df: + for pair, message in analyzed_df.items(): + await channel.send({"type": RPCMessageType.ANALYZED_DF, "data": message}) + @router.websocket("/message/ws") async def message_endpoint( ws: WebSocket, - channel_manager=Depends(get_channel_manager) + rpc: RPC = Depends(get_rpc), + channel_manager=Depends(get_channel_manager), ): try: if is_websocket_alive(ws): @@ -59,7 +78,7 @@ async def message_endpoint( # Process the request here. Should this be a method of RPC? logger.info(f"Request: {request}") - _process_consumer_request(request, channel) + await _process_consumer_request(request, channel, rpc) except WebSocketDisconnect: # Handle client disconnects diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index e391e66af..150c83890 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -151,11 +151,9 @@ class ApiServer(RPCHandler): app.include_router(api_backtest, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) - if self._config.get('api_server', {}).get('enable_message_ws', False): - logger.info("Enabling Message WebSocket") - app.include_router(ws_router, prefix="/api/v1", - dependencies=[Depends(get_ws_token)] - ) + app.include_router(ws_router, prefix="/api/v1", + dependencies=[Depends(get_ws_token)] + ) app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index ae72089b5..4544afc29 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,7 +8,7 @@ import asyncio import logging import socket from threading import Thread -from typing import Any, Dict +from typing import Any, Dict, Optional import websockets @@ -58,6 +58,11 @@ class ExternalMessageConsumer: # callbacks for the messages self.topics = [RPCMessageType.WHITELIST, RPCMessageType.ANALYZED_DF] + self._message_handlers = { + RPCMessageType.WHITELIST: self._consume_whitelist_message, + RPCMessageType.ANALYZED_DF: self._consume_analyzed_df_message, + } + self.start() def start(self): @@ -152,6 +157,11 @@ class ExternalMessageConsumer: self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) ) + # Now request the initial data from this Producer + await channel.send( + self.compose_consumer_request(RPCRequestType.INITIAL_DATA) + ) + # Now receive data, if none is within the time limit, ping while True: try: @@ -198,7 +208,11 @@ class ExternalMessageConsumer: logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") break - def compose_consumer_request(self, type_: RPCRequestType, data: Any) -> Dict[str, Any]: + def compose_consumer_request( + self, + type_: RPCRequestType, + data: Optional[Any] = None + ) -> Dict[str, Any]: """ Create a request for sending to a producer @@ -208,8 +222,6 @@ class ExternalMessageConsumer: """ return {'type': type_, 'data': data} - # How we do things here isn't set in stone. There seems to be some interest - # in figuring out a better way, but we shall do this for now. def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): """ Handles external messages from a Producer @@ -225,27 +237,44 @@ class ExternalMessageConsumer: logger.debug(f"Received message of type {message_type}") - # Handle Whitelists - if message_type == RPCMessageType.WHITELIST: - pairlist = message_data + message_handler = self._message_handlers.get(message_type) - # Add the pairlist data to the DataProvider - self._dp.set_producer_pairs(pairlist, producer_name=producer_name) + if not message_handler: + logger.info(f"Received unhandled message: {message_data}, ignoring...") + return - # Handle analyzed dataframes - elif message_type == RPCMessageType.ANALYZED_DF: - key, value = message_data.get('key'), message_data.get('value') + message_handler(producer_name, message_data) - if key and value: - pair, timeframe, candle_type = key + def _consume_whitelist_message(self, producer_name: str, message_data: Any): + # We expect List[str] + if not isinstance(message_data, list): + return - # Convert the JSON to a pandas DataFrame - dataframe = json_to_dataframe(value) + # Add the pairlist data to the DataProvider + self._dp.set_producer_pairs(message_data, producer_name=producer_name) - # If set, remove the Entry and Exit signals from the Producer - if self._emc_config.get('remove_entry_exit_signals', False): - dataframe = remove_entry_exit_signals(dataframe) + logger.debug(f"Consumed message from {producer_name} of type RPCMessageType.WHITELIST") - # Add the dataframe to the dataprovider - self._dp.add_external_df(pair, timeframe, dataframe, - candle_type, producer_name=producer_name) + def _consume_analyzed_df_message(self, producer_name: str, message_data: Any): + # We expect a Dict[str, Any] + if not isinstance(message_data, dict): + return + + key, value = message_data.get('key'), message_data.get('value') + + if key and value: + pair, timeframe, candle_type = key + + # Convert the JSON to a pandas DataFrame + dataframe = json_to_dataframe(value) + + # If set, remove the Entry and Exit signals from the Producer + if self._emc_config.get('remove_entry_exit_signals', False): + dataframe = remove_entry_exit_signals(dataframe) + + # Add the dataframe to the dataprovider + self._dp.add_external_df(pair, timeframe, dataframe, + candle_type, producer_name=producer_name) + + logger.debug( + f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index ed7f13a96..c4752c570 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -24,7 +24,7 @@ from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirecti from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import decimals_per_coin, shorten_date +from freqtrade.misc import dataframe_to_json, decimals_per_coin, shorten_date from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -1035,16 +1035,51 @@ class RPC: def _rpc_analysed_dataframe(self, pair: str, timeframe: str, limit: Optional[int]) -> Dict[str, Any]: + """ Analyzed dataframe in Dict form """ - _data, last_analyzed = self._freqtrade.dataprovider.get_analyzed_dataframe( - pair, timeframe) - _data = _data.copy() - if limit: - _data = _data.iloc[-limit:] + _data, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'], pair, timeframe, _data, last_analyzed) - @staticmethod + def __rpc_analysed_dataframe_raw(self, pair: str, timeframe: str, + limit: Optional[int]) -> Tuple[DataFrame, datetime]: + """ Get the dataframe and last analyze from the dataprovider """ + _data, last_analyzed = self._freqtrade.dataprovider.get_analyzed_dataframe( + pair, timeframe) + _data = _data.copy() + + if limit: + _data = _data.iloc[-limit:] + return _data, last_analyzed + + def _ws_all_analysed_dataframes( + self, + pairlist: List[str], + limit: Optional[int] + ) -> Dict[str, Any]: + """ Get the analysed dataframes of each pair in the pairlist """ + timeframe = self._freqtrade.config['timeframe'] + candle_type = self._freqtrade.config.get('candle_type_def', CandleType.SPOT) + _data = {} + + for pair in pairlist: + dataframe, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) + _data[pair] = { + "key": (pair, timeframe, candle_type), + "value": dataframe_to_json(dataframe) + } + + return _data + + def _ws_initial_data(self): + """ Websocket friendly initial data, whitelists and all analyzed dataframes """ + whitelist = self._freqtrade.active_pair_whitelist + # We only get the last 500 candles, should we remove the limit? + analyzed_df = self._ws_all_analysed_dataframes(whitelist, 500) + + return {"whitelist": whitelist, "analyzed_df": analyzed_df} + + @ staticmethod def _rpc_analysed_history_full(config, pair: str, timeframe: str, timerange: str, exchange) -> Dict[str, Any]: timerange_parsed = TimeRange.parse_timerange(timerange) @@ -1075,7 +1110,7 @@ class RPC: self._freqtrade.strategy.plot_config['subplots'] = {} return self._freqtrade.strategy.plot_config - @staticmethod + @ staticmethod def _rpc_sysinfo() -> Dict[str, Any]: return { "cpu_pct": psutil.cpu_percent(interval=1, percpu=True), diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3488a6e3c..d6374566c 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -81,6 +81,8 @@ class RPCManager: # Removed actually showing the message because the logs would be # completely spammed of the json dataframe logger.info('Sending rpc message of type: %s', msg.get('type')) + # Log actual message in debug? + # logger.debug(msg) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 34e475ed7..7fcae870a 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -725,9 +725,7 @@ class IStrategy(ABC, HyperStrategyMixin): candle_type = self.config.get('candle_type_def', CandleType.SPOT) self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) - - if emit_df: - self.dp.emit_df((pair, self.timeframe, candle_type), dataframe) + self.dp.emit_df((pair, self.timeframe, candle_type), dataframe) else: logger.debug("Skipping TA Analysis for already analyzed candle") From dccde88c83c4e8dfa7c8b70a6c4a7f9459ce177c Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 1 Sep 2022 23:15:03 -0600 Subject: [PATCH 026/199] fix dataframe serializing --- freqtrade/data/dataprovider.py | 3 +- freqtrade/misc.py | 3 +- freqtrade/rpc/api_server/ws/channel.py | 4 +-- freqtrade/rpc/api_server/ws/serializer.py | 37 +++++++++++++++------- freqtrade/rpc/external_message_consumer.py | 9 +++--- freqtrade/rpc/rpc.py | 7 ++-- 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 90302f88e..ef3067f38 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -17,7 +17,6 @@ from freqtrade.data.history import load_pair_history from freqtrade.enums import CandleType, RPCMessageType, RunMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange, timeframe_to_seconds -from freqtrade.misc import dataframe_to_json from freqtrade.rpc import RPCManager from freqtrade.util import PeriodicCache @@ -119,7 +118,7 @@ class DataProvider: 'type': RPCMessageType.ANALYZED_DF, 'data': { 'key': pair_key, - 'value': dataframe_to_json(dataframe) + 'value': dataframe } } ) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index ceace4ed8..6a93b6f26 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -269,7 +269,8 @@ def json_to_dataframe(data: str) -> pandas.DataFrame: :returns: A pandas DataFrame from the JSON string """ dataframe = pandas.read_json(data) - dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) + if 'date' in dataframe.columns: + dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) return dataframe diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index 6bc5b9d6b..b47fe7550 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -3,7 +3,7 @@ from threading import RLock from typing import List, Type from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy -from freqtrade.rpc.api_server.ws.serializer import ORJSONWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.api_server.ws.serializer import RapidJSONWebSocketSerializer, WebSocketSerializer from freqtrade.rpc.api_server.ws.types import WebSocketType @@ -18,7 +18,7 @@ class WebSocketChannel: def __init__( self, websocket: WebSocketType, - serializer_cls: Type[WebSocketSerializer] = ORJSONWebSocketSerializer + serializer_cls: Type[WebSocketSerializer] = RapidJSONWebSocketSerializer ): # The WebSocket object self._websocket = WebSocketProxy(websocket) diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index ae2857f0b..c11ca9a99 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -3,8 +3,10 @@ import logging from abc import ABC, abstractmethod import msgpack -import orjson +import rapidjson +from pandas import DataFrame +from freqtrade.misc import dataframe_to_json, json_to_dataframe from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy @@ -34,27 +36,23 @@ class WebSocketSerializer(ABC): async def close(self, code: int = 1000): await self._websocket.close(code) -# Going to explore using MsgPack as the serialization, -# as that might be the best method for sending pandas -# dataframes over the wire - class JSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data): - return json.dumps(data) + return json.dumps(data, default=_json_default) def _deserialize(self, data): - return json.loads(data) + return json.loads(data, object_hook=_json_object_hook) -class ORJSONWebSocketSerializer(WebSocketSerializer): - ORJSON_OPTIONS = orjson.OPT_NAIVE_UTC | orjson.OPT_SERIALIZE_NUMPY +# ORJSON does not support .loads(object_hook=x) parameter, so we must use RapidJSON +class RapidJSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data): - return orjson.dumps(data, option=self.ORJSON_OPTIONS) + return rapidjson.dumps(data, default=_json_default) def _deserialize(self, data): - return orjson.loads(data) + return rapidjson.loads(data, object_hook=_json_object_hook) class MsgPackWebSocketSerializer(WebSocketSerializer): @@ -63,3 +61,20 @@ class MsgPackWebSocketSerializer(WebSocketSerializer): def _deserialize(self, data): return msgpack.unpackb(data, raw=False) + + +# Support serializing pandas DataFrames +def _json_default(z): + if isinstance(z, DataFrame): + return { + '__type__': 'dataframe', + '__value__': dataframe_to_json(z) + } + raise TypeError + + +# Support deserializing JSON to pandas DataFrames +def _json_object_hook(z): + if z.get('__type__') == 'dataframe': + return json_to_dataframe(z.get('__value__')) + return z diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 4544afc29..4c7f6570d 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -10,11 +10,12 @@ import socket from threading import Thread from typing import Any, Dict, Optional +import pandas import websockets from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType, RPCRequestType -from freqtrade.misc import json_to_dataframe, remove_entry_exit_signals +from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws.channel import WebSocketChannel @@ -262,11 +263,9 @@ class ExternalMessageConsumer: key, value = message_data.get('key'), message_data.get('value') - if key and value: + if key and isinstance(value, pandas.DataFrame): pair, timeframe, candle_type = key - - # Convert the JSON to a pandas DataFrame - dataframe = json_to_dataframe(value) + dataframe = value # If set, remove the Entry and Exit signals from the Producer if self._emc_config.get('remove_entry_exit_signals', False): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index c4752c570..96b43f36b 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -24,7 +24,7 @@ from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirecti from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import dataframe_to_json, decimals_per_coin, shorten_date +from freqtrade.misc import decimals_per_coin, shorten_date from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -1064,10 +1064,7 @@ class RPC: for pair in pairlist: dataframe, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) - _data[pair] = { - "key": (pair, timeframe, candle_type), - "value": dataframe_to_json(dataframe) - } + _data[pair] = {"key": (pair, timeframe, candle_type), "value": dataframe} return _data From eb4cd6ba82b8d348b02a37d65c1567f0678f056d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 1 Sep 2022 23:52:13 -0600 Subject: [PATCH 027/199] split initial data into separate requests --- freqtrade/enums/rpcmessagetype.py | 4 +++- freqtrade/rpc/api_server/api_ws.py | 22 +++++++++---------- freqtrade/rpc/external_message_consumer.py | 25 +++++++++++++--------- freqtrade/rpc/rpc.py | 11 +++++----- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index c213826ae..929f6d083 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -33,4 +33,6 @@ class RPCMessageType(str, Enum): # Enum for parsing requests from ws consumers class RPCRequestType(str, Enum): SUBSCRIBE = 'subscribe' - INITIAL_DATA = 'initial_data' + + WHITELIST = 'whitelist' + ANALYZED_DF = 'analyzed_df' diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 52507106d..cf5b6cde0 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -33,7 +33,7 @@ async def _process_consumer_request( return if not isinstance(data, list): - logger.error(f"Improper request from channel: {channel} - {request}") + logger.error(f"Improper subscribe request from channel: {channel} - {request}") return # If all topics passed are a valid RPCMessageType, set subscriptions on channel @@ -42,19 +42,19 @@ async def _process_consumer_request( logger.debug(f"{channel} subscribed to topics: {data}") channel.set_subscriptions(data) - elif type == RPCRequestType.INITIAL_DATA: - # Acquire the data - initial_data = rpc._ws_initial_data() + elif type == RPCRequestType.WHITELIST: + # They requested the whitelist + whitelist = rpc._ws_request_whitelist() - # We now loop over it sending it in pieces - whitelist_data, analyzed_df = initial_data.get('whitelist'), initial_data.get('analyzed_df') + await channel.send({"type": RPCMessageType.WHITELIST, "data": whitelist}) - if whitelist_data: - await channel.send({"type": RPCMessageType.WHITELIST, "data": whitelist_data}) + elif type == RPCRequestType.ANALYZED_DF: + # They requested the full historical analyzed dataframes + analyzed_df = rpc._ws_request_analyzed_df() - if analyzed_df: - for pair, message in analyzed_df.items(): - await channel.send({"type": RPCMessageType.ANALYZED_DF, "data": message}) + # For every dataframe, send as a separate message + for _, message in analyzed_df.items(): + await channel.send({"type": RPCMessageType.ANALYZED_DF, "data": message}) @router.websocket("/message/ws") diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 4c7f6570d..c925624f8 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -158,10 +158,12 @@ class ExternalMessageConsumer: self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) ) - # Now request the initial data from this Producer - await channel.send( - self.compose_consumer_request(RPCRequestType.INITIAL_DATA) - ) + # Now request the initial data from this Producer for every topic + # we've subscribed to + for topic in self.topics: + # without .upper() we get KeyError + request_type = RPCRequestType[topic.upper()] + await channel.send(self.compose_consumer_request(request_type)) # Now receive data, if none is within the time limit, ping while True: @@ -191,9 +193,12 @@ class ExternalMessageConsumer: await asyncio.sleep(self.sleep_time) break - except Exception as e: - logger.exception(e) - continue + + # Catch invalid ws_url, and break the loop + except websockets.exceptions.InvalidURI as e: + logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") + break + except ( socket.gaierror, ConnectionRefusedError, @@ -204,9 +209,9 @@ class ExternalMessageConsumer: continue - # Catch invalid ws_url, and break the loop - except websockets.exceptions.InvalidURI as e: - logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") + except Exception as e: + # An unforseen error has occurred, log and stop + logger.exception(e) break def compose_consumer_request( diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 96b43f36b..378677e44 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1068,13 +1068,14 @@ class RPC: return _data - def _ws_initial_data(self): - """ Websocket friendly initial data, whitelists and all analyzed dataframes """ + def _ws_request_analyzed_df(self): + """ Historical Analyzed Dataframes for WebSocket """ whitelist = self._freqtrade.active_pair_whitelist - # We only get the last 500 candles, should we remove the limit? - analyzed_df = self._ws_all_analysed_dataframes(whitelist, 500) + return self._ws_all_analysed_dataframes(whitelist, 500) - return {"whitelist": whitelist, "analyzed_df": analyzed_df} + def _ws_request_whitelist(self): + """ Whitelist data for WebSocket """ + return self._freqtrade.active_pair_whitelist @ staticmethod def _rpc_analysed_history_full(config, pair: str, timeframe: str, From 5b0b802f311f0999412482a1722e87afe306892d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 2 Sep 2022 00:05:36 -0600 Subject: [PATCH 028/199] hybrid json ws serializer --- freqtrade/rpc/api_server/ws/channel.py | 5 +++-- freqtrade/rpc/api_server/ws/serializer.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index b47fe7550..8891d3296 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -3,7 +3,8 @@ from threading import RLock from typing import List, Type from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy -from freqtrade.rpc.api_server.ws.serializer import RapidJSONWebSocketSerializer, WebSocketSerializer +from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, + WebSocketSerializer) from freqtrade.rpc.api_server.ws.types import WebSocketType @@ -18,7 +19,7 @@ class WebSocketChannel: def __init__( self, websocket: WebSocketType, - serializer_cls: Type[WebSocketSerializer] = RapidJSONWebSocketSerializer + serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer ): # The WebSocket object self._websocket = WebSocketProxy(websocket) diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index c11ca9a99..109708cc9 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -3,6 +3,7 @@ import logging from abc import ABC, abstractmethod import msgpack +import orjson import rapidjson from pandas import DataFrame @@ -55,6 +56,14 @@ class RapidJSONWebSocketSerializer(WebSocketSerializer): return rapidjson.loads(data, object_hook=_json_object_hook) +class HybridJSONWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data): + return orjson.dumps(data, default=_json_default) + + def _deserialize(self, data): + return rapidjson.loads(data, object_hook=_json_object_hook) + + class MsgPackWebSocketSerializer(WebSocketSerializer): def _serialize(self, data): return msgpack.packb(data, use_bin_type=True) From cf917ad2f5b983f11ca1032a7f3f9b4742c88ca0 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 2 Sep 2022 15:05:16 -0600 Subject: [PATCH 029/199] initial candle request limit, better error reporting, split up _handle_producer_connection --- freqtrade/constants.py | 8 +- freqtrade/rpc/api_server/api_ws.py | 27 ++++-- freqtrade/rpc/api_server/ws/channel.py | 14 ++- freqtrade/rpc/api_server/ws/proxy.py | 10 +- freqtrade/rpc/external_message_consumer.py | 106 ++++++++++++++------- freqtrade/rpc/rpc.py | 4 +- 6 files changed, 119 insertions(+), 50 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 63222f2ff..352e48148 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -505,7 +505,13 @@ CONF_SCHEMA = { 'reply_timeout': {'type': 'integer'}, 'sleep_time': {'type': 'integer'}, 'ping_timeout': {'type': 'integer'}, - 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False} + 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False}, + 'initial_candle_limit': { + 'type': 'integer', + 'minimum': 100, + 'maximum': 1500, + 'default': 500 + } }, 'required': ['producers'] }, diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index cf5b6cde0..95cfd031a 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -26,6 +26,8 @@ async def _process_consumer_request( ): type, data = request.get('type'), request.get('data') + logger.debug(f"Request of type {type} from {channel}") + # If we have a request of type SUBSCRIBE, set the topics in this channel if type == RPCRequestType.SUBSCRIBE: # If the request is empty, do nothing @@ -49,8 +51,16 @@ async def _process_consumer_request( await channel.send({"type": RPCMessageType.WHITELIST, "data": whitelist}) elif type == RPCRequestType.ANALYZED_DF: + limit = None + + if data: + # Limit the amount of candles per dataframe to 'limit' or 1500 + limit = max(data.get('limit', 500), 1500) + # They requested the full historical analyzed dataframes - analyzed_df = rpc._ws_request_analyzed_df() + analyzed_df = rpc._ws_request_analyzed_df(limit) + + logger.debug(f"ANALYZED_DF RESULT: {analyzed_df}") # For every dataframe, send as a separate message for _, message in analyzed_df.items(): @@ -65,32 +75,33 @@ async def message_endpoint( ): try: if is_websocket_alive(ws): - logger.info(f"Consumer connected - {ws.client}") - # TODO: # Return a channel ID, pass that instead of ws to the rest of the methods channel = await channel_manager.on_connect(ws) + logger.info(f"Consumer connected - {channel}") + # Keep connection open until explicitly closed, and process requests try: while not channel.is_closed(): request = await channel.recv() - # Process the request here. Should this be a method of RPC? - logger.info(f"Request: {request}") + # Process the request here await _process_consumer_request(request, channel, rpc) except WebSocketDisconnect: # Handle client disconnects - logger.info(f"Consumer disconnected - {ws.client}") + logger.info(f"Consumer disconnected - {channel}") await channel_manager.on_disconnect(ws) except Exception as e: - logger.info(f"Consumer connection failed - {ws.client}") + logger.info(f"Consumer connection failed - {channel}") logger.exception(e) # Handle cases like - # RuntimeError('Cannot call "send" once a closed message has been sent') await channel_manager.on_disconnect(ws) - except Exception: + except Exception as e: logger.error(f"Failed to serve - {ws.client}") + # Log tracebacks to keep track of what errors are happening + logger.exception(e) await channel_manager.on_disconnect(ws) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index 8891d3296..1f0cd9c7a 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -1,6 +1,7 @@ import logging from threading import RLock -from typing import List, Type +from typing import List, Optional, Type +from uuid import uuid4 from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, @@ -19,8 +20,12 @@ class WebSocketChannel: def __init__( self, websocket: WebSocketType, + channel_id: Optional[str] = None, serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer ): + + self.channel_id = channel_id if channel_id else uuid4().hex[:8] + # The WebSocket object self._websocket = WebSocketProxy(websocket) # The Serializing class for the WebSocket object @@ -34,6 +39,13 @@ class WebSocketChannel: # Wrap the WebSocket in the Serializing class self._wrapped_ws = self._serializer_cls(self._websocket) + def __repr__(self): + return f"WebSocketChannel({self.channel_id}, {self.remote_addr})" + + @property + def remote_addr(self): + return self._websocket.remote_addr + async def send(self, data): """ Send data on the wrapped websocket diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index 6acc1d363..73d1481b9 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Any, Tuple, Union from fastapi import WebSocket as FastAPIWebSocket from websockets import WebSocketClientProtocol as WebSocket @@ -15,6 +15,14 @@ class WebSocketProxy: def __init__(self, websocket: WebSocketType): self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket + @property + def remote_addr(self) -> Tuple[Any, ...]: + if hasattr(self._websocket, "remote_address"): + return self._websocket.remote_address + elif hasattr(self._websocket, "client"): + return tuple(self._websocket.client) + return ("unknown", 0) + async def send(self, data): """ Send data on the wrapped websocket diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index c925624f8..3b39b02c8 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,7 +8,7 @@ import asyncio import logging import socket from threading import Thread -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pandas import websockets @@ -54,11 +54,25 @@ class ExternalMessageConsumer: self.ping_timeout = self._emc_config.get('ping_timeout', 2) self.sleep_time = self._emc_config.get('sleep_time', 5) + self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 500) + # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating # callbacks for the messages self.topics = [RPCMessageType.WHITELIST, RPCMessageType.ANALYZED_DF] + # Allow setting data for each initial request + self._initial_requests: List[Dict[str, Any]] = [ + { + "type": RPCRequestType.WHITELIST, + "data": None + }, + { + "type": RPCRequestType.ANALYZED_DF, + "data": {"limit": self.initial_candle_limit} + } + ] + self._message_handlers = { RPCMessageType.WHITELIST: self._consume_whitelist_message, RPCMessageType.ANALYZED_DF: self._consume_analyzed_df_message, @@ -145,12 +159,14 @@ class ExternalMessageConsumer: while self._running: try: url, token = producer['url'], producer['ws_token'] + name = producer["name"] ws_url = f"{url}?token={token}" # This will raise InvalidURI if the url is bad async with websockets.connect(ws_url) as ws: - logger.info("Connection successful") - channel = WebSocketChannel(ws) + channel = WebSocketChannel(ws, channel_id=name) + + logger.info(f"Producer connection success - {channel}") # Tell the producer we only want these topics # Should always be the first thing we send @@ -158,41 +174,16 @@ class ExternalMessageConsumer: self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) ) - # Now request the initial data from this Producer for every topic - # we've subscribed to - for topic in self.topics: - # without .upper() we get KeyError - request_type = RPCRequestType[topic.upper()] - await channel.send(self.compose_consumer_request(request_type)) + # Now request the initial data from this Producer + for request in self._initial_requests: + request_type = request.get('type', 'none') # Default to string + request_data = request.get('data') + await channel.send( + self.compose_consumer_request(request_type, request_data) + ) # Now receive data, if none is within the time limit, ping - while True: - try: - message = await asyncio.wait_for( - channel.recv(), - timeout=self.reply_timeout - ) - - async with lock: - # Handle the message - self.handle_producer_message(producer, message) - - except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): - # We haven't received data yet. Check the connection and continue. - try: - # ping - ping = await channel.ping() - - await asyncio.wait_for(ping, timeout=self.ping_timeout) - logger.debug(f"Connection to {url} still alive...") - - continue - except Exception: - logger.info( - f"Ping error {url} - retrying in {self.sleep_time}s") - await asyncio.sleep(self.sleep_time) - - break + await self._receive_messages(channel, producer, lock) # Catch invalid ws_url, and break the loop except websockets.exceptions.InvalidURI as e: @@ -214,6 +205,47 @@ class ExternalMessageConsumer: logger.exception(e) break + async def _receive_messages( + self, + channel: WebSocketChannel, + producer: Dict[str, Any], + lock: asyncio.Lock + ): + """ + Loop to handle receiving messages from a Producer + + :param channel: The WebSocketChannel object for the WebSocket + :param producer: Dictionary containing producer info + :param lock: An asyncio Lock + """ + while True: + try: + message = await asyncio.wait_for( + channel.recv(), + timeout=self.reply_timeout + ) + + async with lock: + # Handle the message + self.handle_producer_message(producer, message) + + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await channel.ping() + + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.debug(f"Connection to {channel} still alive...") + + continue + except Exception: + logger.info( + f"Ping error {channel} - retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + + break + def compose_consumer_request( self, type_: RPCRequestType, @@ -241,7 +273,7 @@ class ExternalMessageConsumer: if message_data is None: return - logger.debug(f"Received message of type {message_type}") + logger.debug(f"Received message of type {message_type} from `{producer_name}`") message_handler = self._message_handlers.get(message_type) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 378677e44..7b29665eb 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1068,10 +1068,10 @@ class RPC: return _data - def _ws_request_analyzed_df(self): + def _ws_request_analyzed_df(self, limit: Optional[int]): """ Historical Analyzed Dataframes for WebSocket """ whitelist = self._freqtrade.active_pair_whitelist - return self._ws_all_analysed_dataframes(whitelist, 500) + return self._ws_all_analysed_dataframes(whitelist, limit) def _ws_request_whitelist(self): """ Whitelist data for WebSocket """ From 05cbcf834ce8cec8b858c89725cc5086bad7c970 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 2 Sep 2022 16:01:33 -0600 Subject: [PATCH 030/199] minor logging changes --- freqtrade/rpc/external_message_consumer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 3b39b02c8..d3b82cadf 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -54,6 +54,7 @@ class ExternalMessageConsumer: self.ping_timeout = self._emc_config.get('ping_timeout', 2) self.sleep_time = self._emc_config.get('sleep_time', 5) + # The amount of candles per dataframe on the initial request self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 500) # Setting these explicitly as they probably shouldn't be changed by a user @@ -73,6 +74,7 @@ class ExternalMessageConsumer: } ] + # Specify which function to use for which RPCMessageType self._message_handlers = { RPCMessageType.WHITELIST: self._consume_whitelist_message, RPCMessageType.ANALYZED_DF: self._consume_analyzed_df_message, @@ -139,7 +141,7 @@ class ExternalMessageConsumer: """ Main connection loop for the consumer - :param producer: Dictionary containing producer info: {'url': '', 'ws_token': ''} + :param producer: Dictionary containing producer info :param lock: An asyncio Lock """ try: @@ -153,7 +155,7 @@ class ExternalMessageConsumer: Actually creates and handles the websocket connection, pinging on timeout and handling connection errors. - :param producer: Dictionary containing producer info: {'url': '', 'ws_token': ''} + :param producer: Dictionary containing producer info :param lock: An asyncio Lock """ while self._running: @@ -176,10 +178,8 @@ class ExternalMessageConsumer: # Now request the initial data from this Producer for request in self._initial_requests: - request_type = request.get('type', 'none') # Default to string - request_data = request.get('data') await channel.send( - self.compose_consumer_request(request_type, request_data) + self.compose_consumer_request(request['type'], request['data']) ) # Now receive data, if none is within the time limit, ping @@ -218,7 +218,7 @@ class ExternalMessageConsumer: :param producer: Dictionary containing producer info :param lock: An asyncio Lock """ - while True: + while self._running: try: message = await asyncio.wait_for( channel.recv(), @@ -273,7 +273,7 @@ class ExternalMessageConsumer: if message_data is None: return - logger.debug(f"Received message of type {message_type} from `{producer_name}`") + logger.info(f"Received message of type {message_type} from `{producer_name}`") message_handler = self._message_handlers.get(message_type) From 160186885404b4b15be4251a80b4ac054d930954 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 4 Sep 2022 09:42:43 -0600 Subject: [PATCH 031/199] dataprovider fix, updated config example --- config_examples/config_full.example.json | 3 +-- freqtrade/data/dataprovider.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index d0efa5bb6..2ebad4924 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -173,8 +173,7 @@ "CORS_origins": [], "username": "freqtrader", "password": "SuperSecurePassword", - "ws_token": "a_secret_ws_token", - "enable_message_ws": false + "ws_token": "a_secret_ws_token" }, "external_message_consumer": { "enabled": false, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index ef3067f38..af0f2a70a 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -166,7 +166,7 @@ class DataProvider: # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) - if pair_key not in self.__producer_pairs_df: + if pair_key not in self.__producer_pairs_df[producer_name]: # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) From 07f806a314ec6e963a33b79782e8b658782b235d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 4 Sep 2022 10:22:10 -0600 Subject: [PATCH 032/199] minor improvements, fixes, old config+constant removal --- .gitignore | 2 -- config_examples/config_full.example.json | 5 +++- freqtrade/constants.py | 3 +- freqtrade/data/dataprovider.py | 33 +++++++++++++++------- freqtrade/freqtradebot.py | 4 --- freqtrade/rpc/api_server/api_auth.py | 1 + freqtrade/rpc/api_server/api_ws.py | 15 +++++++--- freqtrade/rpc/api_server/webserver.py | 5 ++-- freqtrade/rpc/external_message_consumer.py | 6 ++-- freqtrade/strategy/interface.py | 15 ++++------ 10 files changed, 51 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 6a47a7f81..e400c01f5 100644 --- a/.gitignore +++ b/.gitignore @@ -113,5 +113,3 @@ target/ !config_examples/config_full.example.json !config_examples/config_kraken.example.json !config_examples/config_freqai.example.json -!config_examples/config_leader.example.json -!config_examples/config_follower.example.json diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 2ebad4924..99d695406 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -175,12 +175,15 @@ "password": "SuperSecurePassword", "ws_token": "a_secret_ws_token" }, + // The ExternalMessageConsumer config should only be enabled on an instance + // that listens to outside data from another instance. This should not be enabled + // in your producer of data. "external_message_consumer": { "enabled": false, "producers": [ { "name": "default", - "url": "ws://some.freqtrade.bot/api/v1/message/ws", + "url": "ws://localhost:8081/api/v1/message/ws", "ws_token": "a_secret_ws_token" } ], diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 352e48148..bc00d7cfc 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -33,8 +33,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', - 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter', - 'ExternalPairList'] + 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index af0f2a70a..32152f2f5 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -48,8 +48,11 @@ class DataProvider: self.__producer_pairs: Dict[str, List[str]] = {} self._msg_queue: deque = deque() + self._default_candle_type = self._config.get('candle_type_def', CandleType.SPOT) + self._default_timeframe = self._config.get('timeframe', '1h') + self.__msg_cache = PeriodicCache( - maxsize=1000, ttl=timeframe_to_seconds(self._config.get('timeframe', '1h'))) + maxsize=1000, ttl=timeframe_to_seconds(self._default_timeframe)) self._num_sources = len( self._config.get('external_message_consumer', {}).get('producers', []) @@ -84,7 +87,7 @@ class DataProvider: dataframe, datetime.now(timezone.utc)) # For multiple producers we will want to merge the pairlists instead of overwriting - def set_producer_pairs(self, pairlist: List[str], producer_name: str = "default"): + def _set_producer_pairs(self, pairlist: List[str], producer_name: str = "default"): """ Set the pairs received to later be used. This only supports 1 Producer right now. @@ -101,7 +104,7 @@ class DataProvider: """ return self.__producer_pairs.get(producer_name, []) - def emit_df( + def _emit_df( self, pair_key: PairWithTimeframe, dataframe: DataFrame @@ -123,12 +126,12 @@ class DataProvider: } ) - def add_external_df( + def _add_external_df( self, pair: str, - timeframe: str, dataframe: DataFrame, - candle_type: CandleType, + timeframe: Optional[str] = None, + candle_type: Optional[CandleType] = None, producer_name: str = "default" ) -> None: """ @@ -138,18 +141,22 @@ class DataProvider: :param timeframe: Timeframe to get data for :param candle_type: Any of the enum CandleType (must match trading mode!) """ - pair_key = (pair, timeframe, candle_type) + _timeframe = self._default_timeframe if not timeframe else timeframe + _candle_type = self._default_candle_type if not candle_type else candle_type + + pair_key = (pair, _timeframe, _candle_type) if producer_name not in self.__producer_pairs_df: self.__producer_pairs_df[producer_name] = {} self.__producer_pairs_df[producer_name][pair_key] = (dataframe, datetime.now(timezone.utc)) + logger.debug(f"External DataFrame for {pair_key} from {producer_name} added.") def get_external_df( self, pair: str, - timeframe: str, - candle_type: CandleType, + timeframe: Optional[str] = None, + candle_type: Optional[CandleType] = None, producer_name: str = "default" ) -> Tuple[DataFrame, datetime]: """ @@ -160,16 +167,22 @@ class DataProvider: :param timeframe: Timeframe to get data for :param candle_type: Any of the enum CandleType (must match trading mode!) """ - pair_key = (pair, timeframe, candle_type) + _timeframe = self._default_timeframe if not timeframe else timeframe + _candle_type = self._default_candle_type if not candle_type else candle_type + pair_key = (pair, _timeframe, _candle_type) + + # If we have no data from this Producer yet if producer_name not in self.__producer_pairs_df: # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + # If we do have data from that Producer, but no data on this pair_key if pair_key not in self.__producer_pairs_df[producer_name]: # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + # We have it, return this data return self.__producer_pairs_df[producer_name][pair_key] def add_pairlisthandler(self, pairlists) -> None: diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 888994ffb..ce01fc872 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -199,10 +199,6 @@ class FreqtradeBot(LoggingMixin): strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() - # This just means we won't broadcast dataframes if we're listening to a producer - # Doesn't necessarily NEED to be this way, as maybe we'd like to broadcast - # even if we are using external dataframes in the future. - self.strategy.analyze(self.active_pair_whitelist) with self._exit_lock: diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index fd90918e1..6655dbf86 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -50,6 +50,7 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access"): # This should be reimplemented to better realign with the existing tools provided # by FastAPI regarding API Tokens +# https://github.com/tiangolo/fastapi/blob/master/fastapi/security/api_key.py async def get_ws_token( ws: WebSocket, token: Union[str, None] = None, diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 95cfd031a..d11d1acfe 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -2,23 +2,30 @@ import logging from typing import Any, Dict from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +# fastapi does not make this available through it, so import directly from starlette +from starlette.websockets import WebSocketState from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel -from freqtrade.rpc.api_server.ws.utils import is_websocket_alive from freqtrade.rpc.rpc import RPC -# from typing import Any, Dict - - logger = logging.getLogger(__name__) # Private router, protected by API Key authentication router = APIRouter() +async def is_websocket_alive(ws: WebSocket) -> bool: + if ( + ws.application_state == WebSocketState.CONNECTED and + ws.client_state == WebSocketState.CONNECTED + ): + return True + return False + + async def _process_consumer_request( request: Dict[str, Any], channel: WebSocketChannel, diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 150c83890..ad93e77a7 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -205,7 +205,7 @@ class ApiServer(RPCHandler): # For testing, shouldn't happen when stable except Exception as e: - logger.info(f"Exception happened in background task: {e}") + logger.exception(f"Exception happened in background task: {e}") def start_api(self): """ @@ -244,8 +244,7 @@ class ApiServer(RPCHandler): if self._standalone: self._server.run() else: - if self._config.get('api_server', {}).get('enable_message_ws', False): - self.start_message_queue() + self.start_message_queue() self._server.run_in_thread() except Exception: logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index d3b82cadf..7f2ac01fb 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -289,7 +289,7 @@ class ExternalMessageConsumer: return # Add the pairlist data to the DataProvider - self._dp.set_producer_pairs(message_data, producer_name=producer_name) + self._dp._set_producer_pairs(message_data, producer_name=producer_name) logger.debug(f"Consumed message from {producer_name} of type RPCMessageType.WHITELIST") @@ -309,8 +309,8 @@ class ExternalMessageConsumer: dataframe = remove_entry_exit_signals(dataframe) # Add the dataframe to the dataprovider - self._dp.add_external_df(pair, timeframe, dataframe, - candle_type, producer_name=producer_name) + self._dp._add_external_df(pair, dataframe, timeframe, + candle_type, producer_name=producer_name) logger.debug( f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 7fcae870a..73948a37a 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -700,8 +700,7 @@ class IStrategy(ABC, HyperStrategyMixin): def _analyze_ticker_internal( self, dataframe: DataFrame, - metadata: dict, - emit_df: bool = False + metadata: dict ) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame @@ -725,7 +724,7 @@ class IStrategy(ABC, HyperStrategyMixin): candle_type = self.config.get('candle_type_def', CandleType.SPOT) self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) - self.dp.emit_df((pair, self.timeframe, candle_type), dataframe) + self.dp._emit_df((pair, self.timeframe, candle_type), dataframe) else: logger.debug("Skipping TA Analysis for already analyzed candle") @@ -737,8 +736,7 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze_pair( self, - pair: str, - emit_df: bool = False + pair: str ) -> None: """ Fetch data for this pair from dataprovider and analyze. @@ -759,7 +757,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = strategy_safe_wrapper( self._analyze_ticker_internal, message="" - )(dataframe, {'pair': pair}, emit_df) + )(dataframe, {'pair': pair}) self.assert_df(dataframe, df_len, df_close, df_date) except StrategyError as error: @@ -772,15 +770,14 @@ class IStrategy(ABC, HyperStrategyMixin): def analyze( self, - pairs: List[str], - emit_df: bool = False + pairs: List[str] ) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze """ for pair in pairs: - self.analyze_pair(pair, emit_df) + self.analyze_pair(pair) @ staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: From 8a08f8ff8d1d53a13bd7ba47a2fd09426aa9cacf Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 4 Sep 2022 10:27:34 -0600 Subject: [PATCH 033/199] revert rpc manager --- freqtrade/rpc/rpc_manager.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index d6374566c..3ba2f9d4d 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -49,17 +49,6 @@ class RPCManager: apiserver.add_rpc_handler(self._rpc) self.registered_modules.append(apiserver) - # Enable External Signals mode - # For this to be enabled, the API server must also be enabled - # if config.get('external_signal', {}).get('enabled', False): - # logger.info('Enabling RPC.ExternalSignalController') - # from freqtrade.rpc.external_signal import ExternalSignalController - # external_signals = ExternalSignalController(self._rpc, config, apiserver) - # self.registered_modules.append(external_signals) - # - # # Attach the controller to FreqTrade - # freqtrade.external_signal_controller = external_signals - def cleanup(self) -> None: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') @@ -78,11 +67,8 @@ class RPCManager: 'status': 'stopping bot' } """ - # Removed actually showing the message because the logs would be - # completely spammed of the json dataframe - logger.info('Sending rpc message of type: %s', msg.get('type')) - # Log actual message in debug? - # logger.debug(msg) + if msg.get('type') is not RPCMessageType.ANALYZED_DF: + logger.info('Sending rpc message: %s', msg) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) From 8f261d8edff162e26ad42876b2a4ce88beb08d45 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 5 Sep 2022 13:47:17 -0600 Subject: [PATCH 034/199] change from bytes to text in websocket, remove old logs --- freqtrade/rpc/api_server/api_ws.py | 2 -- freqtrade/rpc/api_server/ws/channel.py | 3 --- freqtrade/rpc/api_server/ws/proxy.py | 15 +++++++++------ freqtrade/rpc/api_server/ws/serializer.py | 2 ++ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index d11d1acfe..aaa526401 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -67,8 +67,6 @@ async def _process_consumer_request( # They requested the full historical analyzed dataframes analyzed_df = rpc._ws_request_analyzed_df(limit) - logger.debug(f"ANALYZED_DF RESULT: {analyzed_df}") - # For every dataframe, send as a separate message for _, message in analyzed_df.items(): await channel.send({"type": RPCMessageType.ANALYZED_DF, "data": message}) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index 1f0cd9c7a..952b3b9f5 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -50,7 +50,6 @@ class WebSocketChannel: """ Send data on the wrapped websocket """ - # logger.info(f"Serialized Send - {self._wrapped_ws._serialize(data)}") await self._wrapped_ws.send(data) async def recv(self): @@ -168,8 +167,6 @@ class ChannelManager: :param direct_channel: The WebSocketChannel object to send data through :param data: The data to send """ - # We iterate over the channels to get reference to the websocket object - # so we can disconnect incase of failure await channel.send(data) def has_channels(self): diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index 73d1481b9..ea977a228 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -27,11 +27,14 @@ class WebSocketProxy: """ Send data on the wrapped websocket """ - if isinstance(data, str): - data = data.encode() - if hasattr(self._websocket, "send_bytes"): - await self._websocket.send_bytes(data) + if not isinstance(data, str): + # We use HybridJSONWebSocketSerializer, which when serialized returns + # bytes because of ORJSON, so we explicitly decode into a string + data = str(data, "utf-8") + + if hasattr(self._websocket, "send_text"): + await self._websocket.send_text(data) else: await self._websocket.send(data) @@ -39,8 +42,8 @@ class WebSocketProxy: """ Receive data on the wrapped websocket """ - if hasattr(self._websocket, "receive_bytes"): - return await self._websocket.receive_bytes() + if hasattr(self._websocket, "receive_text"): + return await self._websocket.receive_text() else: return await self._websocket.recv() diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index 109708cc9..8ff617f45 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -58,9 +58,11 @@ class RapidJSONWebSocketSerializer(WebSocketSerializer): class HybridJSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data): + # ORJSON returns bytes return orjson.dumps(data, default=_json_default) def _deserialize(self, data): + # RapidJSON expects strings return rapidjson.loads(data, object_hook=_json_object_hook) From b949ea301c5828612f094c1c5040a67adcd3a47e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 5 Sep 2022 19:29:07 -0600 Subject: [PATCH 035/199] fix failed apiserver tests --- freqtrade/rpc/api_server/webserver.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index ad93e77a7..6ad3f143e 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -114,6 +114,10 @@ class ApiServer(RPCHandler): self._thread.join() + self._thread = None + self._loop = None + self._background_task = None + @classmethod def shutdown(cls): cls.__initialized = False @@ -169,15 +173,15 @@ class ApiServer(RPCHandler): app.add_exception_handler(RPCException, self.handle_rpc_exception) def start_message_queue(self): + if self._thread: + return + # Create a new loop, as it'll be just for the background thread self._loop = asyncio.new_event_loop() # Start the thread - if not self._thread: - self._thread = Thread(target=self._loop.run_forever) - self._thread.start() - else: - raise RuntimeError("Threaded loop is already running") + self._thread = Thread(target=self._loop.run_forever) + self._thread.start() # Finally, submit the coro to the thread self._background_task = asyncio.run_coroutine_threadsafe( From a0d774fdc4e3be40e4ca138782f38f0db37a88fb Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 5 Sep 2022 20:23:00 -0600 Subject: [PATCH 036/199] change default initial candle limit to 1500 --- freqtrade/constants.py | 3 +-- freqtrade/rpc/external_message_consumer.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 03030d930..2279acc13 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -508,9 +508,8 @@ CONF_SCHEMA = { 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False}, 'initial_candle_limit': { 'type': 'integer', - 'minimum': 100, 'maximum': 1500, - 'default': 500 + 'default': 1500 } }, 'required': ['producers'] diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 7f2ac01fb..f0b177647 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -55,7 +55,7 @@ class ExternalMessageConsumer: self.sleep_time = self._emc_config.get('sleep_time', 5) # The amount of candles per dataframe on the initial request - self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 500) + self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating From d526dfb171a25b99059f21f23c40965ef7ab3837 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Sep 2022 07:03:31 +0200 Subject: [PATCH 037/199] Revert some more changes in rpc_manager --- freqtrade/rpc/rpc_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3ba2f9d4d..fa2178b1a 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -20,7 +20,6 @@ class RPCManager: def __init__(self, freqtrade) -> None: """ Initializes all enabled rpc modules """ self.registered_modules: List[RPCHandler] = [] - self._freqtrade = freqtrade self._rpc = RPC(freqtrade) config = freqtrade.config # Enable telegram @@ -53,7 +52,7 @@ class RPCManager: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') while self.registered_modules: - mod = self.registered_modules.pop() # popleft to cleanup API server last? + mod = self.registered_modules.pop() logger.info('Cleaning up rpc.%s ...', mod.name) mod.cleanup() del mod From 38f14349e9a70e582e0e1e2acecbbf18ab921385 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 5 Sep 2022 23:25:25 -0600 Subject: [PATCH 038/199] move bytes decoding to serializer --- freqtrade/rpc/api_server/ws/proxy.py | 6 ------ freqtrade/rpc/api_server/ws/serializer.py | 7 +++---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index ea977a228..e43ce6441 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -27,12 +27,6 @@ class WebSocketProxy: """ Send data on the wrapped websocket """ - - if not isinstance(data, str): - # We use HybridJSONWebSocketSerializer, which when serialized returns - # bytes because of ORJSON, so we explicitly decode into a string - data = str(data, "utf-8") - if hasattr(self._websocket, "send_text"): await self._websocket.send_text(data) else: diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index 8ff617f45..c0c24bb28 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -57,11 +57,10 @@ class RapidJSONWebSocketSerializer(WebSocketSerializer): class HybridJSONWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - # ORJSON returns bytes - return orjson.dumps(data, default=_json_default) + def _serialize(self, data) -> str: + return str(orjson.dumps(data, default=_json_default), "utf-8") - def _deserialize(self, data): + def _deserialize(self, data: str): # RapidJSON expects strings return rapidjson.loads(data, object_hook=_json_object_hook) From 3535aa7724c4a202007841a1efa18c70fe728ab5 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 6 Sep 2022 12:12:05 -0600 Subject: [PATCH 039/199] add last_analyzed to emitted dataframe --- freqtrade/data/dataprovider.py | 10 ++++++++-- freqtrade/rpc/external_message_consumer.py | 21 +++++++++++++-------- freqtrade/rpc/rpc.py | 3 ++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 76e184296..44296ab40 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -121,7 +121,7 @@ class DataProvider: 'type': RPCMessageType.ANALYZED_DF, 'data': { 'key': pair_key, - 'value': dataframe + 'value': (dataframe, datetime.now(timezone.utc)) } } ) @@ -130,6 +130,7 @@ class DataProvider: self, pair: str, dataframe: DataFrame, + last_analyzed: Optional[str] = None, timeframe: Optional[str] = None, candle_type: Optional[CandleType] = None, producer_name: str = "default" @@ -149,7 +150,12 @@ class DataProvider: if producer_name not in self.__producer_pairs_df: self.__producer_pairs_df[producer_name] = {} - self.__producer_pairs_df[producer_name][pair_key] = (dataframe, datetime.now(timezone.utc)) + if not last_analyzed: + _last_analyzed = datetime.now(timezone.utc) + else: + _last_analyzed = datetime.fromisoformat(last_analyzed) + + self.__producer_pairs_df[producer_name][pair_key] = (dataframe, _last_analyzed) logger.debug(f"External DataFrame for {pair_key} from {producer_name} added.") def get_external_df( diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index f0b177647..28628c1f6 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -10,7 +10,6 @@ import socket from threading import Thread from typing import Any, Dict, List, Optional -import pandas import websockets from freqtrade.data.dataprovider import DataProvider @@ -225,9 +224,12 @@ class ExternalMessageConsumer: timeout=self.reply_timeout ) - async with lock: - # Handle the message - self.handle_producer_message(producer, message) + try: + async with lock: + # Handle the message + self.handle_producer_message(producer, message) + except Exception as e: + logger.exception(f"Error handling producer message: {e}") except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): # We haven't received data yet. Check the connection and continue. @@ -300,17 +302,20 @@ class ExternalMessageConsumer: key, value = message_data.get('key'), message_data.get('value') - if key and isinstance(value, pandas.DataFrame): + if key and value: pair, timeframe, candle_type = key - dataframe = value + dataframe, last_analyzed = value # If set, remove the Entry and Exit signals from the Producer if self._emc_config.get('remove_entry_exit_signals', False): dataframe = remove_entry_exit_signals(dataframe) # Add the dataframe to the dataprovider - self._dp._add_external_df(pair, dataframe, timeframe, - candle_type, producer_name=producer_name) + self._dp._add_external_df(pair, dataframe, + last_analyzed=last_analyzed, + timeframe=timeframe, + candle_type=candle_type, + producer_name=producer_name) logger.debug( f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 3757c58c2..98dad278f 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1064,7 +1064,8 @@ class RPC: for pair in pairlist: dataframe, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) - _data[pair] = {"key": (pair, timeframe, candle_type), "value": dataframe} + _data[pair] = {"key": (pair, timeframe, candle_type), + "value": (dataframe, last_analyzed)} return _data From b1c02674492993571c1cbc10144277eb29fda7a9 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 6 Sep 2022 12:40:58 -0600 Subject: [PATCH 040/199] mypy fixes --- freqtrade/rpc/api_server/ws/channel.py | 4 +++- freqtrade/rpc/api_server/ws/proxy.py | 10 ++++++---- freqtrade/rpc/api_server/ws/types.py | 2 +- freqtrade/rpc/external_message_consumer.py | 7 ++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index 952b3b9f5..cffe3092d 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -3,6 +3,8 @@ from threading import RLock from typing import List, Optional, Type from uuid import uuid4 +from fastapi import WebSocket as FastAPIWebSocket + from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, WebSocketSerializer) @@ -105,7 +107,7 @@ class ChannelManager: :param websocket: The WebSocket object to attach to the Channel """ - if hasattr(websocket, "accept"): + if isinstance(websocket, FastAPIWebSocket): try: await websocket.accept() except RuntimeError: diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index e43ce6441..da3e04887 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -1,7 +1,7 @@ from typing import Any, Tuple, Union from fastapi import WebSocket as FastAPIWebSocket -from websockets import WebSocketClientProtocol as WebSocket +from websockets.client import WebSocketClientProtocol as WebSocket from freqtrade.rpc.api_server.ws.types import WebSocketType @@ -17,10 +17,12 @@ class WebSocketProxy: @property def remote_addr(self) -> Tuple[Any, ...]: - if hasattr(self._websocket, "remote_address"): + if isinstance(self._websocket, WebSocket): return self._websocket.remote_address - elif hasattr(self._websocket, "client"): - return tuple(self._websocket.client) + elif isinstance(self._websocket, FastAPIWebSocket): + if self._websocket.client: + client, port = self._websocket.client.host, self._websocket.client.port + return (client, port) return ("unknown", 0) async def send(self, data): diff --git a/freqtrade/rpc/api_server/ws/types.py b/freqtrade/rpc/api_server/ws/types.py index 814fe6649..9855f9e06 100644 --- a/freqtrade/rpc/api_server/ws/types.py +++ b/freqtrade/rpc/api_server/ws/types.py @@ -1,7 +1,7 @@ from typing import Any, Dict, TypeVar from fastapi import WebSocket as FastAPIWebSocket -from websockets import WebSocketClientProtocol as WebSocket +from websockets.client import WebSocketClientProtocol as WebSocket WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 28628c1f6..c1ad0512e 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,7 +8,7 @@ import asyncio import logging import socket from threading import Thread -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional import websockets @@ -18,6 +18,11 @@ from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws.channel import WebSocketChannel +if TYPE_CHECKING: + import websockets.connect + import websockets.exceptions + + logger = logging.getLogger(__name__) From 5934495dda06c4c62950c8eebe77fb431d394eb9 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 7 Sep 2022 15:08:01 -0600 Subject: [PATCH 041/199] add websocket request/message schemas --- freqtrade/data/dataprovider.py | 10 +- freqtrade/rpc/api_server/api_ws.py | 40 ++++++-- freqtrade/rpc/api_server/ws/schema.py | 78 +++++++++++++++ freqtrade/rpc/external_message_consumer.py | 111 +++++++++------------ freqtrade/rpc/rpc.py | 8 +- 5 files changed, 165 insertions(+), 82 deletions(-) create mode 100644 freqtrade/rpc/api_server/ws/schema.py diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 44296ab40..4b5494e97 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -121,7 +121,8 @@ class DataProvider: 'type': RPCMessageType.ANALYZED_DF, 'data': { 'key': pair_key, - 'value': (dataframe, datetime.now(timezone.utc)) + 'df': dataframe, + 'la': datetime.now(timezone.utc) } } ) @@ -130,7 +131,7 @@ class DataProvider: self, pair: str, dataframe: DataFrame, - last_analyzed: Optional[str] = None, + last_analyzed: Optional[datetime] = None, timeframe: Optional[str] = None, candle_type: Optional[CandleType] = None, producer_name: str = "default" @@ -150,10 +151,7 @@ class DataProvider: if producer_name not in self.__producer_pairs_df: self.__producer_pairs_df[producer_name] = {} - if not last_analyzed: - _last_analyzed = datetime.now(timezone.utc) - else: - _last_analyzed = datetime.fromisoformat(last_analyzed) + _last_analyzed = datetime.now(timezone.utc) if not last_analyzed else last_analyzed self.__producer_pairs_df[producer_name][pair_key] = (dataframe, _last_analyzed) logger.debug(f"External DataFrame for {pair_key} from {producer_name} added.") diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index aaa526401..64c1cebb5 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -8,6 +8,8 @@ from starlette.websockets import WebSocketState from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel +from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, + WSRequestSchema, WSWhitelistMessage) from freqtrade.rpc.rpc import RPC @@ -18,6 +20,9 @@ router = APIRouter() async def is_websocket_alive(ws: WebSocket) -> bool: + """ + Check if a FastAPI Websocket is still open + """ if ( ws.application_state == WebSocketState.CONNECTED and ws.client_state == WebSocketState.CONNECTED @@ -31,7 +36,17 @@ async def _process_consumer_request( channel: WebSocketChannel, rpc: RPC ): - type, data = request.get('type'), request.get('data') + """ + Validate and handle a request from a websocket consumer + """ + # Validate the request, makes sure it matches the schema + try: + websocket_request = WSRequestSchema.parse_obj(request) + except ValidationError as e: + logger.error(f"Invalid request from {channel}: {e}") + return + + type, data = websocket_request.type, websocket_request.data logger.debug(f"Request of type {type} from {channel}") @@ -41,35 +56,35 @@ async def _process_consumer_request( if not data: return - if not isinstance(data, list): - logger.error(f"Improper subscribe request from channel: {channel} - {request}") - return - # If all topics passed are a valid RPCMessageType, set subscriptions on channel if all([any(x.value == topic for x in RPCMessageType) for topic in data]): - - logger.debug(f"{channel} subscribed to topics: {data}") channel.set_subscriptions(data) + # We don't send a response for subscriptions + elif type == RPCRequestType.WHITELIST: - # They requested the whitelist + # Get whitelist whitelist = rpc._ws_request_whitelist() - await channel.send({"type": RPCMessageType.WHITELIST, "data": whitelist}) + # Format response + response = WSWhitelistMessage(data=whitelist) + # Send it back + await channel.send(response.dict(exclude_none=True)) elif type == RPCRequestType.ANALYZED_DF: limit = None if data: # Limit the amount of candles per dataframe to 'limit' or 1500 - limit = max(data.get('limit', 500), 1500) + limit = max(data.get('limit', 1500), 1500) # They requested the full historical analyzed dataframes analyzed_df = rpc._ws_request_analyzed_df(limit) # For every dataframe, send as a separate message for _, message in analyzed_df.items(): - await channel.send({"type": RPCMessageType.ANALYZED_DF, "data": message}) + response = WSAnalyzedDFMessage(data=message) + await channel.send(response.dict(exclude_none=True)) @router.websocket("/message/ws") @@ -78,6 +93,9 @@ async def message_endpoint( rpc: RPC = Depends(get_rpc), channel_manager=Depends(get_channel_manager), ): + """ + Message WebSocket endpoint, facilitates sending RPC messages + """ try: if is_websocket_alive(ws): # TODO: diff --git a/freqtrade/rpc/api_server/ws/schema.py b/freqtrade/rpc/api_server/ws/schema.py new file mode 100644 index 000000000..3221911de --- /dev/null +++ b/freqtrade/rpc/api_server/ws/schema.py @@ -0,0 +1,78 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pandas import DataFrame +from pydantic import BaseModel, ValidationError + +from freqtrade.constants import PairWithTimeframe +from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType + + +__all__ = ('WSRequestSchema', 'WSMessageSchema', 'ValidationError') + + +class BaseArbitraryModel(BaseModel): + class Config: + arbitrary_types_allowed = True + + +class WSRequestSchema(BaseArbitraryModel): + type: RPCRequestType + data: Optional[Any] = None + + +class WSMessageSchema(BaseArbitraryModel): + type: RPCMessageType + data: Optional[Any] = None + + class Config: + extra = 'allow' + + +# ------------------------------ REQUEST SCHEMAS ---------------------------- + + +class WSSubscribeRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.SUBSCRIBE + data: List[RPCMessageType] + + +class WSWhitelistRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.WHITELIST + data: None = None + + +class WSAnalyzedDFRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.ANALYZED_DF + data: Dict[str, Any] = {"limit": 1500} + + +# ------------------------------ MESSAGE SCHEMAS ---------------------------- + +class WSWhitelistMessage(WSMessageSchema): + type: RPCMessageType = RPCMessageType.WHITELIST + data: List[str] + + +class WSAnalyzedDFMessage(WSMessageSchema): + class AnalyzedDFData(BaseArbitraryModel): + key: PairWithTimeframe + df: DataFrame + la: datetime + + type: RPCMessageType = RPCMessageType.ANALYZED_DF + data: AnalyzedDFData + +# -------------------------------------------------------------------------- + + +if __name__ == "__main__": + message = WSAnalyzedDFMessage( + data={ + "key": ("1", "5m", "spot"), + "df": DataFrame(), + "la": datetime.now() + } + ) + + print(message) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index c1ad0512e..d1e970826 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,14 +8,18 @@ import asyncio import logging import socket from threading import Thread -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List import websockets from freqtrade.data.dataprovider import DataProvider -from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.enums import RPCMessageType from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws.channel import WebSocketChannel +from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, + WSAnalyzedDFRequest, WSMessageSchema, + WSRequestSchema, WSSubscribeRequest, + WSWhitelistMessage, WSWhitelistRequest) if TYPE_CHECKING: @@ -67,15 +71,10 @@ class ExternalMessageConsumer: self.topics = [RPCMessageType.WHITELIST, RPCMessageType.ANALYZED_DF] # Allow setting data for each initial request - self._initial_requests: List[Dict[str, Any]] = [ - { - "type": RPCRequestType.WHITELIST, - "data": None - }, - { - "type": RPCRequestType.ANALYZED_DF, - "data": {"limit": self.initial_candle_limit} - } + self._initial_requests: List[WSRequestSchema] = [ + WSSubscribeRequest(data=self.topics), + WSWhitelistRequest(), + WSAnalyzedDFRequest() ] # Specify which function to use for which RPCMessageType @@ -174,16 +173,10 @@ class ExternalMessageConsumer: logger.info(f"Producer connection success - {channel}") - # Tell the producer we only want these topics - # Should always be the first thing we send - await channel.send( - self.compose_consumer_request(RPCRequestType.SUBSCRIBE, self.topics) - ) - # Now request the initial data from this Producer for request in self._initial_requests: await channel.send( - self.compose_consumer_request(request['type'], request['data']) + request.dict(exclude_none=True) ) # Now receive data, if none is within the time limit, ping @@ -253,74 +246,66 @@ class ExternalMessageConsumer: break - def compose_consumer_request( - self, - type_: RPCRequestType, - data: Optional[Any] = None - ) -> Dict[str, Any]: - """ - Create a request for sending to a producer - - :param type_: The RPCRequestType - :param data: The data to send - :returns: Dict[str, Any] - """ - return {'type': type_, 'data': data} - def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): """ Handles external messages from a Producer """ producer_name = producer.get('name', 'default') - # Should we have a default message type? - message_type = message.get('type', RPCMessageType.STATUS) - message_data = message.get('data') + + try: + producer_message = WSMessageSchema.parse_obj(message) + except ValidationError as e: + logger.error(f"Invalid message from {producer_name}: {e}") + return # We shouldn't get empty messages - if message_data is None: + if producer_message.data is None: return - logger.info(f"Received message of type {message_type} from `{producer_name}`") + logger.info(f"Received message of type {producer_message.type} from `{producer_name}`") - message_handler = self._message_handlers.get(message_type) + message_handler = self._message_handlers.get(producer_message.type) if not message_handler: - logger.info(f"Received unhandled message: {message_data}, ignoring...") + logger.info(f"Received unhandled message: {producer_message.data}, ignoring...") return - message_handler(producer_name, message_data) + message_handler(producer_name, producer_message) - def _consume_whitelist_message(self, producer_name: str, message_data: Any): - # We expect List[str] - if not isinstance(message_data, list): + def _consume_whitelist_message(self, producer_name: str, message: Any): + try: + # Validate the message + message = WSWhitelistMessage.parse_obj(message) + except ValidationError: return # Add the pairlist data to the DataProvider - self._dp._set_producer_pairs(message_data, producer_name=producer_name) + self._dp._set_producer_pairs(message.data, producer_name=producer_name) - logger.debug(f"Consumed message from {producer_name} of type RPCMessageType.WHITELIST") + logger.debug(f"Consumed message from {producer_name} of type `RPCMessageType.WHITELIST`") - def _consume_analyzed_df_message(self, producer_name: str, message_data: Any): - # We expect a Dict[str, Any] - if not isinstance(message_data, dict): + def _consume_analyzed_df_message(self, producer_name: str, message: Any): + try: + message = WSAnalyzedDFMessage.parse_obj(message) + except ValidationError: return - key, value = message_data.get('key'), message_data.get('value') + key = message.data.key + df = message.data.df + la = message.data.la - if key and value: - pair, timeframe, candle_type = key - dataframe, last_analyzed = value + pair, timeframe, candle_type = key - # If set, remove the Entry and Exit signals from the Producer - if self._emc_config.get('remove_entry_exit_signals', False): - dataframe = remove_entry_exit_signals(dataframe) + # If set, remove the Entry and Exit signals from the Producer + if self._emc_config.get('remove_entry_exit_signals', False): + df = remove_entry_exit_signals(df) - # Add the dataframe to the dataprovider - self._dp._add_external_df(pair, dataframe, - last_analyzed=last_analyzed, - timeframe=timeframe, - candle_type=candle_type, - producer_name=producer_name) + # Add the dataframe to the dataprovider + self._dp._add_external_df(pair, df, + last_analyzed=la, + timeframe=timeframe, + candle_type=candle_type, + producer_name=producer_name) - logger.debug( - f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") + logger.debug( + f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index df90b982e..9821bc001 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1068,8 +1068,12 @@ class RPC: for pair in pairlist: dataframe, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) - _data[pair] = {"key": (pair, timeframe, candle_type), - "value": (dataframe, last_analyzed)} + + _data[pair] = { + "key": (pair, timeframe, candle_type), + "df": dataframe, + "la": last_analyzed + } return _data From a50923f7963a58bddd76284cf470979eb7db0695 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 7 Sep 2022 17:14:26 -0600 Subject: [PATCH 042/199] add producers attribute to dataprovider --- freqtrade/data/dataprovider.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 4b5494e97..8ca638046 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -54,10 +54,8 @@ class DataProvider: self.__msg_cache = PeriodicCache( maxsize=1000, ttl=timeframe_to_seconds(self._default_timeframe)) - self._num_sources = len( - self._config.get('external_message_consumer', {}).get('producers', []) - ) - self.external_data_enabled = self._num_sources > 0 + self.producers = self._config.get('external_message_consumer', {}).get('producers', []) + self.external_data_enabled = len(self.producers) > 0 def _set_dataframe_max_index(self, limit_index: int): """ From 045c3f0f3ae7f27fc8702ec12680060d9c896d09 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 8 Sep 2022 07:01:37 +0200 Subject: [PATCH 043/199] Reduce diff by avoiding unnecessary changes --- freqtrade/misc.py | 2 +- freqtrade/strategy/interface.py | 34 ++++++++------------------------- tests/rpc/test_rpc_apiserver.py | 1 - 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 6a93b6f26..b2aca5fd6 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -14,7 +14,7 @@ import pandas import rapidjson from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN -from freqtrade.enums.signaltype import SignalTagType, SignalType +from freqtrade.enums import SignalTagType, SignalType logger = logging.getLogger(__name__) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 93a582e80..0a9a155ea 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -698,11 +698,7 @@ class IStrategy(ABC, HyperStrategyMixin): lock_time = timeframe_to_next_date(self.timeframe, candle_date) return PairLocks.is_pair_locked(pair, lock_time, side=side) - def analyze_ticker( - self, - dataframe: DataFrame, - metadata: dict - ) -> DataFrame: + def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame add several TA indicators and entry order signal to it @@ -716,11 +712,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = self.advise_exit(dataframe, metadata) return dataframe - def _analyze_ticker_internal( - self, - dataframe: DataFrame, - metadata: dict - ) -> DataFrame: + def _analyze_ticker_internal(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Parses the given candle (OHLCV) data and returns a populated DataFrame add several TA indicators and buy signal to it @@ -753,20 +745,16 @@ class IStrategy(ABC, HyperStrategyMixin): return dataframe - def analyze_pair( - self, - pair: str - ) -> None: + def analyze_pair(self, pair: str) -> None: """ Fetch data for this pair from dataprovider and analyze. Stores the dataframe into the dataprovider. The analyzed dataframe is then accessible via `dp.get_analyzed_dataframe()`. :param pair: Pair to analyze. """ - candle_type = self.config.get('candle_type_def', CandleType.SPOT) - - dataframe = self.dp.ohlcv(pair, self.timeframe, candle_type) - + dataframe = self.dp.ohlcv( + pair, self.timeframe, candle_type=self.config.get('candle_type_def', CandleType.SPOT) + ) if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning('Empty candle (OHLCV) data for pair %s', pair) return @@ -787,10 +775,7 @@ class IStrategy(ABC, HyperStrategyMixin): logger.warning('Empty dataframe for pair %s', pair) return - def analyze( - self, - pairs: List[str] - ) -> None: + def analyze(self, pairs: List[str]) -> None: """ Analyze all pairs using analyze_pair(). :param pairs: List of pairs to analyze @@ -798,7 +783,7 @@ class IStrategy(ABC, HyperStrategyMixin): for pair in pairs: self.analyze_pair(pair) - @ staticmethod + @staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: """ keep some data for dataframes """ return len(dataframe), dataframe["close"].iloc[-1], dataframe["date"].iloc[-1] @@ -1219,9 +1204,6 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe = _create_and_merge_informative_pair( self, dataframe, metadata, inf_data, populate_fn) - # If in follower mode, get analyzed dataframe from leader df's in dp - # otherise run populate_indicators - return self.populate_indicators(dataframe, metadata) def advise_entry(self, dataframe: DataFrame, metadata: dict) -> DataFrame: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 146303701..5dfa77d8b 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -406,7 +406,6 @@ def test_api_cleanup(default_conf, mocker, caplog): apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) - apiserver.start_api() apiserver.cleanup() assert apiserver._server.cleanup.call_count == 1 From 83770d20fac8d2acd8cf5d8567cc9d85a2b75ad1 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:10:32 -0600 Subject: [PATCH 044/199] fix existing freqtradebot tests --- tests/test_freqtradebot.py | 42 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index aff0504b3..837a4315f 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1317,9 +1317,9 @@ def test_create_stoploss_order_invalid_order( assert create_order_mock.call_args[1]['amount'] == trade.amount # Rpc is sending first buy, then sell - assert rpc_mock.call_count == 2 - assert rpc_mock.call_args_list[1][0][0]['sell_reason'] == ExitType.EMERGENCY_EXIT.value - assert rpc_mock.call_args_list[1][0][0]['order_type'] == 'market' + assert rpc_mock.call_count == 3 + assert rpc_mock.call_args_list[2][0][0]['sell_reason'] == ExitType.EMERGENCY_EXIT.value + assert rpc_mock.call_args_list[2][0][0]['order_type'] == 'market' @pytest.mark.parametrize("is_short", [False, True]) @@ -2434,7 +2434,7 @@ def test_manage_open_orders_entry_usercustom( # Trade should be closed since the function returns true freqtrade.manage_open_orders() assert cancel_order_wr_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 0 @@ -2473,7 +2473,7 @@ def test_manage_open_orders_entry( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 0 @@ -2603,7 +2603,7 @@ def test_check_handle_cancelled_buy( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 0 assert log_has_re( @@ -2634,7 +2634,7 @@ def test_manage_open_orders_buy_exception( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 + assert rpc_mock.call_count == 1 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 1 @@ -2681,7 +2681,7 @@ def test_manage_open_orders_exit_usercustom( # Return false - No impact freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 + assert rpc_mock.call_count == 1 assert open_trade_usdt.is_open is False assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2691,7 +2691,7 @@ def test_manage_open_orders_exit_usercustom( # Return Error - No impact freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 + assert rpc_mock.call_count == 1 assert open_trade_usdt.is_open is False assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2701,7 +2701,7 @@ def test_manage_open_orders_exit_usercustom( freqtrade.strategy.check_entry_timeout = MagicMock(return_value=True) freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 assert open_trade_usdt.is_open is True assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2761,7 +2761,7 @@ def test_manage_open_orders_exit( # check it does cancel sell orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 assert open_trade_usdt.is_open is True # Custom user sell-timeout is never called assert freqtrade.strategy.check_exit_timeout.call_count == 0 @@ -2800,7 +2800,7 @@ def test_check_handle_cancelled_exit( # check it does cancel sell orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 assert open_trade_usdt.is_open is True exit_name = 'Buy' if is_short else 'Sell' assert log_has_re(f"{exit_name} order cancelled on exchange for Trade.*", caplog) @@ -2838,7 +2838,7 @@ def test_manage_open_orders_partial( # note this is for a partially-complete buy order freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 assert trades[0].amount == 23.0 @@ -2885,7 +2885,7 @@ def test_manage_open_orders_partial_fee( assert log_has_re(r"Applying fee on amount for Trade.*", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that trade has been updated @@ -2935,7 +2935,7 @@ def test_manage_open_orders_partial_except( assert log_has_re(r"Could not update trade amount: .*", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that trade has been updated @@ -3150,7 +3150,7 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: reason = CANCEL_REASON['TIMEOUT'] assert freqtrade.handle_cancel_exit(trade, order, reason) assert cancel_order_mock.call_count == 1 - assert send_msg_mock.call_count == 1 + assert send_msg_mock.call_count == 2 assert trade.close_rate is None assert trade.exit_reason is None @@ -3583,7 +3583,7 @@ def test_execute_trade_exit_with_stoploss_on_exchange( trade.is_short = is_short assert trade assert cancel_order.call_count == 1 - assert rpc_mock.call_count == 3 + assert rpc_mock.call_count == 4 @pytest.mark.parametrize("is_short", [False, True]) @@ -3653,10 +3653,10 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( assert trade.stoploss_order_id is None assert trade.is_open is False assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value - assert rpc_mock.call_count == 3 - assert rpc_mock.call_args_list[0][0][0]['type'] == RPCMessageType.ENTRY - assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY_FILL - assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.EXIT_FILL + assert rpc_mock.call_count == 4 + assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY + assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL + assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL @pytest.mark.parametrize( From 4fac12544385caee6a9a64858bbb445b72ea6989 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:14:30 -0600 Subject: [PATCH 045/199] fix apiserver cleanup issues in tests --- tests/rpc/test_rpc_apiserver.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 5dfa77d8b..0efcc00c1 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -54,6 +54,7 @@ def botclient(default_conf, mocker): apiserver.add_rpc_handler(rpc) yield ftbot, TestClient(apiserver.app) # Cleanup ... ? + apiserver.cleanup() finally: ApiServer.shutdown() @@ -261,6 +262,7 @@ def test_api__init__(default_conf, mocker): with pytest.raises(OperationalException, match="RPC Handler already attached."): apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.cleanup() ApiServer.shutdown() @@ -388,6 +390,7 @@ def test_api_run(default_conf, mocker, caplog): MagicMock(side_effect=Exception)) apiserver.start_api() assert log_has("Api server failed to start.", caplog) + apiserver.cleanup() ApiServer.shutdown() From 9b752475dbf0c1d6de6e245781ffdb789c54d3b8 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:20:03 -0600 Subject: [PATCH 046/199] re-add fix to freqtradebot test --- tests/test_freqtradebot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 6f9ed407c..8b95792e5 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3655,11 +3655,11 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( assert trade.stoploss_order_id is None assert trade.is_open is False assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value - assert rpc_mock.call_count == 3 - assert rpc_mock.call_args_list[0][0][0]['type'] == RPCMessageType.ENTRY - assert rpc_mock.call_args_list[0][0][0]['amount'] > 20 - assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY_FILL - assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.EXIT_FILL + assert rpc_mock.call_count == 4 + assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY + assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 + assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL + assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL @pytest.mark.parametrize( From df3c1261464f8f72063f019ea865759c74b5a18b Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:34:37 -0600 Subject: [PATCH 047/199] fix mypy error --- freqtrade/rpc/api_server/api_ws.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 64c1cebb5..8a3fcfba2 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -9,7 +9,8 @@ from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, - WSRequestSchema, WSWhitelistMessage) + WSMessageSchema, WSRequestSchema, + WSWhitelistMessage) from freqtrade.rpc.rpc import RPC @@ -47,6 +48,7 @@ async def _process_consumer_request( return type, data = websocket_request.type, websocket_request.data + response: WSMessageSchema logger.debug(f"Request of type {type} from {channel}") @@ -61,6 +63,7 @@ async def _process_consumer_request( channel.set_subscriptions(data) # We don't send a response for subscriptions + return elif type == RPCRequestType.WHITELIST: # Get whitelist @@ -70,6 +73,7 @@ async def _process_consumer_request( response = WSWhitelistMessage(data=whitelist) # Send it back await channel.send(response.dict(exclude_none=True)) + return elif type == RPCRequestType.ANALYZED_DF: limit = None @@ -86,6 +90,8 @@ async def _process_consumer_request( response = WSAnalyzedDFMessage(data=message) await channel.send(response.dict(exclude_none=True)) + return + @router.websocket("/message/ws") async def message_endpoint( From 379b1cbc9001f38189afc1bd963bcc6f55601ed3 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:37:41 -0600 Subject: [PATCH 048/199] remove unnecessary returns --- freqtrade/rpc/api_server/api_ws.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 8a3fcfba2..45cc20e4d 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -73,7 +73,6 @@ async def _process_consumer_request( response = WSWhitelistMessage(data=whitelist) # Send it back await channel.send(response.dict(exclude_none=True)) - return elif type == RPCRequestType.ANALYZED_DF: limit = None @@ -90,8 +89,6 @@ async def _process_consumer_request( response = WSAnalyzedDFMessage(data=message) await channel.send(response.dict(exclude_none=True)) - return - @router.websocket("/message/ws") async def message_endpoint( From b3b0c918d98326df8361e5f03302c7ecbd54be65 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:44:03 -0600 Subject: [PATCH 049/199] cleanup old code --- freqtrade/rpc/api_server/ws/schema.py | 12 ------------ freqtrade/rpc/api_server/ws/serializer.py | 10 ---------- freqtrade/rpc/api_server/ws/utils.py | 12 ------------ 3 files changed, 34 deletions(-) delete mode 100644 freqtrade/rpc/api_server/ws/utils.py diff --git a/freqtrade/rpc/api_server/ws/schema.py b/freqtrade/rpc/api_server/ws/schema.py index 3221911de..0baa8d233 100644 --- a/freqtrade/rpc/api_server/ws/schema.py +++ b/freqtrade/rpc/api_server/ws/schema.py @@ -64,15 +64,3 @@ class WSAnalyzedDFMessage(WSMessageSchema): data: AnalyzedDFData # -------------------------------------------------------------------------- - - -if __name__ == "__main__": - message = WSAnalyzedDFMessage( - data={ - "key": ("1", "5m", "spot"), - "df": DataFrame(), - "la": datetime.now() - } - ) - - print(message) diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index c0c24bb28..d1ddd84f0 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -46,16 +46,6 @@ class JSONWebSocketSerializer(WebSocketSerializer): return json.loads(data, object_hook=_json_object_hook) -# ORJSON does not support .loads(object_hook=x) parameter, so we must use RapidJSON - -class RapidJSONWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - return rapidjson.dumps(data, default=_json_default) - - def _deserialize(self, data): - return rapidjson.loads(data, object_hook=_json_object_hook) - - class HybridJSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data) -> str: return str(orjson.dumps(data, default=_json_default), "utf-8") diff --git a/freqtrade/rpc/api_server/ws/utils.py b/freqtrade/rpc/api_server/ws/utils.py deleted file mode 100644 index 1ceecab88..000000000 --- a/freqtrade/rpc/api_server/ws/utils.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import WebSocket -# fastapi does not make this available through it, so import directly from starlette -from starlette.websockets import WebSocketState - - -async def is_websocket_alive(ws: WebSocket) -> bool: - if ( - ws.application_state == WebSocketState.CONNECTED and - ws.client_state == WebSocketState.CONNECTED - ): - return True - return False From fac6626459ed8faa8da1f790cd89421f8c026ba4 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 10:54:31 -0600 Subject: [PATCH 050/199] update default timeouts --- config_examples/config_full.example.json | 4 ++-- freqtrade/rpc/external_message_consumer.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 9788a0fa9..37c604c72 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -187,9 +187,9 @@ "ws_token": "a_secret_ws_token" } ], - "reply_timeout": 10, + "reply_timeout": 30, "ping_timeout": 5, - "sleep_time": 5, + "sleep_time": 10, "remove_entry_exit_signals": false }, "bot_name": "freqtrade", diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index d1e970826..525f4282c 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -58,9 +58,9 @@ class ExternalMessageConsumer: if self.enabled and len(self.producers) < 1: raise ValueError("You must specify at least 1 Producer to connect to.") - self.reply_timeout = self._emc_config.get('reply_timeout', 10) - self.ping_timeout = self._emc_config.get('ping_timeout', 2) - self.sleep_time = self._emc_config.get('sleep_time', 5) + self.reply_timeout = self._emc_config.get('reply_timeout', 30) + self.ping_timeout = self._emc_config.get('ping_timeout', 5) + self.sleep_time = self._emc_config.get('sleep_time', 10) # The amount of candles per dataframe on the initial request self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) From b9e7af1ce235cae8be27309da01df8ae70144100 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 11:25:30 -0600 Subject: [PATCH 051/199] fix ws token auth --- freqtrade/rpc/api_server/api_auth.py | 2 +- freqtrade/rpc/api_server/api_ws.py | 5 +++++ freqtrade/rpc/api_server/webserver.py | 7 ++----- tests/rpc/test_rpc_apiserver.py | 21 ++++++++++++++++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 6655dbf86..0d1378b6d 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -62,7 +62,7 @@ async def get_ws_token( # Just return the token if it matches return token else: - logger.debug("Denying websocket request") + logger.info("Denying websocket request") # If it doesn't match, close the websocket connection await ws.close(code=status.WS_1008_POLICY_VIOLATION) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 45cc20e4d..384bd4115 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from starlette.websockets import WebSocketState from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.rpc.api_server.api_auth import get_ws_token from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, @@ -95,6 +96,7 @@ async def message_endpoint( ws: WebSocket, rpc: RPC = Depends(get_rpc), channel_manager=Depends(get_channel_manager), + token: str = Depends(get_ws_token) ): """ Message WebSocket endpoint, facilitates sending RPC messages @@ -105,6 +107,9 @@ async def message_endpoint( # Return a channel ID, pass that instead of ws to the rest of the methods channel = await channel_manager.on_connect(ws) + if not channel: + return + logger.info(f"Consumer connected - {channel}") # Keep connection open until explicitly closed, and process requests diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 6ad3f143e..73e80dd48 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -139,8 +139,7 @@ class ApiServer(RPCHandler): ) def configure_app(self, app: FastAPI, config): - from freqtrade.rpc.api_server.api_auth import (get_ws_token, http_basic_or_jwt_token, - router_login) + from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login from freqtrade.rpc.api_server.api_backtest import router as api_backtest from freqtrade.rpc.api_server.api_v1 import router as api_v1 from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public @@ -155,9 +154,7 @@ class ApiServer(RPCHandler): app.include_router(api_backtest, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) - app.include_router(ws_router, prefix="/api/v1", - dependencies=[Depends(get_ws_token)] - ) + app.include_router(ws_router, prefix="/api/v1") app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 0efcc00c1..ccfe31424 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -10,7 +10,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock import pandas as pd import pytest import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, WebSocketDisconnect from fastapi.exceptions import HTTPException from fastapi.testclient import TestClient from requests.auth import _basic_auth_str @@ -31,6 +31,7 @@ from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_mock_ BASE_URI = "/api/v1" _TEST_USER = "FreqTrader" _TEST_PASS = "SuperSecurePassword1!" +_TEST_WS_TOKEN = "secret_Ws_t0ken" @pytest.fixture @@ -44,6 +45,7 @@ def botclient(default_conf, mocker): "CORS_origins": ['http://example.com'], "username": _TEST_USER, "password": _TEST_PASS, + "ws_token": _TEST_WS_TOKEN }}) ftbot = get_patched_freqtradebot(mocker, default_conf) @@ -155,6 +157,23 @@ def test_api_auth(): get_user_from_token(b'not_a_token', 'secret1234') +def test_api_ws_auth(botclient): + ftbot, client = botclient + + bad_token = "bad-ws_token" + url = f"/api/v1/message/ws?token={bad_token}" + + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect(url) as websocket: + websocket.receive() + + good_token = _TEST_WS_TOKEN + url = f"/api/v1/message/ws?token={good_token}" + + with client.websocket_connect(url) as websocket: + websocket.send(1) + + def test_api_unauthorized(botclient): ftbot, client = botclient rc = client.get(f"{BASE_URI}/ping") From 2b9c8550b0e6bddc85187958a2cb2816e95c4c2e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 13:58:28 -0600 Subject: [PATCH 052/199] moved ws_schemas, first ws tests --- freqtrade/rpc/api_server/api_ws.py | 19 ++++++++------- .../{ws/schema.py => ws_schemas.py} | 5 +--- freqtrade/rpc/external_message_consumer.py | 9 +++---- tests/rpc/test_rpc_apiserver.py | 24 +++++++++++++++++-- 4 files changed, 38 insertions(+), 19 deletions(-) rename freqtrade/rpc/api_server/{ws/schema.py => ws_schemas.py} (92%) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 384bd4115..f6eb59f87 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -2,6 +2,7 @@ import logging from typing import Any, Dict from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from pydantic import ValidationError # fastapi does not make this available through it, so import directly from starlette from starlette.websockets import WebSocketState @@ -9,9 +10,8 @@ from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.api_auth import get_ws_token from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel -from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, - WSMessageSchema, WSRequestSchema, - WSWhitelistMessage) +from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, + WSRequestSchema, WSWhitelistMessage) from freqtrade.rpc.rpc import RPC @@ -102,13 +102,11 @@ async def message_endpoint( Message WebSocket endpoint, facilitates sending RPC messages """ try: - if is_websocket_alive(ws): - # TODO: - # Return a channel ID, pass that instead of ws to the rest of the methods - channel = await channel_manager.on_connect(ws) + # TODO: + # Return a channel ID, pass that instead of ws to the rest of the methods + channel = await channel_manager.on_connect(ws) - if not channel: - return + if await is_websocket_alive(ws): logger.info(f"Consumer connected - {channel}") @@ -131,6 +129,9 @@ async def message_endpoint( # RuntimeError('Cannot call "send" once a closed message has been sent') await channel_manager.on_disconnect(ws) + else: + ws.close() + except Exception as e: logger.error(f"Failed to serve - {ws.client}") # Log tracebacks to keep track of what errors are happening diff --git a/freqtrade/rpc/api_server/ws/schema.py b/freqtrade/rpc/api_server/ws_schemas.py similarity index 92% rename from freqtrade/rpc/api_server/ws/schema.py rename to freqtrade/rpc/api_server/ws_schemas.py index 0baa8d233..255226d84 100644 --- a/freqtrade/rpc/api_server/ws/schema.py +++ b/freqtrade/rpc/api_server/ws_schemas.py @@ -2,15 +2,12 @@ from datetime import datetime from typing import Any, Dict, List, Optional from pandas import DataFrame -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from freqtrade.constants import PairWithTimeframe from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType -__all__ = ('WSRequestSchema', 'WSMessageSchema', 'ValidationError') - - class BaseArbitraryModel(BaseModel): class Config: arbitrary_types_allowed = True diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 525f4282c..abeedb0a4 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -11,15 +11,16 @@ from threading import Thread from typing import TYPE_CHECKING, Any, Dict, List import websockets +from pydantic import ValidationError from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws.channel import WebSocketChannel -from freqtrade.rpc.api_server.ws.schema import (ValidationError, WSAnalyzedDFMessage, - WSAnalyzedDFRequest, WSMessageSchema, - WSRequestSchema, WSSubscribeRequest, - WSWhitelistMessage, WSWhitelistRequest) +from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzedDFRequest, + WSMessageSchema, WSRequestSchema, + WSSubscribeRequest, WSWhitelistMessage, + WSWhitelistRequest) if TYPE_CHECKING: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index ccfe31424..de093b66f 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -56,8 +56,8 @@ def botclient(default_conf, mocker): apiserver.add_rpc_handler(rpc) yield ftbot, TestClient(apiserver.app) # Cleanup ... ? - apiserver.cleanup() finally: + apiserver.cleanup() ApiServer.shutdown() @@ -171,7 +171,7 @@ def test_api_ws_auth(botclient): url = f"/api/v1/message/ws?token={good_token}" with client.websocket_connect(url) as websocket: - websocket.send(1) + pass def test_api_unauthorized(botclient): @@ -1685,3 +1685,23 @@ def test_health(botclient): ret = rc.json() assert ret['last_process_ts'] == 0 assert ret['last_process'] == '1970-01-01T00:00:00+00:00' + + +def test_api_ws_subscribe(botclient, mocker): + ftbot, client = botclient + ws_url = f"/api/v1/message/ws?token={_TEST_WS_TOKEN}" + + sub_mock = mocker.patch( + 'freqtrade.rpc.api_server.ws.channel.WebSocketChannel.set_subscriptions', MagicMock()) + + with client.websocket_connect(ws_url) as ws: + ws.send_json({'type': 'subscribe', 'data': ['whitelist']}) + + # Check call count is now 1 as we sent a valid subscribe request + assert sub_mock.call_count == 1 + + with client.websocket_connect(ws_url) as ws: + ws.send_json({'type': 'subscribe', 'data': 'whitelist'}) + + # Call count hasn't changed as the subscribe request was invalid + assert sub_mock.call_count == 1 From c9d4f666c5fa95e4671ea0a4258abf9ff69b7386 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 14:00:22 -0600 Subject: [PATCH 053/199] minor apiserver test change --- tests/rpc/test_rpc_apiserver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index de093b66f..6a37e7cdd 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -51,13 +51,15 @@ def botclient(default_conf, mocker): ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock()) + apiserver = None try: apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(rpc) yield ftbot, TestClient(apiserver.app) # Cleanup ... ? finally: - apiserver.cleanup() + if apiserver: + apiserver.cleanup() ApiServer.shutdown() From 75cf8dbfe428eb2e3874638cf8ae262bda2768bf Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 15:11:36 -0600 Subject: [PATCH 054/199] missed await --- freqtrade/rpc/api_server/api_ws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index f6eb59f87..16d5ef9a7 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -130,7 +130,7 @@ async def message_endpoint( await channel_manager.on_disconnect(ws) else: - ws.close() + await ws.close() except Exception as e: logger.error(f"Failed to serve - {ws.client}") From 1466d2d26f5cb90fdc1e6343c8fb604f2a0ca657 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 16:27:09 -0600 Subject: [PATCH 055/199] initial message ws docs --- docs/rest-api.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index cc82aadda..5dc7637e5 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -31,7 +31,8 @@ Sample configuration: "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "Freqtrader", - "password": "SuperSecret1!" + "password": "SuperSecret1!", + "ws_token": "sercet_Ws_t0ken" }, ``` @@ -66,7 +67,7 @@ secrets.token_hex() !!! Danger "Password selection" Please make sure to select a very strong, unique password to protect your bot from unauthorized access. - Also change `jwt_secret_key` to something random (no need to remember this, but it'll be used to encrypt your session, so it better be something unique!). + Also change `jwt_secret_key` to something random (no need to remember this, but it'll be used to encrypt your session, so it better be something unique!). ### Configuration with docker @@ -274,7 +275,7 @@ reload_config Reload configuration. show_config - + Returns part of the configuration, relevant for trading operations. start @@ -322,6 +323,70 @@ whitelist ``` +### Message WebSocket + +The API Server makes available a websocket endpoint for subscribing to RPC messages +from the FreqTrade Bot. This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. + +Assuming your rest API is set to `127.0.0.1` on port `8080`, the endpoint is available at `http://localhost:8080/api/v1/message/ws`. + +To access the websocket endpoint, the `ws_token` is required as a query parameter in the endpoint URL. This is set in your rest API config section. To generate a safe `ws_token` you can run the following code: + +``` python +>>> import secrets +>>> secrets.token_urlsafe(16) +'zs9XYCbTPKvux46UJckflw' +``` + +You could then connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=zs9XYCbTPKvux46UJckflw`. + +!!! warning "Warning" + + Please do not use the above example token. To make sure you are secure, generate a completely new token. + + +#### Using the WebSocket + +Once connected to the WebSocket, the bot will broadcast RPC messages to anyone who is subscribed to them. To subscribe to a message, you must first subscribe to the message types. This can be done by sending a JSON request through the WebSocket: + +``` json +{ + "type": "subscribe", + "data": ["whitelist", "analyzed_df"] // A list of string message types +} +``` + +#### Message types + +| Message Type | Description | +|--------------|-------------| +| whitelist | The list of pairs in the bot's whitelist. +| analyzed_df | The dataframe and last_analyzed datetime for a pair. +| entry | Trade has signaled an entry +| entry_fill | Trade enter has filled +| entry_cancel | Trade enter has been canceled +| exit | Trade has signaled an exit +| exit_fill | Trade exit has filled +| exit_cancel | Trade exit has been canceled +| protection_trigger | A protection has triggered for a pair +| protection_trigger_global | A protection has triggered for a pair +| status | A bot's status change +| startup | Startup messages +| warning | Any warnings + +Now anytime those types of RPC messages are sent in the bot, you will receive them through the WebSocket as long as the connection is active. They typically take the same form as the request: + +``` json +{ + "type": "analyzed_df", + "data": { + "key": ["NEO/BTC", "5m", "spot"], + "df": {}, // The dataframe + "la": "2022-09-08 22:14:41.457786+00:00" + } +} +``` + ### OpenAPI interface To enable the builtin openAPI interface (Swagger UI), specify `"enable_openapi": true` in the api_server configuration. From 46cd0ce9948e6d753a4a19b26ff8797d86bcca2c Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 8 Sep 2022 16:30:31 -0600 Subject: [PATCH 056/199] fix sentence in docs --- docs/rest-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index 5dc7637e5..f4ac2c350 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -347,7 +347,7 @@ You could then connect to the endpoint at `http://localhost:8080/api/v1/message/ #### Using the WebSocket -Once connected to the WebSocket, the bot will broadcast RPC messages to anyone who is subscribed to them. To subscribe to a message, you must first subscribe to the message types. This can be done by sending a JSON request through the WebSocket: +Once connected to the WebSocket, the bot will broadcast RPC messages to anyone who is subscribed to them. To subscribe to a list of messages, you must send a JSON request through the WebSocket like the one below. The `data` key must be a list of message type strings. ``` json { From e256ebd7271bf138a381108cb237ab574947d025 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 9 Sep 2022 07:13:05 +0200 Subject: [PATCH 057/199] Add ws_token to auto-generated config --- freqtrade/commands/build_config_commands.py | 1 + freqtrade/enums/rpcmessagetype.py | 1 - freqtrade/rpc/rpc.py | 4 ++-- freqtrade/templates/base_config.json.j2 | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index 01cfa800a..1abd26328 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -211,6 +211,7 @@ def ask_user_config() -> Dict[str, Any]: ) # Force JWT token to be a random string answers['api_server_jwt_key'] = secrets.token_hex() + answers['api_server_ws_token'] = secrets.token_urlsafe(25) return answers diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 929f6d083..fae121a09 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -1,7 +1,6 @@ from enum import Enum -# We need to inherit from str so we can use as a str class RPCMessageType(str, Enum): STATUS = 'status' WARNING = 'warning' diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 9821bc001..943c1c667 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1086,7 +1086,7 @@ class RPC: """ Whitelist data for WebSocket """ return self._freqtrade.active_pair_whitelist - @ staticmethod + @staticmethod def _rpc_analysed_history_full(config, pair: str, timeframe: str, timerange: str, exchange) -> Dict[str, Any]: timerange_parsed = TimeRange.parse_timerange(timerange) @@ -1117,7 +1117,7 @@ class RPC: self._freqtrade.strategy.plot_config['subplots'] = {} return self._freqtrade.strategy.plot_config - @ staticmethod + @staticmethod def _rpc_sysinfo() -> Dict[str, Any]: return { "cpu_pct": psutil.cpu_percent(interval=1, percpu=True), diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index 681af84c6..299734a50 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -67,6 +67,7 @@ "verbosity": "error", "enable_openapi": false, "jwt_secret_key": "{{ api_server_jwt_key }}", + "ws_token": "{{ api_server_ws_token }}", "CORS_origins": [], "username": "{{ api_server_username }}", "password": "{{ api_server_password }}" From 426f8f37e9f45a8dc7df3bce11b1a0b3ace6c5b3 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 10:45:49 -0600 Subject: [PATCH 058/199] change var names --- freqtrade/constants.py | 2 +- freqtrade/rpc/api_server/webserver.py | 52 +++++++++++----------- freqtrade/rpc/external_message_consumer.py | 8 ++-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 2279acc13..e77940b3c 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -502,7 +502,7 @@ CONF_SCHEMA = { 'required': ['name', 'url', 'ws_token'] } }, - 'reply_timeout': {'type': 'integer'}, + 'wait_timeout': {'type': 'integer'}, 'sleep_time': {'type': 'integer'}, 'ping_timeout': {'type': 'integer'}, 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False}, diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 73e80dd48..557857ecc 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -49,9 +49,9 @@ class ApiServer(RPCHandler): # Exchange - only available in webserver mode. _exchange = None # websocket message queue stuff - _channel_manager = None - _thread = None - _loop = None + _ws_channel_manager = None + _ws_thread = None + _ws_loop = None def __new__(cls, *args, **kwargs): """ @@ -69,14 +69,14 @@ class ApiServer(RPCHandler): return self._standalone: bool = standalone self._server = None - self._queue = None - self._background_task = None + self._ws_queue = None + self._ws_background_task = None ApiServer.__initialized = True api_config = self._config['api_server'] - ApiServer._channel_manager = ChannelManager() + ApiServer._ws_channel_manager = ChannelManager() self.app = FastAPI(title="Freqtrade API", docs_url='/docs' if api_config.get('enable_openapi', False) else None, @@ -105,18 +105,18 @@ class ApiServer(RPCHandler): logger.info("Stopping API Server") self._server.cleanup() - if self._thread and self._loop: + if self._ws_thread and self._ws_loop: logger.info("Stopping API Server background tasks") - if self._background_task: + if self._ws_background_task: # Cancel the queue task - self._background_task.cancel() + self._ws_background_task.cancel() - self._thread.join() + self._ws_thread.join() - self._thread = None - self._loop = None - self._background_task = None + self._ws_thread = None + self._ws_loop = None + self._ws_background_task = None @classmethod def shutdown(cls): @@ -127,8 +127,8 @@ class ApiServer(RPCHandler): cls._rpc = None def send_msg(self, msg: Dict[str, str]) -> None: - if self._queue: - sync_q = self._queue.sync_q + if self._ws_queue: + sync_q = self._ws_queue.sync_q sync_q.put(msg) def handle_rpc_exception(self, request, exc): @@ -170,24 +170,24 @@ class ApiServer(RPCHandler): app.add_exception_handler(RPCException, self.handle_rpc_exception) def start_message_queue(self): - if self._thread: + if self._ws_thread: return # Create a new loop, as it'll be just for the background thread - self._loop = asyncio.new_event_loop() + self._ws_loop = asyncio.new_event_loop() # Start the thread - self._thread = Thread(target=self._loop.run_forever) - self._thread.start() + self._ws_thread = Thread(target=self._ws_loop.run_forever) + self._ws_thread.start() # Finally, submit the coro to the thread - self._background_task = asyncio.run_coroutine_threadsafe( - self._broadcast_queue_data(), loop=self._loop) + self._ws_background_task = asyncio.run_coroutine_threadsafe( + self._broadcast_queue_data(), loop=self._ws_loop) async def _broadcast_queue_data(self): # Instantiate the queue in this coroutine so it's attached to our loop - self._queue = ThreadedQueue() - async_queue = self._queue.async_q + self._ws_queue = ThreadedQueue() + async_queue = self._ws_queue.async_q try: while True: @@ -196,13 +196,13 @@ class ApiServer(RPCHandler): message = await async_queue.get() logger.debug(f"Found message of type: {message.get('type')}") # Broadcast it - await self._channel_manager.broadcast(message) + await self._ws_channel_manager.broadcast(message) # Sleep, make this configurable? await asyncio.sleep(0.1) except asyncio.CancelledError: # Disconnect channels and stop the loop on cancel - await self._channel_manager.disconnect_all() - self._loop.stop() + await self._ws_channel_manager.disconnect_all() + self._ws_loop.stop() # For testing, shouldn't happen when stable except Exception as e: diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index abeedb0a4..1c2a27617 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -59,9 +59,9 @@ class ExternalMessageConsumer: if self.enabled and len(self.producers) < 1: raise ValueError("You must specify at least 1 Producer to connect to.") - self.reply_timeout = self._emc_config.get('reply_timeout', 30) - self.ping_timeout = self._emc_config.get('ping_timeout', 5) - self.sleep_time = self._emc_config.get('sleep_time', 10) + self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds + self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds + self.sleep_time = self._emc_config.get('sleep_time', 10) # in seconds # The amount of candles per dataframe on the initial request self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) @@ -220,7 +220,7 @@ class ExternalMessageConsumer: try: message = await asyncio.wait_for( channel.recv(), - timeout=self.reply_timeout + timeout=self.wait_timeout ) try: From 445ab1beeed411531dbe2ba27cba7c026c83949a Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 10:56:54 -0600 Subject: [PATCH 059/199] update docs --- docs/rest-api.md | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index f4ac2c350..485fca6b5 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -330,15 +330,36 @@ from the FreqTrade Bot. This can be used to consume real-time data from your bot Assuming your rest API is set to `127.0.0.1` on port `8080`, the endpoint is available at `http://localhost:8080/api/v1/message/ws`. -To access the websocket endpoint, the `ws_token` is required as a query parameter in the endpoint URL. This is set in your rest API config section. To generate a safe `ws_token` you can run the following code: +To access the websocket endpoint, the `ws_token` is required as a query parameter in the endpoint URL. + + + To generate a safe `ws_token` you can run the following code: ``` python >>> import secrets ->>> secrets.token_urlsafe(16) -'zs9XYCbTPKvux46UJckflw' +>>> secrets.token_urlsafe(25) +'hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q' ``` -You could then connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=zs9XYCbTPKvux46UJckflw`. + +You would then add that token under `ws_token` in your `api_server` config. Like so: + +``` json +"api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "Freqtrader", + "password": "SuperSecret1!", + "ws_token": "hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q" // <----- +}, +``` + +You could then connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q`. !!! warning "Warning" @@ -355,24 +376,7 @@ Once connected to the WebSocket, the bot will broadcast RPC messages to anyone w "data": ["whitelist", "analyzed_df"] // A list of string message types } ``` - -#### Message types - -| Message Type | Description | -|--------------|-------------| -| whitelist | The list of pairs in the bot's whitelist. -| analyzed_df | The dataframe and last_analyzed datetime for a pair. -| entry | Trade has signaled an entry -| entry_fill | Trade enter has filled -| entry_cancel | Trade enter has been canceled -| exit | Trade has signaled an exit -| exit_fill | Trade exit has filled -| exit_cancel | Trade exit has been canceled -| protection_trigger | A protection has triggered for a pair -| protection_trigger_global | A protection has triggered for a pair -| status | A bot's status change -| startup | Startup messages -| warning | Any warnings +For a list of message types, please refer to the RPCMessageType enum in `freqtrade/enums/rpcmessagetype.py` Now anytime those types of RPC messages are sent in the bot, you will receive them through the WebSocket as long as the connection is active. They typically take the same form as the request: From ad9659769331c718098f2ee8acc17c0fbf3ebca0 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 10:59:38 -0600 Subject: [PATCH 060/199] wording --- docs/rest-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-api.md b/docs/rest-api.md index 485fca6b5..704f38f00 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -359,7 +359,7 @@ You would then add that token under `ws_token` in your `api_server` config. Like }, ``` -You could then connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q`. +You can now connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q`. !!! warning "Warning" From 09679cc79814d7921280846f1291d169ffe23b97 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 11:27:20 -0600 Subject: [PATCH 061/199] fix dependency --- freqtrade/rpc/api_server/deps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py index 360771d77..abd3db036 100644 --- a/freqtrade/rpc/api_server/deps.py +++ b/freqtrade/rpc/api_server/deps.py @@ -42,7 +42,7 @@ def get_exchange(config=Depends(get_config)): def get_channel_manager(): - return ApiServer._channel_manager + return ApiServer._ws_channel_manager def is_webserver_mode(config=Depends(get_config)): From 6cbc03a96a6ca4234ec1ef06153927894ae38f35 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 11:38:42 -0600 Subject: [PATCH 062/199] support jwt token in place of ws token --- freqtrade/rpc/api_server/api_auth.py | 32 ++++++++++++++++++++-------- freqtrade/rpc/api_server/api_ws.py | 4 ++++ tests/rpc/test_rpc_apiserver.py | 12 ++++++----- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 0d1378b6d..a2b722f0a 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, Union import jwt -from fastapi import APIRouter, Depends, HTTPException, WebSocket, status +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, status from fastapi.security import OAuth2PasswordBearer from fastapi.security.http import HTTPBasic, HTTPBasicCredentials @@ -29,7 +29,8 @@ httpbasic = HTTPBasic(auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) -def get_user_from_token(token, secret_key: str, token_type: str = "access"): +def get_user_from_token(token, secret_key: str, token_type: str = "access", + raise_on_error: bool = True) -> Union[bool, str]: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -39,12 +40,21 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access"): payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) username: str = payload.get("identity", {}).get('u') if username is None: - raise credentials_exception + if raise_on_error: + raise credentials_exception + else: + return False if payload.get("type") != token_type: - raise credentials_exception + if raise_on_error: + raise credentials_exception + else: + return False except jwt.PyJWTError: - raise credentials_exception + if raise_on_error: + raise credentials_exception + else: + return False return username @@ -53,14 +63,18 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access"): # https://github.com/tiangolo/fastapi/blob/master/fastapi/security/api_key.py async def get_ws_token( ws: WebSocket, - token: Union[str, None] = None, + ws_token: Union[str, None] = Query(..., alias="token"), api_config: Dict[str, Any] = Depends(get_api_config) ): - secret_ws_token = api_config['ws_token'] + secret_ws_token = api_config.get('ws_token', 'secret_ws_t0ken.') + secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') - if token == secret_ws_token: + if secrets.compare_digest(secret_ws_token, ws_token): # Just return the token if it matches - return token + return ws_token + elif user := get_user_from_token(ws_token, secret_jwt_key, raise_on_error=False): + # If the token is a jwt, and it's valid return the user + return user else: logger.info("Denying websocket request") # If it doesn't match, close the websocket connection diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 16d5ef9a7..25d29a7ce 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -132,6 +132,10 @@ async def message_endpoint( else: await ws.close() + except RuntimeError: + # WebSocket was closed + await channel_manager.on_disconnect(ws) + except Exception as e: logger.error(f"Failed to serve - {ws.client}") # Log tracebacks to keep track of what errors are happening diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 6a37e7cdd..f1aa465f0 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -161,18 +161,20 @@ def test_api_auth(): def test_api_ws_auth(botclient): ftbot, client = botclient + def url(token): return f"/api/v1/message/ws?token={token}" bad_token = "bad-ws_token" - url = f"/api/v1/message/ws?token={bad_token}" - with pytest.raises(WebSocketDisconnect): - with client.websocket_connect(url) as websocket: + with client.websocket_connect(url(bad_token)) as websocket: websocket.receive() good_token = _TEST_WS_TOKEN - url = f"/api/v1/message/ws?token={good_token}" + with client.websocket_connect(url(good_token)) as websocket: + pass - with client.websocket_connect(url) as websocket: + jwt_secret = ftbot.config['api_server'].get('jwt_secret_key', 'super-secret') + jwt_token = create_token({'identity': {'u': 'Freqtrade'}}, jwt_secret) + with client.websocket_connect(url(jwt_token)) as websocket: pass From 826eb8525470e8fa638f61247ed80e2f369493c5 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 11:58:30 -0600 Subject: [PATCH 063/199] update confige example --- config_examples/config_full.example.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 37c604c72..d8d552814 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -173,7 +173,7 @@ "CORS_origins": [], "username": "freqtrader", "password": "SuperSecurePassword", - "ws_token": "a_secret_ws_token" + "ws_token": "secret_ws_t0ken." }, // The ExternalMessageConsumer config should only be enabled on an instance // that listens to outside data from another instance. This should not be enabled @@ -184,11 +184,11 @@ { "name": "default", "url": "ws://localhost:8081/api/v1/message/ws", - "ws_token": "a_secret_ws_token" + "ws_token": "secret_ws_t0ken." } ], - "reply_timeout": 30, - "ping_timeout": 5, + "poll_timeout": 300, + "ping_timeout": 10, "sleep_time": 10, "remove_entry_exit_signals": false }, From 2f6a61521f2bed67a40ed43d08ef53232b7e2ac5 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 9 Sep 2022 17:14:40 -0600 Subject: [PATCH 064/199] add more tests --- tests/data/test_dataprovider.py | 70 +++++++++++++++++++++++++++++++++ tests/rpc/test_rpc_apiserver.py | 6 +++ tests/test_misc.py | 20 ++++++++-- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 49603feac..812688cb1 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -144,6 +144,76 @@ def test_available_pairs(mocker, default_conf, ohlcv_history): assert dp.available_pairs == [("XRP/BTC", timeframe), ("UNITTEST/BTC", timeframe), ] +def test_producer_pairs(mocker, default_conf, ohlcv_history): + dataprovider = DataProvider(default_conf, None) + + producer = "default" + whitelist = ["XRP/BTC", "ETH/BTC"] + assert len(dataprovider.get_producer_pairs(producer)) == 0 + + dataprovider._set_producer_pairs(whitelist, producer) + assert len(dataprovider.get_producer_pairs(producer)) == 2 + + new_whitelist = ["BTC/USDT"] + dataprovider._set_producer_pairs(new_whitelist, producer) + assert dataprovider.get_producer_pairs(producer) == new_whitelist + + assert dataprovider.get_producer_pairs("bad") == [] + + +def test_external_df(mocker, default_conf, ohlcv_history): + dataprovider = DataProvider(default_conf, None) + + pair = 'BTC/USDT' + timeframe = default_conf['timeframe'] + candle_type = CandleType.SPOT + + empty_la = datetime.fromtimestamp(0, tz=timezone.utc) + + # no data has been added, any request should return an empty dataframe + dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) + assert dataframe.empty + assert la == empty_la + + # the data is added, should return that added dataframe + dataprovider._add_external_df(pair, ohlcv_history, timeframe=timeframe, candle_type=candle_type) + dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) + assert len(dataframe) > 0 + assert la > empty_la + + # no data on this producer, should return empty dataframe + dataframe, la = dataprovider.get_external_df(pair, producer_name='bad') + assert dataframe.empty + assert la == empty_la + + # non existent timeframe, empty dataframe + datframe, la = dataprovider.get_external_df(pair, timeframe='1h') + assert dataframe.empty + assert la == empty_la + + +def test_emit_df(mocker, default_conf, ohlcv_history): + mocker.patch('freqtrade.rpc.rpc_manager.RPCManager.__init__', MagicMock()) + rpc_mock = mocker.patch('freqtrade.rpc.rpc_manager.RPCManager', MagicMock()) + send_mock = mocker.patch('freqtrade.rpc.rpc_manager.RPCManager.send_msg', MagicMock()) + + dataprovider = DataProvider(default_conf, exchange=None, rpc=rpc_mock) + dataprovider_no_rpc = DataProvider(default_conf, exchange=None) + + pair = "BTC/USDT" + + # No emit yet + assert send_mock.call_count == 0 + + # Rpc is added, we call emit, should call send_msg + dataprovider._emit_df(pair, ohlcv_history) + assert send_mock.call_count == 1 + + # No rpc added, emit called, should not call send_msg + dataprovider_no_rpc._emit_df(pair, ohlcv_history) + assert send_mock.call_count == 1 + + def test_refresh(mocker, default_conf, ohlcv_history): refresh_mock = MagicMock() mocker.patch("freqtrade.exchange.Exchange.refresh_latest_ohlcv", refresh_mock) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index f1aa465f0..17705e62e 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -158,6 +158,12 @@ def test_api_auth(): with pytest.raises(HTTPException): get_user_from_token(b'not_a_token', 'secret1234') + # Check returning false instead of error on bad token + assert not get_user_from_token(b'not_a_token', 'secret1234', raise_on_error=False) + + # Check returning false instead of error on bad token type + assert not get_user_from_token(token, 'secret1234', token_type='refresh', raise_on_error=False) + def test_api_ws_auth(botclient): ftbot, client = botclient diff --git a/tests/test_misc.py b/tests/test_misc.py index 107932be4..514fec54a 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -7,10 +7,11 @@ from unittest.mock import MagicMock import pytest -from freqtrade.misc import (decimals_per_coin, deep_merge_dicts, file_dump_json, file_load_json, - format_ms_time, pair_to_filename, parse_db_uri_for_logging, plural, - render_template, render_template_with_fallback, round_coin_value, - safe_value_fallback, safe_value_fallback2, shorten_date) +from freqtrade.misc import (dataframe_to_json, decimals_per_coin, deep_merge_dicts, file_dump_json, + file_load_json, format_ms_time, json_to_dataframe, pair_to_filename, + parse_db_uri_for_logging, plural, render_template, + render_template_with_fallback, round_coin_value, safe_value_fallback, + safe_value_fallback2, shorten_date) def test_decimals_per_coin(): @@ -219,3 +220,14 @@ def test_deep_merge_dicts(): res2['first']['rows']['test'] = 'asdf' assert deep_merge_dicts(a, deepcopy(b), allow_null_overrides=False) == res2 + + +def test_dataframe_json(ohlcv_history): + from pandas.testing import assert_frame_equal + json = dataframe_to_json(ohlcv_history) + dataframe = json_to_dataframe(json) + + assert list(ohlcv_history.columns) == list(dataframe.columns) + assert len(ohlcv_history) == len(dataframe) + + assert_frame_equal(ohlcv_history, dataframe) From b344f78d007c20b75acefa181c2a2129f4787ecd Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 10 Sep 2022 14:19:11 +0200 Subject: [PATCH 065/199] Improve logic for token validation --- freqtrade/rpc/api_server/api_auth.py | 35 +++++++++++----------------- freqtrade/rpc/api_server/api_ws.py | 4 ++-- tests/rpc/test_rpc_apiserver.py | 6 ----- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index a2b722f0a..767a2d5b9 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -29,8 +29,7 @@ httpbasic = HTTPBasic(auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) -def get_user_from_token(token, secret_key: str, token_type: str = "access", - raise_on_error: bool = True) -> Union[bool, str]: +def get_user_from_token(token, secret_key: str, token_type: str = "access") -> str: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -40,28 +39,19 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access", payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) username: str = payload.get("identity", {}).get('u') if username is None: - if raise_on_error: - raise credentials_exception - else: - return False + raise credentials_exception if payload.get("type") != token_type: - if raise_on_error: - raise credentials_exception - else: - return False + raise credentials_exception except jwt.PyJWTError: - if raise_on_error: - raise credentials_exception - else: - return False + raise credentials_exception return username # This should be reimplemented to better realign with the existing tools provided # by FastAPI regarding API Tokens # https://github.com/tiangolo/fastapi/blob/master/fastapi/security/api_key.py -async def get_ws_token( +async def validate_ws_token( ws: WebSocket, ws_token: Union[str, None] = Query(..., alias="token"), api_config: Dict[str, Any] = Depends(get_api_config) @@ -72,13 +62,16 @@ async def get_ws_token( if secrets.compare_digest(secret_ws_token, ws_token): # Just return the token if it matches return ws_token - elif user := get_user_from_token(ws_token, secret_jwt_key, raise_on_error=False): - # If the token is a jwt, and it's valid return the user - return user else: - logger.info("Denying websocket request") - # If it doesn't match, close the websocket connection - await ws.close(code=status.WS_1008_POLICY_VIOLATION) + try: + user = get_user_from_token(ws_token, secret_jwt_key) + return user + # If the token is a jwt, and it's valid return the user + except HTTPException: + pass + logger.info("Denying websocket request") + # If it doesn't match, close the websocket connection + await ws.close(code=status.WS_1008_POLICY_VIOLATION) def create_token(data: dict, secret_key: str, token_type: str = "access") -> str: diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 25d29a7ce..34b780956 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -7,7 +7,7 @@ from pydantic import ValidationError from starlette.websockets import WebSocketState from freqtrade.enums import RPCMessageType, RPCRequestType -from freqtrade.rpc.api_server.api_auth import get_ws_token +from freqtrade.rpc.api_server.api_auth import validate_ws_token from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, @@ -96,7 +96,7 @@ async def message_endpoint( ws: WebSocket, rpc: RPC = Depends(get_rpc), channel_manager=Depends(get_channel_manager), - token: str = Depends(get_ws_token) + token: str = Depends(validate_ws_token) ): """ Message WebSocket endpoint, facilitates sending RPC messages diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 17705e62e..f1aa465f0 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -158,12 +158,6 @@ def test_api_auth(): with pytest.raises(HTTPException): get_user_from_token(b'not_a_token', 'secret1234') - # Check returning false instead of error on bad token - assert not get_user_from_token(b'not_a_token', 'secret1234', raise_on_error=False) - - # Check returning false instead of error on bad token type - assert not get_user_from_token(token, 'secret1234', token_type='refresh', raise_on_error=False) - def test_api_ws_auth(botclient): ftbot, client = botclient From 4250174de94b4b27acd5007b009034002212d86e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 10 Sep 2022 14:29:58 +0200 Subject: [PATCH 066/199] Fix ws exception when no token is provided --- freqtrade/rpc/api_server/api_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 767a2d5b9..93be251ab 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -53,13 +53,13 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access") -> s # https://github.com/tiangolo/fastapi/blob/master/fastapi/security/api_key.py async def validate_ws_token( ws: WebSocket, - ws_token: Union[str, None] = Query(..., alias="token"), + ws_token: Union[str, None] = Query(default=None, alias="token"), api_config: Dict[str, Any] = Depends(get_api_config) ): secret_ws_token = api_config.get('ws_token', 'secret_ws_t0ken.') secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') - if secrets.compare_digest(secret_ws_token, ws_token): + if ws_token and secrets.compare_digest(secret_ws_token, ws_token): # Just return the token if it matches return ws_token else: From d8cdd92140fc908fba0de5d55314ab2181fbc5c8 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 11:47:21 -0600 Subject: [PATCH 067/199] wrap background cleanup in finally, add tests --- freqtrade/rpc/api_server/webserver.py | 9 ++++--- tests/rpc/test_rpc_apiserver.py | 36 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 557857ecc..3fb8159e1 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -200,14 +200,17 @@ class ApiServer(RPCHandler): # Sleep, make this configurable? await asyncio.sleep(0.1) except asyncio.CancelledError: - # Disconnect channels and stop the loop on cancel - await self._ws_channel_manager.disconnect_all() - self._ws_loop.stop() + pass # For testing, shouldn't happen when stable except Exception as e: logger.exception(f"Exception happened in background task: {e}") + finally: + # Disconnect channels and stop the loop on cancel + await self._ws_channel_manager.disconnect_all() + self._ws_loop.stop() + def start_api(self): """ Start API ... should be run in thread. diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index f1aa465f0..a7774c204 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -3,6 +3,8 @@ Unit test file for rpc/api_server.py """ import json +import logging +import time from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock @@ -436,6 +438,7 @@ def test_api_cleanup(default_conf, mocker, caplog): apiserver.cleanup() assert apiserver._server.cleanup.call_count == 1 assert log_has("Stopping API Server", caplog) + assert log_has("Stopping API Server background tasks", caplog) ApiServer.shutdown() @@ -1709,3 +1712,36 @@ def test_api_ws_subscribe(botclient, mocker): # Call count hasn't changed as the subscribe request was invalid assert sub_mock.call_count == 1 + + +def test_api_ws_send_msg(default_conf, mocker, caplog): + try: + caplog.set_level(logging.DEBUG) + + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "username": "TestUser", + "password": "testPass", + }}) + mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + + # Test message_queue coro receives the message + test_message = {"type": "status", "data": "test"} + apiserver.send_msg(test_message) + time.sleep(1) # Not sure how else to wait for the coro to receive the data + assert log_has("Found message of type: status", caplog) + + # Test if exception logged when error occurs in sending + mocker.patch('freqtrade.rpc.api_server.ws.channel.ChannelManager.broadcast', + side_effect=Exception) + + apiserver.send_msg(test_message) + time.sleep(2) # Not sure how else to wait for the coro to receive the data + assert log_has_re(r"Exception happened in background task.*", caplog) + + finally: + apiserver.cleanup() + ApiServer.shutdown() From 866a5649588c77ec686ba65e581d1306d8ea3751 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 12:51:29 -0600 Subject: [PATCH 068/199] update emc start/shutdown, initial emc tests --- freqtrade/rpc/external_message_consumer.py | 21 +++--- tests/conftest.py | 5 ++ tests/rpc/test_rpc_emc.py | 79 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 tests/rpc/test_rpc_emc.py diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 1c2a27617..ae7c1f765 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -90,16 +90,16 @@ class ExternalMessageConsumer: """ Start the main internal loop in another thread to run coroutines """ + if self._thread and self._loop: + return + + logger.info("Starting ExternalMessageConsumer") + self._loop = asyncio.new_event_loop() + self._thread = Thread(target=self._loop.run_forever) + self._thread.start() - if not self._thread: - logger.info("Starting ExternalMessageConsumer") - - self._thread = Thread(target=self._loop.run_forever) - self._thread.start() - self._running = True - else: - raise RuntimeError("A loop is already running") + self._running = True self._main_task = asyncio.run_coroutine_threadsafe(self._main(), loop=self._loop) @@ -121,6 +121,11 @@ class ExternalMessageConsumer: self._thread.join() + self._thread = None + self._loop = None + self._sub_tasks = None + self._main_task = None + async def _main(self): """ The main task coroutine diff --git a/tests/conftest.py b/tests/conftest.py index fffac8e0a..6ce767918 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,11 @@ def log_has(line, logs): return any(line == message for message in logs.messages) +def log_has_when(line, logs, when): + """Check if line is found in caplog's messages during a specified stage""" + return any(line == message.message for message in logs.get_records(when)) + + def log_has_re(line, logs): """Check if line matches some caplog's message.""" return any(re.match(line, message) for message in logs.messages) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py new file mode 100644 index 000000000..6512ff2be --- /dev/null +++ b/tests/rpc/test_rpc_emc.py @@ -0,0 +1,79 @@ +""" +Unit test file for rpc/external_message_consumer.py +""" +import pytest + +from freqtrade.data.dataprovider import DataProvider +from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer +from tests.conftest import log_has, log_has_when + + +@pytest.fixture(autouse=True) +def patched_emc(default_conf, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": "ws://127.0.0.1:8080/api/v1/message/ws", + "ws_token": "secret_Ws_t0ken" + } + ] + } + }) + dataprovider = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dataprovider) + + try: + yield emc + finally: + emc.shutdown() + + +def test_emc_start(patched_emc, caplog): + # Test if the message was printed + assert log_has_when("Starting ExternalMessageConsumer", caplog, "setup") + # Test if the thread and loop objects were created + assert patched_emc._thread and patched_emc._loop + + # Test we call start again nothing happens + prev_thread = patched_emc._thread + patched_emc.start() + assert prev_thread == patched_emc._thread + + +def test_emc_shutdown(patched_emc, caplog): + patched_emc.shutdown() + + assert log_has("Stopping ExternalMessageConsumer", caplog) + # Test the loop has stopped + assert patched_emc._loop is None + # Test if the thread has stopped + assert patched_emc._thread is None + + caplog.clear() + patched_emc.shutdown() + + # Test func didn't run again as it was called once already + assert not log_has("Stopping ExternalMessageConsumer", caplog) + + +def test_emc_init(patched_emc, default_conf, mocker, caplog): + # Test the settings were set correctly + assert patched_emc.initial_candle_limit <= 1500 + assert patched_emc.wait_timeout > 0 + assert patched_emc.sleep_time > 0 + + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [] + } + }) + dataprovider = DataProvider(default_conf, None, None, None) + with pytest.raises(ValueError) as exc: + ExternalMessageConsumer(default_conf, dataprovider) + + # Make sure we failed because of no producers + assert str(exc.value) == "You must specify at least 1 Producer to connect to." From a7baccdb7df6d620b1d23a846d268611cac3ed79 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 13:44:27 -0600 Subject: [PATCH 069/199] update log messages in emc, more tests --- freqtrade/rpc/external_message_consumer.py | 10 ++-- tests/rpc/test_rpc_emc.py | 58 +++++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index ae7c1f765..c571ac510 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -261,19 +261,19 @@ class ExternalMessageConsumer: try: producer_message = WSMessageSchema.parse_obj(message) except ValidationError as e: - logger.error(f"Invalid message from {producer_name}: {e}") + logger.error(f"Invalid message from `{producer_name}`: {e}") return # We shouldn't get empty messages if producer_message.data is None: return - logger.info(f"Received message of type {producer_message.type} from `{producer_name}`") + logger.info(f"Received message of type `{producer_message.type}` from `{producer_name}`") message_handler = self._message_handlers.get(producer_message.type) if not message_handler: - logger.info(f"Received unhandled message: {producer_message.data}, ignoring...") + logger.info(f"Received unhandled message: `{producer_message.data}`, ignoring...") return message_handler(producer_name, producer_message) @@ -288,7 +288,7 @@ class ExternalMessageConsumer: # Add the pairlist data to the DataProvider self._dp._set_producer_pairs(message.data, producer_name=producer_name) - logger.debug(f"Consumed message from {producer_name} of type `RPCMessageType.WHITELIST`") + logger.debug(f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`") def _consume_analyzed_df_message(self, producer_name: str, message: Any): try: @@ -314,4 +314,4 @@ class ExternalMessageConsumer: producer_name=producer_name) logger.debug( - f"Consumed message from {producer_name} of type RPCMessageType.ANALYZED_DF") + f"Consumed message from `{producer_name}` of type `RPCMessageType.ANALYZED_DF`") diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 6512ff2be..f33e80018 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -1,11 +1,14 @@ """ Unit test file for rpc/external_message_consumer.py """ +import logging +from datetime import datetime, timezone + import pytest from freqtrade.data.dataprovider import DataProvider from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer -from tests.conftest import log_has, log_has_when +from tests.conftest import log_has, log_has_re, log_has_when @pytest.fixture(autouse=True) @@ -77,3 +80,56 @@ def test_emc_init(patched_emc, default_conf, mocker, caplog): # Make sure we failed because of no producers assert str(exc.value) == "You must specify at least 1 Producer to connect to." + + +def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): + test_producer = {"name": "test", "url": "ws://test", "ws_token": "test"} + caplog.set_level(logging.DEBUG) + + # Test handle whitelist message + whitelist_message = {"type": "whitelist", "data": ["BTC/USDT"]} + patched_emc.handle_producer_message(test_producer, whitelist_message) + + assert log_has("Received message of type `whitelist` from `test`", caplog) + assert log_has("Consumed message from `test` of type `RPCMessageType.WHITELIST`", caplog) + + # Test handle analyzed_df message + df_message = { + "type": "analyzed_df", + "data": { + "key": ("BTC/USDT", "5m", "spot"), + "df": ohlcv_history, + "la": datetime.now(timezone.utc) + } + } + patched_emc.handle_producer_message(test_producer, df_message) + + assert log_has("Received message of type `analyzed_df` from `test`", caplog) + assert log_has("Consumed message from `test` of type `RPCMessageType.ANALYZED_DF`", caplog) + + # Test unhandled message + unhandled_message = {"type": "status", "data": "RUNNING"} + patched_emc.handle_producer_message(test_producer, unhandled_message) + + assert log_has_re(r"Received unhandled message\: .*", caplog) + + # Test malformed message + caplog.clear() + malformed_message = {"type": "whitelist", "data": {"pair": "BTC/USDT"}} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has("Received message of type `whitelist` from `test`", caplog) + assert not log_has("Consumed message from `test` of type `RPCMessageType.WHITELIST`", caplog) + + malformed_message = { + "type": "analyzed_df", + "data": { + "key": "BTC/USDT", + "df": ohlcv_history, + "la": datetime.now(timezone.utc) + } + } + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has("Received message of type `analyzed_df` from `test`", caplog) + assert not log_has("Consumed message from `test` of type `RPCMessageType.ANALYZED_DF`", caplog) From c5d031733b271e51edbc54a5f2880f724ace7924 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 13:50:36 -0600 Subject: [PATCH 070/199] remove old param in test fixture --- tests/rpc/test_rpc_emc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index f33e80018..d134a9eb1 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -11,7 +11,7 @@ from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from tests.conftest import log_has, log_has_re, log_has_when -@pytest.fixture(autouse=True) +@pytest.fixture def patched_emc(default_conf, mocker): default_conf.update({ "external_message_consumer": { From 2afd5c202c1f906dcd5c04187caa5a67b0337dde Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 14:29:15 -0600 Subject: [PATCH 071/199] update message parsing, tests --- freqtrade/rpc/external_message_consumer.py | 10 +++---- tests/rpc/test_rpc_emc.py | 34 ++++++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index c571ac510..1d917577a 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -264,10 +264,6 @@ class ExternalMessageConsumer: logger.error(f"Invalid message from `{producer_name}`: {e}") return - # We shouldn't get empty messages - if producer_message.data is None: - return - logger.info(f"Received message of type `{producer_message.type}` from `{producer_name}`") message_handler = self._message_handlers.get(producer_message.type) @@ -282,7 +278,8 @@ class ExternalMessageConsumer: try: # Validate the message message = WSWhitelistMessage.parse_obj(message) - except ValidationError: + except ValidationError as e: + logger.error(f"Invalid message from `{producer_name}`: {e}") return # Add the pairlist data to the DataProvider @@ -293,7 +290,8 @@ class ExternalMessageConsumer: def _consume_analyzed_df_message(self, producer_name: str, message: Any): try: message = WSAnalyzedDFMessage.parse_obj(message) - except ValidationError: + except ValidationError as e: + logger.error(f"Invalid message from `{producer_name}`: {e}") return key = message.data.key diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index d134a9eb1..9b14c2039 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -82,16 +82,20 @@ def test_emc_init(patched_emc, default_conf, mocker, caplog): assert str(exc.value) == "You must specify at least 1 Producer to connect to." +# Parametrize this? def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): test_producer = {"name": "test", "url": "ws://test", "ws_token": "test"} + producer_name = test_producer['name'] + caplog.set_level(logging.DEBUG) # Test handle whitelist message whitelist_message = {"type": "whitelist", "data": ["BTC/USDT"]} patched_emc.handle_producer_message(test_producer, whitelist_message) - assert log_has("Received message of type `whitelist` from `test`", caplog) - assert log_has("Consumed message from `test` of type `RPCMessageType.WHITELIST`", caplog) + assert log_has(f"Received message of type `whitelist` from `{producer_name}`", caplog) + assert log_has( + f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`", caplog) # Test handle analyzed_df message df_message = { @@ -104,8 +108,9 @@ def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): } patched_emc.handle_producer_message(test_producer, df_message) - assert log_has("Received message of type `analyzed_df` from `test`", caplog) - assert log_has("Consumed message from `test` of type `RPCMessageType.ANALYZED_DF`", caplog) + assert log_has(f"Received message of type `analyzed_df` from `{producer_name}`", caplog) + assert log_has( + f"Consumed message from `{producer_name}` of type `RPCMessageType.ANALYZED_DF`", caplog) # Test unhandled message unhandled_message = {"type": "status", "data": "RUNNING"} @@ -113,13 +118,12 @@ def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): assert log_has_re(r"Received unhandled message\: .*", caplog) - # Test malformed message + # Test malformed messages caplog.clear() malformed_message = {"type": "whitelist", "data": {"pair": "BTC/USDT"}} patched_emc.handle_producer_message(test_producer, malformed_message) - assert log_has("Received message of type `whitelist` from `test`", caplog) - assert not log_has("Consumed message from `test` of type `RPCMessageType.WHITELIST`", caplog) + assert log_has_re(r"Invalid message .+", caplog) malformed_message = { "type": "analyzed_df", @@ -131,5 +135,17 @@ def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): } patched_emc.handle_producer_message(test_producer, malformed_message) - assert log_has("Received message of type `analyzed_df` from `test`", caplog) - assert not log_has("Consumed message from `test` of type `RPCMessageType.ANALYZED_DF`", caplog) + assert log_has(f"Received message of type `analyzed_df` from `{producer_name}`", caplog) + assert log_has_re(r"Invalid message .+", caplog) + + caplog.clear() + malformed_message = {"some": "stuff"} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has_re(r"Invalid message .+", caplog) + + caplog.clear() + malformed_message = {"type": "whitelist", "data": None} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has_re(r"Invalid message .+", caplog) From 0bc18ea33c4004939acb62997a8e253b5ba2c08e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 15:12:18 -0600 Subject: [PATCH 072/199] call websocket close in channel close --- freqtrade/rpc/api_server/ws/channel.py | 1 + freqtrade/rpc/api_server/ws/proxy.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index cffe3092d..f4a0da47b 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -71,6 +71,7 @@ class WebSocketChannel: Close the WebSocketChannel """ + await self._websocket.close() self._closed = True def is_closed(self) -> bool: diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index da3e04887..2e5a59f05 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -56,8 +56,10 @@ class WebSocketProxy: Close the websocket connection, only supported by FastAPI WebSockets """ if hasattr(self._websocket, "close"): - return await self._websocket.close(code) - pass + try: + return await self._websocket.close(code) + except RuntimeError: + pass async def accept(self): """ @@ -65,4 +67,3 @@ class WebSocketProxy: """ if hasattr(self._websocket, "accept"): return await self._websocket.accept() - pass From 9a1a4dfb5b82677db48bbab6dd1861ed3a238172 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 16:08:05 -0600 Subject: [PATCH 073/199] more ws endpoint tests --- tests/rpc/test_rpc_apiserver.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index a7774c204..2f25f442b 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1714,6 +1714,38 @@ def test_api_ws_subscribe(botclient, mocker): assert sub_mock.call_count == 1 +def test_api_ws_requests(botclient, mocker, caplog): + caplog.set_level(logging.DEBUG) + + ftbot, client = botclient + ws_url = f"/api/v1/message/ws?token={_TEST_WS_TOKEN}" + + # Test whitelist request + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "whitelist", "data": None}) + response = ws.receive_json() + + assert log_has_re(r"Request of type whitelist from.+", caplog) + assert response['type'] == "whitelist" + + # Test analyzed_df request + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "analyzed_df", "data": {}}) + response = ws.receive_json() + + assert log_has_re(r"Request of type analyzed_df from.+", caplog) + assert response['type'] == "analyzed_df" + + caplog.clear() + # Test analyzed_df request with data + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "analyzed_df", "data": {"limit": 100}}) + response = ws.receive_json() + + assert log_has_re(r"Request of type analyzed_df from.+", caplog) + assert response['type'] == "analyzed_df" + + def test_api_ws_send_msg(default_conf, mocker, caplog): try: caplog.set_level(logging.DEBUG) From ed4ba8801f039401231bb3e6a6cc3f018de41f33 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sat, 10 Sep 2022 23:57:17 -0600 Subject: [PATCH 074/199] more emc tests --- freqtrade/rpc/external_message_consumer.py | 5 + tests/rpc/test_rpc_emc.py | 177 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 1d917577a..89fa90c8e 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -203,8 +203,13 @@ class ExternalMessageConsumer: continue + except websockets.exceptions.ConnectionClosedOK: + # Successfully closed, just end + return + except Exception as e: # An unforseen error has occurred, log and stop + logger.error("Unexpected error has occurred:") logger.exception(e) break diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 9b14c2039..a074334c5 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -1,16 +1,26 @@ """ Unit test file for rpc/external_message_consumer.py """ +import asyncio +import functools +import json import logging from datetime import datetime, timezone +from unittest.mock import MagicMock import pytest +import websockets from freqtrade.data.dataprovider import DataProvider from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from tests.conftest import log_has, log_has_re, log_has_when +_TEST_WS_TOKEN = "secret_Ws_t0ken" +_TEST_WS_HOST = "localhost" +_TEST_WS_PORT = 9989 + + @pytest.fixture def patched_emc(default_conf, mocker): default_conf.update({ @@ -20,7 +30,7 @@ def patched_emc(default_conf, mocker): { "name": "default", "url": "ws://127.0.0.1:8080/api/v1/message/ws", - "ws_token": "secret_Ws_t0ken" + "ws_token": _TEST_WS_TOKEN } ] } @@ -149,3 +159,168 @@ def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): patched_emc.handle_producer_message(test_producer, malformed_message) assert log_has_re(r"Invalid message .+", caplog) + + +async def test_emc_create_connection_success(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + test_producer = default_conf['external_message_consumer']['producers'][0] + lock = asyncio.Lock() + + async def eat(websocket): + pass + + try: + async with websockets.serve(eat, _TEST_WS_HOST, _TEST_WS_PORT): + emc._running = True + await emc._create_connection(test_producer, lock) + emc._running = False + + assert log_has_re(r"Producer connection success.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_invalid(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": "ws://localhost:8080/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + test_producer = default_conf['external_message_consumer']['producers'][0] + + try: + # Test invalid URL + test_producer['url'] = "tcp://localhost:8080/api/v1/message/ws" + emc._running = True + await emc._create_connection(test_producer, lock) + emc._running = False + + assert log_has_re(r".+is an invalid WebSocket URL.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_error(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": "ws://localhost:8080/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + # Test unexpected error + mocker.patch('websockets.connect', side_effect=RuntimeError) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(1) + assert log_has("Unexpected error has occurred:", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages(default_conf, caplog, mocker): + """ + Test ExternalMessageConsumer._receive_messages + + Instantiates a patched ExternalMessageConsumer, creates a dummy websocket server, + and listens to the generated messages from the server for 1 second, then checks logs + """ + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + # Dummy generator + async def generate_messages(websocket): + try: + for i in range(3): + message = json.dumps({"type": "whitelist", "data": ["BTC/USDT"]}) + await websocket.send(message) + await asyncio.sleep(1) + except websockets.exceptions.ConnectionClosedOK: + return + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + try: + # Start the dummy websocket server + async with websockets.serve(generate_messages, _TEST_WS_HOST, _TEST_WS_PORT): + # Change running to True, and call change_running in 1 second + emc._running = True + loop.call_later(1, functools.partial(change_running, emc=emc)) + # Create the connection that receives messages + await emc._create_connection(test_producer, lock) + + assert log_has_re(r"Received message of type `whitelist`.+", caplog) + finally: + emc.shutdown() From 0a8b7686d68c6eae10768eff8f1e035deb080630 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 11 Sep 2022 00:50:18 -0600 Subject: [PATCH 075/199] reworked emc tests --- tests/rpc/test_rpc_emc.py | 183 ++++++++++++++++++++++++++++++++------ 1 file changed, 155 insertions(+), 28 deletions(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index a074334c5..e29419102 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -3,7 +3,6 @@ Unit test file for rpc/external_message_consumer.py """ import asyncio import functools -import json import logging from datetime import datetime, timezone from unittest.mock import MagicMock @@ -220,10 +219,11 @@ async def test_emc_create_connection_invalid(default_conf, caplog, mocker): mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', MagicMock()) + test_producer = default_conf['external_message_consumer']['producers'][0] lock = asyncio.Lock() + dp = DataProvider(default_conf, None, None, None) emc = ExternalMessageConsumer(default_conf, dp) - test_producer = default_conf['external_message_consumer']['producers'][0] try: # Test invalid URL @@ -267,13 +267,7 @@ async def test_emc_create_connection_error(default_conf, caplog, mocker): emc.shutdown() -async def test_emc_receive_messages(default_conf, caplog, mocker): - """ - Test ExternalMessageConsumer._receive_messages - - Instantiates a patched ExternalMessageConsumer, creates a dummy websocket server, - and listens to the generated messages from the server for 1 second, then checks logs - """ +async def test_emc_receive_messages_valid(default_conf, caplog, mocker): default_conf.update({ "external_message_consumer": { "enabled": True, @@ -284,9 +278,9 @@ async def test_emc_receive_messages(default_conf, caplog, mocker): "ws_token": _TEST_WS_TOKEN } ], - "wait_timeout": 60, + "wait_timeout": 1, "ping_timeout": 60, - "sleep_timeout": 60 + "sleep_time": 60 } }) @@ -299,28 +293,161 @@ async def test_emc_receive_messages(default_conf, caplog, mocker): dp = DataProvider(default_conf, None, None, None) emc = ExternalMessageConsumer(default_conf, dp) - # Dummy generator - async def generate_messages(websocket): - try: - for i in range(3): - message = json.dumps({"type": "whitelist", "data": ["BTC/USDT"]}) - await websocket.send(message) - await asyncio.sleep(1) - except websockets.exceptions.ConnectionClosedOK: - return - loop = asyncio.get_event_loop() def change_running(emc): emc._running = not emc._running + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": "whitelist", "data": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + try: - # Start the dummy websocket server - async with websockets.serve(generate_messages, _TEST_WS_HOST, _TEST_WS_PORT): - # Change running to True, and call change_running in 1 second - emc._running = True - loop.call_later(1, functools.partial(change_running, emc=emc)) - # Create the connection that receives messages - await emc._create_connection(test_producer, lock) + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) assert log_has_re(r"Received message of type `whitelist`.+", caplog) finally: emc.shutdown() + + +async def test_emc_receive_messages_invalid(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 60, + "sleep_time": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Invalid message from.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_timeout(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 1, + "sleep_time": 1 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + await asyncio.sleep(10) + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Ping error.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_handle_error(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 1, + "sleep_time": 1 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + emc.handle_producer_message = MagicMock(side_effect=Exception) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": "whitelist", "data": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Error handling producer message.+", caplog) + finally: + emc.shutdown() From 5483cf21f694a4bc0ec90bb533d587d5428c7da1 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Sun, 11 Sep 2022 11:42:13 -0600 Subject: [PATCH 076/199] remove default secret_ws_token, set timeout min to 0 --- freqtrade/constants.py | 7 ++++--- freqtrade/rpc/api_server/api_auth.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e77940b3c..2cc2fd115 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -502,12 +502,13 @@ CONF_SCHEMA = { 'required': ['name', 'url', 'ws_token'] } }, - 'wait_timeout': {'type': 'integer'}, - 'sleep_time': {'type': 'integer'}, - 'ping_timeout': {'type': 'integer'}, + 'wait_timeout': {'type': 'integer', 'minimum': 0}, + 'sleep_time': {'type': 'integer', 'minimum': 0}, + 'ping_timeout': {'type': 'integer', 'minimum': 0}, 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False}, 'initial_candle_limit': { 'type': 'integer', + 'minimum': 0, 'maximum': 1500, 'default': 1500 } diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 93be251ab..1ab158ea7 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -56,7 +56,7 @@ async def validate_ws_token( ws_token: Union[str, None] = Query(default=None, alias="token"), api_config: Dict[str, Any] = Depends(get_api_config) ): - secret_ws_token = api_config.get('ws_token', 'secret_ws_t0ken.') + secret_ws_token = api_config.get('ws_token', None) secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') if ws_token and secrets.compare_digest(secret_ws_token, ws_token): From 715a71465d36a1b613b2884855b006e892b94ff9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Sep 2022 07:28:31 +0200 Subject: [PATCH 077/199] Fix auth bug when no token is set --- freqtrade/data/dataprovider.py | 5 ++--- freqtrade/rpc/api_server/api_auth.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 8ca638046..fd7997521 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -88,11 +88,10 @@ class DataProvider: def _set_producer_pairs(self, pairlist: List[str], producer_name: str = "default"): """ Set the pairs received to later be used. - This only supports 1 Producer right now. :param pairlist: List of pairs """ - self.__producer_pairs[producer_name] = pairlist.copy() + self.__producer_pairs[producer_name] = pairlist def get_producer_pairs(self, producer_name: str = "default") -> List[str]: """ @@ -100,7 +99,7 @@ class DataProvider: :returns: List of pairs """ - return self.__producer_pairs.get(producer_name, []) + return self.__producer_pairs.get(producer_name, []).copy() def _emit_df( self, diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 1ab158ea7..e91e5941b 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -59,7 +59,7 @@ async def validate_ws_token( secret_ws_token = api_config.get('ws_token', None) secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') - if ws_token and secrets.compare_digest(secret_ws_token, ws_token): + if ws_token and secret_ws_token and secrets.compare_digest(secret_ws_token, ws_token): # Just return the token if it matches return ws_token else: @@ -69,7 +69,7 @@ async def validate_ws_token( # If the token is a jwt, and it's valid return the user except HTTPException: pass - logger.info("Denying websocket request") + logger.debug("Denying websocket request.") # If it doesn't match, close the websocket connection await ws.close(code=status.WS_1008_POLICY_VIOLATION) From d6205e6cfb653947be23afddaf2679ff236a2e30 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 07:36:11 -0600 Subject: [PATCH 078/199] test logging lines --- freqtrade/rpc/external_message_consumer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 89fa90c8e..59064c90b 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -305,6 +305,9 @@ class ExternalMessageConsumer: pair, timeframe, candle_type = key + logger.debug(message.data.key) + logger.debug(message.data) + # If set, remove the Entry and Exit signals from the Producer if self._emc_config.get('remove_entry_exit_signals', False): df = remove_entry_exit_signals(df) From 457075b82386006e5d543f207f8cd00151a97e49 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 07:47:30 -0600 Subject: [PATCH 079/199] one more line --- freqtrade/data/dataprovider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 94e79c8be..f8ea0073b 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -113,6 +113,7 @@ class DataProvider: :param data: Tuple containing the DataFrame and the datetime it was cached """ if self.__rpc: + logger.debug(f"Sending df {dataframe}") self.__rpc.send_msg( { 'type': RPCMessageType.ANALYZED_DF, From 10852555e593fc84b4d56b58616201154eb1ea24 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 09:53:47 -0600 Subject: [PATCH 080/199] change verbosity of testing log --- freqtrade/data/dataprovider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index f8ea0073b..19d6d6ae1 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -113,7 +113,7 @@ class DataProvider: :param data: Tuple containing the DataFrame and the datetime it was cached """ if self.__rpc: - logger.debug(f"Sending df {dataframe}") + logger.debug(f"Sending df {dataframe.iloc[-1]}") self.__rpc.send_msg( { 'type': RPCMessageType.ANALYZED_DF, From a477b3c244680e0e2e72d12f5a12a1ed557640f0 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 10:45:59 -0600 Subject: [PATCH 081/199] remove log line, fix tests to not connect to actual ip --- freqtrade/data/dataprovider.py | 1 - tests/rpc/test_rpc_emc.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 19d6d6ae1..94e79c8be 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -113,7 +113,6 @@ class DataProvider: :param data: Tuple containing the DataFrame and the datetime it was cached """ if self.__rpc: - logger.debug(f"Sending df {dataframe.iloc[-1]}") self.__rpc.send_msg( { 'type': RPCMessageType.ANALYZED_DF, diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index e29419102..b3f6fdc4d 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -28,7 +28,7 @@ def patched_emc(default_conf, mocker): "producers": [ { "name": "default", - "url": "ws://127.0.0.1:8080/api/v1/message/ws", + "url": "ws://something:port/api/v1/message/ws", "ws_token": _TEST_WS_TOKEN } ] From 0052e5891788ffbfdaf20dd0374599924369644e Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Sep 2022 19:50:22 +0200 Subject: [PATCH 082/199] emc: Fix potential startup timing issue --- freqtrade/rpc/external_message_consumer.py | 6 +++--- tests/rpc/test_rpc_emc.py | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 59064c90b..122863987 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -15,6 +15,7 @@ from pydantic import ValidationError from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType +from freqtrade.exceptions import OperationalException from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws.channel import WebSocketChannel from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzedDFRequest, @@ -57,7 +58,7 @@ class ExternalMessageConsumer: self.producers = self._emc_config.get('producers', []) if self.enabled and len(self.producers) < 1: - raise ValueError("You must specify at least 1 Producer to connect to.") + raise OperationalException("You must specify at least 1 Producer to connect to.") self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds @@ -97,9 +98,8 @@ class ExternalMessageConsumer: self._loop = asyncio.new_event_loop() self._thread = Thread(target=self._loop.run_forever) - self._thread.start() - self._running = True + self._thread.start() self._main_task = asyncio.run_coroutine_threadsafe(self._main(), loop=self._loop) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index b3f6fdc4d..94635810c 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -11,6 +11,7 @@ import pytest import websockets from freqtrade.data.dataprovider import DataProvider +from freqtrade.exceptions import OperationalException from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from tests.conftest import log_has, log_has_re, log_has_when @@ -71,7 +72,7 @@ def test_emc_shutdown(patched_emc, caplog): assert not log_has("Stopping ExternalMessageConsumer", caplog) -def test_emc_init(patched_emc, default_conf, mocker, caplog): +def test_emc_init(patched_emc, default_conf): # Test the settings were set correctly assert patched_emc.initial_candle_limit <= 1500 assert patched_emc.wait_timeout > 0 @@ -84,12 +85,10 @@ def test_emc_init(patched_emc, default_conf, mocker, caplog): } }) dataprovider = DataProvider(default_conf, None, None, None) - with pytest.raises(ValueError) as exc: + with pytest.raises(OperationalException, + match="You must specify at least 1 Producer to connect to."): ExternalMessageConsumer(default_conf, dataprovider) - # Make sure we failed because of no producers - assert str(exc.value) == "You must specify at least 1 Producer to connect to." - # Parametrize this? def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): From 867d59b930b27e774fd391d3f372272a5ddcd7d3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Sep 2022 20:00:01 +0200 Subject: [PATCH 083/199] Improve type specifitivity --- freqtrade/rpc/external_message_consumer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 122863987..bd921cf8e 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,7 +8,7 @@ import asyncio import logging import socket from threading import Thread -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any, Callable, Dict, List import websockets from pydantic import ValidationError @@ -80,7 +80,7 @@ class ExternalMessageConsumer: ] # Specify which function to use for which RPCMessageType - self._message_handlers = { + self._message_handlers: Dict[str, Callable[[str, WSMessageSchema], None]] = { RPCMessageType.WHITELIST: self._consume_whitelist_message, RPCMessageType.ANALYZED_DF: self._consume_analyzed_df_message, } @@ -279,7 +279,7 @@ class ExternalMessageConsumer: message_handler(producer_name, producer_message) - def _consume_whitelist_message(self, producer_name: str, message: Any): + def _consume_whitelist_message(self, producer_name: str, message: WSMessageSchema): try: # Validate the message message = WSWhitelistMessage.parse_obj(message) @@ -292,7 +292,7 @@ class ExternalMessageConsumer: logger.debug(f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`") - def _consume_analyzed_df_message(self, producer_name: str, message: Any): + def _consume_analyzed_df_message(self, producer_name: str, message: WSMessageSchema): try: message = WSAnalyzedDFMessage.parse_obj(message) except ValidationError as e: From b6434040de76f43d66b710da60f1be4f88b80adf Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 12 Sep 2022 20:24:28 +0200 Subject: [PATCH 084/199] Remove plain json serializer implementation --- freqtrade/rpc/api_server/ws/serializer.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index d1ddd84f0..22d177f85 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -1,4 +1,3 @@ -import json import logging from abc import ABC, abstractmethod @@ -38,14 +37,6 @@ class WebSocketSerializer(ABC): await self._websocket.close(code) -class JSONWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - return json.dumps(data, default=_json_default) - - def _deserialize(self, data): - return json.loads(data, object_hook=_json_object_hook) - - class HybridJSONWebSocketSerializer(WebSocketSerializer): def _serialize(self, data) -> str: return str(orjson.dumps(data, default=_json_default), "utf-8") From c19a5fbe06e35839255ad0b961d20c39299b7e1d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 13:57:29 -0600 Subject: [PATCH 085/199] copy data being transferred, remove debug messages in emc --- freqtrade/data/dataprovider.py | 3 ++- freqtrade/rpc/external_message_consumer.py | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 94e79c8be..e3bee7118 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -184,7 +184,8 @@ class DataProvider: return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) # We have it, return this data - return self.__producer_pairs_df[producer_name][pair_key] + df, la = self.__producer_pairs_df[producer_name][pair_key] + return (df.copy(), la) def add_pairlisthandler(self, pairlists) -> None: """ diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index bd921cf8e..89edc8417 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -269,6 +269,10 @@ class ExternalMessageConsumer: logger.error(f"Invalid message from `{producer_name}`: {e}") return + if not producer_message.data: + logger.error(f"Empty message received from `{producer_name}`") + return + logger.info(f"Received message of type `{producer_message.type}` from `{producer_name}`") message_handler = self._message_handlers.get(producer_message.type) @@ -282,32 +286,29 @@ class ExternalMessageConsumer: def _consume_whitelist_message(self, producer_name: str, message: WSMessageSchema): try: # Validate the message - message = WSWhitelistMessage.parse_obj(message) + whitelist_message = WSWhitelistMessage.parse_obj(message) except ValidationError as e: logger.error(f"Invalid message from `{producer_name}`: {e}") return # Add the pairlist data to the DataProvider - self._dp._set_producer_pairs(message.data, producer_name=producer_name) + self._dp._set_producer_pairs(whitelist_message.data.copy(), producer_name=producer_name) logger.debug(f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`") def _consume_analyzed_df_message(self, producer_name: str, message: WSMessageSchema): try: - message = WSAnalyzedDFMessage.parse_obj(message) + df_message = WSAnalyzedDFMessage.parse_obj(message) except ValidationError as e: logger.error(f"Invalid message from `{producer_name}`: {e}") return - key = message.data.key - df = message.data.df - la = message.data.la + key = df_message.data.key + df = df_message.data.df + la = df_message.data.la pair, timeframe, candle_type = key - logger.debug(message.data.key) - logger.debug(message.data) - # If set, remove the Entry and Exit signals from the Producer if self._emc_config.get('remove_entry_exit_signals', False): df = remove_entry_exit_signals(df) From 0697041f146fa1f4d3aaa74a0af6419d0a57143f Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 14:09:12 -0600 Subject: [PATCH 086/199] remove copy statement where not needed --- freqtrade/rpc/external_message_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 89edc8417..a9fdd62ca 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -292,7 +292,7 @@ class ExternalMessageConsumer: return # Add the pairlist data to the DataProvider - self._dp._set_producer_pairs(whitelist_message.data.copy(), producer_name=producer_name) + self._dp._set_producer_pairs(whitelist_message.data, producer_name=producer_name) logger.debug(f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`") From 12a3e90f7860920af11eb08047023ee782c21786 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 14:12:39 -0600 Subject: [PATCH 087/199] fix tests --- tests/rpc/test_rpc_emc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 94635810c..1968c5d39 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -29,7 +29,7 @@ def patched_emc(default_conf, mocker): "producers": [ { "name": "default", - "url": "ws://something:port/api/v1/message/ws", + "url": "ws://null:9891/api/v1/message/ws", "ws_token": _TEST_WS_TOKEN } ] @@ -156,7 +156,7 @@ def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): malformed_message = {"type": "whitelist", "data": None} patched_emc.handle_producer_message(test_producer, malformed_message) - assert log_has_re(r"Invalid message .+", caplog) + assert log_has_re(r"Empty message .+", caplog) async def test_emc_create_connection_success(default_conf, caplog, mocker): From bf2e5dee759bd393dcff91570a9364c944d35f5e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 14:21:39 -0600 Subject: [PATCH 088/199] add running false on shutdown, fix dp typing --- freqtrade/data/dataprovider.py | 14 +++++--------- freqtrade/rpc/external_message_consumer.py | 1 + 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index e3bee7118..dae8ec162 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -128,9 +128,9 @@ class DataProvider: self, pair: str, dataframe: DataFrame, - last_analyzed: Optional[datetime] = None, - timeframe: Optional[str] = None, - candle_type: Optional[CandleType] = None, + last_analyzed: datetime, + timeframe: str, + candle_type: CandleType, producer_name: str = "default" ) -> None: """ @@ -140,10 +140,7 @@ class DataProvider: :param timeframe: Timeframe to get data for :param candle_type: Any of the enum CandleType (must match trading mode!) """ - _timeframe = self._default_timeframe if not timeframe else timeframe - _candle_type = self._default_candle_type if not candle_type else candle_type - - pair_key = (pair, _timeframe, _candle_type) + pair_key = (pair, timeframe, candle_type) if producer_name not in self.__producer_pairs_df: self.__producer_pairs_df[producer_name] = {} @@ -161,8 +158,7 @@ class DataProvider: producer_name: str = "default" ) -> Tuple[DataFrame, datetime]: """ - Get the pair data from the external sources. Will wait if the policy is - set to, and data is not available. + Get the pair data from the external sources. :param pair: pair to get the data for :param timeframe: Timeframe to get data for diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index a9fdd62ca..e167ce24d 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -109,6 +109,7 @@ class ExternalMessageConsumer: """ if self._thread and self._loop: logger.info("Stopping ExternalMessageConsumer") + self._running = False if self._sub_tasks: # Cancel sub tasks From 75ce9067dcfdc32ad39cdddf7c7c89ee25dc1348 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Mon, 12 Sep 2022 16:39:16 -0600 Subject: [PATCH 089/199] fix dp test --- tests/data/test_dataprovider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 812688cb1..9915b6316 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -169,6 +169,7 @@ def test_external_df(mocker, default_conf, ohlcv_history): candle_type = CandleType.SPOT empty_la = datetime.fromtimestamp(0, tz=timezone.utc) + now = datetime.now(timezone.utc) # no data has been added, any request should return an empty dataframe dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) @@ -176,7 +177,7 @@ def test_external_df(mocker, default_conf, ohlcv_history): assert la == empty_la # the data is added, should return that added dataframe - dataprovider._add_external_df(pair, ohlcv_history, timeframe=timeframe, candle_type=candle_type) + dataprovider._add_external_df(pair, ohlcv_history, now, timeframe, candle_type) dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) assert len(dataframe) > 0 assert la > empty_la From 6d0dfd4dc8f122716784759926e21bc1f0c6c00b Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 12:27:41 -0600 Subject: [PATCH 090/199] continue trying connect on ping error --- freqtrade/rpc/external_message_consumer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index e167ce24d..ef2417225 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -252,11 +252,11 @@ class ExternalMessageConsumer: continue except Exception: - logger.info( + logger.error( f"Ping error {channel} - retrying in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) - break + continue def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): """ From 07aa206f71995ffc92f744d40ae40973d1c54813 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 12:36:40 -0600 Subject: [PATCH 091/199] real fix for reconnecting --- freqtrade/rpc/external_message_consumer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index ef2417225..f8e5315f9 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -205,14 +205,14 @@ class ExternalMessageConsumer: continue except websockets.exceptions.ConnectionClosedOK: - # Successfully closed, just end - return + # Successfully closed, just keep trying to connect again indefinitely + continue except Exception as e: # An unforseen error has occurred, log and stop logger.error("Unexpected error has occurred:") logger.exception(e) - break + continue async def _receive_messages( self, @@ -256,7 +256,7 @@ class ExternalMessageConsumer: f"Ping error {channel} - retrying in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) - continue + break def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): """ From aeaca78940131ef4d3165d3d53c2082478d3d192 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 12:39:12 -0600 Subject: [PATCH 092/199] change port in send_msg test --- tests/rpc/test_rpc_apiserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 2f25f442b..f22499086 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1752,7 +1752,7 @@ def test_api_ws_send_msg(default_conf, mocker, caplog): default_conf.update({"api_server": {"enabled": True, "listen_ip_address": "127.0.0.1", - "listen_port": 8080, + "listen_port": 9913, "username": "TestUser", "password": "testPass", }}) From 79c70bd52dc402a1e75252532b2a744e1ff0c891 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 13 Sep 2022 20:12:02 +0200 Subject: [PATCH 093/199] use WebSocketState from fastapi available since 0.82.0 --- freqtrade/rpc/api_server/api_ws.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 34b780956..174a0a85e 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -1,10 +1,9 @@ import logging from typing import Any, Dict -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, WebSocketDisconnect +from fastapi.websockets import WebSocket, WebSocketState from pydantic import ValidationError -# fastapi does not make this available through it, so import directly from starlette -from starlette.websockets import WebSocketState from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.api_auth import validate_ws_token From d2abc9417f53f9a330e11dde257d7e014d120f96 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 13 Sep 2022 20:42:24 +0200 Subject: [PATCH 094/199] Simplify ws imports --- freqtrade/rpc/api_server/api_ws.py | 2 +- freqtrade/rpc/api_server/webserver.py | 2 +- freqtrade/rpc/api_server/ws/__init__.py | 6 ++++++ freqtrade/rpc/external_message_consumer.py | 5 ++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 174a0a85e..c4a3e9d4a 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.rpc.api_server.api_auth import validate_ws_token from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc -from freqtrade.rpc.api_server.ws.channel import WebSocketChannel +from freqtrade.rpc.api_server.ws import WebSocketChannel from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, WSRequestSchema, WSWhitelistMessage) from freqtrade.rpc.rpc import RPC diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 3fb8159e1..7a59a7a4c 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -14,7 +14,7 @@ from starlette.responses import JSONResponse from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer -from freqtrade.rpc.api_server.ws.channel import ChannelManager +from freqtrade.rpc.api_server.ws import ChannelManager from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler diff --git a/freqtrade/rpc/api_server/ws/__init__.py b/freqtrade/rpc/api_server/ws/__init__.py index e69de29bb..c00d29e22 100644 --- a/freqtrade/rpc/api_server/ws/__init__.py +++ b/freqtrade/rpc/api_server/ws/__init__.py @@ -0,0 +1,6 @@ +# flake8: noqa: F401 +# isort: off +from freqtrade.rpc.api_server.ws.types import WebSocketType +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy +from freqtrade.rpc.api_server.ws.serializer import HybridJSONWebSocketSerializer, MsgPackWebSocketSerializer +from freqtrade.rpc.api_server.ws.channel import ChannelManager, WebSocketChannel diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index f8e5315f9..2ff261eee 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -17,7 +17,7 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType from freqtrade.exceptions import OperationalException from freqtrade.misc import remove_entry_exit_signals -from freqtrade.rpc.api_server.ws.channel import WebSocketChannel +from freqtrade.rpc.api_server.ws import WebSocketChannel from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzedDFRequest, WSMessageSchema, WSRequestSchema, WSSubscribeRequest, WSWhitelistMessage, @@ -252,8 +252,7 @@ class ExternalMessageConsumer: continue except Exception: - logger.error( - f"Ping error {channel} - retrying in {self.sleep_time}s") + logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) break From 877d24bcdd1adb0d3378dbc24a7930d24638cba8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 13 Sep 2022 20:42:32 +0200 Subject: [PATCH 095/199] Fix external dependency of test --- tests/rpc/test_rpc_apiserver.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index f22499086..e007e0a9e 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1698,8 +1698,7 @@ def test_api_ws_subscribe(botclient, mocker): ftbot, client = botclient ws_url = f"/api/v1/message/ws?token={_TEST_WS_TOKEN}" - sub_mock = mocker.patch( - 'freqtrade.rpc.api_server.ws.channel.WebSocketChannel.set_subscriptions', MagicMock()) + sub_mock = mocker.patch('freqtrade.rpc.api_server.ws.WebSocketChannel.set_subscriptions') with client.websocket_connect(ws_url) as ws: ws.send_json({'type': 'subscribe', 'data': ['whitelist']}) @@ -1752,18 +1751,24 @@ def test_api_ws_send_msg(default_conf, mocker, caplog): default_conf.update({"api_server": {"enabled": True, "listen_ip_address": "127.0.0.1", - "listen_port": 9913, - "username": "TestUser", - "password": "testPass", + "listen_port": 8080, + "CORS_origins": ['http://example.com'], + "username": _TEST_USER, + "password": _TEST_PASS, + "ws_token": _TEST_WS_TOKEN }}) - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Updater') + mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api') apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.start_message_queue() + # Give the queue thread time to start + time.sleep(0.2) # Test message_queue coro receives the message test_message = {"type": "status", "data": "test"} apiserver.send_msg(test_message) - time.sleep(1) # Not sure how else to wait for the coro to receive the data + time.sleep(0.1) # Not sure how else to wait for the coro to receive the data assert log_has("Found message of type: status", caplog) # Test if exception logged when error occurs in sending @@ -1771,7 +1776,7 @@ def test_api_ws_send_msg(default_conf, mocker, caplog): side_effect=Exception) apiserver.send_msg(test_message) - time.sleep(2) # Not sure how else to wait for the coro to receive the data + time.sleep(0.1) # Not sure how else to wait for the coro to receive the data assert log_has_re(r"Exception happened in background task.*", caplog) finally: From 46a425d1b640d8b15c2115e3478cd635e0dfdbd8 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 13:36:21 -0600 Subject: [PATCH 096/199] fix OOM on emc test --- tests/rpc/test_rpc_emc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 1968c5d39..7bb727810 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -184,14 +184,14 @@ async def test_emc_create_connection_success(default_conf, caplog, mocker): test_producer = default_conf['external_message_consumer']['producers'][0] lock = asyncio.Lock() + emc._running = True + async def eat(websocket): - pass + emc._running = False try: async with websockets.serve(eat, _TEST_WS_HOST, _TEST_WS_PORT): - emc._running = True await emc._create_connection(test_producer, lock) - emc._running = False assert log_has_re(r"Producer connection success.+", caplog) finally: From 7a98775f012d9467b951427d56daead30e630e37 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 13 Sep 2022 22:07:59 +0200 Subject: [PATCH 097/199] Version bump apiVersion --- freqtrade/rpc/api_server/api_v1.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index bf21715b7..53f5c16d7 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -38,7 +38,8 @@ logger = logging.getLogger(__name__) # 2.15: Add backtest history endpoints # 2.16: Additional daily metrics # 2.17: Forceentry - leverage, partial force_exit -API_VERSION = 2.17 +# 2.20: Add websocket endpoints +API_VERSION = 2.20 # Public API, requires no auth. router_public = APIRouter() From d75d5a7dadaf60f3e0fc619af73d2964527a8d4a Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 16:06:25 -0600 Subject: [PATCH 098/199] debug ping error message --- freqtrade/rpc/external_message_consumer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 2ff261eee..fd6ccfacd 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -251,8 +251,9 @@ class ExternalMessageConsumer: logger.debug(f"Connection to {channel} still alive...") continue - except Exception: + except Exception as e: logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s") + logger.debug(e, exc_info=e) await asyncio.sleep(self.sleep_time) break From 06350a13cbad4de1780058dde2b145bca3146171 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 16:39:53 -0600 Subject: [PATCH 099/199] support specifying message size in emc config --- freqtrade/constants.py | 6 ++++++ freqtrade/rpc/external_message_consumer.py | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 2cc2fd115..470ffb2a3 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -511,6 +511,12 @@ CONF_SCHEMA = { 'minimum': 0, 'maximum': 1500, 'default': 1500 + }, + 'max_message_size': { + 'type': 'integer', + 'minimum': 1048576, # 1mb + 'maxmium': 8388608, # 8.3mb, + 'default': 1048576, # 1mb } }, 'required': ['producers'] diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index fd6ccfacd..7dd6c09b0 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -67,6 +67,9 @@ class ExternalMessageConsumer: # The amount of candles per dataframe on the initial request self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) + # Message size limit, default 1mb + self.message_size_limit = self._emc_config.get('message_size_limit', 2**20) + # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating # callbacks for the messages @@ -175,7 +178,7 @@ class ExternalMessageConsumer: ws_url = f"{url}?token={token}" # This will raise InvalidURI if the url is bad - async with websockets.connect(ws_url) as ws: + async with websockets.connect(ws_url, max_size=self.message_size_limit) as ws: channel = WebSocketChannel(ws, channel_id=name) logger.info(f"Producer connection success - {channel}") From aed19ff6cee279f1446338dab54b2bb348145902 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Tue, 13 Sep 2022 19:17:12 -0600 Subject: [PATCH 100/199] fix The future belongs to a different loop error --- freqtrade/rpc/api_server/ws/channel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index f4a0da47b..cffe3092d 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -71,7 +71,6 @@ class WebSocketChannel: Close the WebSocketChannel """ - await self._websocket.close() self._closed = True def is_closed(self) -> bool: From 6126925dbe8c125facc3e0898d3091e798a81f97 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 14 Sep 2022 16:42:14 -0600 Subject: [PATCH 101/199] message size limit in mb, default to 8mb --- freqtrade/constants.py | 8 ++++---- freqtrade/rpc/external_message_consumer.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 470ffb2a3..371cb9578 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -512,11 +512,11 @@ CONF_SCHEMA = { 'maximum': 1500, 'default': 1500 }, - 'max_message_size': { + 'max_message_size': { # In megabytes 'type': 'integer', - 'minimum': 1048576, # 1mb - 'maxmium': 8388608, # 8.3mb, - 'default': 1048576, # 1mb + 'minimum': 1, + 'maxmium': 20, + 'default': 8, } }, 'required': ['producers'] diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 7dd6c09b0..95031488d 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -67,8 +67,9 @@ class ExternalMessageConsumer: # The amount of candles per dataframe on the initial request self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) - # Message size limit, default 1mb - self.message_size_limit = self._emc_config.get('message_size_limit', 2**20) + # Message size limit, in megabytes. Default 8mb, Use bitwise operator << 20 to convert + # as the websockets client expects bytes. + self.message_size_limit = (self._emc_config.get('message_size_limit', 8) << 20) # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating @@ -177,6 +178,9 @@ class ExternalMessageConsumer: name = producer["name"] ws_url = f"{url}?token={token}" + logger.info( + f"Connecting to {name} @ {url}, max message size: {self.message_size_limit}") + # This will raise InvalidURI if the url is bad async with websockets.connect(ws_url, max_size=self.message_size_limit) as ws: channel = WebSocketChannel(ws, channel_id=name) From 8e75852ff309a69bb47e5fcdf576c3132ef1fd17 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 15 Sep 2022 11:12:05 -0600 Subject: [PATCH 102/199] fix constants, update config example, add emc config validation --- config_examples/config_full.example.json | 11 ++++----- freqtrade/constants.py | 11 +++++---- freqtrade/rpc/api_server/api_ws.py | 2 -- freqtrade/rpc/external_message_consumer.py | 27 ++++++++++++++-------- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index d8d552814..5a5096f81 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -175,22 +175,21 @@ "password": "SuperSecurePassword", "ws_token": "secret_ws_t0ken." }, - // The ExternalMessageConsumer config should only be enabled on an instance - // that listens to outside data from another instance. This should not be enabled - // in your producer of data. "external_message_consumer": { "enabled": false, "producers": [ { "name": "default", - "url": "ws://localhost:8081/api/v1/message/ws", + "host": "127.0.0.2", + "port": 8080, "ws_token": "secret_ws_t0ken." } ], - "poll_timeout": 300, + "wait_timeout": 300, "ping_timeout": 10, "sleep_time": 10, - "remove_entry_exit_signals": false + "remove_entry_exit_signals": false, + "message_size_limit": 8 }, "bot_name": "freqtrade", "db_url": "sqlite:///tradesv3.sqlite", diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 371cb9578..2fc855fbd 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -496,23 +496,24 @@ CONF_SCHEMA = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, - 'url': {'type': 'string', 'default': ''}, - 'ws_token': {'type': 'string', 'default': ''}, + 'host': {'type': 'string'}, + 'port': {'type': 'integer', 'default': 8080}, + 'ws_token': {'type': 'string'}, }, - 'required': ['name', 'url', 'ws_token'] + 'required': ['name', 'host', 'ws_token'] } }, 'wait_timeout': {'type': 'integer', 'minimum': 0}, 'sleep_time': {'type': 'integer', 'minimum': 0}, 'ping_timeout': {'type': 'integer', 'minimum': 0}, - 'remove_signals_analyzed_df': {'type': 'boolean', 'default': False}, + 'remove_entry_exit_signals': {'type': 'boolean', 'default': False}, 'initial_candle_limit': { 'type': 'integer', 'minimum': 0, 'maximum': 1500, 'default': 1500 }, - 'max_message_size': { # In megabytes + 'message_size_limit': { # In megabytes 'type': 'integer', 'minimum': 1, 'maxmium': 20, diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index c4a3e9d4a..f55b2dbd3 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -101,8 +101,6 @@ async def message_endpoint( Message WebSocket endpoint, facilitates sending RPC messages """ try: - # TODO: - # Return a channel ID, pass that instead of ws to the rest of the methods channel = await channel_manager.on_connect(ws) if await is_websocket_alive(ws): diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 95031488d..6a8faef81 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -57,9 +57,6 @@ class ExternalMessageConsumer: self.enabled = self._emc_config.get('enabled', False) self.producers = self._emc_config.get('producers', []) - if self.enabled and len(self.producers) < 1: - raise OperationalException("You must specify at least 1 Producer to connect to.") - self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds self.sleep_time = self._emc_config.get('sleep_time', 10) # in seconds @@ -71,6 +68,8 @@ class ExternalMessageConsumer: # as the websockets client expects bytes. self.message_size_limit = (self._emc_config.get('message_size_limit', 8) << 20) + self.validate_config() + # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating # callbacks for the messages @@ -91,6 +90,18 @@ class ExternalMessageConsumer: self.start() + def validate_config(self): + """ + Make sure values are what they are supposed to be + """ + if self.enabled and len(self.producers) < 1: + raise OperationalException("You must specify at least 1 Producer to connect to.") + + if self.enabled and self._config.get('process_only_new_candles', True): + # Warning here or require it? + logger.warning("To receive best performance with external data," + "please set `process_only_new_candles` to False") + def start(self): """ Start the main internal loop in another thread to run coroutines @@ -174,12 +185,10 @@ class ExternalMessageConsumer: """ while self._running: try: - url, token = producer['url'], producer['ws_token'] - name = producer["name"] - ws_url = f"{url}?token={token}" - - logger.info( - f"Connecting to {name} @ {url}, max message size: {self.message_size_limit}") + host, port = producer['host'], producer['port'] + token = producer['ws_token'] + name = producer['name'] + ws_url = f"ws://{host}:{port}/api/v1/message/ws?token={token}" # This will raise InvalidURI if the url is bad async with websockets.connect(ws_url, max_size=self.message_size_limit) as ws: From 7d1645ac20df3392543b5c72f10ffefe65bc85c6 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 15 Sep 2022 17:54:31 -0600 Subject: [PATCH 103/199] fix tests and warning message --- freqtrade/rpc/external_message_consumer.py | 7 +- tests/rpc/test_rpc_emc.py | 96 ++++++++++++---------- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 6a8faef81..e4b3c2609 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -99,7 +99,7 @@ class ExternalMessageConsumer: if self.enabled and self._config.get('process_only_new_candles', True): # Warning here or require it? - logger.warning("To receive best performance with external data," + logger.warning("To receive best performance with external data, " "please set `process_only_new_candles` to False") def start(self): @@ -205,11 +205,6 @@ class ExternalMessageConsumer: # Now receive data, if none is within the time limit, ping await self._receive_messages(channel, producer, lock) - # Catch invalid ws_url, and break the loop - except websockets.exceptions.InvalidURI as e: - logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") - break - except ( socket.gaierror, ConnectionRefusedError, diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 7bb727810..83276aabe 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -29,7 +29,8 @@ def patched_emc(default_conf, mocker): "producers": [ { "name": "default", - "url": "ws://null:9891/api/v1/message/ws", + "host": "null", + "port": 9891, "ws_token": _TEST_WS_TOKEN } ] @@ -166,7 +167,8 @@ async def test_emc_create_connection_success(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], @@ -198,42 +200,43 @@ async def test_emc_create_connection_success(default_conf, caplog, mocker): emc.shutdown() -async def test_emc_create_connection_invalid(default_conf, caplog, mocker): - default_conf.update({ - "external_message_consumer": { - "enabled": True, - "producers": [ - { - "name": "default", - "url": "ws://localhost:8080/api/v1/message/ws", - "ws_token": _TEST_WS_TOKEN - } - ], - "wait_timeout": 60, - "ping_timeout": 60, - "sleep_timeout": 60 - } - }) - - mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', - MagicMock()) - - test_producer = default_conf['external_message_consumer']['producers'][0] - lock = asyncio.Lock() - - dp = DataProvider(default_conf, None, None, None) - emc = ExternalMessageConsumer(default_conf, dp) - - try: - # Test invalid URL - test_producer['url'] = "tcp://localhost:8080/api/v1/message/ws" - emc._running = True - await emc._create_connection(test_producer, lock) - emc._running = False - - assert log_has_re(r".+is an invalid WebSocket URL.+", caplog) - finally: - emc.shutdown() +# async def test_emc_create_connection_invalid(default_conf, caplog, mocker): +# default_conf.update({ +# "external_message_consumer": { +# "enabled": True, +# "producers": [ +# { +# "name": "default", +# "host": _TEST_WS_HOST, +# "port": _TEST_WS_PORT, +# "ws_token": _TEST_WS_TOKEN +# } +# ], +# "wait_timeout": 60, +# "ping_timeout": 60, +# "sleep_timeout": 60 +# } +# }) +# +# mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', +# MagicMock()) +# +# test_producer = default_conf['external_message_consumer']['producers'][0] +# lock = asyncio.Lock() +# +# dp = DataProvider(default_conf, None, None, None) +# emc = ExternalMessageConsumer(default_conf, dp) +# +# try: +# # Test invalid URL +# test_producer['url'] = "tcp://null:8080/api/v1/message/ws" +# emc._running = True +# await emc._create_connection(test_producer, lock) +# emc._running = False +# +# assert log_has_re(r".+is an invalid WebSocket URL.+", caplog) +# finally: +# emc.shutdown() async def test_emc_create_connection_error(default_conf, caplog, mocker): @@ -243,7 +246,8 @@ async def test_emc_create_connection_error(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": "ws://localhost:8080/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], @@ -260,7 +264,7 @@ async def test_emc_create_connection_error(default_conf, caplog, mocker): emc = ExternalMessageConsumer(default_conf, dp) try: - await asyncio.sleep(1) + await asyncio.sleep(0.01) assert log_has("Unexpected error has occurred:", caplog) finally: emc.shutdown() @@ -273,7 +277,8 @@ async def test_emc_receive_messages_valid(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], @@ -319,7 +324,8 @@ async def test_emc_receive_messages_invalid(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], @@ -365,7 +371,8 @@ async def test_emc_receive_messages_timeout(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], @@ -411,7 +418,8 @@ async def test_emc_receive_messages_handle_error(default_conf, caplog, mocker): "producers": [ { "name": "default", - "url": f"ws://{_TEST_WS_HOST}:{_TEST_WS_PORT}/api/v1/message/ws", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, "ws_token": _TEST_WS_TOKEN } ], From 1ad25095c18cc44cdacc95ce9a9f5b0152285b0e Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Thu, 15 Sep 2022 19:40:45 -0600 Subject: [PATCH 104/199] change test server from localhost to 127.0.0.1 --- tests/rpc/test_rpc_emc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 83276aabe..9aca88b4a 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -17,7 +17,7 @@ from tests.conftest import log_has, log_has_re, log_has_when _TEST_WS_TOKEN = "secret_Ws_t0ken" -_TEST_WS_HOST = "localhost" +_TEST_WS_HOST = "127.0.0.1" _TEST_WS_PORT = 9989 From b0b575ead919a9d197174d6da112d4068e6b896f Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 16 Sep 2022 00:02:27 -0600 Subject: [PATCH 105/199] change json serialize to split orient --- freqtrade/misc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index b2aca5fd6..56b3fef0e 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -259,7 +259,7 @@ def dataframe_to_json(dataframe: pandas.DataFrame) -> str: :param dataframe: A pandas DataFrame :returns: A JSON string of the pandas DataFrame """ - return dataframe.to_json(orient='records') + return dataframe.to_json(orient='split') def json_to_dataframe(data: str) -> pandas.DataFrame: @@ -268,7 +268,7 @@ def json_to_dataframe(data: str) -> pandas.DataFrame: :param data: A JSON string :returns: A pandas DataFrame from the JSON string """ - dataframe = pandas.read_json(data) + dataframe = pandas.read_json(data, orient='split') if 'date' in dataframe.columns: dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) From b707a6da35f8af7719e256bc3dfda300e9844a4c Mon Sep 17 00:00:00 2001 From: initrv Date: Fri, 16 Sep 2022 19:17:41 +0300 Subject: [PATCH 106/199] Add ability to plot feature importance --- config_examples/config_freqai.example.json | 3 +- freqtrade/freqai/freqai_interface.py | 9 +++ freqtrade/freqai/utils.py | 71 ++++++++++++++++++++++ requirements-plot.txt | 1 + 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 12eb30128..9494ba0e1 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -77,7 +77,8 @@ "indicator_periods_candles": [ 10, 20 - ] + ], + "plot_feature_importance": true }, "data_split_parameters": { "test_size": 0.33, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 78931bed4..0cc51fdab 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -20,6 +20,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.freqai.utils import plot_feature_importance from freqtrade.strategy.interface import IStrategy @@ -555,6 +556,14 @@ class IFreqaiModel(ABC): model = self.train(unfiltered_dataframe, pair, dk) + if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): + plot_feature_importance( + model=model, + feature_names=dk.training_features_list, + pair=pair, + train_dir=dk.data_path + ) + self.dd.pair_dict[pair]["trained_timestamp"] = new_trained_timerange.stopts dk.set_new_model_names(pair, new_trained_timerange) self.dd.pair_dict[pair]["first"] = False diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 6a70f050f..86d89d4b0 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -1,5 +1,13 @@ import logging from datetime import datetime, timezone +# for plot_feature_importance +from pathlib import Path + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import plotly.io as pio +from plotly.subplots import make_subplots from freqtrade.configuration import TimeRange from freqtrade.data.dataprovider import DataProvider @@ -132,3 +140,66 @@ def get_required_data_timerange( # trading_mode=config.get("trading_mode", "spot"), # prepend=config.get("prepend_data", False), # ) + + +def plot_feature_importance(model, feature_names, pair, train_dir, count_max=25) -> None: + """ + Plot Best and Worst Features by importance for CatBoost model. + Called once per sub-train. + + Required: pip install kaleido + + Usage: plot_feature_importance( + model=model, + feature_names=dk.training_features_list, + pair=pair, + train_dir=dk.data_path) + """ + + # Gather feature importance from model + if "catboost.core" in str(model.__class__): + fi = model.get_feature_importance() + + elif "lightgbm.sklearn" in str(model.__class__): + fi = model.feature_importances_ + + else: + raise NotImplementedError(f"Cannot extract feature importance for {model.__class__}") + + # Data preparation + fi_df = pd.DataFrame({ + "feature_names": np.array(feature_names), + "feature_importance": np.array(fi) + }) + fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] + fi_df_worst = fi_df.nsmallest(count_max, "feature_importance")[::-1] + + # Plotting + fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.5) + fig.add_trace( + go.Bar( + x=fi_df_top["feature_importance"], + y=fi_df_top["feature_names"], + orientation='h', showlegend=False + ), row=1, col=1 + ) + fig.add_trace( + go.Bar( + x=fi_df_worst["feature_importance"], + y=fi_df_worst["feature_names"], + orientation='h', showlegend=False + ), row=1, col=2 + ) + fig.update_layout( + title_text=f"Best and Worst Features {pair}", + width=1000, height=600 + ) + + # Create directory and save image + model_dir, train_name = str(train_dir).rsplit("/", 1) + fi_dir = Path(f"{model_dir}/feature_importance/{pair.split('/')[0]}") + fi_dir.mkdir(parents=True, exist_ok=True) + + pio.write_image(fig, f"{fi_dir}/{train_name}.png", format="png") + + logger.info(f"Freqai saving feature importance plot {fi_dir}/{train_name}.png") diff --git a/requirements-plot.txt b/requirements-plot.txt index 80cd3f4f2..ef3cf9f24 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -2,3 +2,4 @@ -r requirements.txt plotly==5.10.0 +kaleido==0.2.1 From 86aa875bc9d5edeba04f908fe45b011e52045c83 Mon Sep 17 00:00:00 2001 From: initrv Date: Fri, 16 Sep 2022 21:47:12 +0300 Subject: [PATCH 107/199] plot features as html instead of png --- freqtrade/freqai/utils.py | 62 ++++++++++++++++----------------------- requirements-plot.txt | 1 - 2 files changed, 25 insertions(+), 38 deletions(-) diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 86d89d4b0..3f6b8b053 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -1,13 +1,9 @@ import logging from datetime import datetime, timezone -# for plot_feature_importance from pathlib import Path import numpy as np import pandas as pd -import plotly.graph_objects as go -import plotly.io as pio -from plotly.subplots import make_subplots from freqtrade.configuration import TimeRange from freqtrade.data.dataprovider import DataProvider @@ -142,64 +138,56 @@ def get_required_data_timerange( # ) -def plot_feature_importance(model, feature_names, pair, train_dir, count_max=25) -> None: +def plot_feature_importance(model, feature_names, pair, train_dir, count_max=50) -> None: """ Plot Best and Worst Features by importance for CatBoost model. Called once per sub-train. - - Required: pip install kaleido - Usage: plot_feature_importance( model=model, feature_names=dk.training_features_list, pair=pair, train_dir=dk.data_path) """ + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError: + logger.exception("Module plotly not found \n Please install using `pip3 install plotly`") + exit(1) + + from freqtrade.plot.plotting import store_plot_file # Gather feature importance from model if "catboost.core" in str(model.__class__): - fi = model.get_feature_importance() - + feature_importance = model.get_feature_importance() elif "lightgbm.sklearn" in str(model.__class__): - fi = model.feature_importances_ - + feature_importance = model.feature_importances_ else: raise NotImplementedError(f"Cannot extract feature importance for {model.__class__}") # Data preparation fi_df = pd.DataFrame({ "feature_names": np.array(feature_names), - "feature_importance": np.array(fi) + "feature_importance": np.array(feature_importance) }) fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] fi_df_worst = fi_df.nsmallest(count_max, "feature_importance")[::-1] # Plotting + def add_feature_trace(fig, fi_df, col): + return fig.add_trace( + go.Bar( + x=fi_df["feature_importance"], + y=fi_df["feature_names"], + orientation='h', showlegend=False + ), row=1, col=col + ) fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.5) - fig.add_trace( - go.Bar( - x=fi_df_top["feature_importance"], - y=fi_df_top["feature_names"], - orientation='h', showlegend=False - ), row=1, col=1 - ) - fig.add_trace( - go.Bar( - x=fi_df_worst["feature_importance"], - y=fi_df_worst["feature_names"], - orientation='h', showlegend=False - ), row=1, col=2 - ) - fig.update_layout( - title_text=f"Best and Worst Features {pair}", - width=1000, height=600 - ) + fig = add_feature_trace(fig, fi_df_top, 1) + fig = add_feature_trace(fig, fi_df_worst, 2) + fig.update_layout(title_text=f"Best and Worst Features {pair}") - # Create directory and save image + # Store plot file model_dir, train_name = str(train_dir).rsplit("/", 1) fi_dir = Path(f"{model_dir}/feature_importance/{pair.split('/')[0]}") - fi_dir.mkdir(parents=True, exist_ok=True) - - pio.write_image(fig, f"{fi_dir}/{train_name}.png", format="png") - - logger.info(f"Freqai saving feature importance plot {fi_dir}/{train_name}.png") + store_plot_file(fig, f"{train_name}.html", fi_dir) diff --git a/requirements-plot.txt b/requirements-plot.txt index ef3cf9f24..80cd3f4f2 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -2,4 +2,3 @@ -r requirements.txt plotly==5.10.0 -kaleido==0.2.1 From 4422ac7f45f04b8ffb142fadf984c7c9e8438a51 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 16 Sep 2022 19:22:24 -0600 Subject: [PATCH 108/199] constrain port in config, catch value error --- freqtrade/constants.py | 7 ++++++- freqtrade/rpc/external_message_consumer.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 2fc855fbd..835b9dfcc 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -497,7 +497,12 @@ CONF_SCHEMA = { 'properties': { 'name': {'type': 'string'}, 'host': {'type': 'string'}, - 'port': {'type': 'integer', 'default': 8080}, + 'port': { + 'type': 'integer', + 'default': 8080, + 'minimum': 0, + 'maximum': 65535 + }, 'ws_token': {'type': 'string'}, }, 'required': ['name', 'host', 'ws_token'] diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index e4b3c2609..220f98706 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -205,6 +205,10 @@ class ExternalMessageConsumer: # Now receive data, if none is within the time limit, ping await self._receive_messages(channel, producer, lock) + except (websockets.exceptions.InvalidURI, ValueError) as e: + logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") + break + except ( socket.gaierror, ConnectionRefusedError, From 1c92734f39074eb7078f45dc296d18c338467959 Mon Sep 17 00:00:00 2001 From: initrv Date: Sat, 17 Sep 2022 18:53:43 +0300 Subject: [PATCH 109/199] simplify plot_feature_importance call --- freqtrade/freqai/freqai_interface.py | 11 +++----- freqtrade/freqai/utils.py | 38 +++++++++++++--------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 0cc51fdab..3fa8a801b 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -556,14 +556,6 @@ class IFreqaiModel(ABC): model = self.train(unfiltered_dataframe, pair, dk) - if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): - plot_feature_importance( - model=model, - feature_names=dk.training_features_list, - pair=pair, - train_dir=dk.data_path - ) - self.dd.pair_dict[pair]["trained_timestamp"] = new_trained_timerange.stopts dk.set_new_model_names(pair, new_trained_timerange) self.dd.pair_dict[pair]["first"] = False @@ -571,6 +563,9 @@ class IFreqaiModel(ABC): self.dd.pair_to_end_of_training_queue(pair) self.dd.save_data(model, pair, dk) + if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): + plot_feature_importance(model, pair, dk) + if self.freqai_info.get("purge_old_models", False): self.dd.purge_old_models() diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 3f6b8b053..34528acdd 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -1,6 +1,7 @@ import logging from datetime import datetime, timezone from pathlib import Path +from typing import Any import numpy as np import pandas as pd @@ -11,6 +12,7 @@ from freqtrade.data.history.history_utils import refresh_backtest_ohlcv_data from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.exchange.exchange import market_is_active +from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist @@ -138,36 +140,30 @@ def get_required_data_timerange( # ) -def plot_feature_importance(model, feature_names, pair, train_dir, count_max=50) -> None: +def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, + count_max: int = 25) -> None: """ - Plot Best and Worst Features by importance for CatBoost model. - Called once per sub-train. - Usage: plot_feature_importance( - model=model, - feature_names=dk.training_features_list, - pair=pair, - train_dir=dk.data_path) + Plot Best and worst features by importance for a single sub-train. + :param model: Any = A model which was `fit` using a common library + such as catboost or lightgbm + :param pair: str = pair e.g. BTC/USD + :param dk: FreqaiDataKitchen = non-persistent data container for current coin/loop + :param count_max: int = the amount of features to be loaded per column """ - try: - import plotly.graph_objects as go - from plotly.subplots import make_subplots - except ImportError: - logger.exception("Module plotly not found \n Please install using `pip3 install plotly`") - exit(1) + from freqtrade.plot.plotting import go, make_subplots, store_plot_file - from freqtrade.plot.plotting import store_plot_file - - # Gather feature importance from model + # Extract feature importance from model if "catboost.core" in str(model.__class__): feature_importance = model.get_feature_importance() elif "lightgbm.sklearn" in str(model.__class__): feature_importance = model.feature_importances_ else: - raise NotImplementedError(f"Cannot extract feature importance for {model.__class__}") + # TODO: Add support for more libraries + raise NotImplementedError(f"Cannot extract feature importance from {model.__class__}") # Data preparation fi_df = pd.DataFrame({ - "feature_names": np.array(feature_names), + "feature_names": np.array(dk.training_features_list), "feature_importance": np.array(feature_importance) }) fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] @@ -185,9 +181,9 @@ def plot_feature_importance(model, feature_names, pair, train_dir, count_max=50) fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.5) fig = add_feature_trace(fig, fi_df_top, 1) fig = add_feature_trace(fig, fi_df_worst, 2) - fig.update_layout(title_text=f"Best and Worst Features {pair}") + fig.update_layout(title_text=f"Best and worst features by importance {pair}") # Store plot file - model_dir, train_name = str(train_dir).rsplit("/", 1) + model_dir, train_name = str(dk.data_path).rsplit("/", 1) fi_dir = Path(f"{model_dir}/feature_importance/{pair.split('/')[0]}") store_plot_file(fig, f"{train_name}.html", fi_dir) From 2c23effbf27adefa9949fe21c462dc3ef5e36b8d Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 17 Sep 2022 19:17:44 +0200 Subject: [PATCH 110/199] allow plot to plot multitargets, add test --- freqtrade/freqai/freqai_interface.py | 2 +- freqtrade/freqai/utils.py | 68 ++++++++++++++------------- freqtrade/plot/plotting.py | 5 +- tests/freqai/test_freqai_interface.py | 35 ++++++++++++++ 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 3fa8a801b..c6b45d61b 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -563,7 +563,7 @@ class IFreqaiModel(ABC): self.dd.pair_to_end_of_training_queue(pair) self.dd.save_data(model, pair, dk) - if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): + if self.freqai_info["feature_parameters"].get("plot_feature_importance", True): plot_feature_importance(model, pair, dk) if self.freqai_info.get("purge_old_models", False): diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 34528acdd..3f278d436 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -1,6 +1,5 @@ import logging from datetime import datetime, timezone -from pathlib import Path from typing import Any import numpy as np @@ -153,37 +152,42 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, from freqtrade.plot.plotting import go, make_subplots, store_plot_file # Extract feature importance from model - if "catboost.core" in str(model.__class__): - feature_importance = model.get_feature_importance() - elif "lightgbm.sklearn" in str(model.__class__): - feature_importance = model.feature_importances_ - else: - # TODO: Add support for more libraries - raise NotImplementedError(f"Cannot extract feature importance from {model.__class__}") + models = {} + if 'FreqaiMultiOutputRegressor' in str(model.__class__): + for estimator, label in zip(model.estimators_, dk.label_list): + models[label] = estimator - # Data preparation - fi_df = pd.DataFrame({ - "feature_names": np.array(dk.training_features_list), - "feature_importance": np.array(feature_importance) - }) - fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] - fi_df_worst = fi_df.nsmallest(count_max, "feature_importance")[::-1] + for label in models: + mdl = models[label] + if "catboost.core" in str(mdl.__class__): + feature_importance = mdl.get_feature_importance() + elif "lightgbm.sklearn" or "xgb" in str(mdl.__class__): + feature_importance = mdl.feature_importances_ + else: + # TODO: Add support for more libraries + raise NotImplementedError(f"Cannot extract feature importance from {mdl.__class__}") - # Plotting - def add_feature_trace(fig, fi_df, col): - return fig.add_trace( - go.Bar( - x=fi_df["feature_importance"], - y=fi_df["feature_names"], - orientation='h', showlegend=False - ), row=1, col=col - ) - fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.5) - fig = add_feature_trace(fig, fi_df_top, 1) - fig = add_feature_trace(fig, fi_df_worst, 2) - fig.update_layout(title_text=f"Best and worst features by importance {pair}") + # Data preparation + fi_df = pd.DataFrame({ + "feature_names": np.array(dk.training_features_list), + "feature_importance": np.array(feature_importance) + }) + fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] + fi_df_worst = fi_df.nsmallest(count_max, "feature_importance")[::-1] - # Store plot file - model_dir, train_name = str(dk.data_path).rsplit("/", 1) - fi_dir = Path(f"{model_dir}/feature_importance/{pair.split('/')[0]}") - store_plot_file(fig, f"{train_name}.html", fi_dir) + # Plotting + def add_feature_trace(fig, fi_df, col): + return fig.add_trace( + go.Bar( + x=fi_df["feature_importance"], + y=fi_df["feature_names"], + orientation='h', showlegend=False + ), row=1, col=col + ) + fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.5) + fig = add_feature_trace(fig, fi_df_top, 1) + fig = add_feature_trace(fig, fi_df_worst, 2) + fig.update_layout(title_text=f"Best and worst features by importance {pair}") + + store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path, + include_plotlyjs="cdn") diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index f8e95300a..8a00c7899 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -601,7 +601,8 @@ def generate_plot_filename(pair: str, timeframe: str) -> str: return file_name -def store_plot_file(fig, filename: str, directory: Path, auto_open: bool = False) -> None: +def store_plot_file(fig, filename: str, directory: Path, + auto_open: bool = False, include_plotlyjs=True) -> None: """ Generate a plot html file from pre populated fig plotly object :param fig: Plotly Figure to plot @@ -614,7 +615,7 @@ def store_plot_file(fig, filename: str, directory: Path, auto_open: bool = False _filename = directory.joinpath(filename) plot(fig, filename=str(_filename), - auto_open=auto_open) + auto_open=auto_open, include_plotlyjs=include_plotlyjs) logger.info(f"Stored plot as {_filename}") diff --git a/tests/freqai/test_freqai_interface.py b/tests/freqai/test_freqai_interface.py index a66594d7f..b600cc535 100644 --- a/tests/freqai/test_freqai_interface.py +++ b/tests/freqai/test_freqai_interface.py @@ -315,3 +315,38 @@ def test_principal_component_analysis(mocker, freqai_conf): assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_pca_object.pkl") shutil.rmtree(Path(freqai.dk.full_path)) + + +def test_plot_feature_importance(mocker, freqai_conf): + + from freqtrade.freqai.utils import plot_feature_importance + + freqai_conf.update({"timerange": "20180110-20180130"}) + freqai_conf.get("freqai", {}).get("feature_parameters", {}).update( + {"princpial_component_analysis": "true"}) + + strategy = get_patched_freqai_strategy(mocker, freqai_conf) + exchange = get_patched_exchange(mocker, freqai_conf) + strategy.dp = DataProvider(freqai_conf, exchange) + strategy.freqai_info = freqai_conf.get("freqai", {}) + freqai = strategy.freqai + freqai.live = True + freqai.dk = FreqaiDataKitchen(freqai_conf) + timerange = TimeRange.parse_timerange("20180110-20180130") + freqai.dd.load_all_pair_histories(timerange, freqai.dk) + + freqai.dd.pair_dict = MagicMock() + + data_load_timerange = TimeRange.parse_timerange("20180110-20180130") + new_timerange = TimeRange.parse_timerange("20180120-20180130") + + freqai.extract_data_and_train_model( + new_timerange, "ADA/BTC", strategy, freqai.dk, data_load_timerange) + + model = freqai.dd.load_data("ADA/BTC", freqai.dk) + + plot_feature_importance(model, "ADA/BTC", freqai.dk) + + assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}.html") + + shutil.rmtree(Path(freqai.dk.full_path)) From 68f7a315048703285e978dc58e023506153069f2 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 00:00:14 +0200 Subject: [PATCH 111/199] ensure continued operation despite not being able to plot --- freqtrade/freqai/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 3f278d436..10afbaf52 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -156,6 +156,8 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, if 'FreqaiMultiOutputRegressor' in str(model.__class__): for estimator, label in zip(model.estimators_, dk.label_list): models[label] = estimator + else: + models[dk.label_list[0]] for label in models: mdl = models[label] @@ -164,8 +166,8 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, elif "lightgbm.sklearn" or "xgb" in str(mdl.__class__): feature_importance = mdl.feature_importances_ else: - # TODO: Add support for more libraries - raise NotImplementedError(f"Cannot extract feature importance from {mdl.__class__}") + logger.info('Model type not support for generating feature importances.') + return # Data preparation fi_df = pd.DataFrame({ From 1ef875901aec7fabd11f794669d62c1047c009dd Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 00:01:42 +0200 Subject: [PATCH 112/199] maintian user privacy by keeping plotly offline --- freqtrade/freqai/utils.py | 3 +-- freqtrade/plot/plotting.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 10afbaf52..50303019a 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -191,5 +191,4 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, fig = add_feature_trace(fig, fi_df_worst, 2) fig.update_layout(title_text=f"Best and worst features by importance {pair}") - store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path, - include_plotlyjs="cdn") + store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path) diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 8a00c7899..752a25d2d 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -602,7 +602,7 @@ def generate_plot_filename(pair: str, timeframe: str) -> str: def store_plot_file(fig, filename: str, directory: Path, - auto_open: bool = False, include_plotlyjs=True) -> None: + auto_open: bool = False) -> None: """ Generate a plot html file from pre populated fig plotly object :param fig: Plotly Figure to plot @@ -615,7 +615,7 @@ def store_plot_file(fig, filename: str, directory: Path, _filename = directory.joinpath(filename) plot(fig, filename=str(_filename), - auto_open=auto_open, include_plotlyjs=include_plotlyjs) + auto_open=auto_open) logger.info(f"Stored plot as {_filename}") From fa3d4b58ab283bd5d5c490994eceaeec0a747aa3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:30:59 +0200 Subject: [PATCH 113/199] Revert unnecessary formatting --- freqtrade/plot/plotting.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 752a25d2d..f8e95300a 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -601,8 +601,7 @@ def generate_plot_filename(pair: str, timeframe: str) -> str: return file_name -def store_plot_file(fig, filename: str, directory: Path, - auto_open: bool = False) -> None: +def store_plot_file(fig, filename: str, directory: Path, auto_open: bool = False) -> None: """ Generate a plot html file from pre populated fig plotly object :param fig: Plotly Figure to plot From 4634936265cd5d72d609bdb588129f2fa648b07b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:39:03 +0200 Subject: [PATCH 114/199] additional support for --data-dir --- freqtrade/commands/cli_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 9aacbcc97..f383f0768 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -69,7 +69,7 @@ AVAILABLE_CLI_OPTIONS = { metavar='PATH', ), "datadir": Arg( - '-d', '--datadir', + '-d', '--datadir', '--data-dir', help='Path to directory with historical backtesting data.', metavar='PATH', ), From ab78fb373af68d1cf39ed11310a00aa051724a6e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:45:24 +0200 Subject: [PATCH 115/199] Improve freqAI strategy formatting and readability --- freqtrade/templates/FreqaiExampleStrategy.py | 34 +++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 907106453..d71fd91e8 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -170,25 +170,31 @@ class FreqaiExampleStrategy(IStrategy): dataframe = self.freqai.start(dataframe, metadata, self) for val in self.std_dev_multiplier_buy.range: - dataframe[f'target_roi_{val}'] = dataframe["&-s_close_mean"] + \ - dataframe["&-s_close_std"] * val + dataframe[f'target_roi_{val}'] = ( + dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * val + ) for val in self.std_dev_multiplier_sell.range: - dataframe[f'sell_roi_{val}'] = dataframe["&-s_close_mean"] - \ - dataframe["&-s_close_std"] * val + dataframe[f'sell_roi_{val}'] = ( + dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * val + ) return dataframe def populate_entry_trend(self, df: DataFrame, metadata: dict) -> DataFrame: - enter_long_conditions = [df["do_predict"] == 1, df["&-s_close"] - > df[f"target_roi_{self.std_dev_multiplier_buy.value}"]] + enter_long_conditions = [ + df["do_predict"] == 1, + df["&-s_close"] > df[f"target_roi_{self.std_dev_multiplier_buy.value}"], + ] if enter_long_conditions: df.loc[ reduce(lambda x, y: x & y, enter_long_conditions), ["enter_long", "enter_tag"] ] = (1, "long") - enter_short_conditions = [df["do_predict"] == 1, df["&-s_close"] - < df[f"sell_roi_{self.std_dev_multiplier_sell.value}"]] + enter_short_conditions = [ + df["do_predict"] == 1, + df["&-s_close"] < df[f"sell_roi_{self.std_dev_multiplier_sell.value}"], + ] if enter_short_conditions: df.loc[ @@ -198,13 +204,17 @@ class FreqaiExampleStrategy(IStrategy): return df def populate_exit_trend(self, df: DataFrame, metadata: dict) -> DataFrame: - exit_long_conditions = [df["do_predict"] == 1, df["&-s_close"] < - df[f"sell_roi_{self.std_dev_multiplier_sell.value}"] * 0.25] + exit_long_conditions = [ + df["do_predict"] == 1, + df["&-s_close"] < df[f"sell_roi_{self.std_dev_multiplier_sell.value}"] * 0.25, + ] if exit_long_conditions: df.loc[reduce(lambda x, y: x & y, exit_long_conditions), "exit_long"] = 1 - exit_short_conditions = [df["do_predict"] == 1, df["&-s_close"] > - df[f"target_roi_{self.std_dev_multiplier_buy.value}"] * 0.25] + exit_short_conditions = [ + df["do_predict"] == 1, + df["&-s_close"] > df[f"target_roi_{self.std_dev_multiplier_buy.value}"] * 0.25, + ] if exit_short_conditions: df.loc[reduce(lambda x, y: x & y, exit_short_conditions), "exit_short"] = 1 From faf84295a521dd2ff2ea1b87d317039b8a0bce20 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:50:41 +0200 Subject: [PATCH 116/199] Separate strategy subtemplates for better overview --- freqtrade/commands/deploy_commands.py | 20 +++++++++---------- .../buy_trend_full.j2 | 0 .../buy_trend_minimal.j2 | 0 .../indicators_full.j2 | 0 .../indicators_minimal.j2 | 0 .../plot_config_full.j2 | 0 .../plot_config_minimal.j2 | 0 .../sell_trend_full.j2 | 0 .../sell_trend_minimal.j2 | 0 .../strategy_methods_advanced.j2 | 0 .../strategy_methods_empty.j2 | 0 tests/test_misc.py | 4 ++-- 12 files changed, 12 insertions(+), 12 deletions(-) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/buy_trend_full.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/buy_trend_minimal.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/indicators_full.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/indicators_minimal.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/plot_config_full.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/plot_config_minimal.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/sell_trend_full.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/sell_trend_minimal.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/strategy_methods_advanced.j2 (100%) rename freqtrade/templates/{subtemplates => strategy_subtemplates}/strategy_methods_empty.j2 (100%) diff --git a/freqtrade/commands/deploy_commands.py b/freqtrade/commands/deploy_commands.py index 92c9adf66..9ec33eac4 100644 --- a/freqtrade/commands/deploy_commands.py +++ b/freqtrade/commands/deploy_commands.py @@ -36,24 +36,24 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st """ fallback = 'full' indicators = render_template_with_fallback( - templatefile=f"subtemplates/indicators_{subtemplate}.j2", - templatefallbackfile=f"subtemplates/indicators_{fallback}.j2", + templatefile=f"strategy_subtemplates/indicators_{subtemplate}.j2", + templatefallbackfile=f"strategy_subtemplates/indicators_{fallback}.j2", ) buy_trend = render_template_with_fallback( - templatefile=f"subtemplates/buy_trend_{subtemplate}.j2", - templatefallbackfile=f"subtemplates/buy_trend_{fallback}.j2", + templatefile=f"strategy_subtemplates/buy_trend_{subtemplate}.j2", + templatefallbackfile=f"strategy_subtemplates/buy_trend_{fallback}.j2", ) sell_trend = render_template_with_fallback( - templatefile=f"subtemplates/sell_trend_{subtemplate}.j2", - templatefallbackfile=f"subtemplates/sell_trend_{fallback}.j2", + templatefile=f"strategy_subtemplates/sell_trend_{subtemplate}.j2", + templatefallbackfile=f"strategy_subtemplates/sell_trend_{fallback}.j2", ) plot_config = render_template_with_fallback( - templatefile=f"subtemplates/plot_config_{subtemplate}.j2", - templatefallbackfile=f"subtemplates/plot_config_{fallback}.j2", + templatefile=f"strategy_subtemplates/plot_config_{subtemplate}.j2", + templatefallbackfile=f"strategy_subtemplates/plot_config_{fallback}.j2", ) additional_methods = render_template_with_fallback( - templatefile=f"subtemplates/strategy_methods_{subtemplate}.j2", - templatefallbackfile="subtemplates/strategy_methods_empty.j2", + templatefile=f"strategy_subtemplates/strategy_methods_{subtemplate}.j2", + templatefallbackfile="strategy_subtemplates/strategy_methods_empty.j2", ) strategy_text = render_template(templatefile='base_strategy.py.j2', diff --git a/freqtrade/templates/subtemplates/buy_trend_full.j2 b/freqtrade/templates/strategy_subtemplates/buy_trend_full.j2 similarity index 100% rename from freqtrade/templates/subtemplates/buy_trend_full.j2 rename to freqtrade/templates/strategy_subtemplates/buy_trend_full.j2 diff --git a/freqtrade/templates/subtemplates/buy_trend_minimal.j2 b/freqtrade/templates/strategy_subtemplates/buy_trend_minimal.j2 similarity index 100% rename from freqtrade/templates/subtemplates/buy_trend_minimal.j2 rename to freqtrade/templates/strategy_subtemplates/buy_trend_minimal.j2 diff --git a/freqtrade/templates/subtemplates/indicators_full.j2 b/freqtrade/templates/strategy_subtemplates/indicators_full.j2 similarity index 100% rename from freqtrade/templates/subtemplates/indicators_full.j2 rename to freqtrade/templates/strategy_subtemplates/indicators_full.j2 diff --git a/freqtrade/templates/subtemplates/indicators_minimal.j2 b/freqtrade/templates/strategy_subtemplates/indicators_minimal.j2 similarity index 100% rename from freqtrade/templates/subtemplates/indicators_minimal.j2 rename to freqtrade/templates/strategy_subtemplates/indicators_minimal.j2 diff --git a/freqtrade/templates/subtemplates/plot_config_full.j2 b/freqtrade/templates/strategy_subtemplates/plot_config_full.j2 similarity index 100% rename from freqtrade/templates/subtemplates/plot_config_full.j2 rename to freqtrade/templates/strategy_subtemplates/plot_config_full.j2 diff --git a/freqtrade/templates/subtemplates/plot_config_minimal.j2 b/freqtrade/templates/strategy_subtemplates/plot_config_minimal.j2 similarity index 100% rename from freqtrade/templates/subtemplates/plot_config_minimal.j2 rename to freqtrade/templates/strategy_subtemplates/plot_config_minimal.j2 diff --git a/freqtrade/templates/subtemplates/sell_trend_full.j2 b/freqtrade/templates/strategy_subtemplates/sell_trend_full.j2 similarity index 100% rename from freqtrade/templates/subtemplates/sell_trend_full.j2 rename to freqtrade/templates/strategy_subtemplates/sell_trend_full.j2 diff --git a/freqtrade/templates/subtemplates/sell_trend_minimal.j2 b/freqtrade/templates/strategy_subtemplates/sell_trend_minimal.j2 similarity index 100% rename from freqtrade/templates/subtemplates/sell_trend_minimal.j2 rename to freqtrade/templates/strategy_subtemplates/sell_trend_minimal.j2 diff --git a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 similarity index 100% rename from freqtrade/templates/subtemplates/strategy_methods_advanced.j2 rename to freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 diff --git a/freqtrade/templates/subtemplates/strategy_methods_empty.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_empty.j2 similarity index 100% rename from freqtrade/templates/subtemplates/strategy_methods_empty.j2 rename to freqtrade/templates/strategy_subtemplates/strategy_methods_empty.j2 diff --git a/tests/test_misc.py b/tests/test_misc.py index 107932be4..4b52079bf 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -184,8 +184,8 @@ def test_render_template_fallback(mocker): templatefile='subtemplates/indicators_does-not-exist.j2',) val = render_template_with_fallback( - templatefile='subtemplates/indicators_does-not-exist.j2', - templatefallbackfile='subtemplates/indicators_minimal.j2', + templatefile='strategy_subtemplates/indicators_does-not-exist.j2', + templatefallbackfile='strategy_subtemplates/indicators_minimal.j2', ) assert isinstance(val, str) assert 'if self.dp' in val From 7a73adb95500f8a2852a113aecfe71cc7071a07b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:57:26 +0200 Subject: [PATCH 117/199] Improve default strategy template --- freqtrade/templates/base_strategy.py.j2 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index 5a4504687..d8930f65e 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -1,6 +1,6 @@ # pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement # flake8: noqa: F401 - +# isort: skip_file # --- Do not remove these libs --- import numpy as np # noqa import pandas as pd # noqa @@ -9,13 +9,13 @@ from datetime import datetime # noqa from typing import Optional, Union # noqa from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter, - IStrategy, IntParameter) + IntParameter, IStrategy, merge_informative_pair) # -------------------------------- # Add your lib to import here import talib.abstract as ta import pandas_ta as pta -import freqtrade.vendor.qtpylib.indicators as qtpylib +from technical import qtpylib class {{ strategy }}(IStrategy): From 9f23588154b24bb1d35cf0d5e7ef0e7db82a2cd5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 08:58:33 +0200 Subject: [PATCH 118/199] strategy template - remove pointless noqa's --- freqtrade/templates/base_strategy.py.j2 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index d8930f65e..53426b211 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -2,11 +2,11 @@ # flake8: noqa: F401 # isort: skip_file # --- Do not remove these libs --- -import numpy as np # noqa -import pandas as pd # noqa -from pandas import DataFrame # noqa -from datetime import datetime # noqa -from typing import Optional, Union # noqa +import numpy as np +import pandas as pd +from pandas import DataFrame +from datetime import datetime +from typing import Optional, Union from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter, IntParameter, IStrategy, merge_informative_pair) From 188f75d8ec312e6d2687fc2442ef2517abfbd62e Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 12:49:08 +0200 Subject: [PATCH 119/199] set model in models dict --- freqtrade/freqai/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 50303019a..57bc17cdf 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -157,7 +157,7 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, for estimator, label in zip(model.estimators_, dk.label_list): models[label] = estimator else: - models[dk.label_list[0]] + models[dk.label_list[0]] = model for label in models: mdl = models[label] From 667853c50444be2768b7e43d9ab58ec25540d0cc Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 13:20:36 +0200 Subject: [PATCH 120/199] Use Alias to type config objects --- freqtrade/configuration/check_exchange.py | 4 +-- freqtrade/configuration/configuration.py | 31 ++++++++++--------- .../configuration/deprecated_settings.py | 11 ++++--- .../configuration/directory_operations.py | 6 ++-- freqtrade/configuration/load_config.py | 4 +-- freqtrade/constants.py | 4 ++- freqtrade/data/converter.py | 4 +-- freqtrade/data/dataprovider.py | 4 +-- freqtrade/edge/edge_positioning.py | 5 ++- freqtrade/exchange/exchange.py | 7 +++-- freqtrade/freqai/data_drawer.py | 3 +- freqtrade/freqai/data_kitchen.py | 3 +- freqtrade/freqai/freqai_interface.py | 6 ++-- freqtrade/freqai/utils.py | 9 +++--- freqtrade/freqtradebot.py | 4 +-- freqtrade/loggers.py | 4 +-- freqtrade/optimize/backtesting.py | 4 +-- freqtrade/optimize/edge_cli.py | 4 +-- freqtrade/optimize/hyperopt.py | 6 ++-- freqtrade/optimize/hyperopt_interface.py | 3 +- freqtrade/optimize/hyperopt_tools.py | 4 +-- freqtrade/plugins/pairlist/SpreadFilter.py | 3 +- freqtrade/plugins/pairlist/StaticPairList.py | 3 +- .../plugins/pairlist/VolatilityFilter.py | 4 +-- freqtrade/plugins/pairlist/VolumePairList.py | 4 +-- freqtrade/plugins/protections/iprotection.py | 4 +-- .../plugins/protections/low_profit_pairs.py | 4 +-- .../protections/max_drawdown_protection.py | 4 +-- .../plugins/protections/stoploss_guard.py | 4 +-- freqtrade/rpc/api_server/webserver.py | 7 +++-- freqtrade/rpc/rpc.py | 8 ++--- freqtrade/strategy/hyper.py | 3 +- freqtrade/wallets.py | 4 +-- freqtrade/worker.py | 11 ++++--- 34 files changed, 102 insertions(+), 91 deletions(-) diff --git a/freqtrade/configuration/check_exchange.py b/freqtrade/configuration/check_exchange.py index 2be13ce4f..c3d859275 100644 --- a/freqtrade/configuration/check_exchange.py +++ b/freqtrade/configuration/check_exchange.py @@ -1,6 +1,6 @@ import logging -from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import (available_exchanges, is_exchange_known_ccxt, @@ -10,7 +10,7 @@ from freqtrade.exchange import (available_exchanges, is_exchange_known_ccxt, logger = logging.getLogger(__name__) -def check_exchange(config: Dict[str, Any], check_for_bad: bool = True) -> bool: +def check_exchange(config: Config, check_for_bad: bool = True) -> bool: """ Check if the exchange name in the config file is supported by Freqtrade :param check_for_bad: if True, check the exchange against the list of known 'bad' diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index 7c68ac46c..76105cc4d 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -13,6 +13,7 @@ from freqtrade.configuration.deprecated_settings import process_temporary_deprec from freqtrade.configuration.directory_operations import create_datadir, create_userdata_dir from freqtrade.configuration.environment_vars import enironment_vars_to_dict from freqtrade.configuration.load_config import load_file, load_from_files +from freqtrade.constants import Config from freqtrade.enums import NON_UTIL_MODES, TRADING_MODES, CandleType, RunMode, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.loggers import setup_logging @@ -30,10 +31,10 @@ class Configuration: def __init__(self, args: Dict[str, Any], runmode: RunMode = None) -> None: self.args = args - self.config: Optional[Dict[str, Any]] = None + self.config: Optional[Config] = None self.runmode = runmode - def get_config(self) -> Dict[str, Any]: + def get_config(self) -> Config: """ Return the config. Use this method to get the bot config :return: Dict: Bot config @@ -65,7 +66,7 @@ class Configuration: :return: Configuration dictionary """ # Load all configs - config: Dict[str, Any] = load_from_files(self.args.get("config", [])) + config: Config = load_from_files(self.args.get("config", [])) # Load environment variables env_data = enironment_vars_to_dict() @@ -108,7 +109,7 @@ class Configuration: return config - def _process_logging_options(self, config: Dict[str, Any]) -> None: + def _process_logging_options(self, config: Config) -> None: """ Extract information for sys.argv and load logging configuration: the -v/--verbose, --logfile options @@ -121,7 +122,7 @@ class Configuration: setup_logging(config) - def _process_trading_options(self, config: Dict[str, Any]) -> None: + def _process_trading_options(self, config: Config) -> None: if config['runmode'] not in TRADING_MODES: return @@ -137,7 +138,7 @@ class Configuration: logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"') - def _process_common_options(self, config: Dict[str, Any]) -> None: + def _process_common_options(self, config: Config) -> None: # Set strategy if not specified in config and or if it's non default if self.args.get('strategy') or not config.get('strategy'): @@ -161,7 +162,7 @@ class Configuration: if 'sd_notify' in self.args and self.args['sd_notify']: config['internals'].update({'sd_notify': True}) - def _process_datadir_options(self, config: Dict[str, Any]) -> None: + def _process_datadir_options(self, config: Config) -> None: """ Extract information for sys.argv and load directory configurations --user-data, --datadir @@ -195,7 +196,7 @@ class Configuration: config['exportfilename'] = (config['user_data_dir'] / 'backtest_results') - def _process_optimize_options(self, config: Dict[str, Any]) -> None: + def _process_optimize_options(self, config: Config) -> None: # This will override the strategy configuration self._args_to_config(config, argname='timeframe', @@ -380,7 +381,7 @@ class Configuration: self._args_to_config(config, argname="hyperopt_ignore_missing_space", logstring="Paramter --ignore-missing-space detected: {}") - def _process_plot_options(self, config: Dict[str, Any]) -> None: + def _process_plot_options(self, config: Config) -> None: self._args_to_config(config, argname='pairs', logstring='Using pairs {}') @@ -432,7 +433,7 @@ class Configuration: self._args_to_config(config, argname='show_timerange', logstring='Detected --show-timerange') - def _process_data_options(self, config: Dict[str, Any]) -> None: + def _process_data_options(self, config: Config) -> None: self._args_to_config(config, argname='new_pairs_days', logstring='Detected --new-pairs-days: {}') self._args_to_config(config, argname='trading_mode', @@ -443,7 +444,7 @@ class Configuration: self._args_to_config(config, argname='candle_types', logstring='Detected --candle-types: {}') - def _process_analyze_options(self, config: Dict[str, Any]) -> None: + def _process_analyze_options(self, config: Config) -> None: self._args_to_config(config, argname='analysis_groups', logstring='Analysis reason groups: {}') @@ -456,7 +457,7 @@ class Configuration: self._args_to_config(config, argname='indicator_list', logstring='Analysis indicator list: {}') - def _process_runmode(self, config: Dict[str, Any]) -> None: + def _process_runmode(self, config: Config) -> None: self._args_to_config(config, argname='dry_run', logstring='Parameter --dry-run detected, ' @@ -469,7 +470,7 @@ class Configuration: config.update({'runmode': self.runmode}) - def _process_freqai_options(self, config: Dict[str, Any]) -> None: + def _process_freqai_options(self, config: Config) -> None: self._args_to_config(config, argname='freqaimodel', logstring='Using freqaimodel class name: {}') @@ -479,7 +480,7 @@ class Configuration: return - def _args_to_config(self, config: Dict[str, Any], argname: str, + def _args_to_config(self, config: Config, argname: str, logstring: str, logfun: Optional[Callable] = None, deprecated_msg: Optional[str] = None) -> None: """ @@ -502,7 +503,7 @@ class Configuration: if deprecated_msg: warnings.warn(f"DEPRECATED: {deprecated_msg}", DeprecationWarning) - def _resolve_pairs_list(self, config: Dict[str, Any]) -> None: + def _resolve_pairs_list(self, config: Config) -> None: """ Helper for download script. Takes first found: diff --git a/freqtrade/configuration/deprecated_settings.py b/freqtrade/configuration/deprecated_settings.py index e88383785..46c19a5b2 100644 --- a/freqtrade/configuration/deprecated_settings.py +++ b/freqtrade/configuration/deprecated_settings.py @@ -3,15 +3,16 @@ Functions to handle deprecated settings """ import logging -from typing import Any, Dict, Optional +from typing import Optional +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) -def check_conflicting_settings(config: Dict[str, Any], +def check_conflicting_settings(config: Config, section_old: Optional[str], name_old: str, section_new: Optional[str], name_new: str) -> None: section_new_config = config.get(section_new, {}) if section_new else config @@ -28,7 +29,7 @@ def check_conflicting_settings(config: Dict[str, Any], ) -def process_removed_setting(config: Dict[str, Any], +def process_removed_setting(config: Config, section1: str, name1: str, section2: Optional[str], name2: str) -> None: """ @@ -47,7 +48,7 @@ def process_removed_setting(config: Dict[str, Any], ) -def process_deprecated_setting(config: Dict[str, Any], +def process_deprecated_setting(config: Config, section_old: Optional[str], name_old: str, section_new: Optional[str], name_new: str ) -> None: @@ -69,7 +70,7 @@ def process_deprecated_setting(config: Dict[str, Any], del section_old_config[name_old] -def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None: +def process_temporary_deprecated_settings(config: Config) -> None: # Kept for future deprecated / moved settings # check_conflicting_settings(config, 'ask_strategy', 'use_sell_signal', diff --git a/freqtrade/configuration/directory_operations.py b/freqtrade/configuration/directory_operations.py index 771fd53cc..f70310ee1 100644 --- a/freqtrade/configuration/directory_operations.py +++ b/freqtrade/configuration/directory_operations.py @@ -1,16 +1,16 @@ import logging import shutil from pathlib import Path -from typing import Any, Dict, Optional +from typing import Optional -from freqtrade.constants import USER_DATA_FILES +from freqtrade.constants import USER_DATA_FILES, Config from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) -def create_datadir(config: Dict[str, Any], datadir: Optional[str] = None) -> Path: +def create_datadir(config: Config, datadir: Optional[str] = None) -> Path: folder = Path(datadir) if datadir else Path(f"{config['user_data_dir']}/data") if not datadir: diff --git a/freqtrade/configuration/load_config.py b/freqtrade/configuration/load_config.py index 3fcbd1f2f..6d0321ba0 100644 --- a/freqtrade/configuration/load_config.py +++ b/freqtrade/configuration/load_config.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List import rapidjson -from freqtrade.constants import MINIMAL_CONFIG +from freqtrade.constants import MINIMAL_CONFIG, Config from freqtrade.exceptions import OperationalException from freqtrade.misc import deep_merge_dicts @@ -80,7 +80,7 @@ def load_from_files(files: List[str], base_path: Path = None, level: int = 0) -> Recursively load configuration files if specified. Sub-files are assumed to be relative to the initial config. """ - config: Dict[str, Any] = {} + config: Config = {} if level > 5: raise OperationalException("Config loop detected.") diff --git a/freqtrade/constants.py b/freqtrade/constants.py index bab8c4816..077c4ecbf 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -3,7 +3,7 @@ """ bot constants """ -from typing import List, Literal, Tuple +from typing import Any, Dict, List, Literal, Tuple from freqtrade.enums import CandleType @@ -603,3 +603,5 @@ LongShort = Literal['long', 'short'] EntryExit = Literal['entry', 'exit'] BuySell = Literal['buy', 'sell'] MakerTaker = Literal['maker', 'taker'] + +Config = Dict[str, Any] diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 84c57be41..bdd010af3 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List import pandas as pd from pandas import DataFrame, to_datetime -from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS, TradeList +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS, Config, TradeList from freqtrade.enums import CandleType @@ -263,7 +263,7 @@ def convert_trades_format(config: Dict[str, Any], convert_from: str, convert_to: def convert_ohlcv_format( - config: Dict[str, Any], + config: Config, convert_from: str, convert_to: str, erase: bool, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index c6519d2b8..43850ddd9 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple from pandas import DataFrame from freqtrade.configuration import TimeRange -from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe +from freqtrade.constants import Config, ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history from freqtrade.enums import CandleType, RunMode from freqtrade.exceptions import ExchangeError, OperationalException @@ -28,7 +28,7 @@ MAX_DATAFRAME_CANDLES = 1000 class DataProvider: - def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None: + def __init__(self, config: Config, exchange: Optional[Exchange], pairlists=None) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index af20e1645..45b4cd8f1 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -11,7 +11,7 @@ import utils_find_1st as utf1st from pandas import DataFrame from freqtrade.configuration import TimeRange -from freqtrade.constants import DATETIME_PRINT_FORMAT, UNLIMITED_STAKE_AMOUNT +from freqtrade.constants import DATETIME_PRINT_FORMAT, UNLIMITED_STAKE_AMOUNT, Config from freqtrade.data.history import get_timerange, load_data, refresh_data from freqtrade.enums import CandleType, ExitType, RunMode from freqtrade.exceptions import OperationalException @@ -42,10 +42,9 @@ class Edge: Author: https://github.com/mishaker """ - config: Dict = {} _cached_pairs: Dict[str, Any] = {} # Keeps a list of pairs - def __init__(self, config: Dict[str, Any], exchange, strategy) -> None: + def __init__(self, config: Config, exchange, strategy) -> None: self.config = config self.exchange = exchange diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index a2ddc16e8..c68fc5873 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -21,7 +21,8 @@ from dateutil import parser from pandas import DataFrame from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BuySell, - EntryExit, ListPairsWithTimeframes, MakerTaker, PairWithTimeframe) + Config, EntryExit, ListPairsWithTimeframes, MakerTaker, + PairWithTimeframe) from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, @@ -91,7 +92,7 @@ class Exchange: # TradingMode.SPOT always supported and not required in this list ] - def __init__(self, config: Dict[str, Any], validate: bool = True, + def __init__(self, config: Config, validate: bool = True, load_leverage_tiers: bool = False) -> None: """ Initializes this module with the given config, @@ -108,7 +109,7 @@ class Exchange: self._loop_lock = Lock() self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) - self._config: Dict = {} + self._config: Config = {} self._config.update(config) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 1c091f1be..a9fe8bdc9 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -16,6 +16,7 @@ from numpy.typing import NDArray from pandas import DataFrame from freqtrade.configuration import TimeRange +from freqtrade.constants import Config from freqtrade.data.history import load_pair_history from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_kitchen import FreqaiDataKitchen @@ -58,7 +59,7 @@ class FreqaiDataDrawer: Juha Nykänen @suikula, Wagner Costa @wagnercosta, Johan Vlugt @Jooopieeert """ - def __init__(self, full_path: Path, config: dict, follow_mode: bool = False): + def __init__(self, full_path: Path, config: Config, follow_mode: bool = False): self.config = config self.freqai_info = config.get("freqai", {}) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index e96a945eb..d2abd0ad2 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -18,6 +18,7 @@ from sklearn.model_selection import train_test_split from sklearn.neighbors import NearestNeighbors from freqtrade.configuration import TimeRange +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.strategy.interface import IStrategy @@ -57,7 +58,7 @@ class FreqaiDataKitchen: def __init__( self, - config: Dict[str, Any], + config: Config, live: bool = False, pair: str = "", ): diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 78931bed4..9a2c64cc3 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -14,7 +14,7 @@ from numpy.typing import NDArray from pandas import DataFrame from freqtrade.configuration import TimeRange -from freqtrade.constants import DATETIME_PRINT_FORMAT +from freqtrade.constants import DATETIME_PRINT_FORMAT, Config from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds @@ -50,7 +50,7 @@ class IFreqaiModel(ABC): Juha Nykänen @suikula, Wagner Costa @wagnercosta, Johan Vlugt @Jooopieeert """ - def __init__(self, config: Dict[str, Any]) -> None: + def __init__(self, config: Config) -> None: self.config = config self.assert_config(self.config) @@ -99,7 +99,7 @@ class IFreqaiModel(ABC): """ return ({}) - def assert_config(self, config: Dict[str, Any]) -> None: + def assert_config(self, config: Config) -> None: if not config.get("freqai", {}): raise OperationalException("No freqai parameters found in configuration file.") diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 6a70f050f..063965ded 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -2,6 +2,7 @@ import logging from datetime import datetime, timezone from freqtrade.configuration import TimeRange +from freqtrade.constants import Config from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history.history_utils import refresh_backtest_ohlcv_data from freqtrade.exceptions import OperationalException @@ -13,7 +14,7 @@ from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist logger = logging.getLogger(__name__) -def download_all_data_for_training(dp: DataProvider, config: dict) -> None: +def download_all_data_for_training(dp: DataProvider, config: Config) -> None: """ Called only once upon start of bot to download the necessary data for populating indicators and training the model. @@ -47,9 +48,7 @@ def download_all_data_for_training(dp: DataProvider, config: dict) -> None: ) -def get_required_data_timerange( - config: dict -) -> TimeRange: +def get_required_data_timerange(config: Config) -> TimeRange: """ Used to compute the required data download time range for auto data-download in FreqAI @@ -86,7 +85,7 @@ def get_required_data_timerange( # Keep below for when we wish to download heterogeneously lengthed data for FreqAI. -# def download_all_data_for_training(dp: DataProvider, config: dict) -> None: +# def download_all_data_for_training(dp: DataProvider, config: Config) -> None: # """ # Called only once upon start of bot to download the necessary data for # populating indicators and training a FreqAI model. diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 3eaec5c98..83abc9bc5 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -13,7 +13,7 @@ from schedule import Scheduler from freqtrade import __version__, constants from freqtrade.configuration import validate_config_consistency -from freqtrade.constants import BuySell, LongShort +from freqtrade.constants import BuySell, Config, LongShort from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge @@ -44,7 +44,7 @@ class FreqtradeBot(LoggingMixin): This is from here the bot start its logic. """ - def __init__(self, config: Dict[str, Any]) -> None: + def __init__(self, config: Config) -> None: """ Init all variables and objects the bot needs to work :param config: configuration dict, you can use Configuration.get_config() diff --git a/freqtrade/loggers.py b/freqtrade/loggers.py index e5b6ddbe9..f365053c9 100644 --- a/freqtrade/loggers.py +++ b/freqtrade/loggers.py @@ -2,8 +2,8 @@ import logging import sys from logging import Formatter from logging.handlers import BufferingHandler, RotatingFileHandler, SysLogHandler -from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException @@ -73,7 +73,7 @@ def setup_logging_pre() -> None: ) -def setup_logging(config: Dict[str, Any]) -> None: +def setup_logging(config: Config) -> None: """ Process -v/--verbose, --logfile options """ diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 105851e60..0a05d740d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -15,7 +15,7 @@ from pandas import DataFrame from freqtrade import constants from freqtrade.configuration import TimeRange, validate_config_consistency -from freqtrade.constants import DATETIME_PRINT_FORMAT, LongShort +from freqtrade.constants import DATETIME_PRINT_FORMAT, Config, LongShort from freqtrade.data import history from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe from freqtrade.data.converter import trim_dataframe, trim_dataframes @@ -70,7 +70,7 @@ class Backtesting: backtesting.start() """ - def __init__(self, config: Dict[str, Any]) -> None: + def __init__(self, config: Config) -> None: LoggingMixin.show_output = False self.config = config diff --git a/freqtrade/optimize/edge_cli.py b/freqtrade/optimize/edge_cli.py index aa3b02529..2eb1c53f5 100644 --- a/freqtrade/optimize/edge_cli.py +++ b/freqtrade/optimize/edge_cli.py @@ -4,10 +4,10 @@ This module contains the edge backtesting interface """ import logging -from typing import Any, Dict from freqtrade import constants from freqtrade.configuration import TimeRange, validate_config_consistency +from freqtrade.constants import Config from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge from freqtrade.optimize.optimize_reports import generate_edge_table @@ -26,7 +26,7 @@ class EdgeCli: edge.start() """ - def __init__(self, config: Dict[str, Any]) -> None: + def __init__(self, config: Config) -> None: self.config = config # Ensure using dry-run diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index f15e0b7d8..aef8405d5 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -21,7 +21,7 @@ from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_ from joblib.externals import cloudpickle from pandas import DataFrame -from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN +from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN, Config from freqtrade.data.converter import trim_dataframes from freqtrade.data.history import get_timerange from freqtrade.enums import HyperoptState @@ -66,7 +66,7 @@ class Hyperopt: hyperopt.start() """ - def __init__(self, config: Dict[str, Any]) -> None: + def __init__(self, config: Config) -> None: self.buy_space: List[Dimension] = [] self.sell_space: List[Dimension] = [] self.protection_space: List[Dimension] = [] @@ -132,7 +132,7 @@ class Hyperopt: self.print_json = self.config.get('print_json', False) @staticmethod - def get_lock_filename(config: Dict[str, Any]) -> str: + def get_lock_filename(config: Config) -> str: return str(config['user_data_dir'] / 'hyperopt.lock') diff --git a/freqtrade/optimize/hyperopt_interface.py b/freqtrade/optimize/hyperopt_interface.py index b1c68caca..a7c64ffb0 100644 --- a/freqtrade/optimize/hyperopt_interface.py +++ b/freqtrade/optimize/hyperopt_interface.py @@ -10,6 +10,7 @@ from typing import Dict, List, Union from sklearn.base import RegressorMixin from skopt.space import Categorical, Dimension, Integer +from freqtrade.constants import Config from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import round_dict from freqtrade.optimize.space import SKDecimal @@ -32,7 +33,7 @@ class IHyperOpt(ABC): timeframe: str strategy: IStrategy - def __init__(self, config: dict) -> None: + def __init__(self, config: Config) -> None: self.config = config # Assign timeframe to be used in hyperopt diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index 9b022d519..d1f776e3d 100755 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -12,7 +12,7 @@ import tabulate from colorama import Fore, Style from pandas import isna, json_normalize -from freqtrade.constants import FTHYPT_FILEVERSION, USERPATH_STRATEGIES +from freqtrade.constants import FTHYPT_FILEVERSION, USERPATH_STRATEGIES, Config from freqtrade.enums import HyperoptState from freqtrade.exceptions import OperationalException from freqtrade.misc import deep_merge_dicts, round_coin_value, round_dict, safe_value_fallback2 @@ -45,7 +45,7 @@ class HyperoptStateContainer(): class HyperoptTools(): @staticmethod - def get_strategy_filename(config: Dict, strategy_name: str) -> Optional[Path]: + def get_strategy_filename(config: Config, strategy_name: str) -> Optional[Path]: """ Get Strategy-location (filename) from strategy_name """ diff --git a/freqtrade/plugins/pairlist/SpreadFilter.py b/freqtrade/plugins/pairlist/SpreadFilter.py index 43856b451..1f20af305 100644 --- a/freqtrade/plugins/pairlist/SpreadFilter.py +++ b/freqtrade/plugins/pairlist/SpreadFilter.py @@ -4,6 +4,7 @@ Spread pair list filter import logging from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) class SpreadFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/StaticPairList.py b/freqtrade/plugins/pairlist/StaticPairList.py index 30fa474e4..83a0fa0c8 100644 --- a/freqtrade/plugins/pairlist/StaticPairList.py +++ b/freqtrade/plugins/pairlist/StaticPairList.py @@ -7,6 +7,7 @@ import logging from copy import deepcopy from typing import Any, Dict, List +from freqtrade.constants import Config from freqtrade.plugins.pairlist.IPairList import IPairList @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) class StaticPairList(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index bab44bdd1..c9af3a7b3 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -11,7 +11,7 @@ import numpy as np from cachetools import TTLCache from pandas import DataFrame -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList @@ -26,7 +26,7 @@ class VolatilityFilter(IPairList): """ def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index d7cc6e5ec..9dcada291 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List from cachetools import TTLCache -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.misc import format_ms_time @@ -25,7 +25,7 @@ SORT_VALUES = ['quoteVolume'] class VolumePairList(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index 890988226..8e1589217 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional -from freqtrade.constants import LongShort +from freqtrade.constants import Config, LongShort from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import plural from freqtrade.mixins import LoggingMixin @@ -30,7 +30,7 @@ class IProtection(LoggingMixin, ABC): # Can stop trading for one pair has_local_stop: bool = False - def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None: + def __init__(self, config: Config, protection_config: Dict[str, Any]) -> None: self._config = config self._protection_config = protection_config self._stop_duration_candles: Optional[int] = None diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 099242b8d..f638673fa 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from typing import Any, Dict, Optional -from freqtrade.constants import LongShort +from freqtrade.constants import Config, LongShort from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -16,7 +16,7 @@ class LowProfitPairs(IProtection): has_global_stop: bool = False has_local_stop: bool = True - def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None: + def __init__(self, config: Config, protection_config: Dict[str, Any]) -> None: super().__init__(config, protection_config) self._trade_limit = protection_config.get('trade_limit', 1) diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index e0b016cb8..8193dc7e4 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -5,7 +5,7 @@ from typing import Any, Dict, Optional import pandas as pd -from freqtrade.constants import LongShort +from freqtrade.constants import Config, LongShort from freqtrade.data.metrics import calculate_max_drawdown from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -19,7 +19,7 @@ class MaxDrawdown(IProtection): has_global_stop: bool = True has_local_stop: bool = False - def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None: + def __init__(self, config: Config, protection_config: Dict[str, Any]) -> None: super().__init__(config, protection_config) self._trade_limit = protection_config.get('trade_limit', 1) diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index e80d13e9d..23ceebbc9 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from typing import Any, Dict, Optional -from freqtrade.constants import LongShort +from freqtrade.constants import Config, LongShort from freqtrade.enums import ExitType from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -17,7 +17,7 @@ class StoplossGuard(IProtection): has_global_stop: bool = True has_local_stop: bool = True - def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None: + def __init__(self, config: Config, protection_config: Dict[str, Any]) -> None: super().__init__(config, protection_config) self._trade_limit = protection_config.get('trade_limit', 10) diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 0da129583..642f25e47 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -8,6 +8,7 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler @@ -37,10 +38,10 @@ class ApiServer(RPCHandler): _bt = None _bt_data = None _bt_timerange = None - _bt_last_config: Dict[str, Any] = {} + _bt_last_config: Config = {} _has_rpc: bool = False _bgtask_running: bool = False - _config: Dict[str, Any] = {} + _config: Config = {} # Exchange - only available in webserver mode. _exchange = None @@ -54,7 +55,7 @@ class ApiServer(RPCHandler): ApiServer.__initialized = False return ApiServer.__instance - def __init__(self, config: Dict[str, Any], standalone: bool = False) -> None: + def __init__(self, config: Config, standalone: bool = False) -> None: ApiServer._config = config if self.__initialized and (standalone or self._standalone): return diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 05599074c..6602cdd35 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -16,7 +16,7 @@ from pandas import DataFrame, NaT from freqtrade import __version__ from freqtrade.configuration.timerange import TimeRange -from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT +from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT, Config from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_max_drawdown from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, @@ -58,7 +58,7 @@ class RPCException(Exception): class RPCHandler: - def __init__(self, rpc: 'RPC', config: Dict[str, Any]) -> None: + def __init__(self, rpc: 'RPC', config: Config) -> None: """ Initializes RPCHandlers :param rpc: instance of RPC Helper class @@ -66,7 +66,7 @@ class RPCHandler: :return: None """ self._rpc = rpc - self._config: Dict[str, Any] = config + self._config: Config = config @property def name(self) -> str: @@ -96,7 +96,7 @@ class RPC: :return: None """ self._freqtrade = freqtrade - self._config: Dict[str, Any] = freqtrade.config + self._config: Config = freqtrade.config if self._config.get('fiat_display_currency'): self._fiat_converter = CryptoToFiatConverter() diff --git a/freqtrade/strategy/hyper.py b/freqtrade/strategy/hyper.py index 47377f238..6f62c9d3d 100644 --- a/freqtrade/strategy/hyper.py +++ b/freqtrade/strategy/hyper.py @@ -6,6 +6,7 @@ import logging from pathlib import Path from typing import Any, Dict, Iterator, List, Tuple, Type, Union +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.misc import deep_merge_dicts, json_load from freqtrade.optimize.hyperopt_tools import HyperoptTools @@ -21,7 +22,7 @@ class HyperStrategyMixin: strategy logic. """ - def __init__(self, config: Dict[str, Any], *args, **kwargs): + def __init__(self, config: Config, *args, **kwargs): """ Initialize hyperoptable strategy mixin. """ diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 41115c72e..0a9ecc638 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -7,7 +7,7 @@ from typing import Dict, NamedTuple, Optional import arrow -from freqtrade.constants import UNLIMITED_STAKE_AMOUNT +from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config from freqtrade.enums import RunMode, TradingMode from freqtrade.exceptions import DependencyException from freqtrade.exchange import Exchange @@ -35,7 +35,7 @@ class PositionWallet(NamedTuple): class Wallets: - def __init__(self, config: dict, exchange: Exchange, log: bool = True) -> None: + def __init__(self, config: Config, exchange: Exchange, log: bool = True) -> None: self._config = config self._log = log self._exchange = exchange diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 66f718af0..dea0acc44 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -9,8 +9,9 @@ from typing import Any, Callable, Dict, Optional import sdnotify -from freqtrade import __version__, constants +from freqtrade import __version__ from freqtrade.configuration import Configuration +from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config from freqtrade.enums import State from freqtrade.exceptions import OperationalException, TemporaryError from freqtrade.freqtradebot import FreqtradeBot @@ -24,7 +25,7 @@ class Worker: Freqtradebot worker class """ - def __init__(self, args: Dict[str, Any], config: Dict[str, Any] = None) -> None: + def __init__(self, args: Dict[str, Any], config: Config = None) -> None: """ Init all variables and objects the bot needs to work """ @@ -53,7 +54,7 @@ class Worker: internals_config = self._config.get('internals', {}) self._throttle_secs = internals_config.get('process_throttle_secs', - constants.PROCESS_THROTTLE_SECS) + PROCESS_THROTTLE_SECS) self._heartbeat_interval = internals_config.get('heartbeat_interval', 60) self._sd_notify = sdnotify.SystemdNotifier() if \ @@ -151,8 +152,8 @@ class Worker: try: self.freqtrade.process() except TemporaryError as error: - logger.warning(f"Error: {error}, retrying in {constants.RETRY_TIMEOUT} seconds...") - time.sleep(constants.RETRY_TIMEOUT) + logger.warning(f"Error: {error}, retrying in {RETRY_TIMEOUT} seconds...") + time.sleep(RETRY_TIMEOUT) except OperationalException: tb = traceback.format_exc() hint = 'Issue `/start` if you think it is safe to restart.' From 994c1c5ea09b77a046c696eb03496389694aa378 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 13:31:52 +0200 Subject: [PATCH 121/199] use Config typing in more places --- docs/advanced-hyperopt.md | 3 ++- freqtrade/data/converter.py | 4 ++-- .../optimize/hyperopt_loss/hyperopt_loss_calmar.py | 3 ++- .../hyperopt_loss_max_drawdown_relative.py | 5 ++--- freqtrade/optimize/hyperopt_loss_interface.py | 4 +++- freqtrade/optimize/hyperopt_tools.py | 10 +++++----- freqtrade/optimize/optimize_reports.py | 7 ++++--- freqtrade/plot/plotting.py | 7 ++++--- freqtrade/plugins/pairlist/AgeFilter.py | 4 ++-- freqtrade/plugins/pairlist/IPairList.py | 3 ++- freqtrade/plugins/pairlist/OffsetFilter.py | 3 ++- freqtrade/plugins/pairlist/PerformanceFilter.py | 3 ++- freqtrade/plugins/pairlist/PrecisionFilter.py | 3 ++- freqtrade/plugins/pairlist/PriceFilter.py | 3 ++- freqtrade/plugins/pairlist/ShuffleFilter.py | 3 ++- freqtrade/plugins/pairlist/pairlist_helpers.py | 6 ++++-- freqtrade/plugins/pairlist/rangestabilityfilter.py | 4 ++-- freqtrade/plugins/pairlistmanager.py | 4 ++-- freqtrade/plugins/protectionmanager.py | 4 ++-- freqtrade/resolvers/exchange_resolver.py | 3 ++- freqtrade/resolvers/freqaimodel_resolver.py | 5 ++--- freqtrade/resolvers/hyperopt_resolver.py | 5 ++--- freqtrade/resolvers/iresolver.py | 5 +++-- freqtrade/resolvers/pairlist_resolver.py | 3 ++- freqtrade/resolvers/protection_resolver.py | 4 +++- freqtrade/resolvers/strategy_resolver.py | 11 +++++------ freqtrade/rpc/discord.py | 4 ++-- freqtrade/rpc/rpc_manager.py | 3 ++- freqtrade/rpc/telegram.py | 4 ++-- freqtrade/rpc/webhook.py | 3 ++- freqtrade/strategy/interface.py | 4 ++-- freqtrade/templates/sample_hyperopt_loss.py | 3 ++- 32 files changed, 79 insertions(+), 61 deletions(-) diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index 8a1ebaff3..9933628d1 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -17,6 +17,7 @@ from typing import Any, Dict from pandas import DataFrame +from freqtrade.constants import Config from freqtrade.optimize.hyperopt import IHyperOptLoss TARGET_TRADES = 600 @@ -31,7 +32,7 @@ class SuperDuperHyperOptLoss(IHyperOptLoss): @staticmethod def hyperopt_loss_function(results: DataFrame, trade_count: int, min_date: datetime, max_date: datetime, - config: Dict, processed: Dict[str, DataFrame], + config: Config, processed: Dict[str, DataFrame], backtest_stats: Dict[str, Any], *args, **kwargs) -> float: """ diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index bdd010af3..bf19c1310 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -5,7 +5,7 @@ import itertools import logging from datetime import datetime, timezone from operator import itemgetter -from typing import Any, Dict, List +from typing import Dict, List import pandas as pd from pandas import DataFrame, to_datetime @@ -237,7 +237,7 @@ def trades_to_ohlcv(trades: TradeList, timeframe: str) -> DataFrame: return df_new.loc[:, DEFAULT_DATAFRAME_COLUMNS] -def convert_trades_format(config: Dict[str, Any], convert_from: str, convert_to: str, erase: bool): +def convert_trades_format(config: Config, convert_from: str, convert_to: str, erase: bool): """ Convert trades from one format to another format. :param config: Config dictionary diff --git a/freqtrade/optimize/hyperopt_loss/hyperopt_loss_calmar.py b/freqtrade/optimize/hyperopt_loss/hyperopt_loss_calmar.py index ea6c151e5..2b591824f 100644 --- a/freqtrade/optimize/hyperopt_loss/hyperopt_loss_calmar.py +++ b/freqtrade/optimize/hyperopt_loss/hyperopt_loss_calmar.py @@ -10,6 +10,7 @@ from typing import Any, Dict from pandas import DataFrame +from freqtrade.constants import Config from freqtrade.data.metrics import calculate_max_drawdown from freqtrade.optimize.hyperopt import IHyperOptLoss @@ -27,7 +28,7 @@ class CalmarHyperOptLoss(IHyperOptLoss): trade_count: int, min_date: datetime, max_date: datetime, - config: Dict, + config: Config, processed: Dict[str, DataFrame], backtest_stats: Dict[str, Any], *args, diff --git a/freqtrade/optimize/hyperopt_loss/hyperopt_loss_max_drawdown_relative.py b/freqtrade/optimize/hyperopt_loss/hyperopt_loss_max_drawdown_relative.py index 3182afb47..669d12ddf 100644 --- a/freqtrade/optimize/hyperopt_loss/hyperopt_loss_max_drawdown_relative.py +++ b/freqtrade/optimize/hyperopt_loss/hyperopt_loss_max_drawdown_relative.py @@ -4,10 +4,9 @@ MaxDrawDownRelativeHyperOptLoss This module defines the alternative HyperOptLoss class which can be used for Hyperoptimization. """ -from typing import Dict - from pandas import DataFrame +from freqtrade.constants import Config from freqtrade.data.metrics import calculate_underwater from freqtrade.optimize.hyperopt import IHyperOptLoss @@ -22,7 +21,7 @@ class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss): """ @staticmethod - def hyperopt_loss_function(results: DataFrame, config: Dict, + def hyperopt_loss_function(results: DataFrame, config: Config, *args, **kwargs) -> float: """ diff --git a/freqtrade/optimize/hyperopt_loss_interface.py b/freqtrade/optimize/hyperopt_loss_interface.py index 8366dcc4f..d7b30dfd3 100644 --- a/freqtrade/optimize/hyperopt_loss_interface.py +++ b/freqtrade/optimize/hyperopt_loss_interface.py @@ -9,6 +9,8 @@ from typing import Any, Dict from pandas import DataFrame +from freqtrade.constants import Config + class IHyperOptLoss(ABC): """ @@ -21,7 +23,7 @@ class IHyperOptLoss(ABC): @abstractmethod def hyperopt_loss_function(*, results: DataFrame, trade_count: int, min_date: datetime, max_date: datetime, - config: Dict, processed: Dict[str, DataFrame], + config: Config, processed: Dict[str, DataFrame], backtest_stats: Dict[str, Any], **kwargs) -> float: """ diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index d1f776e3d..65bdc4db5 100755 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -81,7 +81,7 @@ class HyperoptTools(): ) @staticmethod - def try_export_params(config: Dict[str, Any], strategy_name: str, params: Dict): + def try_export_params(config: Config, strategy_name: str, params: Dict): if params.get(FTHYPT_FILEVERSION, 1) >= 2 and not config.get('disableparamexport', False): # Export parameters ... fn = HyperoptTools.get_strategy_filename(config, strategy_name) @@ -91,7 +91,7 @@ class HyperoptTools(): logger.warning("Strategy not found, not exporting parameter file.") @staticmethod - def has_space(config: Dict[str, Any], space: str) -> bool: + def has_space(config: Config, space: str) -> bool: """ Tell if the space value is contained in the configuration """ @@ -131,7 +131,7 @@ class HyperoptTools(): return False @staticmethod - def load_filtered_results(results_file: Path, config: Dict[str, Any]) -> Tuple[List, int]: + def load_filtered_results(results_file: Path, config: Config) -> Tuple[List, int]: filteroptions = { 'only_best': config.get('hyperopt_list_best', False), 'only_profitable': config.get('hyperopt_list_profitable', False), @@ -346,7 +346,7 @@ class HyperoptTools(): return trials @staticmethod - def get_result_table(config: dict, results: list, total_epochs: int, highlight_best: bool, + def get_result_table(config: Config, results: list, total_epochs: int, highlight_best: bool, print_colorized: bool, remove_header: int) -> str: """ Log result table @@ -444,7 +444,7 @@ class HyperoptTools(): return table @staticmethod - def export_csv_file(config: dict, results: list, csv_file: str) -> None: + def export_csv_file(config: Config, results: list, csv_file: str) -> None: """ Log result to csv-file """ diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index fa6c3f161..6c4dbcfef 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -7,7 +7,8 @@ from typing import Any, Dict, List, Union from pandas import DataFrame, to_datetime from tabulate import tabulate -from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT +from freqtrade.constants import (DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT, + Config) from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change, calculate_max_drawdown) from freqtrade.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value @@ -898,7 +899,7 @@ def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: print() -def show_backtest_results(config: Dict, backtest_stats: Dict): +def show_backtest_results(config: Config, backtest_stats: Dict): stake_currency = config['stake_currency'] for strategy, results in backtest_stats['strategy'].items(): @@ -918,7 +919,7 @@ def show_backtest_results(config: Dict, backtest_stats: Dict): print('\nFor more details, please look at the detail tables above') -def show_sorted_pairlist(config: Dict, backtest_stats: Dict): +def show_sorted_pairlist(config: Config, backtest_stats: Dict): if config.get('backtest_show_pair_list', False): for strategy, results in backtest_stats['strategy'].items(): print(f"Pairs for Strategy {strategy}: \n[") diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index f8e95300a..9c8787242 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -1,10 +1,11 @@ import logging from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional import pandas as pd from freqtrade.configuration import TimeRange +from freqtrade.constants import Config from freqtrade.data.btanalysis import (analyze_trade_parallelism, extract_trades_of_period, load_trades) from freqtrade.data.converter import trim_dataframe @@ -618,7 +619,7 @@ def store_plot_file(fig, filename: str, directory: Path, auto_open: bool = False logger.info(f"Stored plot as {_filename}") -def load_and_plot_trades(config: Dict[str, Any]): +def load_and_plot_trades(config: Config): """ From configuration provided - Initializes plot-script @@ -666,7 +667,7 @@ def load_and_plot_trades(config: Dict[str, Any]): logger.info('End of plotting process. %s plots generated', pair_counter) -def plot_profit(config: Dict[str, Any]) -> None: +def plot_profit(config: Config) -> None: """ Plots the total profit for all pairs. Note, the profit calculation isn't realistic. diff --git a/freqtrade/plugins/pairlist/AgeFilter.py b/freqtrade/plugins/pairlist/AgeFilter.py index 13c992c87..70638936a 100644 --- a/freqtrade/plugins/pairlist/AgeFilter.py +++ b/freqtrade/plugins/pairlist/AgeFilter.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional import arrow from pandas import DataFrame -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class AgeFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index 0155f918b..c02ba5ef5 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod, abstractproperty from copy import deepcopy from typing import Any, Dict, List +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange import Exchange, market_is_active from freqtrade.mixins import LoggingMixin @@ -17,7 +18,7 @@ logger = logging.getLogger(__name__) class IPairList(LoggingMixin, ABC): def __init__(self, exchange: Exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: """ :param exchange: Exchange instance diff --git a/freqtrade/plugins/pairlist/OffsetFilter.py b/freqtrade/plugins/pairlist/OffsetFilter.py index e0f8414ef..4200443c7 100644 --- a/freqtrade/plugins/pairlist/OffsetFilter.py +++ b/freqtrade/plugins/pairlist/OffsetFilter.py @@ -3,6 +3,7 @@ Offset pair list filter """ import logging from typing import Any, Dict, List +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) class OffsetFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index 8e0b407c3..4ba96231e 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -5,6 +5,7 @@ import logging from typing import Any, Dict, List import pandas as pd +from freqtrade.constants import Config from freqtrade.persistence import Trade from freqtrade.plugins.pairlist.IPairList import IPairList @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) class PerformanceFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/PrecisionFilter.py b/freqtrade/plugins/pairlist/PrecisionFilter.py index 61150f03d..7b8b1a30d 100644 --- a/freqtrade/plugins/pairlist/PrecisionFilter.py +++ b/freqtrade/plugins/pairlist/PrecisionFilter.py @@ -3,6 +3,7 @@ Precision pair list filter """ import logging from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) class PrecisionFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/PriceFilter.py b/freqtrade/plugins/pairlist/PriceFilter.py index 009789eaf..e38090e56 100644 --- a/freqtrade/plugins/pairlist/PriceFilter.py +++ b/freqtrade/plugins/pairlist/PriceFilter.py @@ -3,6 +3,7 @@ Price pair list filter """ import logging from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) class PriceFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index 663bba49b..098108949 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -4,6 +4,7 @@ Shuffle pair list filter import logging import random from typing import Any, Dict, List +from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.plugins.pairlist.IPairList import IPairList @@ -15,7 +16,7 @@ logger = logging.getLogger(__name__) class ShuffleFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlist/pairlist_helpers.py b/freqtrade/plugins/pairlist/pairlist_helpers.py index 0cec734fb..9ef3e4614 100644 --- a/freqtrade/plugins/pairlist/pairlist_helpers.py +++ b/freqtrade/plugins/pairlist/pairlist_helpers.py @@ -1,5 +1,7 @@ import re -from typing import Any, Dict, List +from typing import List + +from freqtrade.constants import Config def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], @@ -42,7 +44,7 @@ def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], return result -def dynamic_expand_pairlist(config: Dict[str, Any], markets: List[str]) -> List[str]: +def dynamic_expand_pairlist(config: Config, markets: List[str]) -> List[str]: expanded_pairs = expand_pairlist(config['pairs'], markets) if config.get('freqai', {}).get('enabled', False): corr_pairlist = config['freqai']['feature_parameters']['include_corr_pairlist'] diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index f3e7bc0d6..0bc2cdb47 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -9,7 +9,7 @@ import arrow from cachetools import TTLCache from pandas import DataFrame -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class RangeStabilityFilter(IPairList): def __init__(self, exchange, pairlistmanager, - config: Dict[str, Any], pairlistconfig: Dict[str, Any], + config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) diff --git a/freqtrade/plugins/pairlistmanager.py b/freqtrade/plugins/pairlistmanager.py index 3ddad4a5e..e01abb297 100644 --- a/freqtrade/plugins/pairlistmanager.py +++ b/freqtrade/plugins/pairlistmanager.py @@ -7,7 +7,7 @@ from typing import Dict, List from cachetools import TTLCache, cached -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.mixins import LoggingMixin @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class PairListManager(LoggingMixin): - def __init__(self, exchange, config: dict) -> None: + def __init__(self, exchange, config: Config) -> None: self._exchange = exchange self._config = config self._whitelist = self._config['exchange'].get('pair_whitelist') diff --git a/freqtrade/plugins/protectionmanager.py b/freqtrade/plugins/protectionmanager.py index d33294fa7..54432e677 100644 --- a/freqtrade/plugins/protectionmanager.py +++ b/freqtrade/plugins/protectionmanager.py @@ -5,7 +5,7 @@ import logging from datetime import datetime, timezone from typing import Dict, List, Optional -from freqtrade.constants import LongShort +from freqtrade.constants import Config, LongShort from freqtrade.persistence import PairLocks from freqtrade.persistence.models import PairLock from freqtrade.plugins.protections import IProtection @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) class ProtectionManager(): - def __init__(self, config: Dict, protections: List) -> None: + def __init__(self, config: Config, protections: List) -> None: self._config = config self._protection_handlers: List[IProtection] = [] diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index a2f572ff2..d7a1a22e2 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -2,6 +2,7 @@ This module loads custom exchanges """ import logging +from freqtrade.constants import Config import freqtrade.exchange as exchanges from freqtrade.exchange import MAP_EXCHANGE_CHILDCLASS, Exchange @@ -18,7 +19,7 @@ class ExchangeResolver(IResolver): object_type = Exchange @staticmethod - def load_exchange(exchange_name: str, config: dict, validate: bool = True, + def load_exchange(exchange_name: str, config: Config, validate: bool = True, load_leverage_tiers: bool = False) -> Exchange: """ Load the custom class from config parameter diff --git a/freqtrade/resolvers/freqaimodel_resolver.py b/freqtrade/resolvers/freqaimodel_resolver.py index 5a847bb2b..aa5228ca1 100644 --- a/freqtrade/resolvers/freqaimodel_resolver.py +++ b/freqtrade/resolvers/freqaimodel_resolver.py @@ -5,9 +5,8 @@ This module load a custom model for freqai """ import logging from pathlib import Path -from typing import Dict -from freqtrade.constants import USERPATH_FREQAIMODELS +from freqtrade.constants import USERPATH_FREQAIMODELS, Config from freqtrade.exceptions import OperationalException from freqtrade.freqai.freqai_interface import IFreqaiModel from freqtrade.resolvers import IResolver @@ -29,7 +28,7 @@ class FreqaiModelResolver(IResolver): ) @staticmethod - def load_freqaimodel(config: Dict) -> IFreqaiModel: + def load_freqaimodel(config: Config) -> IFreqaiModel: """ Load the custom class from config parameter :param config: configuration dictionary diff --git a/freqtrade/resolvers/hyperopt_resolver.py b/freqtrade/resolvers/hyperopt_resolver.py index bcfe5e1d8..d050c6fbc 100644 --- a/freqtrade/resolvers/hyperopt_resolver.py +++ b/freqtrade/resolvers/hyperopt_resolver.py @@ -5,9 +5,8 @@ This module load custom hyperopt """ import logging from pathlib import Path -from typing import Dict -from freqtrade.constants import HYPEROPT_LOSS_BUILTIN, USERPATH_HYPEROPTS +from freqtrade.constants import HYPEROPT_LOSS_BUILTIN, USERPATH_HYPEROPTS, Config from freqtrade.exceptions import OperationalException from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss from freqtrade.resolvers import IResolver @@ -26,7 +25,7 @@ class HyperOptLossResolver(IResolver): initial_search_path = Path(__file__).parent.parent.joinpath('optimize/hyperopt_loss').resolve() @staticmethod - def load_hyperoptloss(config: Dict) -> IHyperOptLoss: + def load_hyperoptloss(config: Config) -> IHyperOptLoss: """ Load the custom class from config parameter :param config: configuration dictionary diff --git a/freqtrade/resolvers/iresolver.py b/freqtrade/resolvers/iresolver.py index b99e7a94b..9682e1c2b 100644 --- a/freqtrade/resolvers/iresolver.py +++ b/freqtrade/resolvers/iresolver.py @@ -10,6 +10,7 @@ import sys from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException @@ -43,7 +44,7 @@ class IResolver: initial_search_path: Optional[Path] @classmethod - def build_search_paths(cls, config: Dict[str, Any], user_subdir: Optional[str] = None, + def build_search_paths(cls, config: Config, user_subdir: Optional[str] = None, extra_dirs: List[str] = []) -> List[Path]: abs_paths: List[Path] = [] @@ -153,7 +154,7 @@ class IResolver: return None @classmethod - def load_object(cls, object_name: str, config: dict, *, kwargs: dict, + def load_object(cls, object_name: str, config: Config, *, kwargs: dict, extra_dir: Optional[str] = None) -> Any: """ Search and loads the specified object as configured in hte child class. diff --git a/freqtrade/resolvers/pairlist_resolver.py b/freqtrade/resolvers/pairlist_resolver.py index 72a3cc1dd..f492bcb54 100644 --- a/freqtrade/resolvers/pairlist_resolver.py +++ b/freqtrade/resolvers/pairlist_resolver.py @@ -6,6 +6,7 @@ This module load custom pairlists import logging from pathlib import Path +from freqtrade.constants import Config from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.resolvers import IResolver @@ -24,7 +25,7 @@ class PairListResolver(IResolver): @staticmethod def load_pairlist(pairlist_name: str, exchange, pairlistmanager, - config: dict, pairlistconfig: dict, pairlist_pos: int) -> IPairList: + config: Config, pairlistconfig: dict, pairlist_pos: int) -> IPairList: """ Load the pairlist with pairlist_name :param pairlist_name: Classname of the pairlist diff --git a/freqtrade/resolvers/protection_resolver.py b/freqtrade/resolvers/protection_resolver.py index c54ae1011..11cd6f224 100644 --- a/freqtrade/resolvers/protection_resolver.py +++ b/freqtrade/resolvers/protection_resolver.py @@ -5,6 +5,7 @@ import logging from pathlib import Path from typing import Dict +from freqtrade.constants import Config from freqtrade.plugins.protections import IProtection from freqtrade.resolvers import IResolver @@ -22,7 +23,8 @@ class ProtectionResolver(IResolver): initial_search_path = Path(__file__).parent.parent.joinpath('plugins/protections').resolve() @staticmethod - def load_protection(protection_name: str, config: Dict, protection_config: Dict) -> IProtection: + def load_protection(protection_name: str, config: Config, + protection_config: Dict) -> IProtection: """ Load the protection with protection_name :param protection_name: Classname of the pairlist diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 8b01980ce..c574246ac 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -9,10 +9,10 @@ from base64 import urlsafe_b64decode from inspect import getfullargspec from os import walk from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, List, Optional from freqtrade.configuration.config_validation import validate_migrated_strategy_settings -from freqtrade.constants import REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES, USERPATH_STRATEGIES +from freqtrade.constants import REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES, USERPATH_STRATEGIES, Config from freqtrade.enums import TradingMode from freqtrade.exceptions import OperationalException from freqtrade.resolvers import IResolver @@ -32,7 +32,7 @@ class StrategyResolver(IResolver): initial_search_path = None @staticmethod - def load_strategy(config: Dict[str, Any] = None) -> IStrategy: + def load_strategy(config: Config = None) -> IStrategy: """ Load the custom class from config parameter :param config: configuration dictionary or None @@ -91,8 +91,7 @@ class StrategyResolver(IResolver): return strategy @staticmethod - def _override_attribute_helper(strategy, config: Dict[str, Any], - attribute: str, default: Any): + def _override_attribute_helper(strategy, config: Config, attribute: str, default: Any): """ Override attributes in the strategy. Prevalence: @@ -215,7 +214,7 @@ class StrategyResolver(IResolver): @staticmethod def _load_strategy(strategy_name: str, - config: dict, extra_dir: Optional[str] = None) -> IStrategy: + config: Config, extra_dir: Optional[str] = None) -> IStrategy: """ Search and loads the specified strategy. :param strategy_name: name of the module to import diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 85acfae4e..9efe6f427 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -1,6 +1,6 @@ import logging -from typing import Any, Dict +from freqtrade.constants import Config from freqtrade.enums import RPCMessageType from freqtrade.rpc import RPC from freqtrade.rpc.webhook import Webhook @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) class Discord(Webhook): - def __init__(self, rpc: 'RPC', config: Dict[str, Any]): + def __init__(self, rpc: 'RPC', config: Config): # super().__init__(rpc, config) self.rpc = rpc self.config = config diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3ccf23228..3b60077ad 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -5,6 +5,7 @@ import logging from collections import deque from typing import Any, Dict, List +from freqtrade.constants import Config from freqtrade.enums import RPCMessageType from freqtrade.rpc import RPC, RPCHandler @@ -89,7 +90,7 @@ class RPCManager: 'msg': msg, }) - def startup_messages(self, config: Dict[str, Any], pairlist, protections) -> None: + def startup_messages(self, config: Config, pairlist, protections) -> None: if config['dry_run']: self.send_msg({ 'type': RPCMessageType.WARNING, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 4a759f6ec..c40bdb963 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -24,7 +24,7 @@ from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, from telegram.utils.helpers import escape_markdown from freqtrade.__init__ import __version__ -from freqtrade.constants import DUST_PER_COIN +from freqtrade.constants import DUST_PER_COIN, Config from freqtrade.enums import RPCMessageType, SignalDirection, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.misc import chunks, plural, round_coin_value @@ -88,7 +88,7 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: class Telegram(RPCHandler): """ This class handles all telegram communication """ - def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: + def __init__(self, rpc: RPC, config: Config) -> None: """ Init the Telegram call, and init the super class RPCHandler :param rpc: instance of RPC Helper class diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 1b39a29b7..6109e80bc 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -7,6 +7,7 @@ from typing import Any, Dict from requests import RequestException, post +from freqtrade.constants import Config from freqtrade.enums import RPCMessageType from freqtrade.rpc import RPC, RPCHandler @@ -19,7 +20,7 @@ logger.debug('Included module rpc.webhook ...') class Webhook(RPCHandler): """ This class handles all webhook communication """ - def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: + def __init__(self, rpc: RPC, config: Config) -> None: """ Init the Webhook class, and init the super class RPCHandler :param rpc: instance of RPC Helper class diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 93988ac48..5e765e85b 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -10,7 +10,7 @@ from typing import Dict, List, Optional, Tuple, Union import arrow from pandas import DataFrame -from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RunMode, SignalDirection, SignalTagType, SignalType, TradingMode) @@ -118,7 +118,7 @@ class IStrategy(ABC, HyperStrategyMixin): # Definition of plot_config. See plotting documentation for more details. plot_config: Dict = {} - def __init__(self, config: dict) -> None: + def __init__(self, config: Config) -> None: self.config = config # Dict to determine if analysis is necessary self._last_candle_seen_per_pair: Dict[str, datetime] = {} diff --git a/freqtrade/templates/sample_hyperopt_loss.py b/freqtrade/templates/sample_hyperopt_loss.py index 343349508..5eab92a0c 100644 --- a/freqtrade/templates/sample_hyperopt_loss.py +++ b/freqtrade/templates/sample_hyperopt_loss.py @@ -4,6 +4,7 @@ from typing import Dict from pandas import DataFrame +from freqtrade.constants import Config from freqtrade.optimize.hyperopt import IHyperOptLoss @@ -36,7 +37,7 @@ class SampleHyperOptLoss(IHyperOptLoss): @staticmethod def hyperopt_loss_function(results: DataFrame, trade_count: int, min_date: datetime, max_date: datetime, - config: Dict, processed: Dict[str, DataFrame], + config: Config, processed: Dict[str, DataFrame], *args, **kwargs) -> float: """ Objective function, returns smaller number for better results From 95457d23cae87da5fe11c4f053bb0e1c2b399541 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 13:59:30 +0200 Subject: [PATCH 122/199] escape freqai-specific characters from file naming --- freqtrade/freqai/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 57bc17cdf..19a3098cc 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -190,5 +190,5 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, fig = add_feature_trace(fig, fi_df_top, 1) fig = add_feature_trace(fig, fi_df_worst, 2) fig.update_layout(title_text=f"Best and worst features by importance {pair}") - + label = label.replace('&', '').replace('%', '') # escape two FreqAI specific characters store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path) From eaa43337d2d7c13eeeb8c809d212e047f5935470 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 17:00:55 +0200 Subject: [PATCH 123/199] improve train queue system, ensure crash resilience in train queue. --- freqtrade/freqai/data_drawer.py | 14 +--- freqtrade/freqai/freqai_interface.py | 71 +++++++++++++------- freqtrade/templates/FreqaiExampleStrategy.py | 2 +- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 1c091f1be..67daa626d 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -27,9 +27,7 @@ logger = logging.getLogger(__name__) class pair_info(TypedDict): model_filename: str - first: bool trained_timestamp: int - priority: int data_path: str extras: dict @@ -91,7 +89,7 @@ class FreqaiDataDrawer: self.old_DBSCAN_eps: Dict[str, float] = {} self.empty_pair_dict: pair_info = { "model_filename": "", "trained_timestamp": 0, - "priority": 1, "first": True, "data_path": "", "extras": {}} + "data_path": "", "extras": {}} def load_drawer_from_disk(self): """ @@ -216,7 +214,6 @@ class FreqaiDataDrawer: self.pair_dict[pair] = self.empty_pair_dict.copy() model_filename = "" trained_timestamp = 0 - self.pair_dict[pair]["priority"] = len(self.pair_dict) if not data_path_set and self.follow_mode: logger.warning( @@ -236,18 +233,9 @@ class FreqaiDataDrawer: return else: self.pair_dict[metadata["pair"]] = self.empty_pair_dict.copy() - self.pair_dict[metadata["pair"]]["priority"] = len(self.pair_dict) return - def pair_to_end_of_training_queue(self, pair: str) -> None: - # march all pairs up in the queue - with self.pair_dict_lock: - for p in self.pair_dict: - self.pair_dict[p]["priority"] -= 1 - # send pair to end of queue - self.pair_dict[pair]["priority"] = len(self.pair_dict) - def set_initial_return_values(self, pair: str, pred_df: DataFrame) -> None: """ Set the initial return values to the historical predictions dataframe. This avoids needing diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 78931bed4..85768fcf8 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -3,6 +3,7 @@ import shutil import threading import time from abc import ABC, abstractmethod +from collections import deque from datetime import datetime, timezone from pathlib import Path from threading import Lock @@ -80,6 +81,7 @@ class IFreqaiModel(ABC): self.pair_it = 0 self.pair_it_train = 0 self.total_pairs = len(self.config.get("exchange", {}).get("pair_whitelist")) + self.train_queue = self._set_train_queue() self.last_trade_database_summary: DataFrame = {} self.current_trade_database_summary: DataFrame = {} self.analysis_lock = Lock() @@ -180,30 +182,36 @@ class IFreqaiModel(ABC): :param strategy: IStrategy = The user defined strategy class """ while not self._stop_event.is_set(): - time.sleep(1) - for pair in self.config.get("exchange", {}).get("pair_whitelist"): + pair = self.train_queue[0] - (_, trained_timestamp, _) = self.dd.get_pair_dict_info(pair) + # ensure pair is avaialble in dp + if pair not in strategy.dp.current_whitelist(): + self.train_queue.popleft() + logger.warning(f'{pair} not in current whitelist, removing from train queue.') + continue - if self.dd.pair_dict[pair]["priority"] != 1: - continue - dk = FreqaiDataKitchen(self.config, self.live, pair) - dk.set_paths(pair, trained_timestamp) - ( - retrain, - new_trained_timerange, - data_load_timerange, - ) = dk.check_if_new_training_required(trained_timestamp) - dk.set_paths(pair, new_trained_timerange.stopts) + (_, trained_timestamp, _) = self.dd.get_pair_dict_info(pair) - if retrain: - self.train_timer('start') - self.extract_data_and_train_model( - new_trained_timerange, pair, strategy, dk, data_load_timerange - ) - self.train_timer('stop') + dk = FreqaiDataKitchen(self.config, self.live, pair) + dk.set_paths(pair, trained_timestamp) + ( + retrain, + new_trained_timerange, + data_load_timerange, + ) = dk.check_if_new_training_required(trained_timestamp) + dk.set_paths(pair, new_trained_timerange.stopts) - self.dd.save_historic_predictions_to_disk() + if retrain: + self.train_timer('start') + self.extract_data_and_train_model( + new_trained_timerange, pair, strategy, dk, data_load_timerange + ) + self.train_timer('stop') + + # only rotate the queue after the first has been trained. + self.train_queue.rotate(-1) + + self.dd.save_historic_predictions_to_disk() def start_backtesting( self, dataframe: DataFrame, metadata: dict, dk: FreqaiDataKitchen @@ -557,9 +565,6 @@ class IFreqaiModel(ABC): self.dd.pair_dict[pair]["trained_timestamp"] = new_trained_timerange.stopts dk.set_new_model_names(pair, new_trained_timerange) - self.dd.pair_dict[pair]["first"] = False - if self.dd.pair_dict[pair]["priority"] == 1 and self.scanning: - self.dd.pair_to_end_of_training_queue(pair) self.dd.save_data(model, pair, dk) if self.freqai_info.get("purge_old_models", False): @@ -685,6 +690,26 @@ class IFreqaiModel(ABC): return init_model + def _set_train_queue(self): + """ + Sets train queue from existing train timestamps if they exist + otherwise it sets the train queue based on the provided whitelist. + """ + current_pairlist = self.config.get("exchange", {}).get("pair_whitelist") + if not self.dd.pair_dict: + logger.info('Set fresh train queue from whitelist.') + return deque(current_pairlist) + + best_queue = deque() + + pair_dict_sorted = sorted(self.dd.pair_dict.items(), + key=lambda k: k[1]['trained_timestamp']) + for pair in pair_dict_sorted: + if pair[0] in current_pairlist: + best_queue.appendleft(pair[0]) + logger.info('Set existing queue from trained timestamps.') + return best_queue + # Following methods which are overridden by user made prediction models. # See freqai/prediction_models/CatboostPredictionModel.py for an example. diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 15b2c6c83..0498ea564 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -45,7 +45,7 @@ class FreqaiExampleStrategy(IStrategy): std_dev_multiplier_buy = CategoricalParameter( [0.75, 1, 1.25, 1.5, 1.75], default=1.25, space="buy", optimize=True) std_dev_multiplier_sell = CategoricalParameter( - [0.1, 0.25, 0.4], space="sell", default=0.2, optimize=True) + [0.75, 1, 1.25, 1.5, 1.75], space="sell", default=1.25, optimize=True) def informative_pairs(self): whitelist_pairs = self.dp.current_whitelist() From 470d5d84058e0b000e659bfbd11b7a38a31a8c2b Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 18 Sep 2022 17:08:07 +0200 Subject: [PATCH 124/199] ensure full new pairlist is in the queue --- freqtrade/freqai/freqai_interface.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 85768fcf8..2cec96059 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -707,6 +707,10 @@ class IFreqaiModel(ABC): for pair in pair_dict_sorted: if pair[0] in current_pairlist: best_queue.appendleft(pair[0]) + for pair in current_pairlist: + if pair not in best_queue: + best_queue.appendleft(pair) + logger.info('Set existing queue from trained timestamps.') return best_queue From 584b2381d1ac09ae43f172d21b937c4d199e8958 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 19:36:11 +0200 Subject: [PATCH 125/199] Fix Imports --- freqtrade/plugins/pairlist/OffsetFilter.py | 2 +- freqtrade/plugins/pairlist/PerformanceFilter.py | 2 +- freqtrade/plugins/pairlist/PrecisionFilter.py | 2 +- freqtrade/plugins/pairlist/PriceFilter.py | 2 +- freqtrade/plugins/pairlist/ShuffleFilter.py | 2 +- freqtrade/resolvers/exchange_resolver.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/freqtrade/plugins/pairlist/OffsetFilter.py b/freqtrade/plugins/pairlist/OffsetFilter.py index 4200443c7..149befdeb 100644 --- a/freqtrade/plugins/pairlist/OffsetFilter.py +++ b/freqtrade/plugins/pairlist/OffsetFilter.py @@ -3,8 +3,8 @@ Offset pair list filter """ import logging from typing import Any, Dict, List -from freqtrade.constants import Config +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index 4ba96231e..c29b4f337 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -5,8 +5,8 @@ import logging from typing import Any, Dict, List import pandas as pd -from freqtrade.constants import Config +from freqtrade.constants import Config from freqtrade.persistence import Trade from freqtrade.plugins.pairlist.IPairList import IPairList diff --git a/freqtrade/plugins/pairlist/PrecisionFilter.py b/freqtrade/plugins/pairlist/PrecisionFilter.py index 7b8b1a30d..8f1c9b839 100644 --- a/freqtrade/plugins/pairlist/PrecisionFilter.py +++ b/freqtrade/plugins/pairlist/PrecisionFilter.py @@ -3,8 +3,8 @@ Precision pair list filter """ import logging from typing import Any, Dict -from freqtrade.constants import Config +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList diff --git a/freqtrade/plugins/pairlist/PriceFilter.py b/freqtrade/plugins/pairlist/PriceFilter.py index e38090e56..f2952001a 100644 --- a/freqtrade/plugins/pairlist/PriceFilter.py +++ b/freqtrade/plugins/pairlist/PriceFilter.py @@ -3,8 +3,8 @@ Price pair list filter """ import logging from typing import Any, Dict -from freqtrade.constants import Config +from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index 098108949..b6b5fc3c8 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -4,8 +4,8 @@ Shuffle pair list filter import logging import random from typing import Any, Dict, List -from freqtrade.constants import Config +from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.plugins.pairlist.IPairList import IPairList diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index d7a1a22e2..54a488e8d 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -2,9 +2,9 @@ This module loads custom exchanges """ import logging -from freqtrade.constants import Config import freqtrade.exchange as exchanges +from freqtrade.constants import Config from freqtrade.exchange import MAP_EXCHANGE_CHILDCLASS, Exchange from freqtrade.resolvers import IResolver From a06eee300a552828214217a1024e72931ba444be Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 16:18:27 +0200 Subject: [PATCH 126/199] move ohlcv_get_pairs to parent class --- freqtrade/data/history/hdf5datahandler.py | 20 -------------------- freqtrade/data/history/idatahandler.py | 10 +++++++++- freqtrade/data/history/jsondatahandler.py | 20 -------------------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index 135d97c79..bc44f5f8b 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -20,26 +20,6 @@ class HDF5DataHandler(IDataHandler): _columns = DEFAULT_DATAFRAME_COLUMNS - @classmethod - def ohlcv_get_pairs(cls, datadir: Path, timeframe: str, candle_type: CandleType) -> List[str]: - """ - Returns a list of all pairs with ohlcv data available in this datadir - for the specified timeframe - :param datadir: Directory to search for ohlcv files - :param timeframe: Timeframe to search pairs for - :param candle_type: Any of the enum CandleType (must match trading mode!) - :return: List of Pairs - """ - candle = "" - if candle_type != CandleType.SPOT: - datadir = datadir.joinpath('futures') - candle = f"-{candle_type}" - - _tmp = [re.search(r'^(\S+)(?=\-' + timeframe + candle + '.h5)', p.name) - for p in datadir.glob(f"*{timeframe}{candle}.h5")] - # Check if regex found something and only return these results - return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] - def ohlcv_store( self, pair: str, timeframe: str, data: pd.DataFrame, candle_type: CandleType) -> None: """ diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 846bcc607..08e591c5c 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -61,7 +61,6 @@ class IDataHandler(ABC): ) for match in _tmp if match and len(match.groups()) > 1] @classmethod - @abstractmethod def ohlcv_get_pairs(cls, datadir: Path, timeframe: str, candle_type: CandleType) -> List[str]: """ Returns a list of all pairs with ohlcv data available in this datadir @@ -71,6 +70,15 @@ class IDataHandler(ABC): :param candle_type: Any of the enum CandleType (must match trading mode!) :return: List of Pairs """ + candle = "" + if candle_type != CandleType.SPOT: + datadir = datadir.joinpath('futures') + candle = f"-{candle_type}" + ext = cls._get_file_extension() + _tmp = [re.search(r'^(\S+)(?=\-' + timeframe + candle + f'.{ext})', p.name) + for p in datadir.glob(f"*{timeframe}{candle}.{ext}")] + # Check if regex found something and only return these results + return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] @abstractmethod def ohlcv_store( diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py index a62e5e381..a54b44601 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -23,26 +23,6 @@ class JsonDataHandler(IDataHandler): _use_zip = False _columns = DEFAULT_DATAFRAME_COLUMNS - @classmethod - def ohlcv_get_pairs(cls, datadir: Path, timeframe: str, candle_type: CandleType) -> List[str]: - """ - Returns a list of all pairs with ohlcv data available in this datadir - for the specified timeframe - :param datadir: Directory to search for ohlcv files - :param timeframe: Timeframe to search pairs for - :param candle_type: Any of the enum CandleType (must match trading mode!) - :return: List of Pairs - """ - candle = "" - if candle_type != CandleType.SPOT: - datadir = datadir.joinpath('futures') - candle = f"-{candle_type}" - - _tmp = [re.search(r'^(\S+)(?=\-' + timeframe + candle + '.json)', p.name) - for p in datadir.glob(f"*{timeframe}{candle}.{cls._get_file_extension()}")] - # Check if regex found something and only return these results - return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] - def ohlcv_store( self, pair: str, timeframe: str, data: DataFrame, candle_type: CandleType) -> None: """ From 8116ca847ba942fcc4876ca92fc429c3104503a2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 18 Sep 2022 16:57:03 +0200 Subject: [PATCH 127/199] move trades_get_pairs to parent class --- freqtrade/data/history/hdf5datahandler.py | 16 +--------------- freqtrade/data/history/idatahandler.py | 6 +++++- freqtrade/data/history/jsondatahandler.py | 16 +--------------- 3 files changed, 7 insertions(+), 31 deletions(-) diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index bc44f5f8b..01b7af7e7 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -1,7 +1,5 @@ import logging -import re -from pathlib import Path -from typing import List, Optional +from typing import Optional import numpy as np import pandas as pd @@ -101,18 +99,6 @@ class HDF5DataHandler(IDataHandler): """ raise NotImplementedError() - @classmethod - def trades_get_pairs(cls, datadir: Path) -> List[str]: - """ - Returns a list of all pairs for which trade data is available in this - :param datadir: Directory to search for ohlcv files - :return: List of Pairs - """ - _tmp = [re.search(r'^(\S+)(?=\-trades.h5)', p.name) - for p in datadir.glob("*trades.h5")] - # Check if regex found something and only return these results to avoid exceptions. - return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] - def trades_store(self, pair: str, data: TradeList) -> None: """ Store trades data (list of Dicts) to file diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 08e591c5c..8abccacdc 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -152,13 +152,17 @@ class IDataHandler(ABC): """ @classmethod - @abstractmethod def trades_get_pairs(cls, datadir: Path) -> List[str]: """ Returns a list of all pairs for which trade data is available in this :param datadir: Directory to search for ohlcv files :return: List of Pairs """ + _ext = cls._get_file_extension() + _tmp = [re.search(r'^(\S+)(?=\-trades.' + _ext + ')', p.name) + for p in datadir.glob(f"*trades.{_ext}")] + # Check if regex found something and only return these results to avoid exceptions. + return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] @abstractmethod def trades_store(self, pair: str, data: TradeList) -> None: diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py index a54b44601..f016c0ec1 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -1,7 +1,5 @@ import logging -import re -from pathlib import Path -from typing import List, Optional +from typing import Optional import numpy as np from pandas import DataFrame, read_json, to_datetime @@ -99,18 +97,6 @@ class JsonDataHandler(IDataHandler): """ raise NotImplementedError() - @classmethod - def trades_get_pairs(cls, datadir: Path) -> List[str]: - """ - Returns a list of all pairs for which trade data is available in this - :param datadir: Directory to search for ohlcv files - :return: List of Pairs - """ - _tmp = [re.search(r'^(\S+)(?=\-trades.json)', p.name) - for p in datadir.glob(f"*trades.{cls._get_file_extension()}")] - # Check if regex found something and only return these results to avoid exceptions. - return [cls.rebuild_pair_from_filename(match[0]) for match in _tmp if match] - def trades_store(self, pair: str, data: TradeList) -> None: """ Store trades data (list of Dicts) to file From 4cdc89706e4b951d6140e288d43256f5bc5ec461 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 03:02:04 +0000 Subject: [PATCH 128/199] Bump joblib from 1.1.0 to 1.2.0 Bumps [joblib](https://github.com/joblib/joblib) from 1.1.0 to 1.2.0. - [Release notes](https://github.com/joblib/joblib/releases) - [Changelog](https://github.com/joblib/joblib/blob/master/CHANGES.rst) - [Commits](https://github.com/joblib/joblib/compare/1.1.0...1.2.0) --- updated-dependencies: - dependency-name: joblib dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-freqai.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-freqai.txt b/requirements-freqai.txt index e8d950382..9cdd431fe 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -3,7 +3,7 @@ # Required for freqai scikit-learn==1.1.2 -joblib==1.1.0 +joblib==1.2.0 catboost==1.0.6; platform_machine != 'aarch64' lightgbm==3.3.2 xgboost==1.6.2 diff --git a/requirements.txt b/requirements.txt index 91d3d3c8c..b6a1b87a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ pycoingecko==3.0.0 jinja2==3.1.2 tables==3.7.0 blosc==1.10.6 -joblib==1.1.0 +joblib==1.2.0 # find first, C search in arrays py_find_1st==1.1.5 From f512717943415032663f46be578c32145b0439f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 03:02:16 +0000 Subject: [PATCH 129/199] Bump ccxt from 1.93.35 to 1.93.66 Bumps [ccxt](https://github.com/ccxt/ccxt) from 1.93.35 to 1.93.66. - [Release notes](https://github.com/ccxt/ccxt/releases) - [Changelog](https://github.com/ccxt/ccxt/blob/master/exchanges.cfg) - [Commits](https://github.com/ccxt/ccxt/compare/1.93.35...1.93.66) --- updated-dependencies: - dependency-name: ccxt dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91d3d3c8c..09b3558a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ numpy==1.23.3 pandas==1.4.4 pandas-ta==0.3.14b -ccxt==1.93.35 +ccxt==1.93.66 # Pin cryptography for now due to rust build errors with piwheels cryptography==38.0.1 aiohttp==3.8.1 From cbdb0ce3e74d8b9da8f553750ce031fafa8e8951 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 03:02:23 +0000 Subject: [PATCH 130/199] Bump pyjwt from 2.4.0 to 2.5.0 Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.4.0 to 2.5.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.4.0...2.5.0) --- updated-dependencies: - dependency-name: pyjwt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91d3d3c8c..c1cb2fcf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ sdnotify==0.3.2 # API Server fastapi==0.83.0 uvicorn==0.18.3 -pyjwt==2.4.0 +pyjwt==2.5.0 aiofiles==22.1.0 psutil==5.9.2 From 15c9b6bf4139045dfb97e41c3df637afce32f547 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 03:02:33 +0000 Subject: [PATCH 131/199] Bump mkdocs-material from 8.4.3 to 8.5.2 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 8.4.3 to 8.5.2. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/8.4.3...8.5.2) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs/requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index d63e79004..da6713b76 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 mkdocs==1.3.1 -mkdocs-material==8.4.3 +mkdocs-material==8.5.2 mdx_truly_sane_lists==1.3 pymdown-extensions==9.5 jinja2==3.1.2 From b5f51b5ec215a089e2c10abf2d40557fb0cb4273 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 04:34:24 +0000 Subject: [PATCH 132/199] Bump fastapi from 0.83.0 to 0.85.0 Bumps [fastapi](https://github.com/tiangolo/fastapi) from 0.83.0 to 0.85.0. - [Release notes](https://github.com/tiangolo/fastapi/releases) - [Commits](https://github.com/tiangolo/fastapi/compare/0.83.0...0.85.0) --- updated-dependencies: - dependency-name: fastapi dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c1cb2fcf2..541bfb266 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ orjson==3.8.0 sdnotify==0.3.2 # API Server -fastapi==0.83.0 +fastapi==0.85.0 uvicorn==0.18.3 pyjwt==2.5.0 aiofiles==22.1.0 From d93093100058db73e2c4a9939b716fa344e4436f Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 07:14:33 +0200 Subject: [PATCH 133/199] Bring back sleep - it'll ensure we give up control over the thread. --- freqtrade/freqai/freqai_interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 2cec96059..5425f576c 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -182,6 +182,7 @@ class IFreqaiModel(ABC): :param strategy: IStrategy = The user defined strategy class """ while not self._stop_event.is_set(): + time.sleep(1) pair = self.train_queue[0] # ensure pair is avaialble in dp From 4a0a0c307c5f7366503515d696468ee7b32c5104 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 07:23:26 +0200 Subject: [PATCH 134/199] Use json_load to load leverage tiers --- freqtrade/exchange/binance.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index faa780529..f9fb4a8b1 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -1,5 +1,4 @@ """ Binance exchange subclass """ -import json import logging from datetime import datetime from pathlib import Path @@ -12,7 +11,7 @@ from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.common import retrier -from freqtrade.misc import deep_merge_dicts +from freqtrade.misc import deep_merge_dicts, json_load logger = logging.getLogger(__name__) @@ -200,7 +199,7 @@ class Binance(Exchange): Path(__file__).parent / 'binance_leverage_tiers.json' ) with open(leverage_tiers_path) as json_file: - return json.load(json_file) + return json_load(json_file) else: try: return self._api.fetch_leverage_tiers() From ea58c29ded45033eb175d51f7041facb3ec33fa7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 08:11:25 +0000 Subject: [PATCH 135/199] Add plot_feature_importance to schema definition --- config_examples/config_freqai.example.json | 4 ++-- docs/freqai.md | 19 ++++++++++--------- freqtrade/constants.py | 1 + freqtrade/freqai/freqai_interface.py | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 9494ba0e1..3a8a3b273 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -78,7 +78,7 @@ 10, 20 ], - "plot_feature_importance": true + "plot_feature_importance": false }, "data_split_parameters": { "test_size": 0.33, @@ -94,4 +94,4 @@ "internals": { "process_throttle_secs": 5 } -} +} \ No newline at end of file diff --git a/docs/freqai.md b/docs/freqai.md index 33fac198c..a03162b45 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -109,11 +109,12 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `indicator_max_period_candles` | **No longer used**. User must use the strategy set `startup_candle_count` which defines the maximum *period* used in `populate_any_indicators()` for indicator creation (timeframe independent). FreqAI uses this information in combination with the maximum timeframe to calculate how many data points it should download so that the first data point does not have a NaN
**Datatype:** positive integer. | `indicator_periods_candles` | Calculate indicators for `indicator_periods_candles` time periods and add them to the feature set.
**Datatype:** List of positive integers. | `stratify_training_data` | This value is used to indicate the grouping of the data. For example, 2 would set every 2nd data point into a separate dataset to be pulled from during training/testing. See details about how it works [here](#stratifying-the-data-for-training-and-testing-the-model)
**Datatype:** Positive integer. -| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis)
**Datatype:** Boolean. +| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis) +| `plot_feature_importance` | Create an interactive feature importance plot for each model.
**Datatype:** Boolean.
**Datatype:** Boolean, defaults to `False` | `DI_threshold` | Activates the Dissimilarity Index for outlier detection when > 0. See details about how it works [here](#removing-outliers-with-the-dissimilarity-index).
**Datatype:** Positive float (typically < 1). | `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training data set, as well as from incoming data points. See details about how it works [here](#removing-outliers-using-a-support-vector-machine-svm).
**Datatype:** Boolean. | `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](#removing-outliers-using-a-support-vector-machine-svm).
**Datatype:** Dictionary. -| `use_DBSCAN_to_remove_outliers` | Cluster data using DBSCAN to identify and remove outliers from training and prediction data. See details about how it works [here](#removing-outliers-with-dbscan).
**Datatype:** Boolean. +| `use_DBSCAN_to_remove_outliers` | Cluster data using DBSCAN to identify and remove outliers from training and prediction data. See details about how it works [here](#removing-outliers-with-dbscan).
**Datatype:** Boolean. | `inlier_metric_window` | If set, FreqAI will add the `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`. Details of how the `inlier_metric` is computed can be found [here](#using-the-inliermetric)
**Datatype:** int. Default: 0 | `noise_standard_deviation` | If > 0, FreqAI adds noise to the training features. FreqAI generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. Value should be kept relative to the normalized space between -1 and 1). In other words, since data is always normalized between -1 and 1 in FreqAI, the user can expect a `noise_standard_deviation: 0.05` to see 32% of data randomly increased/decreased by more than 2.5% (i.e. the percent of data falling within the first standard deviation). Good for preventing overfitting.
**Datatype:** int. Default: 0 | `outlier_protection_percentage` | If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, FreqAI will log a warning message and ignore outlier detection while keeping the original dataset intact. If the outlier protection is triggered, no predictions will be made based on the training data.
**Datatype:** Float. Default: `30` @@ -510,7 +511,7 @@ The FreqAI backtesting module can be executed with the following command: freqtrade backtesting --strategy FreqaiExampleStrategy --strategy-path freqtrade/templates --config config_examples/config_freqai.example.json --freqaimodel LightGBMRegressor --timerange 20210501-20210701 ``` -Backtesting mode requires the user to have the data [pre-downloaded](#downloading-data-for-backtesting) (unlike in dry/live mode where FreqAI automatically downloads the necessary data). The user should be careful to consider that the time range of the downloaded data is more than the backtesting time range. This is because FreqAI needs data prior to the desired backtesting time range in order to train a model to be ready to make predictions on the first candle of the user-set backtesting time range. More details on how to calculate the data to download can be found [here](#deciding-the-sliding-training-window-and-backtesting-duration). +Backtesting mode requires the user to have the data [pre-downloaded](#downloading-data-for-backtesting) (unlike in dry/live mode where FreqAI automatically downloads the necessary data). The user should be careful to consider that the time range of the downloaded data is more than the backtesting time range. This is because FreqAI needs data prior to the desired backtesting time range in order to train a model to be ready to make predictions on the first candle of the user-set backtesting time range. More details on how to calculate the data to download can be found [here](#deciding-the-sliding-training-window-and-backtesting-duration). If this command has never been executed with the existing config file, it will train a new model for each pair, for each backtesting window within the expanded `--timerange`. @@ -538,7 +539,7 @@ Users need to have the data pre-downloaded in the same fashion as if they were d - It's not possible to hyperopt indicators in `populate_any_indicators()` function. This means that the user cannot optimize model parameters using hyperopt. Apart from this exception, it is possible to optimize all other [spaces](hyperopt.md#running-hyperopt-with-smaller-search-space). - The [Backtesting](#backtesting) instructions also apply to Hyperopt. -The best method for combining hyperopt and FreqAI is to focus on hyperopting entry/exit thresholds/criteria. Users need to focus on hyperopting parameters that are not used in their FreqAI features. For example, users should not try to hyperopt rolling window lengths in their feature creation, or any of their FreqAI config which changes predictions. In order to efficiently hyperopt the FreqAI strategy, FreqAI stores predictions as dataframes and reuses them. Hence the requirement to hyperopt entry/exit thresholds/criteria only. +The best method for combining hyperopt and FreqAI is to focus on hyperopting entry/exit thresholds/criteria. Users need to focus on hyperopting parameters that are not used in their FreqAI features. For example, users should not try to hyperopt rolling window lengths in their feature creation, or any of their FreqAI config which changes predictions. In order to efficiently hyperopt the FreqAI strategy, FreqAI stores predictions as dataframes and reuses them. Hence the requirement to hyperopt entry/exit thresholds/criteria only. A good example of a hyperoptable parameter in FreqAI is a value for `DI_values` beyond which we consider outliers and below which we consider inliers: @@ -563,7 +564,7 @@ FreqAI will train have trained 8 separate models at the end of `--timerange` (be Although fractional `backtest_period_days` is allowed, the user should be aware that the `--timerange` is divided by this value to determine the number of models that FreqAI will need to train in order to backtest the full range. For example, if the user wants to set a `--timerange` of 10 days, and asks for a `backtest_period_days` of 0.1, FreqAI will need to train 100 models per pair to complete the full backtest. Because of this, a true backtest of FreqAI adaptive training would take a *very* long time. The best way to fully test a model is to run it dry and let it constantly train. In this case, backtesting would take the exact same amount of time as a dry run. ### Downloading data for backtesting -Live/dry instances will download the data automatically for the user, but users who wish to use backtesting functionality still need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). FreqAI users need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that they have a sufficient amount of training data *before* the start of their backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` ([details](#setting-the-startupcandlecount)) from the beginning of the desired backtesting timerange. +Live/dry instances will download the data automatically for the user, but users who wish to use backtesting functionality still need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). FreqAI users need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that they have a sufficient amount of training data *before* the start of their backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` ([details](#setting-the-startupcandlecount)) from the beginning of the desired backtesting timerange. As an example, if we wish to backtest the `--timerange` above of `20210501-20210701`, and we use the example config which sets `train_period_days` to 15. The startup candle count is 40 on a maximum `include_timeframes` of 1h. We would need 20210501 - 15 days - 40 * 1h / 24 hours = 20210414 (16.7 days earlier than the start of the desired training timerange). @@ -662,13 +663,13 @@ The test data is used to evaluate the performance of the model after training. I ### Using the `inlier_metric` -The `inlier_metric` is a metric aimed at quantifying how different a prediction data point is from the most recent historic data points. +The `inlier_metric` is a metric aimed at quantifying how different a prediction data point is from the most recent historic data points. -User can set `inlier_metric_window` to set the look back window. FreqAI will compute the distance between the present prediction point and each of the previous data points (total of `inlier_metric_window` points). +User can set `inlier_metric_window` to set the look back window. FreqAI will compute the distance between the present prediction point and each of the previous data points (total of `inlier_metric_window` points). -This function goes one step further - during training, it computes the `inlier_metric` for all training data points and builds weibull distributions for each each lookback point. The cumulative distribution function for the weibull distribution is used to produce a quantile for each of the data points. The quantiles for each lookback point are averaged to create the `inlier_metric`. +This function goes one step further - during training, it computes the `inlier_metric` for all training data points and builds weibull distributions for each each lookback point. The cumulative distribution function for the weibull distribution is used to produce a quantile for each of the data points. The quantiles for each lookback point are averaged to create the `inlier_metric`. -FreqAI adds this `inlier_metric` score to the training features! In other words, your model is trained to recognize how this temporal inlier metric is related to the user set labels. +FreqAI adds this `inlier_metric` score to the training features! In other words, your model is trained to recognize how this temporal inlier metric is related to the user set labels. This function does **not** remove outliers from the data set. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 077c4ecbf..75825c0d0 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -504,6 +504,7 @@ CONF_SCHEMA = { "weight_factor": {"type": "number", "default": 0}, "principal_component_analysis": {"type": "boolean", "default": False}, "use_SVM_to_remove_outliers": {"type": "boolean", "default": False}, + "plot_feature_importance": {"type": "boolean", "default": False}, "svm_params": {"type": "object", "properties": { "shuffle": {"type": "boolean", "default": False}, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index be1231a53..19e813e4d 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -563,7 +563,7 @@ class IFreqaiModel(ABC): self.dd.pair_to_end_of_training_queue(pair) self.dd.save_data(model, pair, dk) - if self.freqai_info["feature_parameters"].get("plot_feature_importance", True): + if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): plot_feature_importance(model, pair, dk) if self.freqai_info.get("purge_old_models", False): From ad652817ef6875ac1d1705178f3c2921df758e55 Mon Sep 17 00:00:00 2001 From: Robert Caulk Date: Mon, 19 Sep 2022 11:11:23 +0200 Subject: [PATCH 136/199] Ensure train ordering after restart Ensure lowest timestamps get trained first after restart --- freqtrade/freqai/freqai_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 3f5eff167..fa9d5043b 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -711,7 +711,7 @@ class IFreqaiModel(ABC): key=lambda k: k[1]['trained_timestamp']) for pair in pair_dict_sorted: if pair[0] in current_pairlist: - best_queue.appendleft(pair[0]) + best_queue.appendright(pair[0]) for pair in current_pairlist: if pair not in best_queue: best_queue.appendleft(pair) From 995396c7752657ea29815cf1443d0679a6c0e6ba Mon Sep 17 00:00:00 2001 From: Robert Caulk Date: Mon, 19 Sep 2022 11:42:56 +0200 Subject: [PATCH 137/199] Add useful log info --- freqtrade/freqai/freqai_interface.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index fa9d5043b..0dc2baf54 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -703,6 +703,7 @@ class IFreqaiModel(ABC): current_pairlist = self.config.get("exchange", {}).get("pair_whitelist") if not self.dd.pair_dict: logger.info('Set fresh train queue from whitelist.') + logger.info(f'Queue: {current_pairlist}') return deque(current_pairlist) best_queue = deque() @@ -717,6 +718,7 @@ class IFreqaiModel(ABC): best_queue.appendleft(pair) logger.info('Set existing queue from trained timestamps.') + logger.info(f'Best approximation queue: {best_queue}') return best_queue # Following methods which are overridden by user made prediction models. From 9b66297cc0bbb2ae758319a5a4747eeca738451e Mon Sep 17 00:00:00 2001 From: Robert Caulk Date: Mon, 19 Sep 2022 12:47:20 +0200 Subject: [PATCH 138/199] Fix append --- freqtrade/freqai/freqai_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 0dc2baf54..9743c35d4 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -712,7 +712,7 @@ class IFreqaiModel(ABC): key=lambda k: k[1]['trained_timestamp']) for pair in pair_dict_sorted: if pair[0] in current_pairlist: - best_queue.appendright(pair[0]) + best_queue.append(pair[0]) for pair in current_pairlist: if pair not in best_queue: best_queue.appendleft(pair) From 42c75b4a7beb71603cad305f72154410209b791e Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 19 Sep 2022 19:16:32 +0200 Subject: [PATCH 139/199] combine log messages --- freqtrade/freqai/freqai_interface.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 9743c35d4..156c6bee5 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -702,8 +702,8 @@ class IFreqaiModel(ABC): """ current_pairlist = self.config.get("exchange", {}).get("pair_whitelist") if not self.dd.pair_dict: - logger.info('Set fresh train queue from whitelist.') - logger.info(f'Queue: {current_pairlist}') + logger.info('Set fresh train queue from whitelist. ' + f'Queue: {current_pairlist}') return deque(current_pairlist) best_queue = deque() @@ -717,8 +717,8 @@ class IFreqaiModel(ABC): if pair not in best_queue: best_queue.appendleft(pair) - logger.info('Set existing queue from trained timestamps.') - logger.info(f'Best approximation queue: {best_queue}') + logger.info('Set existing queue from trained timestamps. ' + f'Best approximation queue: {best_queue}') return best_queue # Following methods which are overridden by user made prediction models. From eb9ac9cbdaf33218e8f93ba3490949899601ca3a Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 20:17:58 +0200 Subject: [PATCH 140/199] add --exchange to convert-trade-data --- freqtrade/commands/arguments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index 2835f8582..97d8cc130 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -62,9 +62,9 @@ ARGS_BUILD_CONFIG = ["config"] ARGS_BUILD_STRATEGY = ["user_data_dir", "strategy", "template"] -ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase"] +ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase", "exchange"] -ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "exchange", "trading_mode", +ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "trading_mode", "candle_types"] ARGS_CONVERT_TRADES = ["pairs", "timeframes", "exchange", "dataformat_ohlcv", "dataformat_trades"] From 703bcc099a942eedf75bc6fcfa3421ae49c93a25 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 20:32:54 +0200 Subject: [PATCH 141/199] Fix list-pair regex to also support 1INCH/USDT --- freqtrade/data/history/idatahandler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 8abccacdc..8c1823c00 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) class IDataHandler(ABC): - _OHLCV_REGEX = r'^([a-zA-Z_-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)' + _OHLCV_REGEX = r'^([a-zA-Z_\d-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)' def __init__(self, datadir: Path) -> None: self._datadir = datadir @@ -267,7 +267,7 @@ class IDataHandler(ABC): Rebuild pair name from filename Assumes a asset name of max. 7 length to also support BTC-PERP and BTC-PERP:USD names. """ - res = re.sub(r'^(([A-Za-z]{1,10})|^([A-Za-z\-]{1,6}))(_)', r'\g<1>/', pair, 1) + res = re.sub(r'^(([A-Za-z\d]{1,10})|^([A-Za-z\-]{1,6}))(_)', r'\g<1>/', pair, 1) res = re.sub('_', ':', res, 1) return res From 32d46e8a6bd3dcc26fcdaac2764120691b8d8b58 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 20:59:40 +0200 Subject: [PATCH 142/199] Improve fixture naming --- tests/conftest.py | 2 +- tests/data/test_converter.py | 4 ++-- tests/strategy/test_default_strategy.py | 4 ++-- tests/strategy/test_strategy_loading.py | 22 +++++++++++----------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fffac8e0a..4039f9367 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2282,7 +2282,7 @@ def tickers(): @pytest.fixture -def result(testdatadir): +def dataframe_1m(testdatadir): with (testdatadir / 'UNITTEST_BTC-1m.json').open('r') as data_file: return ohlcv_to_dataframe(json.load(data_file), '1m', pair="UNITTEST/BTC", fill_missing=True) diff --git a/tests/data/test_converter.py b/tests/data/test_converter.py index c6b0059a2..f74383d15 100644 --- a/tests/data/test_converter.py +++ b/tests/data/test_converter.py @@ -18,8 +18,8 @@ from tests.conftest import log_has, log_has_re from tests.data.test_history import _clean_test_file -def test_dataframe_correct_columns(result): - assert result.columns.tolist() == ['date', 'open', 'high', 'low', 'close', 'volume'] +def test_dataframe_correct_columns(dataframe_1m): + assert dataframe_1m.columns.tolist() == ['date', 'open', 'high', 'low', 'close', 'volume'] def test_ohlcv_to_dataframe(ohlcv_history_list, caplog): diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py index 5cb8fce16..cb3d61e89 100644 --- a/tests/strategy/test_default_strategy.py +++ b/tests/strategy/test_default_strategy.py @@ -21,14 +21,14 @@ def test_strategy_test_v3_structure(): (True, 'short'), (False, 'long'), ]) -def test_strategy_test_v3(result, fee, is_short, side): +def test_strategy_test_v3(dataframe_1m, fee, is_short, side): strategy = StrategyTestV3({}) metadata = {'pair': 'ETH/BTC'} assert type(strategy.minimal_roi) is dict assert type(strategy.stoploss) is float assert type(strategy.timeframe) is str - indicators = strategy.populate_indicators(result, metadata) + indicators = strategy.populate_indicators(dataframe_1m, metadata) assert type(indicators) is DataFrame assert type(strategy.populate_buy_trend(indicators, metadata)) is DataFrame assert type(strategy.populate_sell_trend(indicators, metadata)) is DataFrame diff --git a/tests/strategy/test_strategy_loading.py b/tests/strategy/test_strategy_loading.py index bf81cd068..adffd0875 100644 --- a/tests/strategy/test_strategy_loading.py +++ b/tests/strategy/test_strategy_loading.py @@ -53,7 +53,7 @@ def test_search_all_strategies_with_failed(): assert len(strategies) == 0 -def test_load_strategy(default_conf, result): +def test_load_strategy(default_conf, dataframe_1m): default_conf.update({'strategy': 'SampleStrategy', 'strategy_path': str(Path(__file__).parents[2] / 'freqtrade/templates') }) @@ -61,22 +61,22 @@ def test_load_strategy(default_conf, result): assert isinstance(strategy.__source__, str) assert 'class SampleStrategy' in strategy.__source__ assert isinstance(strategy.__file__, str) - assert 'rsi' in strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) + assert 'rsi' in strategy.advise_indicators(dataframe_1m, {'pair': 'ETH/BTC'}) -def test_load_strategy_base64(result, caplog, default_conf): +def test_load_strategy_base64(dataframe_1m, caplog, default_conf): filepath = Path(__file__).parents[2] / 'freqtrade/templates/sample_strategy.py' encoded_string = urlsafe_b64encode(filepath.read_bytes()).decode("utf-8") default_conf.update({'strategy': 'SampleStrategy:{}'.format(encoded_string)}) strategy = StrategyResolver.load_strategy(default_conf) - assert 'rsi' in strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) + assert 'rsi' in strategy.advise_indicators(dataframe_1m, {'pair': 'ETH/BTC'}) # Make sure strategy was loaded from base64 (using temp directory)!! assert log_has_re(r"Using resolved strategy SampleStrategy from '" r".*(/|\\).*(/|\\)SampleStrategy\.py'\.\.\.", caplog) -def test_load_strategy_invalid_directory(result, caplog, default_conf): +def test_load_strategy_invalid_directory(caplog, default_conf): default_conf['strategy'] = 'StrategyTestV3' extra_dir = Path.cwd() / 'some/path' with pytest.raises(OperationalException): @@ -104,7 +104,7 @@ def test_load_strategy_noname(default_conf): @pytest.mark.filterwarnings("ignore:deprecated") @pytest.mark.parametrize('strategy_name', ['StrategyTestV2']) -def test_strategy_pre_v3(result, default_conf, strategy_name): +def test_strategy_pre_v3(dataframe_1m, default_conf, strategy_name): default_conf.update({'strategy': strategy_name}) strategy = StrategyResolver.load_strategy(default_conf) @@ -118,7 +118,7 @@ def test_strategy_pre_v3(result, default_conf, strategy_name): assert strategy.timeframe == '5m' assert default_conf['timeframe'] == '5m' - df_indicators = strategy.advise_indicators(result, metadata=metadata) + df_indicators = strategy.advise_indicators(dataframe_1m, metadata=metadata) assert 'adx' in df_indicators dataframe = strategy.advise_entry(df_indicators, metadata=metadata) @@ -417,24 +417,24 @@ def test_call_deprecated_function(default_conf): StrategyResolver.load_strategy(default_conf) -def test_strategy_interface_versioning(result, default_conf): +def test_strategy_interface_versioning(dataframe_1m, default_conf): default_conf.update({'strategy': 'StrategyTestV2'}) strategy = StrategyResolver.load_strategy(default_conf) metadata = {'pair': 'ETH/BTC'} assert strategy.INTERFACE_VERSION == 2 - indicator_df = strategy.advise_indicators(result, metadata=metadata) + indicator_df = strategy.advise_indicators(dataframe_1m, metadata=metadata) assert isinstance(indicator_df, DataFrame) assert 'adx' in indicator_df.columns - enterdf = strategy.advise_entry(result, metadata=metadata) + enterdf = strategy.advise_entry(dataframe_1m, metadata=metadata) assert isinstance(enterdf, DataFrame) assert 'buy' not in enterdf.columns assert 'enter_long' in enterdf.columns - exitdf = strategy.advise_exit(result, metadata=metadata) + exitdf = strategy.advise_exit(dataframe_1m, metadata=metadata) assert isinstance(exitdf, DataFrame) assert 'sell' not in exitdf assert 'exit_long' in exitdf From b5fd11f91b265d65fb4c9485794368169037be3b Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 19 Sep 2022 20:39:19 +0200 Subject: [PATCH 143/199] protect against unforeseen issues in scanning thread --- freqtrade/freqai/freqai_interface.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 156c6bee5..5850cdeb3 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -205,9 +205,13 @@ class IFreqaiModel(ABC): if retrain: self.train_timer('start') - self.extract_data_and_train_model( - new_trained_timerange, pair, strategy, dk, data_load_timerange - ) + try: + self.extract_data_and_train_model( + new_trained_timerange, pair, strategy, dk, data_load_timerange + ) + except Exception as msg: + logger.warning(f'Training {pair} raised exception {msg}, skipping.') + self.train_timer('stop') # only rotate the queue after the first has been trained. From 3274bb07516bef27c28e547113dec4125f826467 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 06:55:22 +0200 Subject: [PATCH 144/199] Remove msgpack for now --- freqtrade/rpc/api_server/ws/__init__.py | 2 +- freqtrade/rpc/api_server/ws/serializer.py | 9 --------- requirements.txt | 3 +-- setup.py | 1 - 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/freqtrade/rpc/api_server/ws/__init__.py b/freqtrade/rpc/api_server/ws/__init__.py index c00d29e22..055b20a9d 100644 --- a/freqtrade/rpc/api_server/ws/__init__.py +++ b/freqtrade/rpc/api_server/ws/__init__.py @@ -2,5 +2,5 @@ # isort: off from freqtrade.rpc.api_server.ws.types import WebSocketType from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy -from freqtrade.rpc.api_server.ws.serializer import HybridJSONWebSocketSerializer, MsgPackWebSocketSerializer +from freqtrade.rpc.api_server.ws.serializer import HybridJSONWebSocketSerializer from freqtrade.rpc.api_server.ws.channel import ChannelManager, WebSocketChannel diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py index 22d177f85..6c402a100 100644 --- a/freqtrade/rpc/api_server/ws/serializer.py +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -1,7 +1,6 @@ import logging from abc import ABC, abstractmethod -import msgpack import orjson import rapidjson from pandas import DataFrame @@ -46,14 +45,6 @@ class HybridJSONWebSocketSerializer(WebSocketSerializer): return rapidjson.loads(data, object_hook=_json_object_hook) -class MsgPackWebSocketSerializer(WebSocketSerializer): - def _serialize(self, data): - return msgpack.packb(data, use_bin_type=True) - - def _deserialize(self, data): - return msgpack.unpackb(data, raw=False) - - # Support serializing pandas DataFrames def _json_default(z): if isinstance(z, DataFrame): diff --git a/requirements.txt b/requirements.txt index 1ace139ee..1579ef55c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,6 +52,5 @@ python-dateutil==2.8.2 schedule==1.1.0 #WS Messages -websockets~=10.3 -msgpack~=1.0.4 +websockets==10.3 janus==1.0.0 diff --git a/setup.py b/setup.py index c7b1f1c7c..2e6e354b0 100644 --- a/setup.py +++ b/setup.py @@ -81,7 +81,6 @@ setup( 'aiofiles', 'schedule', 'websockets', - 'msgpack', 'janus' ], extras_require={ From 6c18fa0847bccb4b830308cc9cc86784764fc137 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 09:30:41 +0000 Subject: [PATCH 145/199] Update proxy docs --- docs/configuration.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8a600e76..bb4f5ce41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,7 @@ You can specify additional configuration files in `add_config_files`. Files spec This is similar to using multiple `--config` parameters, but simpler in usage as you don't have to specify all files for all commands. !!! Tip "Use multiple configuration files to keep secrets secret" - You can use a 2nd configuration file containing your secrets. That way you can share your "primary" configuration file, while still keeping your API keys for yourself. + You can use a 2nd configuration file containing your secrets. That way you can share your "primary" configuration file, while still keeping your API keys for yourself. The 2nd file should only specify what you intend to override. If a key is in more than one of the configurations, then the "last specified configuration" wins (in the above example, `config-private.json`). @@ -110,7 +110,7 @@ This is similar to using multiple `--config` parameters, but simpler in usage as "stake_amount": "unlimited" } ``` - + If multiple files are in the `add_config_files` section, then they will be assumed to be at identical levels, having the last occurrence override the earlier config (unless a parent already defined such a key). ## Configuration parameters @@ -659,17 +659,7 @@ You should also make sure to read the [Exchanges](exchanges.md) section of the d ### Using proxy with Freqtrade -To use a proxy with freqtrade, add the kwarg `"aiohttp_trust_env"=true` to the `"ccxt_async_kwargs"` dict in the exchange section of the configuration. - -An example for this can be found in `config_examples/config_full.example.json` - -``` json -"ccxt_async_config": { - "aiohttp_trust_env": true -} -``` - -Then, export your proxy settings using the variables `"HTTP_PROXY"` and `"HTTPS_PROXY"` set to the appropriate values +To use a proxy with freqtrade, export your proxy settings using the variables `"HTTP_PROXY"` and `"HTTPS_PROXY"` set to the appropriate values. ``` bash export HTTP_PROXY="http://addr:port" @@ -677,6 +667,20 @@ export HTTPS_PROXY="http://addr:port" freqtrade ``` +#### Proxy just exchange requests + +To use a proxy just for exchange connections (skips/ignores telegram and coingecko) - you can also define the proxies as part of the ccxt configuration. + +``` json +"ccxt_config": { + "aiohttp_proxy": "http://addr:port", + "proxies": { + "http": "http://addr:port", + "https": "http://addr:port" + }, +} +``` + ## Next step Now you have configured your config.json, the next step is to [start your bot](bot-usage.md). From 8a91c8e220a1770b08af25f1b3f84f5fba12ed8b Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 13:30:59 +0000 Subject: [PATCH 146/199] Sort and dedup pairs before data conversion --- freqtrade/data/converter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index bf19c1310..67461973f 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -292,6 +292,7 @@ def convert_ohlcv_format( timeframe, candle_type=candle_type )) + config['pairs'] = sorted(set(config['pairs'])) logger.info(f"Converting candle (OHLCV) data for {config['pairs']}") for timeframe in timeframes: @@ -302,7 +303,7 @@ def convert_ohlcv_format( drop_incomplete=False, startup_candles=0, candle_type=candle_type) - logger.info(f"Converting {len(data)} {candle_type} candles for {pair}") + logger.info(f"Converting {len(data)} {timeframe} {candle_type} candles for {pair}") if len(data) > 0: trg.ohlcv_store( pair=pair, From 0bd6ad55a14a84dcd77cb057453f46ae8acfd3a6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 14:14:54 +0000 Subject: [PATCH 147/199] Always show freqtrade version --- freqtrade/freqtradebot.py | 4 +--- freqtrade/main.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 83abc9bc5..d4dc6ef73 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple from schedule import Scheduler -from freqtrade import __version__, constants +from freqtrade import constants from freqtrade.configuration import validate_config_consistency from freqtrade.constants import BuySell, Config, LongShort from freqtrade.data.converter import order_book_to_dataframe @@ -52,8 +52,6 @@ class FreqtradeBot(LoggingMixin): """ self.active_pair_whitelist: List[str] = [] - logger.info('Starting freqtrade %s', __version__) - # Init bot state self.state = State.STOPPED diff --git a/freqtrade/main.py b/freqtrade/main.py index 162b4d029..754c536d0 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -12,6 +12,7 @@ from typing import Any, List if sys.version_info < (3, 8): # pragma: no cover sys.exit("Freqtrade requires Python version >= 3.8") +from freqtrade import __version__ from freqtrade.commands import Arguments from freqtrade.exceptions import FreqtradeException, OperationalException from freqtrade.loggers import setup_logging_pre @@ -34,6 +35,7 @@ def main(sysargv: List[str] = None) -> None: # Call subcommand. if 'func' in args: + logger.info(f'freqtrade {__version__}') return_code = args['func'](args) else: # No subcommand was issued. From 0c01b23cba8564642a2f07ec8a5a817d486821b8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 18:09:14 +0200 Subject: [PATCH 148/199] Capture exceptions in send_msg calls --- freqtrade/rpc/rpc_manager.py | 2 ++ tests/rpc/test_rpc_manager.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 3b60077ad..8390e61aa 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -78,6 +78,8 @@ class RPCManager: mod.send_msg(msg) except NotImplementedError: logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.") + except Exception: + logger.exception('Exception occurred within RPC module %s', mod.name) def process_msg_queue(self, queue: deque) -> None: """ diff --git a/tests/rpc/test_rpc_manager.py b/tests/rpc/test_rpc_manager.py index b9ae16a20..d71f38259 100644 --- a/tests/rpc/test_rpc_manager.py +++ b/tests/rpc/test_rpc_manager.py @@ -82,6 +82,21 @@ def test_send_msg_telegram_disabled(mocker, default_conf, caplog) -> None: assert telegram_mock.call_count == 0 +def test_send_msg_telegram_error(mocker, default_conf, caplog) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', side_effect=ValueError()) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + rpc_manager = RPCManager(freqtradebot) + rpc_manager.send_msg({ + 'type': RPCMessageType.STATUS, + 'status': 'test' + }) + + assert log_has("Sending rpc message: {'type': status, 'status': 'test'}", caplog) + assert log_has("Exception occurred within RPC module telegram", caplog) + + def test_process_msg_queue(mocker, default_conf, caplog) -> None: telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg') mocker.patch('freqtrade.rpc.telegram.Telegram._init') From 3b0874eb373f6079d9da204afa28e35a3c79a933 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 20:00:08 +0200 Subject: [PATCH 149/199] Update exit message handling to gracefully handle kucoins "empty" responses closes #7444 --- freqtrade/freqtradebot.py | 17 +++++++++-------- freqtrade/rpc/telegram.py | 7 ++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index d4dc6ef73..539bbcb38 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1597,14 +1597,14 @@ class FreqtradeBot(LoggingMixin): # second condition is for mypy only; order will always be passed during sub trade if sub_trade and order is not None: amount = order.safe_filled if fill else order.amount - profit_rate = order.safe_price + order_rate: float = order.safe_price - profit = trade.calc_profit(rate=profit_rate, amount=amount, open_rate=trade.open_rate) - profit_ratio = trade.calc_profit_ratio(profit_rate, amount, trade.open_rate) + profit = trade.calc_profit(rate=order_rate, amount=amount, open_rate=trade.open_rate) + profit_ratio = trade.calc_profit_ratio(order_rate, amount, trade.open_rate) else: - profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested - profit = trade.calc_profit(rate=profit_rate) + (0.0 if fill else trade.realized_profit) - profit_ratio = trade.calc_profit_ratio(profit_rate) + order_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested + profit = trade.calc_profit(rate=order_rate) + (0.0 if fill else trade.realized_profit) + profit_ratio = trade.calc_profit_ratio(order_rate) amount = trade.amount gain = "profit" if profit_ratio > 0 else "loss" @@ -1617,11 +1617,12 @@ class FreqtradeBot(LoggingMixin): 'leverage': trade.leverage, 'direction': 'Short' if trade.is_short else 'Long', 'gain': gain, - 'limit': profit_rate, + 'limit': order_rate, # Deprecated + 'order_rate': order_rate, 'order_type': order_type, 'amount': amount, 'open_rate': trade.open_rate, - 'close_rate': profit_rate, + 'close_rate': order_rate, 'current_rate': current_rate, 'profit_amount': profit, 'profit_ratio': profit_ratio, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index c40bdb963..247373817 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -286,7 +286,7 @@ class Telegram(RPCHandler): if msg['type'] in [RPCMessageType.ENTRY_FILL]: message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n" elif msg['type'] in [RPCMessageType.ENTRY]: - message += f"*Open Rate:* `{msg['limit']:.8f}`\n"\ + message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n"\ f"*Current Rate:* `{msg['current_rate']:.8f}`\n" message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}" @@ -353,8 +353,9 @@ class Telegram(RPCHandler): f"*Open Rate:* `{msg['open_rate']:.8f}`\n" ) if msg['type'] == RPCMessageType.EXIT: - message += (f"*Current Rate:* `{msg['current_rate']:.8f}`\n" - f"*Exit Rate:* `{msg['limit']:.8f}`") + message += f"*Current Rate:* `{msg['current_rate']:.8f}`\n" + if msg['order_rate']: + message += f"*Exit Rate:* `{msg['order_rate']:.8f}`" elif msg['type'] == RPCMessageType.EXIT_FILL: message += f"*Exit Rate:* `{msg['close_rate']:.8f}`" From ff36431680758c3a3652486243c0d4409528e999 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 20:31:06 +0200 Subject: [PATCH 150/199] Adjust tests for new messageType handling --- tests/rpc/test_rpc_telegram.py | 15 +++++++++------ tests/test_freqtradebot.py | 5 +++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index f2e490dff..3552d5fe7 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -959,6 +959,7 @@ def test_telegram_forceexit_handle(default_conf, update, ticker, fee, 'gain': 'profit', 'leverage': 1.0, 'limit': 1.173e-05, + 'order_rate': 1.173e-05, 'amount': 91.07468123, 'order_type': 'limit', 'open_rate': 1.098e-05, @@ -1031,6 +1032,7 @@ def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, 'gain': 'loss', 'leverage': 1.0, 'limit': 1.043e-05, + 'order_rate': 1.043e-05, 'amount': 91.07468123, 'order_type': 'limit', 'open_rate': 1.098e-05, @@ -1092,6 +1094,7 @@ def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None 'pair': 'ETH/BTC', 'gain': 'loss', 'leverage': 1.0, + 'order_rate': 1.099e-05, 'limit': 1.099e-05, 'amount': 91.07468123, 'order_type': 'limit', @@ -1744,7 +1747,7 @@ def test_send_msg_enter_notification(default_conf, mocker, caplog, message_type, 'exchange': 'Binance', 'pair': 'ETH/BTC', 'leverage': leverage, - 'limit': 1.099e-05, + 'open_rate': 1.099e-05, 'order_type': 'limit', 'direction': enter, 'stake_amount': 0.01465333, @@ -1915,7 +1918,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'leverage': 1.0, 'direction': 'Long', 'gain': 'loss', - 'limit': 3.201e-05, + 'order_rate': 3.201e-05, 'amount': 1333.3333333333335, 'order_type': 'market', 'open_rate': 7.5e-05, @@ -1950,7 +1953,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'pair': 'KEY/ETH', 'direction': 'Long', 'gain': 'loss', - 'limit': 3.201e-05, + 'order_rate': 3.201e-05, 'amount': 1333.3333333333335, 'order_type': 'market', 'open_rate': 7.5e-05, @@ -1989,7 +1992,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'pair': 'KEY/ETH', 'direction': 'Long', 'gain': 'loss', - 'limit': 3.201e-05, + 'order_rate': 3.201e-05, 'amount': 1333.3333333333335, 'order_type': 'market', 'open_rate': 7.5e-05, @@ -2162,7 +2165,7 @@ def test_send_msg_buy_notification_no_fiat( 'exchange': 'Binance', 'pair': 'ETH/BTC', 'leverage': leverage, - 'limit': 1.099e-05, + 'open_rate': 1.099e-05, 'order_type': 'limit', 'direction': enter, 'stake_amount': 0.01465333, @@ -2205,7 +2208,7 @@ def test_send_msg_sell_notification_no_fiat( 'gain': 'loss', 'leverage': leverage, 'direction': direction, - 'limit': 3.201e-05, + 'order_rate': 3.201e-05, 'amount': 1333.3333333333335, 'order_type': 'limit', 'open_rate': 7.5e-05, diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index c1152ac09..7851a73f8 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3256,6 +3256,7 @@ def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_ 'pair': 'ETH/USDT', 'gain': 'profit', 'limit': 2.0 if is_short else 2.2, + 'order_rate': 2.0 if is_short else 2.2, 'amount': pytest.approx(amt), 'order_type': 'limit', 'buy_tag': None, @@ -3321,6 +3322,7 @@ def test_execute_trade_exit_down(default_conf_usdt, ticker_usdt, fee, ticker_usd 'leverage': 1.0, 'gain': 'loss', 'limit': 2.2 if is_short else 2.01, + 'order_rate': 2.2 if is_short else 2.01, 'amount': pytest.approx(29.70297029) if is_short else 30.0, 'order_type': 'limit', 'buy_tag': None, @@ -3405,6 +3407,7 @@ def test_execute_trade_exit_custom_exit_price( 'leverage': 1.0, 'gain': profit_or_loss, 'limit': limit, + 'order_rate': limit, 'amount': pytest.approx(amount), 'order_type': 'limit', 'buy_tag': None, @@ -3476,6 +3479,7 @@ def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( 'leverage': 1.0, 'gain': 'loss', 'limit': 2.02 if is_short else 1.98, + 'order_rate': 2.02 if is_short else 1.98, 'amount': pytest.approx(29.70297029 if is_short else 30.0), 'order_type': 'limit', 'buy_tag': None, @@ -3741,6 +3745,7 @@ def test_execute_trade_exit_market_order( 'leverage': 1.0, 'gain': profit_or_loss, 'limit': limit, + 'order_rate': limit, 'amount': pytest.approx(amount), 'order_type': 'market', 'buy_tag': None, From 8f41f943b44dbda75e38546ee5c7a5d0ab88d553 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 06:42:55 +0200 Subject: [PATCH 151/199] Fix 0.0 amount message wording --- freqtrade/freqtradebot.py | 2 +- tests/test_integration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 539bbcb38..a4bbf291c 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -594,7 +594,7 @@ class FreqtradeBot(LoggingMixin): amount = trade.amount if amount == 0.0: - logger.info("Amount to sell is 0.0 due to exchange limits - not selling.") + logger.info("Amount to exit is 0.0 due to exchange limits - not exiting.") return remaining = (trade.amount - amount) * current_exit_rate diff --git a/tests/test_integration.py b/tests/test_integration.py index 77ed822d1..a7b4fbdd3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -521,4 +521,4 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non assert trade.orders[-1].ft_order_side == 'sell' assert pytest.approx(trade.stake_amount) == 40.198 assert trade.is_open - assert log_has_re('Amount to sell is 0.0 due to exchange limits - not selling.', caplog) + assert log_has_re('Amount to exit is 0.0 due to exchange limits - not exiting.', caplog) From 02f2096fc35d12859c8921c51d2857168d23ee05 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 06:51:14 +0200 Subject: [PATCH 152/199] Reverse and fix rangestability conditions closes #7447 --- freqtrade/plugins/pairlist/rangestabilityfilter.py | 10 +++------- tests/plugins/test_pairlist.py | 4 ++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 0bc2cdb47..1bc7ad48f 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -100,23 +100,19 @@ class RangeStabilityFilter(IPairList): if cached_res is not None: return cached_res - result = False + result = True if daily_candles is not None and not daily_candles.empty: highest_high = daily_candles['high'].max() lowest_low = daily_candles['low'].min() pct_change = ((highest_high - lowest_low) / lowest_low) if lowest_low > 0 else 0 - if pct_change >= self._min_rate_of_change: - result = True - else: + if pct_change < self._min_rate_of_change: self.log_once(f"Removed {pair} from whitelist, because rate of change " f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " f"which is below the threshold of {self._min_rate_of_change}.", logger.info) result = False if self._max_rate_of_change: - if pct_change <= self._max_rate_of_change: - result = True - else: + if pct_change > self._max_rate_of_change: self.log_once( f"Removed {pair} from whitelist, because rate of change " f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 48a0f81cb..538751251 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -467,6 +467,10 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): {"method": "RangeStabilityFilter", "lookback_days": 10, "max_rate_of_change": 0.01, "refresh_period": 1440}], "BTC", []), # All removed because of max_rate_of_change being 0.017 + ([{"method": "StaticPairList"}, + {"method": "RangeStabilityFilter", "lookback_days": 10, + "min_rate_of_change": 0.018, "max_rate_of_change": 0.02, "refresh_period": 1440}], + "BTC", []), # All removed - limits are above the highest change_rate ([{"method": "StaticPairList"}, {"method": "VolatilityFilter", "lookback_days": 3, "min_volatility": 0.002, "max_volatility": 0.004, "refresh_period": 1440}], From f7b8c5a767970eb84d4071beca8d7d2b8fc5fa5e Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 12:46:43 +0000 Subject: [PATCH 153/199] Reorder telegram noise sample --- docs/telegram-usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index b9324def4..055512f26 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -82,6 +82,8 @@ Example configuration showing the different settings: "warning": "on", "startup": "off", "entry": "silent", + "entry_fill": "on", + "entry_cancel": "silent", "exit": { "roi": "silent", "emergency_exit": "on", @@ -93,9 +95,7 @@ Example configuration showing the different settings: "custom_exit": "silent", "partial_exit": "on" }, - "entry_cancel": "silent", "exit_cancel": "on", - "entry_fill": "off", "exit_fill": "off", "protection_trigger": "off", "protection_trigger_global": "on", From 923182680e4a246d0f2fdcbe31092b9a89eeb10e Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 12:43:17 +0000 Subject: [PATCH 154/199] Explicitly define notification defaults --- freqtrade/constants.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 75825c0d0..1b3edddef 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -289,11 +289,12 @@ CONF_SCHEMA = { 'warning': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, 'startup': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, 'entry': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'entry_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'entry_fill': {'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS, - 'default': 'off' - }, + 'entry_fill': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'off' + }, + 'entry_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS, }, 'exit': { 'type': ['string', 'object'], 'additionalProperties': { @@ -301,12 +302,12 @@ CONF_SCHEMA = { 'enum': TELEGRAM_SETTING_OPTIONS } }, - 'exit_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, 'exit_fill': { 'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS, 'default': 'on' }, + 'exit_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, 'protection_trigger': { 'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS, @@ -315,14 +316,17 @@ CONF_SCHEMA = { 'protection_trigger_global': { 'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'on' }, 'show_candle': { 'type': 'string', 'enum': ['off', 'ohlc'], + 'default': 'off' }, 'strategy_msg': { 'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'on' }, } }, From 366c6c24d897cb2e0d52e7ffbe2b927e553b79ac Mon Sep 17 00:00:00 2001 From: paranoidandy Date: Wed, 21 Sep 2022 15:22:50 +0100 Subject: [PATCH 155/199] Add docs for External Signals API --- docs/configuration.md | 5 +- docs/rest-api.md | 146 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f8a600e76..a37ca53ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,7 +225,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `webhook.webhookexitcancel` | Payload to send on exit order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookexitfill` | Payload to send on exit order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String -| | **Rest API / FreqUI** +| | **Rest API / FreqUI / External Signals** | `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean | `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** IPv4 | `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Integer between 1024 and 65535 @@ -233,6 +233,9 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String +| `api_server.enable_message_ws` | Enable external signal publishing. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean +| `api_server.ws_token` | API token for external signal publishing. Only required if `api_server.enable_message_ws` is `true`. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `external_message_consumer` | Enable consuming of external signals. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Dict | | **Other** | `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below.
**Datatype:** Boolean diff --git a/docs/rest-api.md b/docs/rest-api.md index 704f38f00..939258ca6 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -1,4 +1,4 @@ -# REST API & FreqUI +# REST API, FreqUI & External Signals ## FreqUI @@ -460,3 +460,147 @@ The correct configuration for this case is `http://localhost:8080` - the main pa !!! Note We strongly recommend to also set `jwt_secret_key` to something random and known only to yourself to avoid unauthorized access to your bot. + +## External Signals API + +FreqTrade provides a mechanism whereby a leader instance may provide analyzed dataframes to a subscriber/follower instance (or instances) using websockets. + +Run a bot in Leader mode to broadcast any populated indicators in the dataframes for each pair to bots running in Follower mode. This allows the reuse of computed indicators in multiple bots without needing to compute them multiple times. + +### Leader configuration + +Enable the leader websocket api by adding `enable_message_ws` to your `api_server` section and setting it to `true`, and providing an api token with `ws_token`. See [Security](#security) above for advice on token generation. + +!!! Note + We strongly recommend to also set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. + +```jsonc +{ + //... + "api_server": { + //... + "enable_message_ws": true, + "ws_token": "mysecretapitoken" + //... + } + //... +} +``` + +The leader instance will listen for incoming reqests on the port configured by `api_server.listen_port` to forward its analyzed dataframes for each pair in its active whitelist calculated in its `populate_indicators()`. + +### Follower configuration + +Enable subscribing to a leader instance by adding the `external_message_consumer` section to the follower's config file. + +```jsonc +{ + //... + "external_message_consumer": { + "enabled": true, + "producers": [ + { + "name": "default", + "host": "127.0.0.1", + "port": 8080, + "ws_token": "mysecretapitoken" + } + ], + "reply_timeout": 10, + "ping_timeout": 5, + "sleep_time": 5, + "message_size_limit": 8, // in MB, default=8 + "remove_entry_exit_signals": false + } + //... +} +``` + +Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance opens a websocket connection to a leader instance (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. + +A follower instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. + +### Example - Leader Strategy + +A simple strategy with multiple indicators. No special considerations are required in the strategy itself, only in the configuration file to enable websocket listening. + +```py +class LeaderStrategy(IStrategy): + #... + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Calculate indicators in the standard freqtrade way which can then be broadcast to other instances + """ + dataframe['rsi'] = ta.RSI(dataframe) + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) + dataframe['bb_lowerband'] = bollinger['lower'] + dataframe['bb_middleband'] = bollinger['mid'] + dataframe['bb_upperband'] = bollinger['upper'] + dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9) + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & + (dataframe['tema'] <= dataframe['bb_middleband']) & + (dataframe['tema'] > dataframe['tema'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` + +### Example - Follower Strategy + +A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes to make trading decisions from as the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades. + +```py +class FollowerStrategy(IStrategy): + #... + process_only_new_candles = False # required for followers + + _columns_to_expect = ['rsi_default', 'tema_default', 'bb_middleband_default'] + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Use the websocket api to get pre-populated indicators from another FreqTrade instance. + Use `self.dp.get_external_df(pair)` to get the dataframe + """ + pair = metadata['pair'] + timeframe = self.timeframe + + leader_dataframe, _ = self.dp.get_external_df(pair) + + if not leader_dataframe.empty: + merged_dataframe = merge_informative_pair(dataframe, leader_dataframe, + timeframe, timeframe, + append_timeframe=False, + suffix="default") + return merged_dataframe + else: + dataframe[self._columns_to_expect] = 0 + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + # Use the dataframe columns as if we calculated them ourselves + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi_default'], self.buy_rsi.value)) & + (dataframe['tema_default'] <= dataframe['bb_middleband_default']) & + (dataframe['tema_default'] > dataframe['tema_default'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` \ No newline at end of file From 08e183fb556d3af099f72ef2c78d2e555cdd8510 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 20:59:12 +0200 Subject: [PATCH 156/199] Add note about okx trading mode --- docs/exchanges.md | 2 +- freqtrade/exchange/okx.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/exchanges.md b/docs/exchanges.md index dc2003f9c..a9ba16c64 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -233,7 +233,7 @@ OKX requires a passphrase for each api key, you will therefore need to add this !!! Warning "Futures" OKX Futures has the concept of "position mode" - which can be Net or long/short (hedge mode). - Freqtrade supports both modes - but changing the mode mid-trading is not supported and will lead to exceptions and failures to place trades. + Freqtrade supports both modes (we recommend to use net mode) - but changing the mode mid-trading is not supported and will lead to exceptions and failures to place trades. OKX also only provides MARK candles for the past ~3 months. Backtesting futures prior to that date will therefore lead to slight deviations, as funding-fees cannot be calculated correctly without this data. ## Gate.io diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 49f8ea107..2db5fb6a9 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -71,6 +71,7 @@ class Okx(Exchange): try: if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']: accounts = self._api.fetch_accounts() + self._log_exchange_response('fetch_accounts', accounts) if len(accounts) > 0: self.net_only = accounts[0].get('info', {}).get('posMode') == 'net_mode' except ccxt.DDoSProtection as e: From 91dc5e7aa671ac9e42ec1fc72329089280aacabb Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 21 Sep 2022 21:12:08 +0200 Subject: [PATCH 157/199] Be sure to provide an amount in entry notifications --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a4bbf291c..eb5705c34 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -921,7 +921,7 @@ class FreqtradeBot(LoggingMixin): 'stake_amount': trade.stake_amount, 'stake_currency': self.config['stake_currency'], 'fiat_currency': self.config.get('fiat_display_currency', None), - 'amount': order.safe_amount_after_fee if fill else order.amount, + 'amount': order.safe_amount_after_fee if fill else (order.amount or trade.amount), 'open_date': trade.open_date or datetime.utcnow(), 'current_rate': current_rate, 'sub_trade': sub_trade, From 0811bca8b4ef384ee6c08a08f7fa6b3e344cc086 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 21 Sep 2022 15:50:11 -0600 Subject: [PATCH 158/199] revise docs, update dp method docstring --- docs/advanced-external-signals.md | 133 +++++++++++++++++++++++++++ docs/configuration.md | 5 +- docs/rest-api.md | 144 ------------------------------ freqtrade/data/dataprovider.py | 1 + mkdocs.yml | 1 + 5 files changed, 137 insertions(+), 147 deletions(-) create mode 100644 docs/advanced-external-signals.md diff --git a/docs/advanced-external-signals.md b/docs/advanced-external-signals.md new file mode 100644 index 000000000..caf708ee2 --- /dev/null +++ b/docs/advanced-external-signals.md @@ -0,0 +1,133 @@ +FreqTrade provides a mechanism whereby an instance may listen to messages from an upstream FreqTrade instance using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. + +See [Message Websocket](rest-api.md#message-websocket) in the Rest API docs for setting up the `api_server` configuration for your message websocket. + +!!! Note + We strongly recommend to also set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. + +### Configuration + +Enable subscribing to an instance by adding the `external_message_consumer` section to the follower's config file. + +```jsonc +{ + //... + "external_message_consumer": { + "enabled": true, + "producers": [ + { + "name": "default", // This can be any name you'd like, default is "default" + "host": "127.0.0.1", // The host from your leader's api_server config + "port": 8080, // The port from your leader's api_server config + "ws_token": "mysecretapitoken" // The ws_token from your leader's api_server config + } + ], + } + //... +} +``` + +See [`config_examples/config_full.example.json`](https://github.com/freqtrade/freqtrade/blob/develop/config_examples/config_full.example.json) for all of the parameters you can set. + +Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a leader instance's message websocket (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. + +A follower instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. + +### Example - Leader Strategy + +A simple strategy with multiple indicators. No special considerations are required in the strategy itself. + +```py +class LeaderStrategy(IStrategy): + #... + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Calculate indicators in the standard freqtrade way which can then be broadcast to other instances + """ + dataframe['rsi'] = ta.RSI(dataframe) + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) + dataframe['bb_lowerband'] = bollinger['lower'] + dataframe['bb_middleband'] = bollinger['mid'] + dataframe['bb_upperband'] = bollinger['upper'] + dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9) + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & + (dataframe['tema'] <= dataframe['bb_middleband']) & + (dataframe['tema'] > dataframe['tema'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` + +### Example - Follower Strategy + +A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes to make trading decisions from as the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades. + +```py +class FollowerStrategy(IStrategy): + #... + process_only_new_candles = False # required for followers + + _columns_to_expect = ['rsi_default', 'tema_default', 'bb_middleband_default'] + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Use the websocket api to get pre-populated indicators from another FreqTrade instance. + Use `self.dp.get_external_df(pair)` to get the dataframe + """ + pair = metadata['pair'] + timeframe = self.timeframe + + leader_pairs = self.dp.get_producer_pairs() + # You can specify which producer to get pairs from via: + # self.dp.get_producer_pairs("my_other_producer") + + # This func returns the analyzed dataframe, and when it was analyzed + leader_dataframe, _ = self.dp.get_external_df(pair) + # You can get other data if your leader makes it available: + # self.dp.get_external_df( + # pair, + # timeframe="1h", + # candle_type=CandleType.SPOT, + # producer_name="my_other_producer" + # ) + + if not leader_dataframe.empty: + # If you plan on passing the leader's entry/exit signal directly, + # specify ffill=False or it will have unintended results + merged_dataframe = merge_informative_pair(dataframe, leader_dataframe, + timeframe, timeframe, + append_timeframe=False, + suffix="default") + return merged_dataframe + else: + dataframe[self._columns_to_expect] = 0 + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + # Use the dataframe columns as if we calculated them ourselves + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi_default'], self.buy_rsi.value)) & + (dataframe['tema_default'] <= dataframe['bb_middleband_default']) & + (dataframe['tema_default'] > dataframe['tema_default'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` diff --git a/docs/configuration.md b/docs/configuration.md index a37ca53ab..f4fb46707 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,7 +110,7 @@ This is similar to using multiple `--config` parameters, but simpler in usage as "stake_amount": "unlimited" } ``` - + If multiple files are in the `add_config_files` section, then they will be assumed to be at identical levels, having the last occurrence override the earlier config (unless a parent already defined such a key). ## Configuration parameters @@ -232,9 +232,8 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.verbosity` | Logging verbosity. `info` will print all RPC Calls, while "error" will only display errors.
**Datatype:** Enum, either `info` or `error`. Defaults to `info`. | `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `api_server.ws_token` | API token for the Message WebSocket. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String -| `api_server.enable_message_ws` | Enable external signal publishing. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean -| `api_server.ws_token` | API token for external signal publishing. Only required if `api_server.enable_message_ws` is `true`. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `external_message_consumer` | Enable consuming of external signals. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Dict | | **Other** | `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` diff --git a/docs/rest-api.md b/docs/rest-api.md index 939258ca6..e1ba1e889 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -460,147 +460,3 @@ The correct configuration for this case is `http://localhost:8080` - the main pa !!! Note We strongly recommend to also set `jwt_secret_key` to something random and known only to yourself to avoid unauthorized access to your bot. - -## External Signals API - -FreqTrade provides a mechanism whereby a leader instance may provide analyzed dataframes to a subscriber/follower instance (or instances) using websockets. - -Run a bot in Leader mode to broadcast any populated indicators in the dataframes for each pair to bots running in Follower mode. This allows the reuse of computed indicators in multiple bots without needing to compute them multiple times. - -### Leader configuration - -Enable the leader websocket api by adding `enable_message_ws` to your `api_server` section and setting it to `true`, and providing an api token with `ws_token`. See [Security](#security) above for advice on token generation. - -!!! Note - We strongly recommend to also set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. - -```jsonc -{ - //... - "api_server": { - //... - "enable_message_ws": true, - "ws_token": "mysecretapitoken" - //... - } - //... -} -``` - -The leader instance will listen for incoming reqests on the port configured by `api_server.listen_port` to forward its analyzed dataframes for each pair in its active whitelist calculated in its `populate_indicators()`. - -### Follower configuration - -Enable subscribing to a leader instance by adding the `external_message_consumer` section to the follower's config file. - -```jsonc -{ - //... - "external_message_consumer": { - "enabled": true, - "producers": [ - { - "name": "default", - "host": "127.0.0.1", - "port": 8080, - "ws_token": "mysecretapitoken" - } - ], - "reply_timeout": 10, - "ping_timeout": 5, - "sleep_time": 5, - "message_size_limit": 8, // in MB, default=8 - "remove_entry_exit_signals": false - } - //... -} -``` - -Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance opens a websocket connection to a leader instance (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. - -A follower instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. - -### Example - Leader Strategy - -A simple strategy with multiple indicators. No special considerations are required in the strategy itself, only in the configuration file to enable websocket listening. - -```py -class LeaderStrategy(IStrategy): - #... - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """ - Calculate indicators in the standard freqtrade way which can then be broadcast to other instances - """ - dataframe['rsi'] = ta.RSI(dataframe) - bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) - dataframe['bb_lowerband'] = bollinger['lower'] - dataframe['bb_middleband'] = bollinger['mid'] - dataframe['bb_upperband'] = bollinger['upper'] - dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9) - - return dataframe - - def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """ - Populates the entry signal for the given dataframe - """ - dataframe.loc[ - ( - (qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & - (dataframe['tema'] <= dataframe['bb_middleband']) & - (dataframe['tema'] > dataframe['tema'].shift(1)) & - (dataframe['volume'] > 0) - ), - 'enter_long'] = 1 - - return dataframe -``` - -### Example - Follower Strategy - -A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes to make trading decisions from as the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades. - -```py -class FollowerStrategy(IStrategy): - #... - process_only_new_candles = False # required for followers - - _columns_to_expect = ['rsi_default', 'tema_default', 'bb_middleband_default'] - - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """ - Use the websocket api to get pre-populated indicators from another FreqTrade instance. - Use `self.dp.get_external_df(pair)` to get the dataframe - """ - pair = metadata['pair'] - timeframe = self.timeframe - - leader_dataframe, _ = self.dp.get_external_df(pair) - - if not leader_dataframe.empty: - merged_dataframe = merge_informative_pair(dataframe, leader_dataframe, - timeframe, timeframe, - append_timeframe=False, - suffix="default") - return merged_dataframe - else: - dataframe[self._columns_to_expect] = 0 - - return dataframe - - def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - """ - Populates the entry signal for the given dataframe - """ - # Use the dataframe columns as if we calculated them ourselves - dataframe.loc[ - ( - (qtpylib.crossed_above(dataframe['rsi_default'], self.buy_rsi.value)) & - (dataframe['tema_default'] <= dataframe['bb_middleband_default']) & - (dataframe['tema_default'] > dataframe['tema_default'].shift(1)) & - (dataframe['volume'] > 0) - ), - 'enter_long'] = 1 - - return dataframe -``` \ No newline at end of file diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 1dfa53caa..031a6d704 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -163,6 +163,7 @@ class DataProvider: :param pair: pair to get the data for :param timeframe: Timeframe to get data for :param candle_type: Any of the enum CandleType (must match trading mode!) + :returns: Tuple of the DataFrame and last analyzed timestamp """ _timeframe = self._default_timeframe if not timeframe else timeframe _candle_type = self._default_candle_type if not candle_type else candle_type diff --git a/mkdocs.yml b/mkdocs.yml index 257db7867..27995cfc0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,7 @@ nav: - Advanced Post-installation Tasks: advanced-setup.md - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md + - External Signals: advanced-external-signals.md - FreqAI: freqai.md - Edge Positioning: edge.md - Sandbox Testing: sandbox-testing.md From 128b117af66f5091796ce82d9c453fd4432f6f77 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 21 Sep 2022 16:02:21 -0600 Subject: [PATCH 159/199] support list of tokens in ws_token --- freqtrade/constants.py | 2 +- freqtrade/rpc/api_server/api_auth.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e2a24ea35..a97ded95b 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -401,7 +401,7 @@ CONF_SCHEMA = { }, 'username': {'type': 'string'}, 'password': {'type': 'string'}, - 'ws_token': {'type': 'string'}, + 'ws_token': {'type': ['string', 'array'], 'items': {'type': 'string'}}, 'jwt_secret_key': {'type': 'string'}, 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index e91e5941b..492daf5a2 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -59,9 +59,18 @@ async def validate_ws_token( secret_ws_token = api_config.get('ws_token', None) secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') - if ws_token and secret_ws_token and secrets.compare_digest(secret_ws_token, ws_token): - # Just return the token if it matches - return ws_token + if ws_token and secret_ws_token: + is_valid_ws_token = False + if isinstance(secret_ws_token, str): + is_valid_ws_token = secrets.compare_digest(secret_ws_token, ws_token) + elif isinstance(secret_ws_token, list): + is_valid_ws_token = any([ + secrets.compare_digest(potential, ws_token) + for potential in secret_ws_token + ]) + + if is_valid_ws_token: + return ws_token else: try: user = get_user_from_token(ws_token, secret_jwt_key) From 77ed713232851a72200c1446acf8aae60d9a104d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 21 Sep 2022 16:04:25 -0600 Subject: [PATCH 160/199] add catch for invalid message error --- freqtrade/rpc/external_message_consumer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 220f98706..61b8ac2db 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -212,7 +212,8 @@ class ExternalMessageConsumer: except ( socket.gaierror, ConnectionRefusedError, - websockets.exceptions.InvalidStatusCode + websockets.exceptions.InvalidStatusCode, + websockets.exceptions.InvalidMessage ) as e: logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) @@ -224,7 +225,7 @@ class ExternalMessageConsumer: continue except Exception as e: - # An unforseen error has occurred, log and stop + # An unforseen error has occurred, log and continue logger.error("Unexpected error has occurred:") logger.exception(e) continue From 6a6ae809f465f4d92cc6e2db45877ae621ded5a3 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Wed, 21 Sep 2022 18:23:00 -0600 Subject: [PATCH 161/199] fix jwt auth --- freqtrade/rpc/api_server/api_auth.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index 492daf5a2..ee66fce2b 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -59,6 +59,7 @@ async def validate_ws_token( secret_ws_token = api_config.get('ws_token', None) secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') + # Check if ws_token is/in secret_ws_token if ws_token and secret_ws_token: is_valid_ws_token = False if isinstance(secret_ws_token, str): @@ -71,13 +72,16 @@ async def validate_ws_token( if is_valid_ws_token: return ws_token - else: - try: - user = get_user_from_token(ws_token, secret_jwt_key) - return user - # If the token is a jwt, and it's valid return the user - except HTTPException: - pass + + # Check if ws_token is a JWT + try: + user = get_user_from_token(ws_token, secret_jwt_key) + return user + # If the token is a jwt, and it's valid return the user + except HTTPException: + pass + + # No checks passed, deny the connection logger.debug("Denying websocket request.") # If it doesn't match, close the websocket connection await ws.close(code=status.WS_1008_POLICY_VIOLATION) From b1dbc3a65f3bdab8b1f2ad7fcc127f7de1d42569 Mon Sep 17 00:00:00 2001 From: Wagner Costa Santos Date: Thu, 22 Sep 2022 12:13:51 -0300 Subject: [PATCH 162/199] remove function remove_training_from_backtesting and ensure BT period is correct with startup_candle_count --- freqtrade/data/dataprovider.py | 12 +++++++++--- freqtrade/freqai/data_kitchen.py | 23 ----------------------- freqtrade/optimize/backtesting.py | 9 +++++++-- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 43850ddd9..51cacc2c5 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -90,8 +90,12 @@ class DataProvider: if saved_pair not in self.__cached_pairs_backtesting: timerange = TimeRange.parse_timerange(None if self._config.get( 'timerange') is None else str(self._config.get('timerange'))) + # It is not necessary to add the training candles, as they + # were already added at the beginning of the backtest. + add_train_candles = False + # Move informative start time respecting startup_candle_count - startup_candles = self.get_required_startup(str(timeframe)) + startup_candles = self.get_required_startup(str(timeframe), add_train_candles) tf_seconds = timeframe_to_seconds(str(timeframe)) timerange.subtract_start(tf_seconds * startup_candles) self.__cached_pairs_backtesting[saved_pair] = load_pair_history( @@ -105,7 +109,7 @@ class DataProvider: ) return self.__cached_pairs_backtesting[saved_pair].copy() - def get_required_startup(self, timeframe: str) -> int: + def get_required_startup(self, timeframe: str, add_train_candles: bool = True) -> int: freqai_config = self._config.get('freqai', {}) if not freqai_config.get('enabled', False): return self._config.get('startup_candle_count', 0) @@ -115,7 +119,9 @@ class DataProvider: # make sure the startupcandles is at least the set maximum indicator periods self._config['startup_candle_count'] = max(startup_candles, max(indicator_periods)) tf_seconds = timeframe_to_seconds(timeframe) - train_candles = freqai_config['train_period_days'] * 86400 / tf_seconds + train_candles = 0 + if add_train_candles: + train_candles = freqai_config['train_period_days'] * 86400 / tf_seconds total_candles = int(self._config['startup_candle_count'] + train_candles) logger.info(f'Increasing startup_candle_count for freqai to {total_candles}') return total_candles diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index d2abd0ad2..dd2ef3bad 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -466,27 +466,6 @@ class FreqaiDataKitchen: return df - def remove_training_from_backtesting( - self - ) -> DataFrame: - """ - Function which takes the backtesting time range and - remove training data from dataframe, keeping only the - startup_candle_count candles - """ - startup_candle_count = self.config.get('startup_candle_count', 0) - tf = self.config['timeframe'] - tr = self.config["timerange"] - - backtesting_timerange = TimeRange.parse_timerange(tr) - if startup_candle_count > 0 and backtesting_timerange: - backtesting_timerange.subtract_start(timeframe_to_seconds(tf) * startup_candle_count) - - start = datetime.fromtimestamp(backtesting_timerange.startts, tz=timezone.utc) - df = self.return_dataframe - df = df.loc[df["date"] >= start, :] - return df - def principal_component_analysis(self) -> None: """ Performs Principal Component Analysis on the data for dimensionality reduction @@ -979,8 +958,6 @@ class FreqaiDataKitchen: to_keep = [col for col in dataframe.columns if not col.startswith("&")] self.return_dataframe = pd.concat([dataframe[to_keep], self.full_df], axis=1) - - self.return_dataframe = self.remove_training_from_backtesting() self.full_df = DataFrame() return diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 0a05d740d..20429eb97 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -139,9 +139,14 @@ class Backtesting: # Get maximum required startup period self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) + self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) + + if self.config.get('freqai', {}).get('enabled', False): + # For FreqAI, increase the required_startup to includes the training data + self.required_startup = self.dataprovider.get_required_startup(self.timeframe) + # Add maximum startup candle count to configuration for informative pairs support self.config['startup_candle_count'] = self.required_startup - self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) # strategies which define "can_short=True" will fail to load in Spot mode. @@ -217,7 +222,7 @@ class Backtesting: pairs=self.pairlists.whitelist, timeframe=self.timeframe, timerange=self.timerange, - startup_candles=self.dataprovider.get_required_startup(self.timeframe), + startup_candles=self.config['startup_candle_count'], fail_without_data=True, data_format=self.config.get('dataformat_ohlcv', 'json'), candle_type=self.config.get('candle_type_def', CandleType.SPOT) From 71e6c54ea452a75e0cc1580fc275b19de076a6a7 Mon Sep 17 00:00:00 2001 From: th0rntwig <107926911+th0rntwig@users.noreply.github.com> Date: Thu, 22 Sep 2022 18:11:50 +0200 Subject: [PATCH 163/199] Normalise distances before Weibull fit (#7432) * Normalise distances before Weibull * Track inlier-metric params --- freqtrade/freqai/data_kitchen.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index d2abd0ad2..005005368 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -775,12 +775,22 @@ class FreqaiDataKitchen: def compute_inlier_metric(self, set_='train') -> None: """ - Compute inlier metric from backwards distance distributions. This metric defines how well features from a timepoint fit into previous timepoints. """ + def normalise(dataframe: DataFrame, key: str) -> DataFrame: + if set_ == 'train': + min_value = dataframe.min() + max_value = dataframe.max() + self.data[f'{key}_min'] = min_value + self.data[f'{key}_max'] = max_value + else: + min_value = self.data[f'{key}_min'] + max_value = self.data[f'{key}_max'] + return (dataframe - min_value) / (max_value - min_value) + no_prev_pts = self.freqai_config["feature_parameters"]["inlier_metric_window"] if set_ == 'train': @@ -825,7 +835,12 @@ class FreqaiDataKitchen: inliers = pd.DataFrame(index=distances.index) for key in distances.keys(): current_distances = distances[key].dropna() - fit_params = stats.weibull_min.fit(current_distances) + current_distances = normalise(current_distances, key) + if set_ == 'train': + fit_params = stats.weibull_min.fit(current_distances) + self.data[f'{key}_fit_params'] = fit_params + else: + fit_params = self.data[f'{key}_fit_params'] quantiles = stats.weibull_min.cdf(current_distances, *fit_params) df_inlier = pd.DataFrame( From e6c5c22ea08114d4ea1f5409d1afc4e3cd8d4ec3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 22 Sep 2022 19:58:38 +0200 Subject: [PATCH 164/199] Update websocket/follower docs --- docs/advanced-external-signals.md | 37 ++++++++++++++++++---- docs/configuration.md | 2 +- docs/rest-api.md | 15 ++++----- freqtrade/rpc/external_message_consumer.py | 2 +- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/docs/advanced-external-signals.md b/docs/advanced-external-signals.md index caf708ee2..3fb3ce6ce 100644 --- a/docs/advanced-external-signals.md +++ b/docs/advanced-external-signals.md @@ -1,15 +1,17 @@ -FreqTrade provides a mechanism whereby an instance may listen to messages from an upstream FreqTrade instance using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. +# External Signals + +freqtrade provides a mechanism whereby an instance may listen to messages from an upstream freqtrade instance using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. See [Message Websocket](rest-api.md#message-websocket) in the Rest API docs for setting up the `api_server` configuration for your message websocket. !!! Note We strongly recommend to also set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. -### Configuration +## Configuration Enable subscribing to an instance by adding the `external_message_consumer` section to the follower's config file. -```jsonc +```json { //... "external_message_consumer": { @@ -22,17 +24,38 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect "ws_token": "mysecretapitoken" // The ws_token from your leader's api_server config } ], + // The following configurations are optional, and usually not required + // "wait_timeout": 300, + // "ping_timeout": 10, + // "sleep_time": 10, + // "remove_entry_exit_signals": false, + // "message_size_limit": 8 } //... } ``` -See [`config_examples/config_full.example.json`](https://github.com/freqtrade/freqtrade/blob/develop/config_examples/config_full.example.json) for all of the parameters you can set. +| Parameter | Description | +|------------|-------------| +| `enabled` | **Required.** Enable follower mode. If set to false, all other settings in this section are ignored.
*Defaults to `false`.*
**Datatype:** boolean . +| `producers` | **Required.** List of producers
**Datatype:** Array. +| `producers.name` | **Required.** Name of this producer. This name must be used in calls to `get_producer_pairs()` and `get_external_df()` if more than one producer is used.
**Datatype:** string +| `producers.host` | **Required.** The hostname or IP address from your leader.
**Datatype:** string +| `producers.port` | **Required.** The port matching the above host.
**Datatype:** string +| `producers.ws_token` | **Required.** `ws_token` as configured on the leader.
**Datatype:** string +| | **Optional settings** +| `wait_timeout` | Timeout until we ping again if no message is received.
*Defaults to `300`.*
**Datatype:** Integer - in seconds. +| `wait_timeout` | Ping timeout
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `sleep_time` | Sleep time before retrying to connect.
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `remove_entry_exit_signals` | Remove signal columns from the dataframe (set them to 0) on dataframe receipt.
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `message_size_limit` | Size limit per message
*Defaults to `8`.*
**Datatype:** Integer - Megabytes. -Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a leader instance's message websocket (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. +Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a leader instance's messages (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. A follower instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. +## Examples + ### Example - Leader Strategy A simple strategy with multiple indicators. No special considerations are required in the strategy itself. @@ -71,7 +94,7 @@ class LeaderStrategy(IStrategy): ### Example - Follower Strategy -A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes to make trading decisions from as the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades. +A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes available to make trading decisions based on the indicators calculated in the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades, and only use the indicators as specified. ```py class FollowerStrategy(IStrategy): @@ -82,7 +105,7 @@ class FollowerStrategy(IStrategy): def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Use the websocket api to get pre-populated indicators from another FreqTrade instance. + Use the websocket api to get pre-populated indicators from another freqtrade instance. Use `self.dp.get_external_df(pair)` to get the dataframe """ pair = metadata['pair'] diff --git a/docs/configuration.md b/docs/configuration.md index f4fb46707..211feef05 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,7 +234,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.ws_token` | API token for the Message WebSocket. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String -| `external_message_consumer` | Enable consuming of external signals. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Dict +| `external_message_consumer` | Enable consuming of external signals. See the [External signals](advanced-external-signals.md) for more details.
**Datatype:** Dict | | **Other** | `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below.
**Datatype:** Boolean diff --git a/docs/rest-api.md b/docs/rest-api.md index e1ba1e889..1f09704b7 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -1,4 +1,4 @@ -# REST API, FreqUI & External Signals +# REST API & FreqUI ## FreqUI @@ -325,15 +325,14 @@ whitelist ### Message WebSocket -The API Server makes available a websocket endpoint for subscribing to RPC messages -from the FreqTrade Bot. This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. +The API Server includes a websocket endpoint for subscribing to RPC messages +from the freqtrade Bot. This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. Assuming your rest API is set to `127.0.0.1` on port `8080`, the endpoint is available at `http://localhost:8080/api/v1/message/ws`. To access the websocket endpoint, the `ws_token` is required as a query parameter in the endpoint URL. - - To generate a safe `ws_token` you can run the following code: +To generate a safe `ws_token` you can run the following code: ``` python >>> import secrets @@ -341,7 +340,6 @@ To access the websocket endpoint, the `ws_token` is required as a query paramete 'hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q' ``` - You would then add that token under `ws_token` in your `api_server` config. Like so: ``` json @@ -361,11 +359,9 @@ You would then add that token under `ws_token` in your `api_server` config. Like You can now connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q`. -!!! warning "Warning" - +!!! Danger "Reuse of example tokens" Please do not use the above example token. To make sure you are secure, generate a completely new token. - #### Using the WebSocket Once connected to the WebSocket, the bot will broadcast RPC messages to anyone who is subscribed to them. To subscribe to a list of messages, you must send a JSON request through the WebSocket like the one below. The `data` key must be a list of message type strings. @@ -376,6 +372,7 @@ Once connected to the WebSocket, the bot will broadcast RPC messages to anyone w "data": ["whitelist", "analyzed_df"] // A list of string message types } ``` + For a list of message types, please refer to the RPCMessageType enum in `freqtrade/enums/rpcmessagetype.py` Now anytime those types of RPC messages are sent in the bot, you will receive them through the WebSocket as long as the connection is active. They typically take the same form as the request: diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 61b8ac2db..a57fac144 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -35,7 +35,7 @@ logger = logging.getLogger(__name__) class ExternalMessageConsumer: """ The main controller class for consuming external messages from - other FreqTrade bot's + other freqtrade bot's """ def __init__( From 1626eb7f97f3a3d4edbd5a5768dbc68916787a9e Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 22 Sep 2022 20:46:40 +0200 Subject: [PATCH 165/199] Update dataprovider function name to `get_producer_df` --- docs/advanced-external-signals.md | 8 ++++---- freqtrade/data/dataprovider.py | 4 ++-- tests/data/test_dataprovider.py | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/advanced-external-signals.md b/docs/advanced-external-signals.md index 3fb3ce6ce..43ff8f6d0 100644 --- a/docs/advanced-external-signals.md +++ b/docs/advanced-external-signals.md @@ -39,7 +39,7 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect |------------|-------------| | `enabled` | **Required.** Enable follower mode. If set to false, all other settings in this section are ignored.
*Defaults to `false`.*
**Datatype:** boolean . | `producers` | **Required.** List of producers
**Datatype:** Array. -| `producers.name` | **Required.** Name of this producer. This name must be used in calls to `get_producer_pairs()` and `get_external_df()` if more than one producer is used.
**Datatype:** string +| `producers.name` | **Required.** Name of this producer. This name must be used in calls to `get_producer_pairs()` and `get_producer_df()` if more than one producer is used.
**Datatype:** string | `producers.host` | **Required.** The hostname or IP address from your leader.
**Datatype:** string | `producers.port` | **Required.** The port matching the above host.
**Datatype:** string | `producers.ws_token` | **Required.** `ws_token` as configured on the leader.
**Datatype:** string @@ -106,7 +106,7 @@ class FollowerStrategy(IStrategy): def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Use the websocket api to get pre-populated indicators from another freqtrade instance. - Use `self.dp.get_external_df(pair)` to get the dataframe + Use `self.dp.get_producer_df(pair)` to get the dataframe """ pair = metadata['pair'] timeframe = self.timeframe @@ -116,9 +116,9 @@ class FollowerStrategy(IStrategy): # self.dp.get_producer_pairs("my_other_producer") # This func returns the analyzed dataframe, and when it was analyzed - leader_dataframe, _ = self.dp.get_external_df(pair) + leader_dataframe, _ = self.dp.get_producer_df(pair) # You can get other data if your leader makes it available: - # self.dp.get_external_df( + # self.dp.get_producer_df( # pair, # timeframe="1h", # candle_type=CandleType.SPOT, diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 031a6d704..1a0903516 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -150,7 +150,7 @@ class DataProvider: self.__producer_pairs_df[producer_name][pair_key] = (dataframe, _last_analyzed) logger.debug(f"External DataFrame for {pair_key} from {producer_name} added.") - def get_external_df( + def get_producer_df( self, pair: str, timeframe: Optional[str] = None, @@ -158,7 +158,7 @@ class DataProvider: producer_name: str = "default" ) -> Tuple[DataFrame, datetime]: """ - Get the pair data from the external sources. + Get the pair data from producers. :param pair: pair to get the data for :param timeframe: Timeframe to get data for diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 9915b6316..8500fa06c 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -161,7 +161,7 @@ def test_producer_pairs(mocker, default_conf, ohlcv_history): assert dataprovider.get_producer_pairs("bad") == [] -def test_external_df(mocker, default_conf, ohlcv_history): +def test_get_producer_df(mocker, default_conf, ohlcv_history): dataprovider = DataProvider(default_conf, None) pair = 'BTC/USDT' @@ -172,23 +172,23 @@ def test_external_df(mocker, default_conf, ohlcv_history): now = datetime.now(timezone.utc) # no data has been added, any request should return an empty dataframe - dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) + dataframe, la = dataprovider.get_producer_df(pair, timeframe, candle_type) assert dataframe.empty assert la == empty_la # the data is added, should return that added dataframe dataprovider._add_external_df(pair, ohlcv_history, now, timeframe, candle_type) - dataframe, la = dataprovider.get_external_df(pair, timeframe, candle_type) + dataframe, la = dataprovider.get_producer_df(pair, timeframe, candle_type) assert len(dataframe) > 0 assert la > empty_la # no data on this producer, should return empty dataframe - dataframe, la = dataprovider.get_external_df(pair, producer_name='bad') + dataframe, la = dataprovider.get_producer_df(pair, producer_name='bad') assert dataframe.empty assert la == empty_la # non existent timeframe, empty dataframe - datframe, la = dataprovider.get_external_df(pair, timeframe='1h') + datframe, la = dataprovider.get_producer_df(pair, timeframe='1h') assert dataframe.empty assert la == empty_la From 06a5cfa4010d9a276b33ea19d51a213bbf174ce0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 22 Sep 2022 21:08:06 +0200 Subject: [PATCH 166/199] Update "branding" to producer/follower mode --- docs/configuration.md | 2 +- ...ternal-signals.md => producer-consumer.md} | 57 +++++++++++-------- docs/rest-api.md | 7 ++- mkdocs.yml | 2 +- 4 files changed, 38 insertions(+), 30 deletions(-) rename docs/{advanced-external-signals.md => producer-consumer.md} (68%) diff --git a/docs/configuration.md b/docs/configuration.md index 211feef05..fe94613ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,7 +234,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.ws_token` | API token for the Message WebSocket. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String -| `external_message_consumer` | Enable consuming of external signals. See the [External signals](advanced-external-signals.md) for more details.
**Datatype:** Dict +| `external_message_consumer` | Enable [Producer/Consumer mode](producer-consumer.md) for more details.
**Datatype:** Dict | | **Other** | `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below.
**Datatype:** Boolean diff --git a/docs/advanced-external-signals.md b/docs/producer-consumer.md similarity index 68% rename from docs/advanced-external-signals.md rename to docs/producer-consumer.md index 43ff8f6d0..b69406edf 100644 --- a/docs/advanced-external-signals.md +++ b/docs/producer-consumer.md @@ -1,15 +1,15 @@ -# External Signals +# Producer / Consumer mode -freqtrade provides a mechanism whereby an instance may listen to messages from an upstream freqtrade instance using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. +freqtrade provides a mechanism whereby an instance (also called `consumer`) may listen to messages from an upstream freqtrade instance (also called `producer`) using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. -See [Message Websocket](rest-api.md#message-websocket) in the Rest API docs for setting up the `api_server` configuration for your message websocket. +See [Message Websocket](rest-api.md#message-websocket) in the Rest API docs for setting up the `api_server` configuration for your message websocket (this will be your producer). !!! Note - We strongly recommend to also set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. + We strongly recommend to set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. ## Configuration -Enable subscribing to an instance by adding the `external_message_consumer` section to the follower's config file. +Enable subscribing to an instance by adding the `external_message_consumer` section to the consumer's config file. ```json { @@ -19,9 +19,9 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect "producers": [ { "name": "default", // This can be any name you'd like, default is "default" - "host": "127.0.0.1", // The host from your leader's api_server config - "port": 8080, // The port from your leader's api_server config - "ws_token": "mysecretapitoken" // The ws_token from your leader's api_server config + "host": "127.0.0.1", // The host from your producer's api_server config + "port": 8080, // The port from your producer's api_server config + "ws_token": "sercet_Ws_t0ken" // The ws_token from your producer's api_server config } ], // The following configurations are optional, and usually not required @@ -37,12 +37,12 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect | Parameter | Description | |------------|-------------| -| `enabled` | **Required.** Enable follower mode. If set to false, all other settings in this section are ignored.
*Defaults to `false`.*
**Datatype:** boolean . +| `enabled` | **Required.** Enable consumer mode. If set to false, all other settings in this section are ignored.
*Defaults to `false`.*
**Datatype:** boolean . | `producers` | **Required.** List of producers
**Datatype:** Array. | `producers.name` | **Required.** Name of this producer. This name must be used in calls to `get_producer_pairs()` and `get_producer_df()` if more than one producer is used.
**Datatype:** string -| `producers.host` | **Required.** The hostname or IP address from your leader.
**Datatype:** string +| `producers.host` | **Required.** The hostname or IP address from your producer.
**Datatype:** string | `producers.port` | **Required.** The port matching the above host.
**Datatype:** string -| `producers.ws_token` | **Required.** `ws_token` as configured on the leader.
**Datatype:** string +| `producers.ws_token` | **Required.** `ws_token` as configured on the producer.
**Datatype:** string | | **Optional settings** | `wait_timeout` | Timeout until we ping again if no message is received.
*Defaults to `300`.*
**Datatype:** Integer - in seconds. | `wait_timeout` | Ping timeout
*Defaults to `10`.*
**Datatype:** Integer - in seconds. @@ -50,18 +50,18 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect | `remove_entry_exit_signals` | Remove signal columns from the dataframe (set them to 0) on dataframe receipt.
*Defaults to `10`.*
**Datatype:** Integer - in seconds. | `message_size_limit` | Size limit per message
*Defaults to `8`.*
**Datatype:** Integer - Megabytes. -Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a leader instance's messages (or multiple leader instances in advanced configurations) and requests the leader's most recently analyzed dataframes for each pair in the active whitelist. +Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a producer instance's messages (or multiple producer instances in advanced configurations) and requests the producer's most recently analyzed dataframes for each pair in the active whitelist. -A follower instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. +A consumer instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. ## Examples -### Example - Leader Strategy +### Example - Producer Strategy A simple strategy with multiple indicators. No special considerations are required in the strategy itself. ```py -class LeaderStrategy(IStrategy): +class ProducerStrategy(IStrategy): #... def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ @@ -92,14 +92,18 @@ class LeaderStrategy(IStrategy): return dataframe ``` -### Example - Follower Strategy +!!! Tip "FreqAI" + You can use this to setup [FreqAI](freqai.md) on a powerful machine, while you run consumers on simple machines like raspberries, which can interpret the signals generated from the producer in different ways. -A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes available to make trading decisions based on the indicators calculated in the leader. In this example the follower has the same entry criteria, however this is not necessary. The follower may use different logic to enter/exit trades, and only use the indicators as specified. + +### Example - Consumer Strategy + +A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes available to make trading decisions based on the indicators calculated in the producer. In this example the consumer has the same entry criteria, however this is not necessary. The consumer may use different logic to enter/exit trades, and only use the indicators as specified. ```py -class FollowerStrategy(IStrategy): +class ConsumerStrategy(IStrategy): #... - process_only_new_candles = False # required for followers + process_only_new_candles = False # required for consumers _columns_to_expect = ['rsi_default', 'tema_default', 'bb_middleband_default'] @@ -111,13 +115,13 @@ class FollowerStrategy(IStrategy): pair = metadata['pair'] timeframe = self.timeframe - leader_pairs = self.dp.get_producer_pairs() + producer_pairs = self.dp.get_producer_pairs() # You can specify which producer to get pairs from via: # self.dp.get_producer_pairs("my_other_producer") # This func returns the analyzed dataframe, and when it was analyzed - leader_dataframe, _ = self.dp.get_producer_df(pair) - # You can get other data if your leader makes it available: + producer_dataframe, _ = self.dp.get_producer_df(pair) + # You can get other data if the producer makes it available: # self.dp.get_producer_df( # pair, # timeframe="1h", @@ -125,10 +129,10 @@ class FollowerStrategy(IStrategy): # producer_name="my_other_producer" # ) - if not leader_dataframe.empty: - # If you plan on passing the leader's entry/exit signal directly, + if not producer_dataframe.empty: + # If you plan on passing the producer's entry/exit signal directly, # specify ffill=False or it will have unintended results - merged_dataframe = merge_informative_pair(dataframe, leader_dataframe, + merged_dataframe = merge_informative_pair(dataframe, producer_dataframe, timeframe, timeframe, append_timeframe=False, suffix="default") @@ -154,3 +158,6 @@ class FollowerStrategy(IStrategy): return dataframe ``` + +!!! Tip "Using upstream signals" + By setting `remove_entry_exit_signals=false`, you can also use the producer's signals directly. They should be available as `enter_long_default` (assuming `suffix="default"` was used) - and can be used as either signal directly, or as additional indicator. diff --git a/docs/rest-api.md b/docs/rest-api.md index 1f09704b7..c7d762648 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -94,7 +94,6 @@ Make sure that the following 2 lines are available in your docker-compose file: !!! Danger "Security warning" By using `8080:8080` in the docker port mapping, the API will be available to everyone connecting to the server under the correct port, so others may be able to control your bot. - ## Rest API ### Consuming the API @@ -325,8 +324,10 @@ whitelist ### Message WebSocket -The API Server includes a websocket endpoint for subscribing to RPC messages -from the freqtrade Bot. This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. +The API Server includes a websocket endpoint for subscribing to RPC messages from the freqtrade Bot. +This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. + +This is also used to setup [Producer/Consumer mode](producer-consumer.md) in Freqtrade. Assuming your rest API is set to `127.0.0.1` on port `8080`, the endpoint is available at `http://localhost:8080/api/v1/message/ws`. diff --git a/mkdocs.yml b/mkdocs.yml index 27995cfc0..fd0280e83 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,7 +35,7 @@ nav: - Advanced Post-installation Tasks: advanced-setup.md - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md - - External Signals: advanced-external-signals.md + - Producer/Consumer mode: producer-consumer.md - FreqAI: freqai.md - Edge Positioning: edge.md - Sandbox Testing: sandbox-testing.md From 2a5bc58df898b7d9aef48ff39a750895a5be3279 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:09:34 +0200 Subject: [PATCH 167/199] Split datahandler and history tests --- tests/data/test_datahandler.py | 353 ++++++++++++++++++++++ tests/data/test_history.py | 372 +----------------------- tests/optimize/test_optimize_reports.py | 18 +- 3 files changed, 377 insertions(+), 366 deletions(-) create mode 100644 tests/data/test_datahandler.py diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py new file mode 100644 index 000000000..e8b8a1213 --- /dev/null +++ b/tests/data/test_datahandler.py @@ -0,0 +1,353 @@ +# pragma pylint: disable=missing-docstring, protected-access, C0103 + +import re +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pandas import DataFrame + +from freqtrade.configuration import TimeRange +from freqtrade.constants import AVAILABLE_DATAHANDLERS +from freqtrade.data.history.hdf5datahandler import HDF5DataHandler +from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, get_datahandlerclass +from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler +from freqtrade.enums import CandleType, TradingMode +from tests.conftest import log_has + + +def test_datahandler_ohlcv_get_pairs(testdatadir): + pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'UNITTEST/BTC', 'XLM/BTC', 'ETH/BTC', 'TRX/BTC', 'LTC/BTC', + 'XMR/BTC', 'ZEC/BTC', 'ADA/BTC', 'ETC/BTC', 'NXT/BTC', + 'DASH/BTC', 'XRP/ETH'} + + pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '8m', candle_type=CandleType.SPOT) + assert set(pairs) == {'UNITTEST/BTC'} + + pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) + assert set(pairs) == {'UNITTEST/BTC'} + + pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) + assert set(pairs) == {'UNITTEST/USDT', 'XRP/USDT'} + + pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.FUTURES) + assert set(pairs) == {'XRP/USDT'} + + pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) + assert set(pairs) == {'UNITTEST/USDT:USDT'} + + +@pytest.mark.parametrize('filename,pair,timeframe,candletype', [ + ('XMR_BTC-5m.json', 'XMR_BTC', '5m', ''), + ('XMR_USDT-1h.h5', 'XMR_USDT', '1h', ''), + ('BTC-PERP-1h.h5', 'BTC-PERP', '1h', ''), + ('BTC_USDT-2h.jsongz', 'BTC_USDT', '2h', ''), + ('BTC_USDT-2h-mark.jsongz', 'BTC_USDT', '2h', 'mark'), + ('XMR_USDT-1h-mark.h5', 'XMR_USDT', '1h', 'mark'), + ('XMR_USDT-1h-random.h5', 'XMR_USDT', '1h', 'random'), + ('BTC-PERP-1h-index.h5', 'BTC-PERP', '1h', 'index'), + ('XMR_USDT_USDT-1h-mark.h5', 'XMR_USDT_USDT', '1h', 'mark'), +]) +def test_datahandler_ohlcv_regex(filename, pair, timeframe, candletype): + regex = JsonDataHandler._OHLCV_REGEX + + match = re.search(regex, filename) + assert len(match.groups()) > 1 + assert match[1] == pair + assert match[2] == timeframe + assert match[3] == candletype + + +@pytest.mark.parametrize('input,expected', [ + ('XMR_USDT', 'XMR/USDT'), + ('BTC_USDT', 'BTC/USDT'), + ('USDT_BUSD', 'USDT/BUSD'), + ('BTC_USDT_USDT', 'BTC/USDT:USDT'), # Futures + ('XRP_USDT_USDT', 'XRP/USDT:USDT'), # futures + ('BTC-PERP', 'BTC-PERP'), + ('BTC-PERP_USDT', 'BTC-PERP:USDT'), # potential FTX case + ('UNITTEST_USDT', 'UNITTEST/USDT'), +]) +def test_rebuild_pair_from_filename(input, expected): + + assert IDataHandler.rebuild_pair_from_filename(input) == expected + + +def test_datahandler_ohlcv_get_available_data(testdatadir): + paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + # Convert to set to avoid failures due to sorting + assert set(paircombs) == { + ('UNITTEST/BTC', '5m', CandleType.SPOT), + ('ETH/BTC', '5m', CandleType.SPOT), + ('XLM/BTC', '5m', CandleType.SPOT), + ('TRX/BTC', '5m', CandleType.SPOT), + ('LTC/BTC', '5m', CandleType.SPOT), + ('XMR/BTC', '5m', CandleType.SPOT), + ('ZEC/BTC', '5m', CandleType.SPOT), + ('UNITTEST/BTC', '1m', CandleType.SPOT), + ('ADA/BTC', '5m', CandleType.SPOT), + ('ETC/BTC', '5m', CandleType.SPOT), + ('NXT/BTC', '5m', CandleType.SPOT), + ('DASH/BTC', '5m', CandleType.SPOT), + ('XRP/ETH', '1m', CandleType.SPOT), + ('XRP/ETH', '5m', CandleType.SPOT), + ('UNITTEST/BTC', '30m', CandleType.SPOT), + ('UNITTEST/BTC', '8m', CandleType.SPOT), + ('NOPAIR/XXX', '4m', CandleType.SPOT), + } + + paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.FUTURES) + # Convert to set to avoid failures due to sorting + assert set(paircombs) == { + ('UNITTEST/USDT', '1h', 'mark'), + ('XRP/USDT', '1h', 'futures'), + ('XRP/USDT', '1h', 'mark'), + ('XRP/USDT', '8h', 'mark'), + ('XRP/USDT', '8h', 'funding_rate'), + } + + paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + assert set(paircombs) == {('UNITTEST/BTC', '8m', CandleType.SPOT)} + paircombs = HDF5DataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + assert set(paircombs) == {('UNITTEST/BTC', '5m', CandleType.SPOT)} + + +def test_jsondatahandler_trades_get_pairs(testdatadir): + pairs = JsonGzDataHandler.trades_get_pairs(testdatadir) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'XRP/ETH', 'XRP/OLD'} + + +def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 2 + + +def test_jsondatahandler_ohlcv_load(testdatadir, caplog): + dh = JsonDataHandler(testdatadir) + df = dh.ohlcv_load('XRP/ETH', '5m', 'spot') + assert len(df) == 711 + + df_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', candle_type="mark") + assert len(df_mark) == 99 + + df_no_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', 'spot') + assert len(df_no_mark) == 0 + + # Failure case (empty array) + df1 = dh.ohlcv_load('NOPAIR/XXX', '4m', 'spot') + assert len(df1) == 0 + assert log_has("Could not load data for NOPAIR/XXX.", caplog) + assert df.columns.equals(df1.columns) + + +def test_jsondatahandler_trades_load(testdatadir, caplog): + dh = JsonGzDataHandler(testdatadir) + logmsg = "Old trades format detected - converting" + dh.trades_load('XRP/ETH') + assert not log_has(logmsg, caplog) + + # Test conversation is happening + dh.trades_load('XRP/OLD') + assert log_has(logmsg, caplog) + + +def test_jsondatahandler_trades_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 1 + + +@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) +def test_datahandler_ohlcv_append(datahandler, testdatadir, ): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.SPOT) + with pytest.raises(NotImplementedError): + dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.MARK) + + +@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) +def test_datahandler_trades_append(datahandler, testdatadir): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.trades_append('UNITTEST/ETH', []) + + +def test_hdf5datahandler_trades_get_pairs(testdatadir): + pairs = HDF5DataHandler.trades_get_pairs(testdatadir) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'XRP/ETH'} + + +def test_hdf5datahandler_trades_load(testdatadir): + dh = HDF5DataHandler(testdatadir) + trades = dh.trades_load('XRP/ETH') + assert isinstance(trades, list) + + trades1 = dh.trades_load('UNITTEST/NONEXIST') + assert trades1 == [] + # data goes from 2019-10-11 - 2019-10-13 + timerange = TimeRange.parse_timerange('20191011-20191012') + + trades2 = dh._trades_load('XRP/ETH', timerange) + assert len(trades) > len(trades2) + # Check that ID is None (If it's nan, it's wrong) + assert trades2[0][2] is None + + # unfiltered load has trades before starttime + assert len([t for t in trades if t[0] < timerange.startts * 1000]) >= 0 + # filtered list does not have trades before starttime + assert len([t for t in trades2 if t[0] < timerange.startts * 1000]) == 0 + # unfiltered load has trades after endtime + assert len([t for t in trades if t[0] > timerange.stopts * 1000]) > 0 + # filtered list does not have trades after endtime + assert len([t for t in trades2 if t[0] > timerange.stopts * 1000]) == 0 + + +def test_hdf5datahandler_trades_store(testdatadir, tmpdir): + tmpdir1 = Path(tmpdir) + dh = HDF5DataHandler(testdatadir) + trades = dh.trades_load('XRP/ETH') + + dh1 = HDF5DataHandler(tmpdir1) + dh1.trades_store('XRP/NEW', trades) + file = tmpdir1 / 'XRP_NEW-trades.h5' + assert file.is_file() + # Load trades back + trades_new = dh1.trades_load('XRP/NEW') + + assert len(trades_new) == len(trades) + assert trades[0][0] == trades_new[0][0] + assert trades[0][1] == trades_new[0][1] + # assert trades[0][2] == trades_new[0][2] # This is nan - so comparison does not make sense + assert trades[0][3] == trades_new[0][3] + assert trades[0][4] == trades_new[0][4] + assert trades[0][5] == trades_new[0][5] + assert trades[0][6] == trades_new[0][6] + assert trades[-1][0] == trades_new[-1][0] + assert trades[-1][1] == trades_new[-1][1] + # assert trades[-1][2] == trades_new[-1][2] # This is nan - so comparison does not make sense + assert trades[-1][3] == trades_new[-1][3] + assert trades[-1][4] == trades_new[-1][4] + assert trades[-1][5] == trades_new[-1][5] + assert trades[-1][6] == trades_new[-1][6] + + +def test_hdf5datahandler_trades_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = HDF5DataHandler(testdatadir) + assert not dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 1 + + +@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ + # Data goes from 2018-01-10 - 2018-01-30 + ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), + # Mark data goes from to 2021-11-15 2021-11-19 + ('UNITTEST/USDT:USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), +]) +def test_hdf5datahandler_ohlcv_load_and_resave( + testdatadir, + tmpdir, + pair, + timeframe, + candle_type, + candle_append, + startdt, enddt +): + tmpdir1 = Path(tmpdir) + tmpdir2 = tmpdir1 + if candle_type not in ('', 'spot'): + tmpdir2 = tmpdir1 / 'futures' + tmpdir2.mkdir() + dh = HDF5DataHandler(testdatadir) + ohlcv = dh._ohlcv_load(pair, timeframe, None, candle_type=candle_type) + assert isinstance(ohlcv, DataFrame) + assert len(ohlcv) > 0 + + file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.h5" + assert not file.is_file() + + dh1 = HDF5DataHandler(tmpdir1) + dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) + assert file.is_file() + + assert not ohlcv[ohlcv['date'] < startdt].empty + + timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") + + # Call private function to ensure timerange is filtered in hdf5 + ohlcv = dh._ohlcv_load(pair, timeframe, timerange, candle_type=candle_type) + ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) + assert len(ohlcv) == len(ohlcv1) + assert ohlcv.equals(ohlcv1) + assert ohlcv[ohlcv['date'] < startdt].empty + assert ohlcv[ohlcv['date'] > enddt].empty + + # Try loading inexisting file + ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) + assert ohlcv.empty + + +def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = HDF5DataHandler(testdatadir) + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 2 + + +def test_gethandlerclass(): + cl = get_datahandlerclass('json') + assert cl == JsonDataHandler + assert issubclass(cl, IDataHandler) + cl = get_datahandlerclass('jsongz') + assert cl == JsonGzDataHandler + assert issubclass(cl, IDataHandler) + assert issubclass(cl, JsonDataHandler) + cl = get_datahandlerclass('hdf5') + assert cl == HDF5DataHandler + assert issubclass(cl, IDataHandler) + with pytest.raises(ValueError, match=r"No datahandler for .*"): + get_datahandlerclass('DeadBeef') + + +def test_get_datahandler(testdatadir): + dh = get_datahandler(testdatadir, 'json') + assert type(dh) == JsonDataHandler + dh = get_datahandler(testdatadir, 'jsongz') + assert type(dh) == JsonGzDataHandler + dh1 = get_datahandler(testdatadir, 'jsongz', dh) + assert id(dh1) == id(dh) + + dh = get_datahandler(testdatadir, 'hdf5') + assert type(dh) == HDF5DataHandler diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 8081e984f..5642442b2 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -1,7 +1,6 @@ # pragma pylint: disable=missing-docstring, protected-access, C0103 import json -import re import uuid from pathlib import Path from shutil import copyfile @@ -13,18 +12,17 @@ from pandas import DataFrame from pandas.testing import assert_frame_equal from freqtrade.configuration import TimeRange -from freqtrade.constants import AVAILABLE_DATAHANDLERS, DATETIME_PRINT_FORMAT +from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.data.converter import ohlcv_to_dataframe -from freqtrade.data.history.hdf5datahandler import HDF5DataHandler from freqtrade.data.history.history_utils import (_download_pair_history, _download_trades_history, _load_cached_data_for_updating, convert_trades_to_ohlcv, get_timerange, load_data, load_pair_history, refresh_backtest_ohlcv_data, refresh_backtest_trades_data, refresh_data, validate_backtest_data) -from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, get_datahandlerclass +from freqtrade.data.history.idatahandler import get_datahandler from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler -from freqtrade.enums import CandleType, TradingMode +from freqtrade.enums import CandleType from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import file_dump_json from freqtrade.resolvers import StrategyResolver @@ -32,25 +30,6 @@ from tests.conftest import (CURRENT_TEST_STRATEGY, get_patched_exchange, log_has patch_exchange) -# Change this if modifying UNITTEST/BTC testdatafile -_BTC_UNITTEST_LENGTH = 13681 - - -def _backup_file(file: Path, copy_file: bool = False) -> None: - """ - Backup existing file to avoid deleting the user file - :param file: complete path to the file - :param copy_file: keep file in place too. - :return: None - """ - file_swp = str(file) + '.swp' - if file.is_file(): - file.rename(file_swp) - - if copy_file: - copyfile(file_swp, file) - - def _clean_test_file(file: Path) -> None: """ Backup existing file to avoid deleting the user file @@ -67,7 +46,7 @@ def _clean_test_file(file: Path) -> None: file_swp.rename(file) -def test_load_data_30min_timeframe(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_30min_timeframe(caplog, testdatadir) -> None: ld = load_pair_history(pair='UNITTEST/BTC', timeframe='30m', datadir=testdatadir) assert isinstance(ld, DataFrame) assert not log_has( @@ -76,7 +55,7 @@ def test_load_data_30min_timeframe(mocker, caplog, default_conf, testdatadir) -> ) -def test_load_data_7min_timeframe(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_7min_timeframe(caplog, testdatadir) -> None: ld = load_pair_history(pair='UNITTEST/BTC', timeframe='7m', datadir=testdatadir) assert isinstance(ld, DataFrame) assert ld.empty @@ -108,7 +87,7 @@ def test_load_data_mark(ohlcv_history, mocker, caplog, testdatadir) -> None: ) -def test_load_data_startup_candles(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_startup_candles(mocker, testdatadir) -> None: ltfmock = mocker.patch( 'freqtrade.data.history.jsondatahandler.JsonDataHandler._ohlcv_load', MagicMock(return_value=DataFrame())) @@ -405,7 +384,7 @@ def test_load_partial_missing(testdatadir, caplog) -> None: caplog) -def test_init(default_conf, mocker) -> None: +def test_init(default_conf) -> None: assert {} == load_data( datadir=Path(''), pairs=[], @@ -685,340 +664,3 @@ def test_convert_trades_to_ohlcv(testdatadir, tmpdir, caplog): convert_trades_to_ohlcv(['NoDatapair'], timeframes=['1m', '5m'], datadir=tmpdir1, timerange=tr, erase=True) assert log_has('Could not convert NoDatapair to OHLCV.', caplog) - - -def test_datahandler_ohlcv_get_pairs(testdatadir): - pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'UNITTEST/BTC', 'XLM/BTC', 'ETH/BTC', 'TRX/BTC', 'LTC/BTC', - 'XMR/BTC', 'ZEC/BTC', 'ADA/BTC', 'ETC/BTC', 'NXT/BTC', - 'DASH/BTC', 'XRP/ETH'} - - pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '8m', candle_type=CandleType.SPOT) - assert set(pairs) == {'UNITTEST/BTC'} - - pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) - assert set(pairs) == {'UNITTEST/BTC'} - - pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) - assert set(pairs) == {'UNITTEST/USDT', 'XRP/USDT'} - - pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.FUTURES) - assert set(pairs) == {'XRP/USDT'} - - pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) - assert set(pairs) == {'UNITTEST/USDT:USDT'} - - -@pytest.mark.parametrize('filename,pair,timeframe,candletype', [ - ('XMR_BTC-5m.json', 'XMR_BTC', '5m', ''), - ('XMR_USDT-1h.h5', 'XMR_USDT', '1h', ''), - ('BTC-PERP-1h.h5', 'BTC-PERP', '1h', ''), - ('BTC_USDT-2h.jsongz', 'BTC_USDT', '2h', ''), - ('BTC_USDT-2h-mark.jsongz', 'BTC_USDT', '2h', 'mark'), - ('XMR_USDT-1h-mark.h5', 'XMR_USDT', '1h', 'mark'), - ('XMR_USDT-1h-random.h5', 'XMR_USDT', '1h', 'random'), - ('BTC-PERP-1h-index.h5', 'BTC-PERP', '1h', 'index'), - ('XMR_USDT_USDT-1h-mark.h5', 'XMR_USDT_USDT', '1h', 'mark'), -]) -def test_datahandler_ohlcv_regex(filename, pair, timeframe, candletype): - regex = JsonDataHandler._OHLCV_REGEX - - match = re.search(regex, filename) - assert len(match.groups()) > 1 - assert match[1] == pair - assert match[2] == timeframe - assert match[3] == candletype - - -@pytest.mark.parametrize('input,expected', [ - ('XMR_USDT', 'XMR/USDT'), - ('BTC_USDT', 'BTC/USDT'), - ('USDT_BUSD', 'USDT/BUSD'), - ('BTC_USDT_USDT', 'BTC/USDT:USDT'), # Futures - ('XRP_USDT_USDT', 'XRP/USDT:USDT'), # futures - ('BTC-PERP', 'BTC-PERP'), - ('BTC-PERP_USDT', 'BTC-PERP:USDT'), # potential FTX case - ('UNITTEST_USDT', 'UNITTEST/USDT'), -]) -def test_rebuild_pair_from_filename(input, expected): - - assert IDataHandler.rebuild_pair_from_filename(input) == expected - - -def test_datahandler_ohlcv_get_available_data(testdatadir): - paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - # Convert to set to avoid failures due to sorting - assert set(paircombs) == { - ('UNITTEST/BTC', '5m', CandleType.SPOT), - ('ETH/BTC', '5m', CandleType.SPOT), - ('XLM/BTC', '5m', CandleType.SPOT), - ('TRX/BTC', '5m', CandleType.SPOT), - ('LTC/BTC', '5m', CandleType.SPOT), - ('XMR/BTC', '5m', CandleType.SPOT), - ('ZEC/BTC', '5m', CandleType.SPOT), - ('UNITTEST/BTC', '1m', CandleType.SPOT), - ('ADA/BTC', '5m', CandleType.SPOT), - ('ETC/BTC', '5m', CandleType.SPOT), - ('NXT/BTC', '5m', CandleType.SPOT), - ('DASH/BTC', '5m', CandleType.SPOT), - ('XRP/ETH', '1m', CandleType.SPOT), - ('XRP/ETH', '5m', CandleType.SPOT), - ('UNITTEST/BTC', '30m', CandleType.SPOT), - ('UNITTEST/BTC', '8m', CandleType.SPOT), - ('NOPAIR/XXX', '4m', CandleType.SPOT), - } - - paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.FUTURES) - # Convert to set to avoid failures due to sorting - assert set(paircombs) == { - ('UNITTEST/USDT', '1h', 'mark'), - ('XRP/USDT', '1h', 'futures'), - ('XRP/USDT', '1h', 'mark'), - ('XRP/USDT', '8h', 'mark'), - ('XRP/USDT', '8h', 'funding_rate'), - } - - paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - assert set(paircombs) == {('UNITTEST/BTC', '8m', CandleType.SPOT)} - paircombs = HDF5DataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - assert set(paircombs) == {('UNITTEST/BTC', '5m', CandleType.SPOT)} - - -def test_jsondatahandler_trades_get_pairs(testdatadir): - pairs = JsonGzDataHandler.trades_get_pairs(testdatadir) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'XRP/ETH', 'XRP/OLD'} - - -def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = JsonGzDataHandler(testdatadir) - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 2 - - -def test_jsondatahandler_ohlcv_load(testdatadir, caplog): - dh = JsonDataHandler(testdatadir) - df = dh.ohlcv_load('XRP/ETH', '5m', 'spot') - assert len(df) == 711 - - df_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', candle_type="mark") - assert len(df_mark) == 99 - - df_no_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', 'spot') - assert len(df_no_mark) == 0 - - # Failure case (empty array) - df1 = dh.ohlcv_load('NOPAIR/XXX', '4m', 'spot') - assert len(df1) == 0 - assert log_has("Could not load data for NOPAIR/XXX.", caplog) - assert df.columns.equals(df1.columns) - - -def test_jsondatahandler_trades_load(testdatadir, caplog): - dh = JsonGzDataHandler(testdatadir) - logmsg = "Old trades format detected - converting" - dh.trades_load('XRP/ETH') - assert not log_has(logmsg, caplog) - - # Test conversation is happening - dh.trades_load('XRP/OLD') - assert log_has(logmsg, caplog) - - -def test_jsondatahandler_trades_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = JsonGzDataHandler(testdatadir) - assert not dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 1 - - -@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) -def test_datahandler_ohlcv_append(datahandler, testdatadir, ): - dh = get_datahandler(testdatadir, datahandler) - with pytest.raises(NotImplementedError): - dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.SPOT) - with pytest.raises(NotImplementedError): - dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.MARK) - - -@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) -def test_datahandler_trades_append(datahandler, testdatadir): - dh = get_datahandler(testdatadir, datahandler) - with pytest.raises(NotImplementedError): - dh.trades_append('UNITTEST/ETH', []) - - -def test_hdf5datahandler_trades_get_pairs(testdatadir): - pairs = HDF5DataHandler.trades_get_pairs(testdatadir) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'XRP/ETH'} - - -def test_hdf5datahandler_trades_load(testdatadir): - dh = HDF5DataHandler(testdatadir) - trades = dh.trades_load('XRP/ETH') - assert isinstance(trades, list) - - trades1 = dh.trades_load('UNITTEST/NONEXIST') - assert trades1 == [] - # data goes from 2019-10-11 - 2019-10-13 - timerange = TimeRange.parse_timerange('20191011-20191012') - - trades2 = dh._trades_load('XRP/ETH', timerange) - assert len(trades) > len(trades2) - # Check that ID is None (If it's nan, it's wrong) - assert trades2[0][2] is None - - # unfiltered load has trades before starttime - assert len([t for t in trades if t[0] < timerange.startts * 1000]) >= 0 - # filtered list does not have trades before starttime - assert len([t for t in trades2 if t[0] < timerange.startts * 1000]) == 0 - # unfiltered load has trades after endtime - assert len([t for t in trades if t[0] > timerange.stopts * 1000]) > 0 - # filtered list does not have trades after endtime - assert len([t for t in trades2 if t[0] > timerange.stopts * 1000]) == 0 - - -def test_hdf5datahandler_trades_store(testdatadir, tmpdir): - tmpdir1 = Path(tmpdir) - dh = HDF5DataHandler(testdatadir) - trades = dh.trades_load('XRP/ETH') - - dh1 = HDF5DataHandler(tmpdir1) - dh1.trades_store('XRP/NEW', trades) - file = tmpdir1 / 'XRP_NEW-trades.h5' - assert file.is_file() - # Load trades back - trades_new = dh1.trades_load('XRP/NEW') - - assert len(trades_new) == len(trades) - assert trades[0][0] == trades_new[0][0] - assert trades[0][1] == trades_new[0][1] - # assert trades[0][2] == trades_new[0][2] # This is nan - so comparison does not make sense - assert trades[0][3] == trades_new[0][3] - assert trades[0][4] == trades_new[0][4] - assert trades[0][5] == trades_new[0][5] - assert trades[0][6] == trades_new[0][6] - assert trades[-1][0] == trades_new[-1][0] - assert trades[-1][1] == trades_new[-1][1] - # assert trades[-1][2] == trades_new[-1][2] # This is nan - so comparison does not make sense - assert trades[-1][3] == trades_new[-1][3] - assert trades[-1][4] == trades_new[-1][4] - assert trades[-1][5] == trades_new[-1][5] - assert trades[-1][6] == trades_new[-1][6] - - -def test_hdf5datahandler_trades_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) - assert not dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 1 - - -@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ - # Data goes from 2018-01-10 - 2018-01-30 - ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), - # Mark data goes from to 2021-11-15 2021-11-19 - ('UNITTEST/USDT:USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), -]) -def test_hdf5datahandler_ohlcv_load_and_resave( - testdatadir, - tmpdir, - pair, - timeframe, - candle_type, - candle_append, - startdt, enddt -): - tmpdir1 = Path(tmpdir) - tmpdir2 = tmpdir1 - if candle_type not in ('', 'spot'): - tmpdir2 = tmpdir1 / 'futures' - tmpdir2.mkdir() - dh = HDF5DataHandler(testdatadir) - ohlcv = dh._ohlcv_load(pair, timeframe, None, candle_type=candle_type) - assert isinstance(ohlcv, DataFrame) - assert len(ohlcv) > 0 - - file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.h5" - assert not file.is_file() - - dh1 = HDF5DataHandler(tmpdir1) - dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) - assert file.is_file() - - assert not ohlcv[ohlcv['date'] < startdt].empty - - timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") - - # Call private function to ensure timerange is filtered in hdf5 - ohlcv = dh._ohlcv_load(pair, timeframe, timerange, candle_type=candle_type) - ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) - assert len(ohlcv) == len(ohlcv1) - assert ohlcv.equals(ohlcv1) - assert ohlcv[ohlcv['date'] < startdt].empty - assert ohlcv[ohlcv['date'] > enddt].empty - - # Try loading inexisting file - ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) - assert ohlcv.empty - - -def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 2 - - -def test_gethandlerclass(): - cl = get_datahandlerclass('json') - assert cl == JsonDataHandler - assert issubclass(cl, IDataHandler) - cl = get_datahandlerclass('jsongz') - assert cl == JsonGzDataHandler - assert issubclass(cl, IDataHandler) - assert issubclass(cl, JsonDataHandler) - cl = get_datahandlerclass('hdf5') - assert cl == HDF5DataHandler - assert issubclass(cl, IDataHandler) - with pytest.raises(ValueError, match=r"No datahandler for .*"): - get_datahandlerclass('DeadBeef') - - -def test_get_datahandler(testdatadir): - dh = get_datahandler(testdatadir, 'json') - assert type(dh) == JsonDataHandler - dh = get_datahandler(testdatadir, 'jsongz') - assert type(dh) == JsonGzDataHandler - dh1 = get_datahandler(testdatadir, 'jsongz', dh) - assert id(dh1) == id(dh) - - dh = get_datahandler(testdatadir, 'hdf5') - assert type(dh) == HDF5DataHandler diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 5095f2fde..403075795 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -1,6 +1,7 @@ import re from datetime import timedelta from pathlib import Path +from shutil import copyfile import joblib import pandas as pd @@ -25,7 +26,22 @@ from freqtrade.optimize.optimize_reports import (_get_resample_from_period, gene text_table_exit_reason, text_table_strategy) from freqtrade.resolvers.strategy_resolver import StrategyResolver from tests.conftest import CURRENT_TEST_STRATEGY -from tests.data.test_history import _backup_file, _clean_test_file +from tests.data.test_history import _clean_test_file + + +def _backup_file(file: Path, copy_file: bool = False) -> None: + """ + Backup existing file to avoid deleting the user file + :param file: complete path to the file + :param copy_file: keep file in place too. + :return: None + """ + file_swp = str(file) + '.swp' + if file.is_file(): + file.rename(file_swp) + + if copy_file: + copyfile(file_swp, file) def test_text_table_bt_results(): From f62f2bb1ca7bfbca6c29f109e6eb2fff30d95184 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:17:33 +0200 Subject: [PATCH 168/199] Improve datahandler tests --- tests/data/test_datahandler.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index e8b8a1213..58f32e2d8 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -198,7 +198,7 @@ def test_hdf5datahandler_trades_get_pairs(testdatadir): def test_hdf5datahandler_trades_load(testdatadir): - dh = HDF5DataHandler(testdatadir) + dh = get_datahandler(testdatadir, 'hdf5') trades = dh.trades_load('XRP/ETH') assert isinstance(trades, list) @@ -224,10 +224,10 @@ def test_hdf5datahandler_trades_load(testdatadir): def test_hdf5datahandler_trades_store(testdatadir, tmpdir): tmpdir1 = Path(tmpdir) - dh = HDF5DataHandler(testdatadir) + dh = get_datahandler(testdatadir, 'hdf5') trades = dh.trades_load('XRP/ETH') - dh1 = HDF5DataHandler(tmpdir1) + dh1 = get_datahandler(tmpdir1, 'hdf5') dh1.trades_store('XRP/NEW', trades) file = tmpdir1 / 'XRP_NEW-trades.h5' assert file.is_file() @@ -254,7 +254,8 @@ def test_hdf5datahandler_trades_store(testdatadir, tmpdir): def test_hdf5datahandler_trades_purge(mocker, testdatadir): mocker.patch.object(Path, "exists", MagicMock(return_value=False)) unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) + dh = get_datahandler(testdatadir, 'hdf5') + dh = get_datahandler(testdatadir, 'hdf5') assert not dh.trades_purge('UNITTEST/NONEXIST') assert unlinkmock.call_count == 0 @@ -283,7 +284,7 @@ def test_hdf5datahandler_ohlcv_load_and_resave( if candle_type not in ('', 'spot'): tmpdir2 = tmpdir1 / 'futures' tmpdir2.mkdir() - dh = HDF5DataHandler(testdatadir) + dh = get_datahandler(testdatadir, 'hdf5') ohlcv = dh._ohlcv_load(pair, timeframe, None, candle_type=candle_type) assert isinstance(ohlcv, DataFrame) assert len(ohlcv) > 0 @@ -291,7 +292,7 @@ def test_hdf5datahandler_ohlcv_load_and_resave( file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.h5" assert not file.is_file() - dh1 = HDF5DataHandler(tmpdir1) + dh1 = get_datahandler(tmpdir1, 'hdf5') dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) assert file.is_file() @@ -315,7 +316,7 @@ def test_hdf5datahandler_ohlcv_load_and_resave( def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): mocker.patch.object(Path, "exists", MagicMock(return_value=False)) unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) + dh = get_datahandler(testdatadir, 'hdf5') assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') assert unlinkmock.call_count == 0 From 7b4af854252c63e6f9087b3160a86ce29f0981ed Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:18:08 +0200 Subject: [PATCH 169/199] Remove double-init in test --- tests/data/test_datahandler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 58f32e2d8..6d9f72a81 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -255,7 +255,6 @@ def test_hdf5datahandler_trades_purge(mocker, testdatadir): mocker.patch.object(Path, "exists", MagicMock(return_value=False)) unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) dh = get_datahandler(testdatadir, 'hdf5') - dh = get_datahandler(testdatadir, 'hdf5') assert not dh.trades_purge('UNITTEST/NONEXIST') assert unlinkmock.call_count == 0 From 3c0d2c446d6c886c9eccc3de5ab42cec4943ffb4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 19 Sep 2022 20:23:20 +0200 Subject: [PATCH 170/199] Add Feather datahandler (no trade mode yet) --- freqtrade/constants.py | 2 +- freqtrade/data/history/featherdatahandler.py | 133 +++++++++++++++++++ freqtrade/data/history/idatahandler.py | 3 + 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 freqtrade/data/history/featherdatahandler.py diff --git a/freqtrade/constants.py b/freqtrade/constants.py index fe17b40bc..5727aff0a 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -36,7 +36,7 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] -AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] +AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5', 'feather'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] BACKTEST_CACHE_DEFAULT = 'day' diff --git a/freqtrade/data/history/featherdatahandler.py b/freqtrade/data/history/featherdatahandler.py new file mode 100644 index 000000000..dfb818ca8 --- /dev/null +++ b/freqtrade/data/history/featherdatahandler.py @@ -0,0 +1,133 @@ +import logging +from typing import Optional + +from pandas import DataFrame, read_feather, to_datetime + +from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, TradeList +from freqtrade.enums import CandleType + +from .idatahandler import IDataHandler + + +logger = logging.getLogger(__name__) + + +class FeatherDataHandler(IDataHandler): + + _columns = DEFAULT_DATAFRAME_COLUMNS + + def ohlcv_store( + self, pair: str, timeframe: str, data: DataFrame, candle_type: CandleType) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: None + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type) + self.create_dir_if_needed(filename) + + data.reset_index(drop=True).loc[:, self._columns].to_feather( + filename, compression_level=9, compression='lz4') + + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange], candle_type: CandleType + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: DataFrame with ohlcv data, or empty DataFrame + """ + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type) + if not filename.exists(): + # Fallback mode for 1M files + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) + if not filename.exists(): + return DataFrame(columns=self._columns) + try: + pairdata = read_feather(filename) + pairdata.columns = self._columns + except ValueError: + logger.error(f"Could not load data for {pair}.") + return DataFrame(columns=self._columns) + pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', + 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata['date'] = to_datetime(pairdata['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + return pairdata + + def ohlcv_append( + self, + pair: str, + timeframe: str, + data: DataFrame, + candle_type: CandleType + ) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + :param candle_type: Any of the enum CandleType (must match trading mode!) + """ + raise NotImplementedError() + + def trades_store(self, pair: str, data: TradeList) -> None: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + # filename = self._pair_trades_filename(self._datadir, pair) + + raise NotImplementedError() + # array = pa.array(data) + # array + # feather.write_feather(data, filename) + + def trades_append(self, pair: str, data: TradeList): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + raise NotImplementedError() + + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: + """ + Load a pair from file, either .json.gz or .json + # TODO: respect timerange ... + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + raise NotImplementedError() + # filename = self._pair_trades_filename(self._datadir, pair) + # tradesdata = misc.file_load_json(filename) + + # if not tradesdata: + # return [] + + # return tradesdata + + @classmethod + def _get_file_extension(cls): + return "feather" diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 8c1823c00..c98fd362f 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -375,6 +375,9 @@ def get_datahandlerclass(datatype: str) -> Type[IDataHandler]: elif datatype == 'hdf5': from .hdf5datahandler import HDF5DataHandler return HDF5DataHandler + elif datatype == 'feather': + from .featherdatahandler import FeatherDataHandler + return FeatherDataHandler else: raise ValueError(f"No datahandler for datatype {datatype} available.") From dc2b93228bb0febb3f65ed7238e29b6c9b772d19 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 20 Sep 2022 13:42:15 +0000 Subject: [PATCH 171/199] Add ParquetDataHandler --- freqtrade/constants.py | 2 +- freqtrade/data/history/idatahandler.py | 3 + freqtrade/data/history/parquetdatahandler.py | 132 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 freqtrade/data/history/parquetdatahandler.py diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 5727aff0a..76b37b2d8 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -36,7 +36,7 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] -AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5', 'feather'] +AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5', 'feather', 'parquet'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] BACKTEST_CACHE_DEFAULT = 'day' diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index c98fd362f..eb5ad3621 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -378,6 +378,9 @@ def get_datahandlerclass(datatype: str) -> Type[IDataHandler]: elif datatype == 'feather': from .featherdatahandler import FeatherDataHandler return FeatherDataHandler + elif datatype == 'parquet': + from .parquetdatahandler import ParquetDataHandler + return ParquetDataHandler else: raise ValueError(f"No datahandler for datatype {datatype} available.") diff --git a/freqtrade/data/history/parquetdatahandler.py b/freqtrade/data/history/parquetdatahandler.py new file mode 100644 index 000000000..283d90ec0 --- /dev/null +++ b/freqtrade/data/history/parquetdatahandler.py @@ -0,0 +1,132 @@ +import logging +from typing import Optional + +from pandas import DataFrame, read_parquet, to_datetime + +from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, TradeList +from freqtrade.enums import CandleType + +from .idatahandler import IDataHandler + + +logger = logging.getLogger(__name__) + + +class ParquetDataHandler(IDataHandler): + + _columns = DEFAULT_DATAFRAME_COLUMNS + + def ohlcv_store( + self, pair: str, timeframe: str, data: DataFrame, candle_type: CandleType) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: None + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type) + self.create_dir_if_needed(filename) + + data.reset_index(drop=True).loc[:, self._columns].to_parquet(filename) + + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange], candle_type: CandleType + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: DataFrame with ohlcv data, or empty DataFrame + """ + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type) + if not filename.exists(): + # Fallback mode for 1M files + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) + if not filename.exists(): + return DataFrame(columns=self._columns) + try: + pairdata = read_parquet(filename) + pairdata.columns = self._columns + except ValueError: + logger.error(f"Could not load data for {pair}.") + return DataFrame(columns=self._columns) + pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', + 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata['date'] = to_datetime(pairdata['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + return pairdata + + def ohlcv_append( + self, + pair: str, + timeframe: str, + data: DataFrame, + candle_type: CandleType + ) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + :param candle_type: Any of the enum CandleType (must match trading mode!) + """ + raise NotImplementedError() + + def trades_store(self, pair: str, data: TradeList) -> None: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + # filename = self._pair_trades_filename(self._datadir, pair) + + raise NotImplementedError() + # array = pa.array(data) + # array + # feather.write_feather(data, filename) + + def trades_append(self, pair: str, data: TradeList): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + raise NotImplementedError() + + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: + """ + Load a pair from file, either .json.gz or .json + # TODO: respect timerange ... + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + raise NotImplementedError() + # filename = self._pair_trades_filename(self._datadir, pair) + # tradesdata = misc.file_load_json(filename) + + # if not tradesdata: + # return [] + + # return tradesdata + + @classmethod + def _get_file_extension(cls): + return "parquet" From 044891f5433735d0fc38e808a6e527113e4d67f6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:02:18 +0200 Subject: [PATCH 172/199] Add conditional formats depending on mode --- freqtrade/commands/cli_options.py | 2 +- freqtrade/constants.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index f383f0768..e50fb86d8 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -440,7 +440,7 @@ AVAILABLE_CLI_OPTIONS = { "dataformat_trades": Arg( '--data-format-trades', help='Storage format for downloaded trades data. (default: `jsongz`).', - choices=constants.AVAILABLE_DATAHANDLERS, + choices=constants.AVAILABLE_DATAHANDLERS_TRADES, ), "show_timerange": Arg( '--show-timerange', diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 76b37b2d8..4c2bd6e18 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -36,7 +36,8 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] -AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5', 'feather', 'parquet'] +AVAILABLE_DATAHANDLERS_TRADES = ['json', 'jsongz', 'hdf5'] +AVAILABLE_DATAHANDLERS = AVAILABLE_DATAHANDLERS_TRADES + ['feather', 'parquet'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] BACKTEST_CACHE_DEFAULT = 'day' @@ -434,7 +435,7 @@ CONF_SCHEMA = { }, 'dataformat_trades': { 'type': 'string', - 'enum': AVAILABLE_DATAHANDLERS, + 'enum': AVAILABLE_DATAHANDLERS_TRADES, 'default': 'jsongz' }, 'position_adjustment_enable': {'type': 'boolean'}, From 983a16d9370759d5105ba2fa3d31d9c9a2be158f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:02:28 +0200 Subject: [PATCH 173/199] Rudimentary "not implemented" test --- tests/data/test_datahandler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 6d9f72a81..090ea3eba 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -152,6 +152,15 @@ def test_jsondatahandler_ohlcv_load(testdatadir, caplog): assert df.columns.equals(df1.columns) +@pytest.mark.parametrize('datahandler', ['feather', 'parquet']) +def test_datahandler_trades_not_supported(datahandler, testdatadir, ): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.trades_load('UNITTEST/ETH') + with pytest.raises(NotImplementedError): + dh.trades_store('UNITTEST/ETH', MagicMock()) + + def test_jsondatahandler_trades_load(testdatadir, caplog): dh = JsonGzDataHandler(testdatadir) logmsg = "Old trades format detected - converting" From 5fb56b09f2eddad2ca5852983b8286d08fd29111 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:20:09 +0200 Subject: [PATCH 174/199] Test Feather/parquet datahandler init --- tests/data/test_datahandler.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 090ea3eba..737f1f59f 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -9,9 +9,11 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import AVAILABLE_DATAHANDLERS +from freqtrade.data.history.featherdatahandler import FeatherDataHandler from freqtrade.data.history.hdf5datahandler import HDF5DataHandler from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, get_datahandlerclass from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler +from freqtrade.data.history.parquetdatahandler import ParquetDataHandler from freqtrade.enums import CandleType, TradingMode from tests.conftest import log_has @@ -339,13 +341,24 @@ def test_gethandlerclass(): cl = get_datahandlerclass('json') assert cl == JsonDataHandler assert issubclass(cl, IDataHandler) + cl = get_datahandlerclass('jsongz') assert cl == JsonGzDataHandler assert issubclass(cl, IDataHandler) assert issubclass(cl, JsonDataHandler) + cl = get_datahandlerclass('hdf5') assert cl == HDF5DataHandler assert issubclass(cl, IDataHandler) + + cl = get_datahandlerclass('feather') + assert cl == FeatherDataHandler + assert issubclass(cl, IDataHandler) + + cl = get_datahandlerclass('parquet') + assert cl == ParquetDataHandler + assert issubclass(cl, IDataHandler) + with pytest.raises(ValueError, match=r"No datahandler for .*"): get_datahandlerclass('DeadBeef') From a4eaff4da622d6ad09556c2105318ad040922167 Mon Sep 17 00:00:00 2001 From: Emre Date: Fri, 23 Sep 2022 01:18:34 -0700 Subject: [PATCH 175/199] Add training elapsed time --- .../freqai/base_models/BaseClassifierModel.py | 18 ++++++++++++------ .../freqai/base_models/BaseRegressionModel.py | 18 ++++++++++++------ .../freqai/base_models/BaseTensorFlowModel.py | 18 ++++++++++++------ .../base_models/FreqaiMultiOutputRegressor.py | 1 - 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/freqtrade/freqai/base_models/BaseClassifierModel.py b/freqtrade/freqai/base_models/BaseClassifierModel.py index 5142ffb0d..70f212d2a 100644 --- a/freqtrade/freqai/base_models/BaseClassifierModel.py +++ b/freqtrade/freqai/base_models/BaseClassifierModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any, Tuple import numpy as np @@ -32,7 +33,9 @@ class BaseClassifierModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -45,10 +48,10 @@ class BaseClassifierModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -57,13 +60,16 @@ class BaseClassifierModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' " features" + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) - logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model diff --git a/freqtrade/freqai/base_models/BaseRegressionModel.py b/freqtrade/freqai/base_models/BaseRegressionModel.py index 1d87e42c0..2450bf305 100644 --- a/freqtrade/freqai/base_models/BaseRegressionModel.py +++ b/freqtrade/freqai/base_models/BaseRegressionModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any, Tuple import numpy as np @@ -31,7 +32,9 @@ class BaseRegressionModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -44,10 +47,10 @@ class BaseRegressionModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -56,13 +59,16 @@ class BaseRegressionModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' " features" + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) - logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model diff --git a/freqtrade/freqai/base_models/BaseTensorFlowModel.py b/freqtrade/freqai/base_models/BaseTensorFlowModel.py index eea80f3a2..00f9d6cba 100644 --- a/freqtrade/freqai/base_models/BaseTensorFlowModel.py +++ b/freqtrade/freqai/base_models/BaseTensorFlowModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any from pandas import DataFrame @@ -28,7 +29,9 @@ class BaseTensorFlowModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -41,10 +44,10 @@ class BaseTensorFlowModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -53,12 +56,15 @@ class BaseTensorFlowModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' " features" + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) - logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model diff --git a/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py b/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py index a9db81e31..54136d5e0 100644 --- a/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py +++ b/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py @@ -1,4 +1,3 @@ - from joblib import Parallel from sklearn.multioutput import MultiOutputRegressor, _fit_estimator from sklearn.utils.fixes import delayed From 0bbb6faeba7520ef533d4dbbe0c0bd95acd5304c Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 07:30:11 +0200 Subject: [PATCH 176/199] Add generic datahandler test --- tests/data/test_datahandler.py | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 737f1f59f..07c9a20df 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -323,6 +323,61 @@ def test_hdf5datahandler_ohlcv_load_and_resave( assert ohlcv.empty +@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ + # Data goes from 2018-01-10 - 2018-01-30 + ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), + # Mark data goes from to 2021-11-15 2021-11-19 + ('UNITTEST/USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), +]) +@pytest.mark.parametrize('datahandler', ['hdf5', 'feather', 'parquet']) +def test_generic_datahandler_ohlcv_load_and_resave( + datahandler, + testdatadir, + tmpdir, + pair, + timeframe, + candle_type, + candle_append, + startdt, enddt +): + tmpdir1 = Path(tmpdir) + tmpdir2 = tmpdir1 + if candle_type not in ('', 'spot'): + tmpdir2 = tmpdir1 / 'futures' + tmpdir2.mkdir() + # Load data from one common file + dhbase = get_datahandler(testdatadir, 'json') + ohlcv = dhbase._ohlcv_load(pair, timeframe, None, candle_type=candle_type) + assert isinstance(ohlcv, DataFrame) + assert len(ohlcv) > 0 + + # Get data to test + dh = get_datahandler(testdatadir, datahandler) + + file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.{dh._get_file_extension()}" + assert not file.is_file() + + dh1 = get_datahandler(tmpdir1, datahandler) + dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) + assert file.is_file() + + assert not ohlcv[ohlcv['date'] < startdt].empty + + timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") + + ohlcv = dhbase.ohlcv_load(pair, timeframe, timerange=timerange, candle_type=candle_type) + ohlcv1 = dh1.ohlcv_load('UNITTEST/NEW', timeframe, timerange=timerange, candle_type=candle_type) + + assert len(ohlcv) == len(ohlcv1) + assert ohlcv.equals(ohlcv1) + assert ohlcv[ohlcv['date'] < startdt].empty + assert ohlcv[ohlcv['date'] > enddt].empty + + # Try loading inexisting file + ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) + assert ohlcv.empty + + def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): mocker.patch.object(Path, "exists", MagicMock(return_value=False)) unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) From 48352b8a375dae287ca85f0aae47eb875bee10d2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 14:49:17 +0000 Subject: [PATCH 177/199] Update hdf5 handler to reset index on load --- freqtrade/data/history/hdf5datahandler.py | 1 + tests/data/test_datahandler.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index 01b7af7e7..fd46115de 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -81,6 +81,7 @@ class HDF5DataHandler(IDataHandler): raise ValueError("Wrong dataframe format") pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata = pairdata.reset_index(drop=True) return pairdata def ohlcv_append( diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 07c9a20df..8e1b0050a 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -366,7 +366,13 @@ def test_generic_datahandler_ohlcv_load_and_resave( timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") ohlcv = dhbase.ohlcv_load(pair, timeframe, timerange=timerange, candle_type=candle_type) - ohlcv1 = dh1.ohlcv_load('UNITTEST/NEW', timeframe, timerange=timerange, candle_type=candle_type) + if datahandler == 'hdf5': + ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) + if candle_type == 'mark': + ohlcv1['volume'] = 0.0 + else: + ohlcv1 = dh1.ohlcv_load('UNITTEST/NEW', timeframe, + timerange=timerange, candle_type=candle_type) assert len(ohlcv) == len(ohlcv1) assert ohlcv.equals(ohlcv1) From 7e1e388b9ce492d390671df30b70ceabd5415e06 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 18:24:30 +0200 Subject: [PATCH 178/199] Add feather/parquet docs --- docs/data-download.md | 44 ++++++++++++++++++-- freqtrade/data/history/featherdatahandler.py | 9 ++-- freqtrade/data/history/parquetdatahandler.py | 9 ++-- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/docs/data-download.md b/docs/data-download.md index 2b76d4f74..60e3f5efe 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -179,9 +179,11 @@ freqtrade download-data --exchange binance --pairs ETH/USDT XRP/USDT BTC/USDT -- Freqtrade currently supports 3 data-formats for both OHLCV and trades data: -* `json` (plain "text" json files) -* `jsongz` (a gzip-zipped version of json files) -* `hdf5` (a high performance datastore) +* `json` - plain "text" json files +* `jsongz` - a gzip-zipped version of json files +* `hdf5` - a high performance datastore +* `feather` - a dataformat based on Apache Arrow +* `parquet` - columnar datastore By default, OHLCV data is stored as `json` data, while trades data is stored as `jsongz` data. @@ -200,6 +202,42 @@ If the default data-format has been changed during download, then the keys `data !!! Note You can convert between data-formats using the [convert-data](#sub-command-convert-data) and [convert-trade-data](#sub-command-convert-trade-data) methods. +#### Dataformat comparison + +The following comparisons have been made with the following data, and by using the linux `time` command. + +``` +Found 6 pair / timeframe combinations. ++----------+-------------+--------+---------------------+---------------------+ +| Pair | Timeframe | Type | From | To | +|----------+-------------+--------+---------------------+---------------------| +| BTC/USDT | 5m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:25:00 | +| ETH/USDT | 1m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:26:00 | +| BTC/USDT | 1m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:30:00 | +| XRP/USDT | 5m | spot | 2018-05-04 08:10:00 | 2022-09-13 19:15:00 | +| XRP/USDT | 1m | spot | 2018-05-04 08:11:00 | 2022-09-13 19:22:00 | +| ETH/USDT | 5m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:20:00 | ++----------+-------------+--------+---------------------+---------------------+ +``` + +Timings have been taken in a not very scientific way with the following command, which forces reading the data into memory. + +``` bash +time freqtrade list-data --show-timerange --data-format-ohlcv +``` + +| Format | Size | timing | +|------------|-------------|-------------| +| `json` | 149Mb | 25.6s | +| `jsongz` | 39Mb | 27s | +| `hdf5` | 145Mb | 3.9s | +| `feather` | 72Mb | 3.5s | +| `parquet` | 83Mb | 3.8s | + +Size has been taken from the BTC/USDT 1m spot combination for the timerange specified above. + +To have a best performance/size mix, we recommend the use of either feather or parquet. + #### Sub-command convert data ``` diff --git a/freqtrade/data/history/featherdatahandler.py b/freqtrade/data/history/featherdatahandler.py index dfb818ca8..22a6805e7 100644 --- a/freqtrade/data/history/featherdatahandler.py +++ b/freqtrade/data/history/featherdatahandler.py @@ -58,12 +58,9 @@ class FeatherDataHandler(IDataHandler): self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) if not filename.exists(): return DataFrame(columns=self._columns) - try: - pairdata = read_feather(filename) - pairdata.columns = self._columns - except ValueError: - logger.error(f"Could not load data for {pair}.") - return DataFrame(columns=self._columns) + + pairdata = read_feather(filename) + pairdata.columns = self._columns pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', 'low': 'float', 'close': 'float', 'volume': 'float'}) pairdata['date'] = to_datetime(pairdata['date'], diff --git a/freqtrade/data/history/parquetdatahandler.py b/freqtrade/data/history/parquetdatahandler.py index 283d90ec0..57581861d 100644 --- a/freqtrade/data/history/parquetdatahandler.py +++ b/freqtrade/data/history/parquetdatahandler.py @@ -57,12 +57,9 @@ class ParquetDataHandler(IDataHandler): self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) if not filename.exists(): return DataFrame(columns=self._columns) - try: - pairdata = read_parquet(filename) - pairdata.columns = self._columns - except ValueError: - logger.error(f"Could not load data for {pair}.") - return DataFrame(columns=self._columns) + + pairdata = read_parquet(filename) + pairdata.columns = self._columns pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', 'low': 'float', 'close': 'float', 'volume': 'float'}) pairdata['date'] = to_datetime(pairdata['date'], From 4576d291a95e8703f7206f0441e0961be276ad2b Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 18:25:26 +0200 Subject: [PATCH 179/199] Update data command outputs --- docs/data-download.md | 64 +++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/docs/data-download.md b/docs/data-download.md index 60e3f5efe..700ca04f4 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -26,7 +26,7 @@ usage: freqtrade download-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [--timerange TIMERANGE] [--dl-trades] [--exchange EXCHANGE] [-t TIMEFRAMES [TIMEFRAMES ...]] [--erase] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [--data-format-trades {json,jsongz,hdf5}] [--trading-mode {spot,margin,futures}] [--prepend] @@ -55,7 +55,7 @@ optional arguments: list. Default: `1m 5m`. --erase Clean all existing data for the selected exchange/pairs/timeframes. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). --data-format-trades {json,jsongz,hdf5} @@ -76,7 +76,7 @@ Common arguments: `userdir/config.json` or `config.json` whichever exists). Multiple --config options may be used. Can be set to `-` to read config from stdin. - -d PATH, --datadir PATH + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -244,32 +244,32 @@ To have a best performance/size mix, we recommend the use of either feather or p usage: freqtrade convert-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] --format-from - {json,jsongz,hdf5} --format-to - {json,jsongz,hdf5} [--erase] - [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...]] + {json,jsongz,hdf5,feather,parquet} --format-to + {json,jsongz,hdf5,feather,parquet} [--erase] [--exchange EXCHANGE] + [-t TIMEFRAMES [TIMEFRAMES ...]] [--trading-mode {spot,margin,futures}] - [--candle-types {spot,,futures,mark,index,premiumIndex,funding_rate} [{spot,,futures,mark,index,premiumIndex,funding_rate} ...]] + [--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]] optional arguments: -h, --help show this help message and exit -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - --format-from {json,jsongz,hdf5} + --format-from {json,jsongz,hdf5,feather,parquet} Source format for data conversion. - --format-to {json,jsongz,hdf5} + --format-to {json,jsongz,hdf5,feather,parquet} Destination format for data conversion. --erase Clean all existing data for the selected exchange/pairs/timeframes. - -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...] - Specify which tickers to download. Space-separated - list. Default: `1m 5m`. --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --trading-mode {spot,margin,futures} + -t TIMEFRAMES [TIMEFRAMES ...], --timeframes TIMEFRAMES [TIMEFRAMES ...] + Specify which tickers to download. Space-separated + list. Default: `1m 5m`. + --trading-mode {spot,margin,futures}, --tradingmode {spot,margin,futures} Select Trading mode - --candle-types {spot,,futures,mark,index,premiumIndex,funding_rate} [{spot,,futures,mark,index,premiumIndex,funding_rate} ...] + --candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...] Select candle type to use Common arguments: @@ -283,7 +283,7 @@ Common arguments: `userdir/config.json` or `config.json` whichever exists). Multiple --config options may be used. Can be set to `-` to read config from stdin. - -d PATH, --datadir PATH + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -305,20 +305,24 @@ freqtrade convert-data --format-from json --format-to jsongz --datadir ~/.freqtr usage: freqtrade convert-trade-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] --format-from - {json,jsongz,hdf5} --format-to - {json,jsongz,hdf5} [--erase] + {json,jsongz,hdf5,feather,parquet} + --format-to + {json,jsongz,hdf5,feather,parquet} + [--erase] [--exchange EXCHANGE] optional arguments: -h, --help show this help message and exit -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] - Show profits for only these pairs. Pairs are space- + Limit command to these pairs. Pairs are space- separated. - --format-from {json,jsongz,hdf5} + --format-from {json,jsongz,hdf5,feather,parquet} Source format for data conversion. - --format-to {json,jsongz,hdf5} + --format-to {json,jsongz,hdf5,feather,parquet} Destination format for data conversion. --erase Clean all existing data for the selected exchange/pairs/timeframes. + --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no + config is provided. Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). @@ -331,7 +335,7 @@ Common arguments: `userdir/config.json` or `config.json` whichever exists). Multiple --config options may be used. Can be set to `-` to read config from stdin. - -d PATH, --datadir PATH + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -356,9 +360,9 @@ This command will allow you to repeat this last step for additional timeframes w usage: freqtrade trades-to-ohlcv [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] - [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...]] + [-t TIMEFRAMES [TIMEFRAMES ...]] [--exchange EXCHANGE] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [--data-format-trades {json,jsongz,hdf5}] optional arguments: @@ -366,12 +370,12 @@ optional arguments: -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...] + -t TIMEFRAMES [TIMEFRAMES ...], --timeframes TIMEFRAMES [TIMEFRAMES ...] Specify which tickers to download. Space-separated list. Default: `1m 5m`. --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). --data-format-trades {json,jsongz,hdf5} @@ -389,7 +393,7 @@ Common arguments: `userdir/config.json` or `config.json` whichever exists). Multiple --config options may be used. Can be set to `-` to read config from stdin. - -d PATH, --datadir PATH + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -409,7 +413,7 @@ You can get a list of downloaded data using the `list-data` sub-command. ``` usage: freqtrade list-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--exchange EXCHANGE] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [-p PAIRS [PAIRS ...]] [--trading-mode {spot,margin,futures}] [--show-timerange] @@ -418,13 +422,13 @@ optional arguments: -h, --help show this help message and exit --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - --trading-mode {spot,margin,futures} + --trading-mode {spot,margin,futures}, --tradingmode {spot,margin,futures} Select Trading mode --show-timerange Show timerange available for available data. (May take a while to calculate). @@ -440,7 +444,7 @@ Common arguments: `userdir/config.json` or `config.json` whichever exists). Multiple --config options may be used. Can be set to `-` to read config from stdin. - -d PATH, --datadir PATH + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. From 7c093388e7d01ddc859d23ca29f5aa4fb3ac4336 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 19:36:23 +0200 Subject: [PATCH 180/199] Add pyarrow dependency --- environment.yml | 1 + requirements.txt | 2 ++ setup.py | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index d6d85de9d..5298b2baa 100644 --- a/environment.yml +++ b/environment.yml @@ -34,6 +34,7 @@ dependencies: - schedule - python-dateutil - joblib + - pyarrow # ============================ diff --git a/requirements.txt b/requirements.txt index 690e33a09..c12d3fb08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ jinja2==3.1.2 tables==3.7.0 blosc==1.10.6 joblib==1.2.0 +pyarrow==9.0.0 # find first, C search in arrays py_find_1st==1.1.5 @@ -54,3 +55,4 @@ schedule==1.1.0 #WS Messages websockets==10.3 janus==1.0.0 + diff --git a/setup.py b/setup.py index 2e6e354b0..cdd461f3f 100644 --- a/setup.py +++ b/setup.py @@ -8,13 +8,11 @@ hyperopt = [ 'scikit-learn', 'scikit-optimize>=0.7.0', 'filelock', - 'joblib', 'progressbar2', ] freqai = [ 'scikit-learn', - 'joblib', 'catboost; platform_machine != "aarch64"', 'lightgbm', ] @@ -74,6 +72,8 @@ setup( 'pandas', 'tables', 'blosc', + 'joblib', + 'pyarrow' 'fastapi', 'uvicorn', 'psutil', From 2fffe7c5ddaee734aad278afe6470601d48bf371 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 23 Sep 2022 20:03:33 +0200 Subject: [PATCH 181/199] Fix missing comma --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cdd461f3f..1547b7974 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,7 @@ setup( 'tables', 'blosc', 'joblib', - 'pyarrow' + 'pyarrow', 'fastapi', 'uvicorn', 'psutil', From 255ff000af39b93a72e95acf08e357f92eb7ecfd Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 23 Sep 2022 12:12:47 -0600 Subject: [PATCH 182/199] typo in configuration.md --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index b3dbcd817..556414e21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,7 +225,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `webhook.webhookexitcancel` | Payload to send on exit order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookexitfill` | Payload to send on exit order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String -| | **Rest API / FreqUI / External Signals** +| | **Rest API / FreqUI / Producer-Consumer** | `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean | `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** IPv4 | `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Integer between 1024 and 65535 From b8e1d29a1be5af7e4a3950005c1aa79f9a7eefb2 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 23 Sep 2022 12:36:05 -0600 Subject: [PATCH 183/199] catch connectionclosederror --- freqtrade/rpc/external_message_consumer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index a57fac144..0aab5ba05 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -220,8 +220,11 @@ class ExternalMessageConsumer: continue - except websockets.exceptions.ConnectionClosedOK: - # Successfully closed, just keep trying to connect again indefinitely + except ( + websockets.exceptions.ConnectionClosedError, + websockets.exceptions.ConnectionClosedOk + ): + # Just keep trying to connect again indefinitely continue except Exception as e: From 4c7cef570f54c00f4debfb3b68bad8f93ac0fe14 Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 23 Sep 2022 12:58:26 -0600 Subject: [PATCH 184/199] typo in exception --- freqtrade/rpc/external_message_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 0aab5ba05..f60948202 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -222,7 +222,7 @@ class ExternalMessageConsumer: except ( websockets.exceptions.ConnectionClosedError, - websockets.exceptions.ConnectionClosedOk + websockets.exceptions.ConnectionClosedOK ): # Just keep trying to connect again indefinitely continue From 6b5d71049e514c4d6e1dc2584910b1dcc5184d5f Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 23 Sep 2022 13:10:45 -0600 Subject: [PATCH 185/199] add sleep --- freqtrade/rpc/external_message_consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index f60948202..99ba39f76 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -217,7 +217,6 @@ class ExternalMessageConsumer: ) as e: logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") await asyncio.sleep(self.sleep_time) - continue except ( @@ -225,6 +224,7 @@ class ExternalMessageConsumer: websockets.exceptions.ConnectionClosedOK ): # Just keep trying to connect again indefinitely + await asyncio.sleep(self.sleep_time) continue except Exception as e: From af974443cdbd146f6e5e4e7a302e33ee5226012d Mon Sep 17 00:00:00 2001 From: Timothy Pogue Date: Fri, 23 Sep 2022 13:37:46 -0600 Subject: [PATCH 186/199] add test --- tests/rpc/test_rpc_emc.py | 95 +++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 9aca88b4a..41faaf249 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -200,43 +200,60 @@ async def test_emc_create_connection_success(default_conf, caplog, mocker): emc.shutdown() -# async def test_emc_create_connection_invalid(default_conf, caplog, mocker): -# default_conf.update({ -# "external_message_consumer": { -# "enabled": True, -# "producers": [ -# { -# "name": "default", -# "host": _TEST_WS_HOST, -# "port": _TEST_WS_PORT, -# "ws_token": _TEST_WS_TOKEN -# } -# ], -# "wait_timeout": 60, -# "ping_timeout": 60, -# "sleep_timeout": 60 -# } -# }) -# -# mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', -# MagicMock()) -# -# test_producer = default_conf['external_message_consumer']['producers'][0] -# lock = asyncio.Lock() -# -# dp = DataProvider(default_conf, None, None, None) -# emc = ExternalMessageConsumer(default_conf, dp) -# -# try: -# # Test invalid URL -# test_producer['url'] = "tcp://null:8080/api/v1/message/ws" -# emc._running = True -# await emc._create_connection(test_producer, lock) -# emc._running = False -# -# assert log_has_re(r".+is an invalid WebSocket URL.+", caplog) -# finally: -# emc.shutdown() +async def test_emc_create_connection_invalid_port(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": -1, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(0.01) + assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_invalid_host(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "10000.1241..2121/", + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(0.01) + assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) + finally: + emc.shutdown() async def test_emc_create_connection_error(default_conf, caplog, mocker): @@ -376,7 +393,7 @@ async def test_emc_receive_messages_timeout(default_conf, caplog, mocker): "ws_token": _TEST_WS_TOKEN } ], - "wait_timeout": 1, + "wait_timeout": 0.1, "ping_timeout": 1, "sleep_time": 1 } @@ -396,7 +413,7 @@ async def test_emc_receive_messages_timeout(default_conf, caplog, mocker): class TestChannel: async def recv(self, *args, **kwargs): - await asyncio.sleep(10) + await asyncio.sleep(0.2) async def ping(self, *args, **kwargs): return asyncio.Future() From 6643d90e64007c9b2f86b013738b9f0b80c23f14 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 10:34:14 +0200 Subject: [PATCH 187/199] simplify freqAI start_backtesting --- freqtrade/freqai/freqai_interface.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 5850cdeb3..bdc418083 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -244,7 +244,8 @@ class IFreqaiModel(ABC): # following tr_train. Both of these windows slide through the # entire backtest for tr_train, tr_backtest in zip(dk.training_timeranges, dk.backtesting_timeranges): - (_, _, _) = self.dd.get_pair_dict_info(metadata["pair"]) + pair = metadata["pair"] + (_, _, _) = self.dd.get_pair_dict_info(pair) train_it += 1 total_trains = len(dk.backtesting_timeranges) self.training_timerange = tr_train @@ -266,12 +267,10 @@ class IFreqaiModel(ABC): trained_timestamp_int = int(trained_timestamp.stopts) dk.data_path = Path( - dk.full_path - / - f"sub-train-{metadata['pair'].split('/')[0]}_{trained_timestamp_int}" + dk.full_path / f"sub-train-{pair.split('/')[0]}_{trained_timestamp_int}" ) - dk.set_new_model_names(metadata["pair"], trained_timestamp) + dk.set_new_model_names(pair, trained_timestamp) if dk.check_if_backtest_prediction_exists(): append_df = dk.get_backtesting_prediction() @@ -281,15 +280,15 @@ class IFreqaiModel(ABC): metadata["pair"], dk, trained_timestamp=trained_timestamp_int ): dk.find_features(dataframe_train) - self.model = self.train(dataframe_train, metadata["pair"], dk) - self.dd.pair_dict[metadata["pair"]]["trained_timestamp"] = int( + self.model = self.train(dataframe_train, pair, dk) + self.dd.pair_dict[pair]["trained_timestamp"] = int( trained_timestamp.stopts) if self.save_backtest_models: logger.info('Saving backtest model to disk.') - self.dd.save_data(self.model, metadata["pair"], dk) + self.dd.save_data(self.model, pair, dk) else: - self.model = self.dd.load_data(metadata["pair"], dk) + self.model = self.dd.load_data(pair, dk) self.check_if_feature_list_matches_strategy(dataframe_train, dk) From e429aa16f30bdd1b8f89567156bdb2fec44b523e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 11:21:50 +0200 Subject: [PATCH 188/199] Add note about enter_tag handling closes #7336 --- docs/strategy-advanced.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index a3115bfb2..f55cda5e2 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -106,6 +106,12 @@ def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_r !!! Note `enter_tag` is limited to 100 characters, remaining data will be truncated. +!!! Warning + There is only one `enter_tag` column, which is used for both long and short trades. + As a consequence, this column must be treated as "last write wins" (it's just a dataframe column after all). + In fancy situations, where multiple signals collide (or if signals are deactivated again based on different conditions), this can lead to odd results with the wrong tag applied to an entry signal. + These results are a consequence of the strategy overwriting prior tags - where the last tag will "stick" and will be the one freqtrade will use. + ## Exit tag Similar to [Buy Tagging](#buy-tag), you can also specify a sell tag. From 2cc00a1a2c1af419787b81e37fd9c782b127988f Mon Sep 17 00:00:00 2001 From: paranoidandy Date: Sat, 24 Sep 2022 12:21:01 +0100 Subject: [PATCH 189/199] Allow use of --strategy-list with freqai, with warning (#7455) * Allow use of --strategy-list with freqai, with warning * ensure populate_any_indicators is identical for resused identifiers * use pair instead of metadata["pair"] Co-authored-by: robcaulk --- freqtrade/freqai/data_drawer.py | 10 ++++++++++ freqtrade/freqai/freqai_interface.py | 14 +++++++++----- freqtrade/optimize/backtesting.py | 4 ++-- tests/freqai/test_freqai_backtesting.py | 20 +++++++++++--------- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index b4b92d984..7f4459fa5 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -430,6 +430,16 @@ class FreqaiDataDrawer: return + def load_metadata(self, dk: FreqaiDataKitchen) -> None: + """ + Load only metadata into datakitchen to increase performance during + presaved backtesting (prediction file loading). + """ + with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp: + dk.data = json.load(fp) + dk.training_features_list = dk.data["training_features_list"] + dk.label_list = dk.data["label_list"] + def load_data(self, coin: str, dk: FreqaiDataKitchen) -> Any: """ loads all data required to make a prediction on a sub-train time range diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index bdc418083..e0a45fb38 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -65,7 +65,7 @@ class IFreqaiModel(ABC): self.first = True self.set_full_path() self.follow_mode: bool = self.freqai_info.get("follow_mode", False) - self.save_backtest_models: bool = self.freqai_info.get("save_backtest_models", False) + self.save_backtest_models: bool = self.freqai_info.get("save_backtest_models", True) if self.save_backtest_models: logger.info('Backtesting module configured to save all models.') self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode) @@ -260,7 +260,7 @@ class IFreqaiModel(ABC): tr_train.stopts, tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT) logger.info( - f"Training {metadata['pair']}, {self.pair_it}/{self.total_pairs} pairs" + f"Training {pair}, {self.pair_it}/{self.total_pairs} pairs" f" from {tr_train_startts_str} to {tr_train_stopts_str}, {train_it}/{total_trains} " "trains" ) @@ -273,11 +273,13 @@ class IFreqaiModel(ABC): dk.set_new_model_names(pair, trained_timestamp) if dk.check_if_backtest_prediction_exists(): + self.dd.load_metadata(dk) + self.check_if_feature_list_matches_strategy(dataframe_train, dk) append_df = dk.get_backtesting_prediction() dk.append_predictions(append_df) else: if not self.model_exists( - metadata["pair"], dk, trained_timestamp=trained_timestamp_int + pair, dk, trained_timestamp=trained_timestamp_int ): dk.find_features(dataframe_train) self.model = self.train(dataframe_train, pair, dk) @@ -429,14 +431,16 @@ class IFreqaiModel(ABC): if "training_features_list_raw" in dk.data: feature_list = dk.data["training_features_list_raw"] else: - feature_list = dk.training_features_list + feature_list = dk.data['training_features_list'] if dk.training_features_list != feature_list: raise OperationalException( "Trying to access pretrained model with `identifier` " "but found different features furnished by current strategy." "Change `identifier` to train from scratch, or ensure the" "strategy is furnishing the same features as the pretrained" - "model" + "model. In case of --strategy-list, please be aware that FreqAI " + "requires all strategies to maintain identical " + "populate_any_indicator() functions" ) def data_cleaning_train(self, dk: FreqaiDataKitchen) -> None: diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 0a05d740d..9ba610b69 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -91,8 +91,8 @@ class Backtesting: if self.config.get('strategy_list'): if self.config.get('freqai', {}).get('enabled', False): - raise OperationalException( - "You can't use strategy_list and freqai at the same time.") + logger.warning("Using --strategy-list with FreqAI REQUIRES all strategies " + "to have identical populate_any_indicators.") for strat in list(self.config['strategy_list']): stratconf = deepcopy(self.config) stratconf['strategy'] = strat diff --git a/tests/freqai/test_freqai_backtesting.py b/tests/freqai/test_freqai_backtesting.py index ea127fa99..b1881b2f5 100644 --- a/tests/freqai/test_freqai_backtesting.py +++ b/tests/freqai/test_freqai_backtesting.py @@ -3,21 +3,21 @@ from datetime import datetime, timezone from pathlib import Path from unittest.mock import PropertyMock -import pytest - -from freqtrade.commands.optimize_commands import start_backtesting -from freqtrade.exceptions import OperationalException +from freqtrade.commands.optimize_commands import setup_optimize_configuration +from freqtrade.enums import RunMode from freqtrade.optimize.backtesting import Backtesting from tests.conftest import (CURRENT_TEST_STRATEGY, get_args, log_has_re, patch_exchange, patched_configuration_load_config_file) -def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir): +def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir, caplog): patch_exchange(mocker) + now = datetime.now(timezone.utc) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', PropertyMock(return_value=['HULUMULU/USDT', 'XRP/USDT'])) - # mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) + mocker.patch('freqtrade.optimize.backtesting.history.load_data') + mocker.patch('freqtrade.optimize.backtesting.history.get_timerange', return_value=(now, now)) patched_configuration_load_config_file(mocker, freqai_conf) @@ -30,9 +30,11 @@ def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir): '--strategy-list', CURRENT_TEST_STRATEGY ] args = get_args(args) - with pytest.raises(OperationalException, - match=r"You can't use strategy_list and freqai at the same time\."): - start_backtesting(args) + bt_config = setup_optimize_configuration(args, RunMode.BACKTEST) + Backtesting(bt_config) + assert log_has_re('Using --strategy-list with FreqAI REQUIRES all strategies to have identical ' + 'populate_any_indicators.', caplog) + Backtesting.cleanup() def test_freqai_backtest_load_data(freqai_conf, mocker, caplog): From 00b192b4dfb8b14379ebaac0508fc2be9c8cd718 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 14:51:58 +0200 Subject: [PATCH 190/199] Add test to verify #7449 --- tests/exchange/test_exchange.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 71690ecdf..b91046b75 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -20,6 +20,7 @@ from freqtrade.exchange import (Binance, Bittrex, Exchange, Kraken, amount_to_pr timeframe_to_prev_date, timeframe_to_seconds) from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, API_RETRY_COUNT, calculate_backoff, remove_credentials) +from freqtrade.exchange.exchange import amount_to_contract_precision from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import get_mock_coro, get_patched_exchange, log_has, log_has_re, num_log_has_re @@ -4470,6 +4471,7 @@ def test__amount_to_contracts( ('ADA/USDT:USDT', 10.4445555, 10.4, 10.444), ('LTC/ETH', 30, 30, 30), ('LTC/USD', 30, 30, 30), + ('ADA/USDT:USDT', 1.17, 1.1, 1.17), # contract size of 10 ('ETH/USDT:USDT', 10.111, 10.1, 10), ('ETH/USDT:USDT', 10.188, 10.1, 10), From 4efe2e9bc48746ce871a751e7a3069a6f50106d9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 14:55:58 +0200 Subject: [PATCH 191/199] use FtPrecise to convert to contracts and back --- freqtrade/exchange/exchange.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index c68fc5873..f01e464fa 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2891,7 +2891,7 @@ def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float: :return: num-contracts """ if contract_size and contract_size != 1: - return amount / contract_size + return float(FtPrecise(amount) / FtPrecise(contract_size)) else: return amount @@ -2905,7 +2905,7 @@ def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> """ if contract_size and contract_size != 1: - return num_contracts * contract_size + return float(FtPrecise(num_contracts) * FtPrecise(contract_size)) else: return num_contracts From 98ba57ffaa99cde45d24106354edaeddf4d72525 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 15:25:04 +0200 Subject: [PATCH 192/199] Better test for contract calculation change closes #7449 --- tests/exchange/test_exchange.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index b91046b75..37ba2ca97 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -4499,6 +4499,20 @@ def test_amount_to_contract_precision( assert result_size == expected_fut +@pytest.mark.parametrize('amount,precision,precision_mode,contract_size,expected', [ + (1.17, 1.0, 4, 0.01, 1.17), # Tick size + (1.17, 1.0, 2, 0.01, 1.17), # + (1.16, 1.0, 4, 0.01, 1.16), # + (1.16, 1.0, 2, 0.01, 1.16), # + (1.13, 1.0, 2, 0.01, 1.13), # + (10.988, 1.0, 2, 10, 10), + (10.988, 1.0, 4, 10, 10), +]) +def test_amount_to_contract_precision2(amount, precision, precision_mode, contract_size, expected): + res = amount_to_contract_precision(amount, precision, precision_mode, contract_size) + assert pytest.approx(res) == expected + + @pytest.mark.parametrize('exchange_name,open_rate,is_short,trading_mode,margin_mode', [ # Bittrex ('bittrex', 2.0, False, 'spot', None), From 166ae8e3a1cd7c2169dd41a534f409dba7e0845e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 15:51:20 +0200 Subject: [PATCH 193/199] Remove missleading comment --- freqtrade/data/dataprovider.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 51cacc2c5..ac3b61d1d 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -90,12 +90,10 @@ class DataProvider: if saved_pair not in self.__cached_pairs_backtesting: timerange = TimeRange.parse_timerange(None if self._config.get( 'timerange') is None else str(self._config.get('timerange'))) + # It is not necessary to add the training candles, as they # were already added at the beginning of the backtest. - add_train_candles = False - - # Move informative start time respecting startup_candle_count - startup_candles = self.get_required_startup(str(timeframe), add_train_candles) + startup_candles = self.get_required_startup(str(timeframe), False) tf_seconds = timeframe_to_seconds(str(timeframe)) timerange.subtract_start(tf_seconds * startup_candles) self.__cached_pairs_backtesting[saved_pair] = load_pair_history( From 53c8e0923fe2fc2d0048f004c89a452057c01a8b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:10:42 +0200 Subject: [PATCH 194/199] Improve typing in message_consumer --- freqtrade/rpc/external_message_consumer.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 99ba39f76..bf71c24ea 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -8,7 +8,7 @@ import asyncio import logging import socket from threading import Thread -from typing import TYPE_CHECKING, Any, Callable, Dict, List +from typing import TYPE_CHECKING, Any, Callable, Dict, List, TypedDict import websockets from pydantic import ValidationError @@ -29,6 +29,13 @@ if TYPE_CHECKING: import websockets.exceptions +class Producer(TypedDict): + name: str + host: str + port: int + ws_token: str + + logger = logging.getLogger(__name__) @@ -55,7 +62,7 @@ class ExternalMessageConsumer: self._emc_config = self._config.get('external_message_consumer', {}) self.enabled = self._emc_config.get('enabled', False) - self.producers = self._emc_config.get('producers', []) + self.producers: List[Producer] = self._emc_config.get('producers', []) self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds @@ -162,7 +169,7 @@ class ExternalMessageConsumer: # Stop the loop once we are done self._loop.stop() - async def _handle_producer_connection(self, producer: Dict[str, Any], lock: asyncio.Lock): + async def _handle_producer_connection(self, producer: Producer, lock: asyncio.Lock): """ Main connection loop for the consumer @@ -175,7 +182,7 @@ class ExternalMessageConsumer: # Exit silently pass - async def _create_connection(self, producer: Dict[str, Any], lock: asyncio.Lock): + async def _create_connection(self, producer: Producer, lock: asyncio.Lock): """ Actually creates and handles the websocket connection, pinging on timeout and handling connection errors. @@ -236,7 +243,7 @@ class ExternalMessageConsumer: async def _receive_messages( self, channel: WebSocketChannel, - producer: Dict[str, Any], + producer: Producer, lock: asyncio.Lock ): """ @@ -277,7 +284,7 @@ class ExternalMessageConsumer: break - def handle_producer_message(self, producer: Dict[str, Any], message: Dict[str, Any]): + def handle_producer_message(self, producer: Producer, message: Dict[str, Any]): """ Handles external messages from a Producer """ From 50dfde7048e4ed36e6b5e0ad62f0f59ca1dd6611 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:11:15 +0200 Subject: [PATCH 195/199] Remove unnecessary typing import --- freqtrade/rpc/external_message_consumer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index bf71c24ea..bb96279d9 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -26,7 +26,6 @@ from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzed if TYPE_CHECKING: import websockets.connect - import websockets.exceptions class Producer(TypedDict): From 8d77ba118c60ef062a5874197022be8d35ac819f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:15:15 +0200 Subject: [PATCH 196/199] Fix line endings --- config_examples/config_freqai.example.json | 2 +- docker/Dockerfile.freqai | 1 - freqtrade/exchange/binance_leverage_tiers.json | 2 +- setup.cfg | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 3a8a3b273..fe5e35c1d 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -94,4 +94,4 @@ "internals": { "process_throttle_secs": 5 } -} \ No newline at end of file +} diff --git a/docker/Dockerfile.freqai b/docker/Dockerfile.freqai index 9a2f75700..e9f04f3d6 100644 --- a/docker/Dockerfile.freqai +++ b/docker/Dockerfile.freqai @@ -6,4 +6,3 @@ FROM ${sourceimage}:${sourcetag} COPY requirements-freqai.txt /freqtrade/ RUN pip install -r requirements-freqai.txt --user --no-cache-dir - diff --git a/freqtrade/exchange/binance_leverage_tiers.json b/freqtrade/exchange/binance_leverage_tiers.json index 2fa326bb1..c3b86684b 100644 --- a/freqtrade/exchange/binance_leverage_tiers.json +++ b/freqtrade/exchange/binance_leverage_tiers.json @@ -19209,4 +19209,4 @@ } } ] -} \ No newline at end of file +} diff --git a/setup.cfg b/setup.cfg index d711534d9..60ec8a75f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,4 +49,3 @@ exclude = __pycache__, .eggs, user_data, - From e63f9e1c14ede90ef004cb410ed0f77f9490ddde Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:16:47 +0200 Subject: [PATCH 197/199] Use pre-commit in Ci to check for all things --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d53044d..cb8084e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,11 @@ jobs: pip install pyaml python build_helpers/pre_commit_update.py + pre-commit: + runs-on: ubuntu-22.04 + steps: + - uses: pre-commit/action@v3.0.0 + docs_check: runs-on: ubuntu-20.04 steps: @@ -302,7 +307,7 @@ jobs: # Notify only once - when CI completes (and after deploy) in case it's successfull notify-complete: - needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check ] + needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ] runs-on: ubuntu-20.04 # Discord notification can't handle schedule events if: (github.event_name != 'schedule') @@ -327,7 +332,7 @@ jobs: webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} deploy: - needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check ] + needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ] runs-on: ubuntu-20.04 if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade' From 1bd742f7e986238e1c4d011b4cee71e4218c60d6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:31:29 +0200 Subject: [PATCH 198/199] Properly setup pre-commit job --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb8084e59..b677d924f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -275,7 +275,12 @@ jobs: pre-commit: runs-on: ubuntu-22.04 steps: - - uses: pre-commit/action@v3.0.0 + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: pre-commit/action@v3.0.0 docs_check: runs-on: ubuntu-20.04 From 873eb5f2cad30eae190c3949d24165a8ddb29f6b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 24 Sep 2022 16:38:56 +0200 Subject: [PATCH 199/199] Improve EMC config validations --- freqtrade/configuration/config_validation.py | 19 +++++++ freqtrade/rpc/external_message_consumer.py | 15 ------ tests/rpc/test_rpc_emc.py | 14 +----- tests/test_configuration.py | 52 ++++++++++++++++++++ 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 8d9112bef..7055d9551 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -1,4 +1,5 @@ import logging +from collections import Counter from copy import deepcopy from typing import Any, Dict @@ -85,6 +86,7 @@ def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False) _validate_unlimited_amount(conf) _validate_ask_orderbook(conf) _validate_freqai_hyperopt(conf) + _validate_consumers(conf) validate_migrated_strategy_settings(conf) # validate configuration before returning @@ -332,6 +334,23 @@ def _validate_freqai_hyperopt(conf: Dict[str, Any]) -> None: 'Using analyze-per-epoch parameter is not supported with a FreqAI strategy.') +def _validate_consumers(conf: Dict[str, Any]) -> None: + emc_conf = conf.get('external_message_consumer', {}) + if emc_conf.get('enabled', False): + if len(emc_conf.get('producers', [])) < 1: + raise OperationalException("You must specify at least 1 Producer to connect to.") + + producer_names = [p['name'] for p in emc_conf.get('producers', [])] + duplicates = [item for item, count in Counter(producer_names).items() if count > 1] + if duplicates: + raise OperationalException( + f"Producer names must be unique. Duplicate: {', '.join(duplicates)}") + if conf.get('process_only_new_candles', True): + # Warning here or require it? + logger.warning("To receive best performance with external data, " + "please set `process_only_new_candles` to False") + + def _strategy_settings(conf: Dict[str, Any]) -> None: process_deprecated_setting(conf, None, 'use_sell_signal', None, 'use_exit_signal') diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index bb96279d9..dcfe1d109 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -15,7 +15,6 @@ from pydantic import ValidationError from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RPCMessageType -from freqtrade.exceptions import OperationalException from freqtrade.misc import remove_entry_exit_signals from freqtrade.rpc.api_server.ws import WebSocketChannel from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzedDFRequest, @@ -74,8 +73,6 @@ class ExternalMessageConsumer: # as the websockets client expects bytes. self.message_size_limit = (self._emc_config.get('message_size_limit', 8) << 20) - self.validate_config() - # Setting these explicitly as they probably shouldn't be changed by a user # Unless we somehow integrate this with the strategy to allow creating # callbacks for the messages @@ -96,18 +93,6 @@ class ExternalMessageConsumer: self.start() - def validate_config(self): - """ - Make sure values are what they are supposed to be - """ - if self.enabled and len(self.producers) < 1: - raise OperationalException("You must specify at least 1 Producer to connect to.") - - if self.enabled and self._config.get('process_only_new_candles', True): - # Warning here or require it? - logger.warning("To receive best performance with external data, " - "please set `process_only_new_candles` to False") - def start(self): """ Start the main internal loop in another thread to run coroutines diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 41faaf249..2649c5460 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -11,7 +11,6 @@ import pytest import websockets from freqtrade.data.dataprovider import DataProvider -from freqtrade.exceptions import OperationalException from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from tests.conftest import log_has, log_has_re, log_has_when @@ -73,23 +72,12 @@ def test_emc_shutdown(patched_emc, caplog): assert not log_has("Stopping ExternalMessageConsumer", caplog) -def test_emc_init(patched_emc, default_conf): +def test_emc_init(patched_emc): # Test the settings were set correctly assert patched_emc.initial_candle_limit <= 1500 assert patched_emc.wait_timeout > 0 assert patched_emc.sleep_time > 0 - default_conf.update({ - "external_message_consumer": { - "enabled": True, - "producers": [] - } - }) - dataprovider = DataProvider(default_conf, None, None, None) - with pytest.raises(OperationalException, - match="You must specify at least 1 Producer to connect to."): - ExternalMessageConsumer(default_conf, dataprovider) - # Parametrize this? def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 2825ede5c..99edf0233 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1089,6 +1089,58 @@ def test__validate_pricing_rules(default_conf, caplog) -> None: validate_config_consistency(conf) +def test__validate_consumers(default_conf, caplog) -> None: + conf = deepcopy(default_conf) + conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [] + } + }) + with pytest.raises(OperationalException, + match="You must specify at least 1 Producer to connect to."): + validate_config_consistency(conf) + + conf = deepcopy(default_conf) + conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "127.0.0.1", + "port": 8081, + "ws_token": "secret_ws_t0ken." + }, { + "name": "default", + "host": "127.0.0.1", + "port": 8080, + "ws_token": "secret_ws_t0ken." + } + ]} + }) + with pytest.raises(OperationalException, + match="Producer names must be unique. Duplicate: default"): + validate_config_consistency(conf) + + conf = deepcopy(default_conf) + conf.update({ + "process_only_new_candles": True, + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "127.0.0.1", + "port": 8081, + "ws_token": "secret_ws_t0ken." + } + ]} + }) + validate_config_consistency(conf) + assert log_has_re("To receive best performance with external data.*", caplog) + + def test_load_config_test_comments() -> None: """ Load config with comments