Merge remote-tracking branch 'origin/develop' into gc_improvements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
""" Freqtrade bot """
|
||||
__version__ = '2022.11.dev'
|
||||
__version__ = '2022.12.dev'
|
||||
|
||||
if 'dev' in __version__:
|
||||
try:
|
||||
|
@@ -60,10 +60,4 @@ def start_analysis_entries_exits(args: Dict[str, Any]) -> None:
|
||||
|
||||
logger.info('Starting freqtrade in analysis mode')
|
||||
|
||||
process_entry_exit_reasons(config['exportfilename'],
|
||||
config['exchange']['pair_whitelist'],
|
||||
config['analysis_groups'],
|
||||
config['enter_reason_list'],
|
||||
config['exit_reason_list'],
|
||||
config['indicator_list']
|
||||
)
|
||||
process_entry_exit_reasons(config)
|
||||
|
@@ -106,7 +106,7 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop
|
||||
"disableparamexport", "backtest_breakdown"]
|
||||
|
||||
ARGS_ANALYZE_ENTRIES_EXITS = ["exportfilename", "analysis_groups", "enter_reason_list",
|
||||
"exit_reason_list", "indicator_list"]
|
||||
"exit_reason_list", "indicator_list", "timerange"]
|
||||
|
||||
NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes",
|
||||
"list-markets", "list-pairs", "list-strategies", "list-freqaimodels",
|
||||
|
@@ -462,6 +462,9 @@ class Configuration:
|
||||
self._args_to_config(config, argname='indicator_list',
|
||||
logstring='Analysis indicator list: {}')
|
||||
|
||||
self._args_to_config(config, argname='timerange',
|
||||
logstring='Filter trades by timerange: {}')
|
||||
|
||||
def _process_runmode(self, config: Config) -> None:
|
||||
|
||||
self._args_to_config(config, argname='dry_run',
|
||||
|
@@ -3,11 +3,12 @@ This module contains the argument manager class
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
@@ -29,6 +30,52 @@ class TimeRange:
|
||||
self.startts: int = startts
|
||||
self.stopts: int = stopts
|
||||
|
||||
@property
|
||||
def startdt(self) -> Optional[datetime]:
|
||||
if self.startts:
|
||||
return datetime.fromtimestamp(self.startts, tz=timezone.utc)
|
||||
return None
|
||||
|
||||
@property
|
||||
def stopdt(self) -> Optional[datetime]:
|
||||
if self.stopts:
|
||||
return datetime.fromtimestamp(self.stopts, tz=timezone.utc)
|
||||
return None
|
||||
|
||||
@property
|
||||
def timerange_str(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the timerange as used by parse_timerange.
|
||||
Follows the format yyyymmdd-yyyymmdd - leaving out the parts that are not set.
|
||||
"""
|
||||
start = ''
|
||||
stop = ''
|
||||
if startdt := self.startdt:
|
||||
start = startdt.strftime('%Y%m%d')
|
||||
if stopdt := self.stopdt:
|
||||
stop = stopdt.strftime('%Y%m%d')
|
||||
return f"{start}-{stop}"
|
||||
|
||||
@property
|
||||
def start_fmt(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the start date
|
||||
"""
|
||||
val = 'unbounded'
|
||||
if (startdt := self.startdt) is not None:
|
||||
val = startdt.strftime(DATETIME_PRINT_FORMAT)
|
||||
return val
|
||||
|
||||
@property
|
||||
def stop_fmt(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the stop date
|
||||
"""
|
||||
val = 'unbounded'
|
||||
if (stopdt := self.stopdt) is not None:
|
||||
val = stopdt.strftime(DATETIME_PRINT_FORMAT)
|
||||
return val
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Override the default Equals behavior"""
|
||||
return (self.starttype == other.starttype and self.stoptype == other.stoptype
|
||||
|
@@ -512,6 +512,7 @@ CONF_SCHEMA = {
|
||||
'minimum': 0,
|
||||
'maximum': 65535
|
||||
},
|
||||
'secure': {'type': 'boolean', 'default': False},
|
||||
'ws_token': {'type': 'string'},
|
||||
},
|
||||
'required': ['name', 'host', 'ws_token']
|
||||
@@ -577,9 +578,27 @@ CONF_SCHEMA = {
|
||||
},
|
||||
},
|
||||
"model_training_parameters": {
|
||||
"type": "object"
|
||||
},
|
||||
"rl_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n_estimators": {"type": "integer", "default": 1000}
|
||||
"train_cycles": {"type": "integer"},
|
||||
"max_trade_duration_candles": {"type": "integer"},
|
||||
"add_state_info": {"type": "boolean", "default": False},
|
||||
"max_training_drawdown_pct": {"type": "number", "default": 0.02},
|
||||
"cpu_count": {"type": "integer", "default": 1},
|
||||
"model_type": {"type": "string", "default": "PPO"},
|
||||
"policy_type": {"type": "string", "default": "MlpPolicy"},
|
||||
"net_arch": {"type": "array", "default": [128, 128]},
|
||||
"randomize_startinng_position": {"type": "boolean", "default": False},
|
||||
"model_reward_parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rr": {"type": "number", "default": 1},
|
||||
"profit_aim": {"type": "number", "default": 0.025}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -3,7 +3,6 @@ Functions to convert data from one format to another
|
||||
"""
|
||||
import itertools
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from operator import itemgetter
|
||||
from typing import Dict, List
|
||||
|
||||
@@ -138,11 +137,9 @@ def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date',
|
||||
df = df.iloc[startup_candles:, :]
|
||||
else:
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
df = df.loc[df[df_date_col] >= start, :]
|
||||
df = df.loc[df[df_date_col] >= timerange.startdt, :]
|
||||
if timerange.stoptype == 'date':
|
||||
stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
df = df.loc[df[df_date_col] <= stop, :]
|
||||
df = df.loc[df[df_date_col] <= timerange.stopdt, :]
|
||||
return df
|
||||
|
||||
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import joblib
|
||||
import pandas as pd
|
||||
from tabulate import tabulate
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import Config
|
||||
from freqtrade.data.btanalysis import (get_latest_backtest_filename, load_backtest_data,
|
||||
load_backtest_stats)
|
||||
from freqtrade.exceptions import OperationalException
|
||||
@@ -152,37 +153,55 @@ def _do_group_table_output(bigdf, glist):
|
||||
logger.warning("Invalid group mask specified.")
|
||||
|
||||
|
||||
def _print_results(analysed_trades, stratname, analysis_groups,
|
||||
enter_reason_list, exit_reason_list,
|
||||
indicator_list, columns=None):
|
||||
if columns is None:
|
||||
columns = ['pair', 'open_date', 'close_date', 'profit_abs', 'enter_reason', 'exit_reason']
|
||||
def _select_rows_within_dates(df, timerange=None, df_date_col: str = 'date'):
|
||||
if timerange:
|
||||
if timerange.starttype == 'date':
|
||||
df = df.loc[(df[df_date_col] >= timerange.startdt)]
|
||||
if timerange.stoptype == 'date':
|
||||
df = df.loc[(df[df_date_col] < timerange.stopdt)]
|
||||
return df
|
||||
|
||||
bigdf = pd.DataFrame()
|
||||
|
||||
def _select_rows_by_tags(df, enter_reason_list, exit_reason_list):
|
||||
if enter_reason_list and "all" not in enter_reason_list:
|
||||
df = df.loc[(df['enter_reason'].isin(enter_reason_list))]
|
||||
|
||||
if exit_reason_list and "all" not in exit_reason_list:
|
||||
df = df.loc[(df['exit_reason'].isin(exit_reason_list))]
|
||||
return df
|
||||
|
||||
|
||||
def prepare_results(analysed_trades, stratname,
|
||||
enter_reason_list, exit_reason_list,
|
||||
timerange=None):
|
||||
res_df = pd.DataFrame()
|
||||
for pair, trades in analysed_trades[stratname].items():
|
||||
bigdf = pd.concat([bigdf, trades], ignore_index=True)
|
||||
res_df = pd.concat([res_df, trades], ignore_index=True)
|
||||
|
||||
if bigdf.shape[0] > 0 and ('enter_reason' in bigdf.columns):
|
||||
res_df = _select_rows_within_dates(res_df, timerange)
|
||||
|
||||
if res_df is not None and res_df.shape[0] > 0 and ('enter_reason' in res_df.columns):
|
||||
res_df = _select_rows_by_tags(res_df, enter_reason_list, exit_reason_list)
|
||||
|
||||
return res_df
|
||||
|
||||
|
||||
def print_results(res_df, analysis_groups, indicator_list):
|
||||
if res_df.shape[0] > 0:
|
||||
if analysis_groups:
|
||||
_do_group_table_output(bigdf, analysis_groups)
|
||||
|
||||
if enter_reason_list and "all" not in enter_reason_list:
|
||||
bigdf = bigdf.loc[(bigdf['enter_reason'].isin(enter_reason_list))]
|
||||
|
||||
if exit_reason_list and "all" not in exit_reason_list:
|
||||
bigdf = bigdf.loc[(bigdf['exit_reason'].isin(exit_reason_list))]
|
||||
_do_group_table_output(res_df, analysis_groups)
|
||||
|
||||
if "all" in indicator_list:
|
||||
print(bigdf)
|
||||
print(res_df)
|
||||
elif indicator_list is not None:
|
||||
available_inds = []
|
||||
for ind in indicator_list:
|
||||
if ind in bigdf:
|
||||
if ind in res_df:
|
||||
available_inds.append(ind)
|
||||
ilist = ["pair", "enter_reason", "exit_reason"] + available_inds
|
||||
_print_table(bigdf[ilist], sortcols=['exit_reason'], show_index=False)
|
||||
_print_table(res_df[ilist], sortcols=['exit_reason'], show_index=False)
|
||||
else:
|
||||
print("\\_ No trades to show")
|
||||
print("\\No trades to show")
|
||||
|
||||
|
||||
def _print_table(df, sortcols=None, show_index=False):
|
||||
@@ -201,27 +220,34 @@ def _print_table(df, sortcols=None, show_index=False):
|
||||
)
|
||||
|
||||
|
||||
def process_entry_exit_reasons(backtest_dir: Path,
|
||||
pairlist: List[str],
|
||||
analysis_groups: Optional[List[str]] = ["0", "1", "2"],
|
||||
enter_reason_list: Optional[List[str]] = ["all"],
|
||||
exit_reason_list: Optional[List[str]] = ["all"],
|
||||
indicator_list: Optional[List[str]] = []):
|
||||
def process_entry_exit_reasons(config: Config):
|
||||
try:
|
||||
backtest_stats = load_backtest_stats(backtest_dir)
|
||||
analysis_groups = config.get('analysis_groups', [])
|
||||
enter_reason_list = config.get('enter_reason_list', ["all"])
|
||||
exit_reason_list = config.get('exit_reason_list', ["all"])
|
||||
indicator_list = config.get('indicator_list', [])
|
||||
|
||||
timerange = TimeRange.parse_timerange(None if config.get(
|
||||
'timerange') is None else str(config.get('timerange')))
|
||||
|
||||
backtest_stats = load_backtest_stats(config['exportfilename'])
|
||||
|
||||
for strategy_name, results in backtest_stats['strategy'].items():
|
||||
trades = load_backtest_data(backtest_dir, strategy_name)
|
||||
trades = load_backtest_data(config['exportfilename'], strategy_name)
|
||||
|
||||
if not trades.empty:
|
||||
signal_candles = _load_signal_candles(backtest_dir)
|
||||
analysed_trades_dict = _process_candles_and_indicators(pairlist, strategy_name,
|
||||
trades, signal_candles)
|
||||
_print_results(analysed_trades_dict,
|
||||
strategy_name,
|
||||
analysis_groups,
|
||||
enter_reason_list,
|
||||
exit_reason_list,
|
||||
indicator_list)
|
||||
signal_candles = _load_signal_candles(config['exportfilename'])
|
||||
analysed_trades_dict = _process_candles_and_indicators(
|
||||
config['exchange']['pair_whitelist'], strategy_name,
|
||||
trades, signal_candles)
|
||||
|
||||
res_df = prepare_results(analysed_trades_dict, strategy_name,
|
||||
enter_reason_list, exit_reason_list,
|
||||
timerange=timerange)
|
||||
|
||||
print_results(res_df,
|
||||
analysis_groups,
|
||||
indicator_list)
|
||||
|
||||
except ValueError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import operator
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
@@ -160,9 +160,9 @@ def _load_cached_data_for_updating(
|
||||
end = None
|
||||
if timerange:
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
start = timerange.startdt
|
||||
if timerange.stoptype == 'date':
|
||||
end = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
end = timerange.stopdt
|
||||
|
||||
# Intentionally don't pass timerange in - since we need to load the full dataset.
|
||||
data = data_handler.ohlcv_load(pair, timeframe=timeframe,
|
||||
|
@@ -366,13 +366,11 @@ class IDataHandler(ABC):
|
||||
"""
|
||||
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
if pairdata.iloc[0]['date'] > start:
|
||||
if pairdata.iloc[0]['date'] > timerange.startdt:
|
||||
logger.warning(f"{pair}, {candle_type}, {timeframe}, "
|
||||
f"data starts at {pairdata.iloc[0]['date']:%Y-%m-%d %H:%M:%S}")
|
||||
if timerange.stoptype == 'date':
|
||||
stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
if pairdata.iloc[-1]['date'] < stop:
|
||||
if pairdata.iloc[-1]['date'] < timerange.stopdt:
|
||||
logger.warning(f"{pair}, {candle_type}, {timeframe}, "
|
||||
f"data ends at {pairdata.iloc[-1]['date']:%Y-%m-%d %H:%M:%S}")
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ class Bybit(Exchange):
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 200,
|
||||
"ohlcv_candle_limit": 1000,
|
||||
"ccxt_futures_name": "linear",
|
||||
"ohlcv_has_history": False,
|
||||
}
|
||||
|
@@ -218,3 +218,19 @@ class Kraken(Exchange):
|
||||
fees = sum(df['open_fund'] * df['open_mark'] * amount * time_in_ratio)
|
||||
|
||||
return fees if is_short else -fees
|
||||
|
||||
def _trades_contracts_to_amount(self, trades: List) -> List:
|
||||
"""
|
||||
Fix "last" id issue for kraken data downloads
|
||||
This whole override can probably be removed once the following
|
||||
issue is closed in ccxt: https://github.com/ccxt/ccxt/issues/15827
|
||||
"""
|
||||
super()._trades_contracts_to_amount(trades)
|
||||
if (
|
||||
len(trades) > 0
|
||||
and isinstance(trades[-1].get('info'), list)
|
||||
and len(trades[-1].get('info', [])) > 7
|
||||
):
|
||||
|
||||
trades[-1]['id'] = trades[-1].get('info', [])[-1]
|
||||
return trades
|
||||
|
135
freqtrade/freqai/RL/Base4ActionRLEnv.py
Normal file
135
freqtrade/freqai/RL/Base4ActionRLEnv.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from gym import spaces
|
||||
|
||||
from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment, Positions
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Actions(Enum):
|
||||
Neutral = 0
|
||||
Exit = 1
|
||||
Long_enter = 2
|
||||
Short_enter = 3
|
||||
|
||||
|
||||
class Base4ActionRLEnv(BaseEnvironment):
|
||||
"""
|
||||
Base class for a 4 action environment
|
||||
"""
|
||||
|
||||
def set_action_space(self):
|
||||
self.action_space = spaces.Discrete(len(Actions))
|
||||
|
||||
def step(self, action: int):
|
||||
"""
|
||||
Logic for a single step (incrementing one candle in time)
|
||||
by the agent
|
||||
:param: action: int = the action type that the agent plans
|
||||
to take for the current step.
|
||||
:returns:
|
||||
observation = current state of environment
|
||||
step_reward = the reward from `calculate_reward()`
|
||||
_done = if the agent "died" or if the candles finished
|
||||
info = dict passed back to openai gym lib
|
||||
"""
|
||||
self._done = False
|
||||
self._current_tick += 1
|
||||
|
||||
if self._current_tick == self._end_tick:
|
||||
self._done = True
|
||||
|
||||
self._update_unrealized_total_profit()
|
||||
|
||||
step_reward = self.calculate_reward(action)
|
||||
self.total_reward += step_reward
|
||||
|
||||
trade_type = None
|
||||
if self.is_tradesignal(action):
|
||||
"""
|
||||
Action: Neutral, position: Long -> Close Long
|
||||
Action: Neutral, position: Short -> Close Short
|
||||
|
||||
Action: Long, position: Neutral -> Open Long
|
||||
Action: Long, position: Short -> Close Short and Open Long
|
||||
|
||||
Action: Short, position: Neutral -> Open Short
|
||||
Action: Short, position: Long -> Close Long and Open Short
|
||||
"""
|
||||
|
||||
if action == Actions.Neutral.value:
|
||||
self._position = Positions.Neutral
|
||||
trade_type = "neutral"
|
||||
self._last_trade_tick = None
|
||||
elif action == Actions.Long_enter.value:
|
||||
self._position = Positions.Long
|
||||
trade_type = "long"
|
||||
self._last_trade_tick = self._current_tick
|
||||
elif action == Actions.Short_enter.value:
|
||||
self._position = Positions.Short
|
||||
trade_type = "short"
|
||||
self._last_trade_tick = self._current_tick
|
||||
elif action == Actions.Exit.value:
|
||||
self._update_total_profit()
|
||||
self._position = Positions.Neutral
|
||||
trade_type = "neutral"
|
||||
self._last_trade_tick = None
|
||||
else:
|
||||
print("case not defined")
|
||||
|
||||
if trade_type is not None:
|
||||
self.trade_history.append(
|
||||
{'price': self.current_price(), 'index': self._current_tick,
|
||||
'type': trade_type})
|
||||
|
||||
if self._total_profit < 1 - self.rl_config.get('max_training_drawdown_pct', 0.8):
|
||||
self._done = True
|
||||
|
||||
self._position_history.append(self._position)
|
||||
|
||||
info = dict(
|
||||
tick=self._current_tick,
|
||||
total_reward=self.total_reward,
|
||||
total_profit=self._total_profit,
|
||||
position=self._position.value
|
||||
)
|
||||
|
||||
observation = self._get_observation()
|
||||
|
||||
self._update_history(info)
|
||||
|
||||
return observation, step_reward, self._done, info
|
||||
|
||||
def is_tradesignal(self, action: int) -> bool:
|
||||
"""
|
||||
Determine if the signal is a trade signal
|
||||
e.g.: agent wants a Actions.Long_exit while it is in a Positions.short
|
||||
"""
|
||||
return not ((action == Actions.Neutral.value and self._position == Positions.Neutral) or
|
||||
(action == Actions.Neutral.value and self._position == Positions.Short) or
|
||||
(action == Actions.Neutral.value and self._position == Positions.Long) or
|
||||
(action == Actions.Short_enter.value and self._position == Positions.Short) or
|
||||
(action == Actions.Short_enter.value and self._position == Positions.Long) or
|
||||
(action == Actions.Exit.value and self._position == Positions.Neutral) or
|
||||
(action == Actions.Long_enter.value and self._position == Positions.Long) or
|
||||
(action == Actions.Long_enter.value and self._position == Positions.Short))
|
||||
|
||||
def _is_valid(self, action: int) -> bool:
|
||||
"""
|
||||
Determine if the signal is valid.
|
||||
e.g.: agent wants a Actions.Long_exit while it is in a Positions.short
|
||||
"""
|
||||
# Agent should only try to exit if it is in position
|
||||
if action == Actions.Exit.value:
|
||||
if self._position not in (Positions.Short, Positions.Long):
|
||||
return False
|
||||
|
||||
# Agent should only try to enter if it is not in position
|
||||
if action in (Actions.Short_enter.value, Actions.Long_enter.value):
|
||||
if self._position != Positions.Neutral:
|
||||
return False
|
||||
|
||||
return True
|
145
freqtrade/freqai/RL/Base5ActionRLEnv.py
Normal file
145
freqtrade/freqai/RL/Base5ActionRLEnv.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from gym import spaces
|
||||
|
||||
from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment, Positions
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Actions(Enum):
|
||||
Neutral = 0
|
||||
Long_enter = 1
|
||||
Long_exit = 2
|
||||
Short_enter = 3
|
||||
Short_exit = 4
|
||||
|
||||
|
||||
class Base5ActionRLEnv(BaseEnvironment):
|
||||
"""
|
||||
Base class for a 5 action environment
|
||||
"""
|
||||
|
||||
def set_action_space(self):
|
||||
self.action_space = spaces.Discrete(len(Actions))
|
||||
|
||||
def step(self, action: int):
|
||||
"""
|
||||
Logic for a single step (incrementing one candle in time)
|
||||
by the agent
|
||||
:param: action: int = the action type that the agent plans
|
||||
to take for the current step.
|
||||
:returns:
|
||||
observation = current state of environment
|
||||
step_reward = the reward from `calculate_reward()`
|
||||
_done = if the agent "died" or if the candles finished
|
||||
info = dict passed back to openai gym lib
|
||||
"""
|
||||
self._done = False
|
||||
self._current_tick += 1
|
||||
|
||||
if self._current_tick == self._end_tick:
|
||||
self._done = True
|
||||
|
||||
self._update_unrealized_total_profit()
|
||||
step_reward = self.calculate_reward(action)
|
||||
self.total_reward += step_reward
|
||||
|
||||
trade_type = None
|
||||
if self.is_tradesignal(action):
|
||||
"""
|
||||
Action: Neutral, position: Long -> Close Long
|
||||
Action: Neutral, position: Short -> Close Short
|
||||
|
||||
Action: Long, position: Neutral -> Open Long
|
||||
Action: Long, position: Short -> Close Short and Open Long
|
||||
|
||||
Action: Short, position: Neutral -> Open Short
|
||||
Action: Short, position: Long -> Close Long and Open Short
|
||||
"""
|
||||
|
||||
if action == Actions.Neutral.value:
|
||||
self._position = Positions.Neutral
|
||||
trade_type = "neutral"
|
||||
self._last_trade_tick = None
|
||||
elif action == Actions.Long_enter.value:
|
||||
self._position = Positions.Long
|
||||
trade_type = "long"
|
||||
self._last_trade_tick = self._current_tick
|
||||
elif action == Actions.Short_enter.value:
|
||||
self._position = Positions.Short
|
||||
trade_type = "short"
|
||||
self._last_trade_tick = self._current_tick
|
||||
elif action == Actions.Long_exit.value:
|
||||
self._update_total_profit()
|
||||
self._position = Positions.Neutral
|
||||
trade_type = "neutral"
|
||||
self._last_trade_tick = None
|
||||
elif action == Actions.Short_exit.value:
|
||||
self._update_total_profit()
|
||||
self._position = Positions.Neutral
|
||||
trade_type = "neutral"
|
||||
self._last_trade_tick = None
|
||||
else:
|
||||
print("case not defined")
|
||||
|
||||
if trade_type is not None:
|
||||
self.trade_history.append(
|
||||
{'price': self.current_price(), 'index': self._current_tick,
|
||||
'type': trade_type})
|
||||
|
||||
if (self._total_profit < self.max_drawdown or
|
||||
self._total_unrealized_profit < self.max_drawdown):
|
||||
self._done = True
|
||||
|
||||
self._position_history.append(self._position)
|
||||
|
||||
info = dict(
|
||||
tick=self._current_tick,
|
||||
total_reward=self.total_reward,
|
||||
total_profit=self._total_profit,
|
||||
position=self._position.value
|
||||
)
|
||||
|
||||
observation = self._get_observation()
|
||||
|
||||
self._update_history(info)
|
||||
|
||||
return observation, step_reward, self._done, info
|
||||
|
||||
def is_tradesignal(self, action: int) -> bool:
|
||||
"""
|
||||
Determine if the signal is a trade signal
|
||||
e.g.: agent wants a Actions.Long_exit while it is in a Positions.short
|
||||
"""
|
||||
return not ((action == Actions.Neutral.value and self._position == Positions.Neutral) or
|
||||
(action == Actions.Neutral.value and self._position == Positions.Short) or
|
||||
(action == Actions.Neutral.value and self._position == Positions.Long) or
|
||||
(action == Actions.Short_enter.value and self._position == Positions.Short) or
|
||||
(action == Actions.Short_enter.value and self._position == Positions.Long) or
|
||||
(action == Actions.Short_exit.value and self._position == Positions.Long) or
|
||||
(action == Actions.Short_exit.value and self._position == Positions.Neutral) or
|
||||
(action == Actions.Long_enter.value and self._position == Positions.Long) or
|
||||
(action == Actions.Long_enter.value and self._position == Positions.Short) or
|
||||
(action == Actions.Long_exit.value and self._position == Positions.Short) or
|
||||
(action == Actions.Long_exit.value and self._position == Positions.Neutral))
|
||||
|
||||
def _is_valid(self, action: int) -> bool:
|
||||
# trade signal
|
||||
"""
|
||||
Determine if the signal is valid.
|
||||
e.g.: agent wants a Actions.Long_exit while it is in a Positions.short
|
||||
"""
|
||||
# Agent should only try to exit if it is in position
|
||||
if action in (Actions.Short_exit.value, Actions.Long_exit.value):
|
||||
if self._position not in (Positions.Short, Positions.Long):
|
||||
return False
|
||||
|
||||
# Agent should only try to enter if it is not in position
|
||||
if action in (Actions.Short_enter.value, Actions.Long_enter.value):
|
||||
if self._position != Positions.Neutral:
|
||||
return False
|
||||
|
||||
return True
|
307
freqtrade/freqai/RL/BaseEnvironment.py
Normal file
307
freqtrade/freqai/RL/BaseEnvironment.py
Normal file
@@ -0,0 +1,307 @@
|
||||
import logging
|
||||
import random
|
||||
from abc import abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import gym
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from gym import spaces
|
||||
from gym.utils import seeding
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Positions(Enum):
|
||||
Short = 0
|
||||
Long = 1
|
||||
Neutral = 0.5
|
||||
|
||||
def opposite(self):
|
||||
return Positions.Short if self == Positions.Long else Positions.Long
|
||||
|
||||
|
||||
class BaseEnvironment(gym.Env):
|
||||
"""
|
||||
Base class for environments. This class is agnostic to action count.
|
||||
Inherited classes customize this to include varying action counts/types,
|
||||
See RL/Base5ActionRLEnv.py and RL/Base4ActionRLEnv.py
|
||||
"""
|
||||
|
||||
def __init__(self, df: DataFrame = DataFrame(), prices: DataFrame = DataFrame(),
|
||||
reward_kwargs: dict = {}, window_size=10, starting_point=True,
|
||||
id: str = 'baseenv-1', seed: int = 1, config: dict = {},
|
||||
dp: Optional[DataProvider] = None):
|
||||
"""
|
||||
Initializes the training/eval environment.
|
||||
:param df: dataframe of features
|
||||
:param prices: dataframe of prices to be used in the training environment
|
||||
:param window_size: size of window (temporal) to pass to the agent
|
||||
:param reward_kwargs: extra config settings assigned by user in `rl_config`
|
||||
:param starting_point: start at edge of window or not
|
||||
:param id: string id of the environment (used in backend for multiprocessed env)
|
||||
:param seed: Sets the seed of the environment higher in the gym.Env object
|
||||
:param config: Typical user configuration file
|
||||
:param dp: dataprovider from freqtrade
|
||||
"""
|
||||
self.config = config
|
||||
self.rl_config = config['freqai']['rl_config']
|
||||
self.add_state_info = self.rl_config.get('add_state_info', False)
|
||||
self.id = id
|
||||
self.seed(seed)
|
||||
self.reset_env(df, prices, window_size, reward_kwargs, starting_point)
|
||||
self.max_drawdown = 1 - self.rl_config.get('max_training_drawdown_pct', 0.8)
|
||||
self.compound_trades = config['stake_amount'] == 'unlimited'
|
||||
if self.config.get('fee', None) is not None:
|
||||
self.fee = self.config['fee']
|
||||
elif dp is not None:
|
||||
self.fee = dp._exchange.get_fee(symbol=dp.current_whitelist()[0]) # type: ignore
|
||||
else:
|
||||
self.fee = 0.0015
|
||||
|
||||
def reset_env(self, df: DataFrame, prices: DataFrame, window_size: int,
|
||||
reward_kwargs: dict, starting_point=True):
|
||||
"""
|
||||
Resets the environment when the agent fails (in our case, if the drawdown
|
||||
exceeds the user set max_training_drawdown_pct)
|
||||
:param df: dataframe of features
|
||||
:param prices: dataframe of prices to be used in the training environment
|
||||
:param window_size: size of window (temporal) to pass to the agent
|
||||
:param reward_kwargs: extra config settings assigned by user in `rl_config`
|
||||
:param starting_point: start at edge of window or not
|
||||
"""
|
||||
self.df = df
|
||||
self.signal_features = self.df
|
||||
self.prices = prices
|
||||
self.window_size = window_size
|
||||
self.starting_point = starting_point
|
||||
self.rr = reward_kwargs["rr"]
|
||||
self.profit_aim = reward_kwargs["profit_aim"]
|
||||
|
||||
# # spaces
|
||||
if self.add_state_info:
|
||||
self.total_features = self.signal_features.shape[1] + 3
|
||||
else:
|
||||
self.total_features = self.signal_features.shape[1]
|
||||
self.shape = (window_size, self.total_features)
|
||||
self.set_action_space()
|
||||
self.observation_space = spaces.Box(
|
||||
low=-1, high=1, shape=self.shape, dtype=np.float32)
|
||||
|
||||
# episode
|
||||
self._start_tick: int = self.window_size
|
||||
self._end_tick: int = len(self.prices) - 1
|
||||
self._done: bool = False
|
||||
self._current_tick: int = self._start_tick
|
||||
self._last_trade_tick: Optional[int] = None
|
||||
self._position = Positions.Neutral
|
||||
self._position_history: list = [None]
|
||||
self.total_reward: float = 0
|
||||
self._total_profit: float = 1
|
||||
self._total_unrealized_profit: float = 1
|
||||
self.history: dict = {}
|
||||
self.trade_history: list = []
|
||||
|
||||
@abstractmethod
|
||||
def set_action_space(self):
|
||||
"""
|
||||
Unique to the environment action count. Must be inherited.
|
||||
"""
|
||||
|
||||
def seed(self, seed: int = 1):
|
||||
self.np_random, seed = seeding.np_random(seed)
|
||||
return [seed]
|
||||
|
||||
def reset(self):
|
||||
|
||||
self._done = False
|
||||
|
||||
if self.starting_point is True:
|
||||
if self.rl_config.get('randomize_starting_position', False):
|
||||
length_of_data = int(self._end_tick / 4)
|
||||
start_tick = random.randint(self.window_size + 1, length_of_data)
|
||||
self._start_tick = start_tick
|
||||
self._position_history = (self._start_tick * [None]) + [self._position]
|
||||
else:
|
||||
self._position_history = (self.window_size * [None]) + [self._position]
|
||||
|
||||
self._current_tick = self._start_tick
|
||||
self._last_trade_tick = None
|
||||
self._position = Positions.Neutral
|
||||
|
||||
self.total_reward = 0.
|
||||
self._total_profit = 1. # unit
|
||||
self.history = {}
|
||||
self.trade_history = []
|
||||
self.portfolio_log_returns = np.zeros(len(self.prices))
|
||||
|
||||
self._profits = [(self._start_tick, 1)]
|
||||
self.close_trade_profit = []
|
||||
self._total_unrealized_profit = 1
|
||||
|
||||
return self._get_observation()
|
||||
|
||||
@abstractmethod
|
||||
def step(self, action: int):
|
||||
"""
|
||||
Step depeneds on action types, this must be inherited.
|
||||
"""
|
||||
return
|
||||
|
||||
def _get_observation(self):
|
||||
"""
|
||||
This may or may not be independent of action types, user can inherit
|
||||
this in their custom "MyRLEnv"
|
||||
"""
|
||||
features_window = self.signal_features[(
|
||||
self._current_tick - self.window_size):self._current_tick]
|
||||
if self.add_state_info:
|
||||
features_and_state = DataFrame(np.zeros((len(features_window), 3)),
|
||||
columns=['current_profit_pct',
|
||||
'position',
|
||||
'trade_duration'],
|
||||
index=features_window.index)
|
||||
|
||||
features_and_state['current_profit_pct'] = self.get_unrealized_profit()
|
||||
features_and_state['position'] = self._position.value
|
||||
features_and_state['trade_duration'] = self.get_trade_duration()
|
||||
features_and_state = pd.concat([features_window, features_and_state], axis=1)
|
||||
return features_and_state
|
||||
else:
|
||||
return features_window
|
||||
|
||||
def get_trade_duration(self):
|
||||
"""
|
||||
Get the trade duration if the agent is in a trade
|
||||
"""
|
||||
if self._last_trade_tick is None:
|
||||
return 0
|
||||
else:
|
||||
return self._current_tick - self._last_trade_tick
|
||||
|
||||
def get_unrealized_profit(self):
|
||||
"""
|
||||
Get the unrealized profit if the agent is in a trade
|
||||
"""
|
||||
if self._last_trade_tick is None:
|
||||
return 0.
|
||||
|
||||
if self._position == Positions.Neutral:
|
||||
return 0.
|
||||
elif self._position == Positions.Short:
|
||||
current_price = self.add_exit_fee(self.prices.iloc[self._current_tick].open)
|
||||
last_trade_price = self.add_entry_fee(self.prices.iloc[self._last_trade_tick].open)
|
||||
return (last_trade_price - current_price) / last_trade_price
|
||||
elif self._position == Positions.Long:
|
||||
current_price = self.add_entry_fee(self.prices.iloc[self._current_tick].open)
|
||||
last_trade_price = self.add_exit_fee(self.prices.iloc[self._last_trade_tick].open)
|
||||
return (current_price - last_trade_price) / last_trade_price
|
||||
else:
|
||||
return 0.
|
||||
|
||||
@abstractmethod
|
||||
def is_tradesignal(self, action: int) -> bool:
|
||||
"""
|
||||
Determine if the signal is a trade signal. This is
|
||||
unique to the actions in the environment, and therefore must be
|
||||
inherited.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _is_valid(self, action: int) -> bool:
|
||||
"""
|
||||
Determine if the signal is valid.This is
|
||||
unique to the actions in the environment, and therefore must be
|
||||
inherited.
|
||||
"""
|
||||
return True
|
||||
|
||||
def add_entry_fee(self, price):
|
||||
return price * (1 + self.fee)
|
||||
|
||||
def add_exit_fee(self, price):
|
||||
return price / (1 + self.fee)
|
||||
|
||||
def _update_history(self, info):
|
||||
if not self.history:
|
||||
self.history = {key: [] for key in info.keys()}
|
||||
|
||||
for key, value in info.items():
|
||||
self.history[key].append(value)
|
||||
|
||||
@abstractmethod
|
||||
def calculate_reward(self, action: int) -> float:
|
||||
"""
|
||||
An example reward function. This is the one function that users will likely
|
||||
wish to inject their own creativity into.
|
||||
:param action: int = The action made by the agent for the current candle.
|
||||
:return:
|
||||
float = the reward to give to the agent for current step (used for optimization
|
||||
of weights in NN)
|
||||
"""
|
||||
|
||||
def _update_unrealized_total_profit(self):
|
||||
"""
|
||||
Update the unrealized total profit incase of episode end.
|
||||
"""
|
||||
if self._position in (Positions.Long, Positions.Short):
|
||||
pnl = self.get_unrealized_profit()
|
||||
if self.compound_trades:
|
||||
# assumes unit stake and compounding
|
||||
unrl_profit = self._total_profit * (1 + pnl)
|
||||
else:
|
||||
# assumes unit stake and no compounding
|
||||
unrl_profit = self._total_profit + pnl
|
||||
self._total_unrealized_profit = unrl_profit
|
||||
|
||||
def _update_total_profit(self):
|
||||
pnl = self.get_unrealized_profit()
|
||||
if self.compound_trades:
|
||||
# assumes unit stake and compounding
|
||||
self._total_profit = self._total_profit * (1 + pnl)
|
||||
else:
|
||||
# assumes unit stake and no compounding
|
||||
self._total_profit += pnl
|
||||
|
||||
def current_price(self) -> float:
|
||||
return self.prices.iloc[self._current_tick].open
|
||||
|
||||
# Keeping around incase we want to start building more complex environment
|
||||
# templates in the future.
|
||||
# def most_recent_return(self):
|
||||
# """
|
||||
# Calculate the tick to tick return if in a trade.
|
||||
# Return is generated from rising prices in Long
|
||||
# and falling prices in Short positions.
|
||||
# The actions Sell/Buy or Hold during a Long position trigger the sell/buy-fee.
|
||||
# """
|
||||
# # Long positions
|
||||
# if self._position == Positions.Long:
|
||||
# current_price = self.prices.iloc[self._current_tick].open
|
||||
# previous_price = self.prices.iloc[self._current_tick - 1].open
|
||||
|
||||
# if (self._position_history[self._current_tick - 1] == Positions.Short
|
||||
# or self._position_history[self._current_tick - 1] == Positions.Neutral):
|
||||
# previous_price = self.add_entry_fee(previous_price)
|
||||
|
||||
# return np.log(current_price) - np.log(previous_price)
|
||||
|
||||
# # Short positions
|
||||
# if self._position == Positions.Short:
|
||||
# current_price = self.prices.iloc[self._current_tick].open
|
||||
# previous_price = self.prices.iloc[self._current_tick - 1].open
|
||||
# if (self._position_history[self._current_tick - 1] == Positions.Long
|
||||
# or self._position_history[self._current_tick - 1] == Positions.Neutral):
|
||||
# previous_price = self.add_exit_fee(previous_price)
|
||||
|
||||
# return np.log(previous_price) - np.log(current_price)
|
||||
|
||||
# return 0
|
||||
|
||||
# def update_portfolio_log_returns(self, action):
|
||||
# self.portfolio_log_returns[self._current_tick] = self.most_recent_return(action)
|
400
freqtrade/freqai/RL/BaseReinforcementLearningModel.py
Normal file
400
freqtrade/freqai/RL/BaseReinforcementLearningModel.py
Normal file
@@ -0,0 +1,400 @@
|
||||
import importlib
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
|
||||
|
||||
import gym
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pandas as pd
|
||||
import torch as th
|
||||
import torch.multiprocessing
|
||||
from pandas import DataFrame
|
||||
from stable_baselines3.common.callbacks import EvalCallback
|
||||
from stable_baselines3.common.monitor import Monitor
|
||||
from stable_baselines3.common.utils import set_random_seed
|
||||
from stable_baselines3.common.vec_env import SubprocVecEnv
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.freqai_interface import IFreqaiModel
|
||||
from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv
|
||||
from freqtrade.freqai.RL.BaseEnvironment import Positions
|
||||
from freqtrade.persistence import Trade
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
torch.multiprocessing.set_sharing_strategy('file_system')
|
||||
|
||||
SB3_MODELS = ['PPO', 'A2C', 'DQN']
|
||||
SB3_CONTRIB_MODELS = ['TRPO', 'ARS', 'RecurrentPPO', 'MaskablePPO']
|
||||
|
||||
|
||||
class BaseReinforcementLearningModel(IFreqaiModel):
|
||||
"""
|
||||
User created Reinforcement Learning Model prediction class
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(config=kwargs['config'])
|
||||
self.max_threads = min(self.freqai_info['rl_config'].get(
|
||||
'cpu_count', 1), max(int(self.max_system_threads / 2), 1))
|
||||
th.set_num_threads(self.max_threads)
|
||||
self.reward_params = self.freqai_info['rl_config']['model_reward_parameters']
|
||||
self.train_env: Union[SubprocVecEnv, gym.Env] = None
|
||||
self.eval_env: Union[SubprocVecEnv, gym.Env] = None
|
||||
self.eval_callback: Optional[EvalCallback] = None
|
||||
self.model_type = self.freqai_info['rl_config']['model_type']
|
||||
self.rl_config = self.freqai_info['rl_config']
|
||||
self.continual_learning = self.freqai_info.get('continual_learning', False)
|
||||
if self.model_type in SB3_MODELS:
|
||||
import_str = 'stable_baselines3'
|
||||
elif self.model_type in SB3_CONTRIB_MODELS:
|
||||
import_str = 'sb3_contrib'
|
||||
else:
|
||||
raise OperationalException(f'{self.model_type} not available in stable_baselines3 or '
|
||||
f'sb3_contrib. please choose one of {SB3_MODELS} or '
|
||||
f'{SB3_CONTRIB_MODELS}')
|
||||
|
||||
mod = importlib.import_module(import_str, self.model_type)
|
||||
self.MODELCLASS = getattr(mod, self.model_type)
|
||||
self.policy_type = self.freqai_info['rl_config']['policy_type']
|
||||
self.unset_outlier_removal()
|
||||
self.net_arch = self.rl_config.get('net_arch', [128, 128])
|
||||
self.dd.model_type = "stable_baselines"
|
||||
|
||||
def unset_outlier_removal(self):
|
||||
"""
|
||||
If user has activated any function that may remove training points, this
|
||||
function will set them to false and warn them
|
||||
"""
|
||||
if self.ft_params.get('use_SVM_to_remove_outliers', False):
|
||||
self.ft_params.update({'use_SVM_to_remove_outliers': False})
|
||||
logger.warning('User tried to use SVM with RL. Deactivating SVM.')
|
||||
if self.ft_params.get('use_DBSCAN_to_remove_outliers', False):
|
||||
self.ft_params.update({'use_DBSCAN_to_remove_outliers': False})
|
||||
logger.warning('User tried to use DBSCAN with RL. Deactivating DBSCAN.')
|
||||
if self.freqai_info['data_split_parameters'].get('shuffle', False):
|
||||
self.freqai_info['data_split_parameters'].update({'shuffle': False})
|
||||
logger.warning('User tried to shuffle training data. Setting shuffle to False')
|
||||
|
||||
def train(
|
||||
self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Filter the training data and train a model to it. Train makes heavy use of the datakitchen
|
||||
for storing, saving, loading, and analyzing the data.
|
||||
:param unfiltered_df: Full dataframe for the current training period
|
||||
:param metadata: pair metadata from strategy.
|
||||
:returns:
|
||||
:model: Trained model which can be used to inference (self.predict)
|
||||
"""
|
||||
|
||||
logger.info("--------------------Starting training " f"{pair} --------------------")
|
||||
|
||||
features_filtered, labels_filtered = dk.filter_features(
|
||||
unfiltered_df,
|
||||
dk.training_features_list,
|
||||
dk.label_list,
|
||||
training_filter=True,
|
||||
)
|
||||
|
||||
data_dictionary: Dict[str, Any] = dk.make_train_test_datasets(
|
||||
features_filtered, labels_filtered)
|
||||
dk.fit_labels() # FIXME useless for now, but just satiating append methods
|
||||
|
||||
# normalize all data based on train_dataset only
|
||||
prices_train, prices_test = self.build_ohlc_price_dataframes(dk.data_dictionary, pair, dk)
|
||||
data_dictionary = dk.normalize_data(data_dictionary)
|
||||
|
||||
# data cleaning/analysis
|
||||
self.data_cleaning_train(dk)
|
||||
|
||||
logger.info(
|
||||
f'Training model on {len(dk.data_dictionary["train_features"].columns)}'
|
||||
f' features and {len(data_dictionary["train_features"])} data points'
|
||||
)
|
||||
|
||||
self.set_train_and_eval_environments(data_dictionary, prices_train, prices_test, dk)
|
||||
|
||||
model = self.fit(data_dictionary, dk)
|
||||
|
||||
logger.info(f"--------------------done training {pair}--------------------")
|
||||
|
||||
return model
|
||||
|
||||
def set_train_and_eval_environments(self, data_dictionary: Dict[str, DataFrame],
|
||||
prices_train: DataFrame, prices_test: DataFrame,
|
||||
dk: FreqaiDataKitchen):
|
||||
"""
|
||||
User can override this if they are using a custom MyRLEnv
|
||||
:param data_dictionary: dict = common data dictionary containing train and test
|
||||
features/labels/weights.
|
||||
:param prices_train/test: DataFrame = dataframe comprised of the prices to be used in the
|
||||
environment during training or testing
|
||||
:param dk: FreqaiDataKitchen = the datakitchen for the current pair
|
||||
"""
|
||||
train_df = data_dictionary["train_features"]
|
||||
test_df = data_dictionary["test_features"]
|
||||
|
||||
self.train_env = self.MyRLEnv(df=train_df,
|
||||
prices=prices_train,
|
||||
window_size=self.CONV_WIDTH,
|
||||
reward_kwargs=self.reward_params,
|
||||
config=self.config,
|
||||
dp=self.data_provider)
|
||||
self.eval_env = Monitor(self.MyRLEnv(df=test_df,
|
||||
prices=prices_test,
|
||||
window_size=self.CONV_WIDTH,
|
||||
reward_kwargs=self.reward_params,
|
||||
config=self.config,
|
||||
dp=self.data_provider))
|
||||
self.eval_callback = EvalCallback(self.eval_env, deterministic=True,
|
||||
render=False, eval_freq=len(train_df),
|
||||
best_model_save_path=str(dk.data_path))
|
||||
|
||||
@abstractmethod
|
||||
def fit(self, data_dictionary: Dict[str, Any], dk: FreqaiDataKitchen, **kwargs):
|
||||
"""
|
||||
Agent customizations and abstract Reinforcement Learning customizations
|
||||
go in here. Abstract method, so this function must be overridden by
|
||||
user class.
|
||||
"""
|
||||
return
|
||||
|
||||
def get_state_info(self, pair: str) -> Tuple[float, float, int]:
|
||||
"""
|
||||
State info during dry/live (not backtesting) which is fed back
|
||||
into the model.
|
||||
:param pair: str = COIN/STAKE to get the environment information for
|
||||
:return:
|
||||
:market_side: float = representing short, long, or neutral for
|
||||
pair
|
||||
:current_profit: float = unrealized profit of the current trade
|
||||
:trade_duration: int = the number of candles that the trade has
|
||||
been open for
|
||||
"""
|
||||
open_trades = Trade.get_trades_proxy(is_open=True)
|
||||
market_side = 0.5
|
||||
current_profit: float = 0
|
||||
trade_duration = 0
|
||||
for trade in open_trades:
|
||||
if trade.pair == pair:
|
||||
if self.data_provider._exchange is None: # type: ignore
|
||||
logger.error('No exchange available.')
|
||||
return 0, 0, 0
|
||||
else:
|
||||
current_rate = self.data_provider._exchange.get_rate( # type: ignore
|
||||
pair, refresh=False, side="exit", is_short=trade.is_short)
|
||||
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
trade_duration = int((now - trade.open_date_utc.timestamp()) / self.base_tf_seconds)
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
if trade.is_short:
|
||||
market_side = 0
|
||||
else:
|
||||
market_side = 1
|
||||
|
||||
return market_side, current_profit, int(trade_duration)
|
||||
|
||||
def predict(
|
||||
self, unfiltered_df: DataFrame, dk: FreqaiDataKitchen, **kwargs
|
||||
) -> Tuple[DataFrame, npt.NDArray[np.int_]]:
|
||||
"""
|
||||
Filter the prediction features data and predict with it.
|
||||
:param unfiltered_dataframe: Full dataframe for the current backtest period.
|
||||
:return:
|
||||
:pred_df: dataframe containing the predictions
|
||||
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove
|
||||
data (NaNs) or felt uncertain about data (PCA and DI index)
|
||||
"""
|
||||
|
||||
dk.find_features(unfiltered_df)
|
||||
filtered_dataframe, _ = dk.filter_features(
|
||||
unfiltered_df, dk.training_features_list, training_filter=False
|
||||
)
|
||||
filtered_dataframe = dk.normalize_data_from_metadata(filtered_dataframe)
|
||||
dk.data_dictionary["prediction_features"] = filtered_dataframe
|
||||
|
||||
# optional additional data cleaning/analysis
|
||||
self.data_cleaning_predict(dk)
|
||||
|
||||
pred_df = self.rl_model_predict(
|
||||
dk.data_dictionary["prediction_features"], dk, self.model)
|
||||
pred_df.fillna(0, inplace=True)
|
||||
|
||||
return (pred_df, dk.do_predict)
|
||||
|
||||
def rl_model_predict(self, dataframe: DataFrame,
|
||||
dk: FreqaiDataKitchen, model: Any) -> DataFrame:
|
||||
"""
|
||||
A helper function to make predictions in the Reinforcement learning module.
|
||||
:param dataframe: DataFrame = the dataframe of features to make the predictions on
|
||||
:param dk: FreqaiDatakitchen = data kitchen for the current pair
|
||||
:param model: Any = the trained model used to inference the features.
|
||||
"""
|
||||
output = pd.DataFrame(np.zeros(len(dataframe)), columns=dk.label_list)
|
||||
|
||||
def _predict(window):
|
||||
observations = dataframe.iloc[window.index]
|
||||
if self.live and self.rl_config.get('add_state_info', False):
|
||||
market_side, current_profit, trade_duration = self.get_state_info(dk.pair)
|
||||
observations['current_profit_pct'] = current_profit
|
||||
observations['position'] = market_side
|
||||
observations['trade_duration'] = trade_duration
|
||||
res, _ = model.predict(observations, deterministic=True)
|
||||
return res
|
||||
|
||||
output = output.rolling(window=self.CONV_WIDTH).apply(_predict)
|
||||
|
||||
return output
|
||||
|
||||
def build_ohlc_price_dataframes(self, data_dictionary: dict,
|
||||
pair: str, dk: FreqaiDataKitchen) -> Tuple[DataFrame,
|
||||
DataFrame]:
|
||||
"""
|
||||
Builds the train prices and test prices for the environment.
|
||||
"""
|
||||
|
||||
pair = pair.replace(':', '')
|
||||
train_df = data_dictionary["train_features"]
|
||||
test_df = data_dictionary["test_features"]
|
||||
|
||||
# price data for model training and evaluation
|
||||
tf = self.config['timeframe']
|
||||
ohlc_list = [f'%-{pair}raw_open_{tf}', f'%-{pair}raw_low_{tf}',
|
||||
f'%-{pair}raw_high_{tf}', f'%-{pair}raw_close_{tf}']
|
||||
rename_dict = {f'%-{pair}raw_open_{tf}': 'open', f'%-{pair}raw_low_{tf}': 'low',
|
||||
f'%-{pair}raw_high_{tf}': ' high', f'%-{pair}raw_close_{tf}': 'close'}
|
||||
|
||||
prices_train = train_df.filter(ohlc_list, axis=1)
|
||||
if prices_train.empty:
|
||||
raise OperationalException('Reinforcement learning module didnt find the raw prices '
|
||||
'assigned in populate_any_indicators. Please assign them '
|
||||
'with:\n'
|
||||
'informative[f"%-{pair}raw_close"] = informative["close"]\n'
|
||||
'informative[f"%-{pair}raw_open"] = informative["open"]\n'
|
||||
'informative[f"%-{pair}raw_high"] = informative["high"]\n'
|
||||
'informative[f"%-{pair}raw_low"] = informative["low"]\n')
|
||||
prices_train.rename(columns=rename_dict, inplace=True)
|
||||
prices_train.reset_index(drop=True)
|
||||
|
||||
prices_test = test_df.filter(ohlc_list, axis=1)
|
||||
prices_test.rename(columns=rename_dict, inplace=True)
|
||||
prices_test.reset_index(drop=True)
|
||||
|
||||
return prices_train, prices_test
|
||||
|
||||
def load_model_from_disk(self, dk: FreqaiDataKitchen) -> Any:
|
||||
"""
|
||||
Can be used by user if they are trying to limit_ram_usage *and*
|
||||
perform continual learning.
|
||||
For now, this is unused.
|
||||
"""
|
||||
exists = Path(dk.data_path / f"{dk.model_filename}_model").is_file()
|
||||
if exists:
|
||||
model = self.MODELCLASS.load(dk.data_path / f"{dk.model_filename}_model")
|
||||
else:
|
||||
logger.info('No model file on disk to continue learning from.')
|
||||
|
||||
return model
|
||||
|
||||
def _on_stop(self):
|
||||
"""
|
||||
Hook called on bot shutdown. Close SubprocVecEnv subprocesses for clean shutdown.
|
||||
"""
|
||||
|
||||
if self.train_env:
|
||||
self.train_env.close()
|
||||
|
||||
if self.eval_env:
|
||||
self.eval_env.close()
|
||||
|
||||
# Nested class which can be overridden by user to customize further
|
||||
class MyRLEnv(Base5ActionRLEnv):
|
||||
"""
|
||||
User can override any function in BaseRLEnv and gym.Env. Here the user
|
||||
sets a custom reward based on profit and trade duration.
|
||||
"""
|
||||
|
||||
def calculate_reward(self, action: int) -> float:
|
||||
"""
|
||||
An example reward function. This is the one function that users will likely
|
||||
wish to inject their own creativity into.
|
||||
:param action: int = The action made by the agent for the current candle.
|
||||
:return:
|
||||
float = the reward to give to the agent for current step (used for optimization
|
||||
of weights in NN)
|
||||
"""
|
||||
# first, penalize if the action is not valid
|
||||
if not self._is_valid(action):
|
||||
return -2
|
||||
|
||||
pnl = self.get_unrealized_profit()
|
||||
factor = 100.
|
||||
|
||||
# reward agent for entering trades
|
||||
if (action in (Actions.Long_enter.value, Actions.Short_enter.value)
|
||||
and self._position == Positions.Neutral):
|
||||
return 25
|
||||
# discourage agent from not entering trades
|
||||
if action == Actions.Neutral.value and self._position == Positions.Neutral:
|
||||
return -1
|
||||
|
||||
max_trade_duration = self.rl_config.get('max_trade_duration_candles', 300)
|
||||
if self._last_trade_tick:
|
||||
trade_duration = self._current_tick - self._last_trade_tick
|
||||
else:
|
||||
trade_duration = 0
|
||||
|
||||
if trade_duration <= max_trade_duration:
|
||||
factor *= 1.5
|
||||
elif trade_duration > max_trade_duration:
|
||||
factor *= 0.5
|
||||
|
||||
# discourage sitting in position
|
||||
if (self._position in (Positions.Short, Positions.Long) and
|
||||
action == Actions.Neutral.value):
|
||||
return -1 * trade_duration / max_trade_duration
|
||||
|
||||
# close long
|
||||
if action == Actions.Long_exit.value and self._position == Positions.Long:
|
||||
if pnl > self.profit_aim * self.rr:
|
||||
factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2)
|
||||
return float(pnl * factor)
|
||||
|
||||
# close short
|
||||
if action == Actions.Short_exit.value and self._position == Positions.Short:
|
||||
if pnl > self.profit_aim * self.rr:
|
||||
factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2)
|
||||
return float(pnl * factor)
|
||||
|
||||
return 0.
|
||||
|
||||
|
||||
def make_env(MyRLEnv: Type[gym.Env], env_id: str, rank: int,
|
||||
seed: int, train_df: DataFrame, price: DataFrame,
|
||||
reward_params: Dict[str, int], window_size: int, monitor: bool = False,
|
||||
config: Dict[str, Any] = {}) -> Callable:
|
||||
"""
|
||||
Utility function for multiprocessed env.
|
||||
|
||||
:param env_id: (str) the environment ID
|
||||
:param num_env: (int) the number of environment you wish to have in subprocesses
|
||||
:param seed: (int) the inital seed for RNG
|
||||
:param rank: (int) index of the subprocess
|
||||
:return: (Callable)
|
||||
"""
|
||||
|
||||
def _init() -> gym.Env:
|
||||
|
||||
env = MyRLEnv(df=train_df, prices=price, window_size=window_size,
|
||||
reward_kwargs=reward_params, id=env_id, seed=seed + rank, config=config)
|
||||
if monitor:
|
||||
env = Monitor(env)
|
||||
return env
|
||||
set_random_seed(seed)
|
||||
return _init
|
0
freqtrade/freqai/RL/__init__.py
Normal file
0
freqtrade/freqai/RL/__init__.py
Normal file
@@ -1,9 +1,10 @@
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple, TypedDict
|
||||
|
||||
@@ -81,6 +82,7 @@ class FreqaiDataDrawer:
|
||||
self.historic_predictions_bkp_path = Path(
|
||||
self.full_path / "historic_predictions.backup.pkl")
|
||||
self.pair_dictionary_path = Path(self.full_path / "pair_dictionary.json")
|
||||
self.global_metadata_path = Path(self.full_path / "global_metadata.json")
|
||||
self.metric_tracker_path = Path(self.full_path / "metric_tracker.json")
|
||||
self.follow_mode = follow_mode
|
||||
if follow_mode:
|
||||
@@ -98,6 +100,7 @@ class FreqaiDataDrawer:
|
||||
self.empty_pair_dict: pair_info = {
|
||||
"model_filename": "", "trained_timestamp": 0,
|
||||
"data_path": "", "extras": {}}
|
||||
self.model_type = self.freqai_info.get('model_save_type', 'joblib')
|
||||
|
||||
def update_metric_tracker(self, metric: str, value: float, pair: str) -> None:
|
||||
"""
|
||||
@@ -125,6 +128,17 @@ class FreqaiDataDrawer:
|
||||
self.update_metric_tracker('cpu_load5min', load5 / cpus, pair)
|
||||
self.update_metric_tracker('cpu_load15min', load15 / cpus, pair)
|
||||
|
||||
def load_global_metadata_from_disk(self):
|
||||
"""
|
||||
Locate and load a previously saved global metadata in present model folder.
|
||||
"""
|
||||
exists = self.global_metadata_path.is_file()
|
||||
if exists:
|
||||
with open(self.global_metadata_path, "r") as fp:
|
||||
metatada_dict = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE)
|
||||
return metatada_dict
|
||||
return {}
|
||||
|
||||
def load_drawer_from_disk(self):
|
||||
"""
|
||||
Locate and load a previously saved data drawer full of all pair model metadata in
|
||||
@@ -225,6 +239,15 @@ class FreqaiDataDrawer:
|
||||
rapidjson.dump(self.follower_dict, fp, default=self.np_encoder,
|
||||
number_mode=rapidjson.NM_NATIVE)
|
||||
|
||||
def save_global_metadata_to_disk(self, metadata: Dict[str, Any]):
|
||||
"""
|
||||
Save global metadata json to disk
|
||||
"""
|
||||
with self.save_lock:
|
||||
with open(self.global_metadata_path, 'w') as fp:
|
||||
rapidjson.dump(metadata, fp, default=self.np_encoder,
|
||||
number_mode=rapidjson.NM_NATIVE)
|
||||
|
||||
def create_follower_dict(self):
|
||||
"""
|
||||
Create or dictionary for each follower to maintain unique persistent prediction targets
|
||||
@@ -476,10 +499,12 @@ class FreqaiDataDrawer:
|
||||
save_path = Path(dk.data_path)
|
||||
|
||||
# Save the trained model
|
||||
if not dk.keras:
|
||||
if self.model_type == 'joblib':
|
||||
dump(model, save_path / f"{dk.model_filename}_model.joblib")
|
||||
else:
|
||||
elif self.model_type == 'keras':
|
||||
model.save(save_path / f"{dk.model_filename}_model.h5")
|
||||
elif 'stable_baselines' in self.model_type:
|
||||
model.save(save_path / f"{dk.model_filename}_model.zip")
|
||||
|
||||
if dk.svm_model is not None:
|
||||
dump(dk.svm_model, save_path / f"{dk.model_filename}_svm_model.joblib")
|
||||
@@ -506,11 +531,10 @@ class FreqaiDataDrawer:
|
||||
dk.pca, open(dk.data_path / f"{dk.model_filename}_pca_object.pkl", "wb")
|
||||
)
|
||||
|
||||
# if self.live:
|
||||
# store as much in ram as possible to increase performance
|
||||
self.model_dictionary[coin] = model
|
||||
self.pair_dict[coin]["model_filename"] = dk.model_filename
|
||||
self.pair_dict[coin]["data_path"] = str(dk.data_path)
|
||||
|
||||
if coin not in self.meta_data_dictionary:
|
||||
self.meta_data_dictionary[coin] = {}
|
||||
self.meta_data_dictionary[coin]["train_df"] = dk.data_dictionary["train_features"]
|
||||
@@ -542,14 +566,6 @@ class FreqaiDataDrawer:
|
||||
if dk.live:
|
||||
dk.model_filename = self.pair_dict[coin]["model_filename"]
|
||||
dk.data_path = Path(self.pair_dict[coin]["data_path"])
|
||||
if self.freqai_info.get("follow_mode", False):
|
||||
# follower can be on a different system which is rsynced from the leader:
|
||||
dk.data_path = Path(
|
||||
self.config["user_data_dir"]
|
||||
/ "models"
|
||||
/ dk.data_path.parts[-2]
|
||||
/ dk.data_path.parts[-1]
|
||||
)
|
||||
|
||||
if coin in self.meta_data_dictionary:
|
||||
dk.data = self.meta_data_dictionary[coin]["meta_data"]
|
||||
@@ -568,12 +584,16 @@ class FreqaiDataDrawer:
|
||||
# try to access model in memory instead of loading object from disk to save time
|
||||
if dk.live and coin in self.model_dictionary:
|
||||
model = self.model_dictionary[coin]
|
||||
elif not dk.keras:
|
||||
elif self.model_type == 'joblib':
|
||||
model = load(dk.data_path / f"{dk.model_filename}_model.joblib")
|
||||
else:
|
||||
elif self.model_type == 'keras':
|
||||
from tensorflow import keras
|
||||
|
||||
model = keras.models.load_model(dk.data_path / f"{dk.model_filename}_model.h5")
|
||||
elif self.model_type == 'stable_baselines':
|
||||
mod = importlib.import_module(
|
||||
'stable_baselines3', self.freqai_info['rl_config']['model_type'])
|
||||
MODELCLASS = getattr(mod, self.freqai_info['rl_config']['model_type'])
|
||||
model = MODELCLASS.load(dk.data_path / f"{dk.model_filename}_model")
|
||||
|
||||
if Path(dk.data_path / f"{dk.model_filename}_svm_model.joblib").is_file():
|
||||
dk.svm_model = load(dk.data_path / f"{dk.model_filename}_svm_model.joblib")
|
||||
@@ -583,6 +603,10 @@ class FreqaiDataDrawer:
|
||||
f"Unable to load model, ensure model exists at " f"{dk.data_path} "
|
||||
)
|
||||
|
||||
# load it into ram if it was loaded from disk
|
||||
if coin not in self.model_dictionary:
|
||||
self.model_dictionary[coin] = model
|
||||
|
||||
if self.config["freqai"]["feature_parameters"]["principal_component_analysis"]:
|
||||
dk.pca = cloudpickle.load(
|
||||
open(dk.data_path / f"{dk.model_filename}_pca_object.pkl", "rb")
|
||||
@@ -693,3 +717,31 @@ class FreqaiDataDrawer:
|
||||
).reset_index(drop=True)
|
||||
|
||||
return corr_dataframes, base_dataframes
|
||||
|
||||
def get_timerange_from_live_historic_predictions(self) -> TimeRange:
|
||||
"""
|
||||
Returns timerange information based on historic predictions file
|
||||
:return: timerange calculated from saved live data
|
||||
"""
|
||||
if not self.historic_predictions_path.is_file():
|
||||
raise OperationalException(
|
||||
'Historic predictions not found. Historic predictions data is required '
|
||||
'to run backtest with the freqai-backtest-live-models option '
|
||||
)
|
||||
|
||||
self.load_historic_predictions_from_disk()
|
||||
|
||||
all_pairs_end_dates = []
|
||||
for pair in self.historic_predictions:
|
||||
pair_historic_data = self.historic_predictions[pair]
|
||||
all_pairs_end_dates.append(pair_historic_data.date_pred.max())
|
||||
|
||||
global_metadata = self.load_global_metadata_from_disk()
|
||||
start_date = datetime.fromtimestamp(int(global_metadata["start_dry_live_date"]))
|
||||
end_date = max(all_pairs_end_dates)
|
||||
# add 1 day to string timerange to ensure BT module will load all dataframe data
|
||||
end_date = end_date + timedelta(days=1)
|
||||
backtesting_timerange = TimeRange(
|
||||
'date', 'date', int(start_date.timestamp()), int(end_date.timestamp())
|
||||
)
|
||||
return backtesting_timerange
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from math import cos, sin
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Tuple
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pandas as pd
|
||||
import psutil
|
||||
from pandas import DataFrame
|
||||
from scipy import stats
|
||||
from sklearn import linear_model
|
||||
@@ -86,12 +87,7 @@ class FreqaiDataKitchen:
|
||||
if not self.live:
|
||||
self.full_path = self.get_full_models_path(self.config)
|
||||
|
||||
if self.backtest_live_models:
|
||||
if self.pair:
|
||||
self.set_timerange_from_ready_models()
|
||||
(self.training_timeranges,
|
||||
self.backtesting_timeranges) = self.split_timerange_live_models()
|
||||
else:
|
||||
if not self.backtest_live_models:
|
||||
self.full_timerange = self.create_fulltimerange(
|
||||
self.config["timerange"], self.freqai_config.get("train_period_days", 0)
|
||||
)
|
||||
@@ -102,7 +98,10 @@ class FreqaiDataKitchen:
|
||||
)
|
||||
|
||||
self.data['extra_returns_per_train'] = self.freqai_config.get('extra_returns_per_train', {})
|
||||
self.thread_count = self.freqai_config.get("data_kitchen_thread_count", -1)
|
||||
if not self.freqai_config.get("data_kitchen_thread_count", 0):
|
||||
self.thread_count = max(int(psutil.cpu_count() * 2 - 2), 1)
|
||||
else:
|
||||
self.thread_count = self.freqai_config["data_kitchen_thread_count"]
|
||||
self.train_dates: DataFrame = pd.DataFrame()
|
||||
self.unique_classes: Dict[str, list] = {}
|
||||
self.unique_class_list: list = []
|
||||
@@ -433,9 +432,7 @@ class FreqaiDataKitchen:
|
||||
timerange_train.stopts = timerange_train.startts + train_period_days
|
||||
|
||||
first = False
|
||||
start = datetime.fromtimestamp(timerange_train.startts, tz=timezone.utc)
|
||||
stop = datetime.fromtimestamp(timerange_train.stopts, tz=timezone.utc)
|
||||
tr_training_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d"))
|
||||
tr_training_list.append(timerange_train.timerange_str)
|
||||
tr_training_list_timerange.append(copy.deepcopy(timerange_train))
|
||||
|
||||
# associated backtest period
|
||||
@@ -447,9 +444,7 @@ class FreqaiDataKitchen:
|
||||
if timerange_backtest.stopts > config_timerange.stopts:
|
||||
timerange_backtest.stopts = config_timerange.stopts
|
||||
|
||||
start = datetime.fromtimestamp(timerange_backtest.startts, tz=timezone.utc)
|
||||
stop = datetime.fromtimestamp(timerange_backtest.stopts, tz=timezone.utc)
|
||||
tr_backtesting_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d"))
|
||||
tr_backtesting_list.append(timerange_backtest.timerange_str)
|
||||
tr_backtesting_list_timerange.append(copy.deepcopy(timerange_backtest))
|
||||
|
||||
# ensure we are predicting on exactly same amount of data as requested by user defined
|
||||
@@ -460,29 +455,6 @@ class FreqaiDataKitchen:
|
||||
# print(tr_training_list, tr_backtesting_list)
|
||||
return tr_training_list_timerange, tr_backtesting_list_timerange
|
||||
|
||||
def split_timerange_live_models(
|
||||
self
|
||||
) -> Tuple[list, list]:
|
||||
|
||||
tr_backtesting_list_timerange = []
|
||||
asset = self.pair.split("/")[0]
|
||||
if asset not in self.backtest_live_models_data["assets_end_dates"]:
|
||||
raise OperationalException(
|
||||
f"Model not available for pair {self.pair}. "
|
||||
"Please, try again after removing this pair from the configuration file."
|
||||
)
|
||||
asset_data = self.backtest_live_models_data["assets_end_dates"][asset]
|
||||
backtesting_timerange = self.backtest_live_models_data["backtesting_timerange"]
|
||||
model_end_dates = [x for x in asset_data]
|
||||
model_end_dates.append(backtesting_timerange.stopts)
|
||||
model_end_dates.sort()
|
||||
for index, item in enumerate(model_end_dates):
|
||||
if len(model_end_dates) > (index + 1):
|
||||
tr_to_add = TimeRange("date", "date", item, model_end_dates[index + 1])
|
||||
tr_backtesting_list_timerange.append(tr_to_add)
|
||||
|
||||
return tr_backtesting_list_timerange, tr_backtesting_list_timerange
|
||||
|
||||
def slice_dataframe(self, timerange: TimeRange, df: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Given a full dataframe, extract the user desired window
|
||||
@@ -491,11 +463,9 @@ class FreqaiDataKitchen:
|
||||
it is sliced down to just the present training period.
|
||||
"""
|
||||
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
df = df.loc[df["date"] >= start, :]
|
||||
df = df.loc[df["date"] >= timerange.startdt, :]
|
||||
if not self.live:
|
||||
df = df.loc[df["date"] < stop, :]
|
||||
df = df.loc[df["date"] < timerange.stopdt, :]
|
||||
|
||||
return df
|
||||
|
||||
@@ -980,7 +950,8 @@ class FreqaiDataKitchen:
|
||||
return weights
|
||||
|
||||
def get_predictions_to_append(self, predictions: DataFrame,
|
||||
do_predict: npt.ArrayLike) -> DataFrame:
|
||||
do_predict: npt.ArrayLike,
|
||||
dataframe_backtest: DataFrame) -> DataFrame:
|
||||
"""
|
||||
Get backtest prediction from current backtest period
|
||||
"""
|
||||
@@ -1002,7 +973,9 @@ class FreqaiDataKitchen:
|
||||
if self.freqai_config["feature_parameters"].get("DI_threshold", 0) > 0:
|
||||
append_df["DI_values"] = self.DI_values
|
||||
|
||||
return append_df
|
||||
dataframe_backtest.reset_index(drop=True, inplace=True)
|
||||
merged_df = pd.concat([dataframe_backtest["date"], append_df], axis=1)
|
||||
return merged_df
|
||||
|
||||
def append_predictions(self, append_df: DataFrame) -> None:
|
||||
"""
|
||||
@@ -1012,23 +985,18 @@ class FreqaiDataKitchen:
|
||||
if self.full_df.empty:
|
||||
self.full_df = append_df
|
||||
else:
|
||||
self.full_df = pd.concat([self.full_df, append_df], axis=0)
|
||||
self.full_df = pd.concat([self.full_df, append_df], axis=0, ignore_index=True)
|
||||
|
||||
def fill_predictions(self, dataframe):
|
||||
"""
|
||||
Back fill values to before the backtesting range so that the dataframe matches size
|
||||
when it goes back to the strategy. These rows are not included in the backtest.
|
||||
"""
|
||||
|
||||
len_filler = len(dataframe) - len(self.full_df.index) # startup_candle_count
|
||||
filler_df = pd.DataFrame(
|
||||
np.zeros((len_filler, len(self.full_df.columns))), columns=self.full_df.columns
|
||||
)
|
||||
|
||||
self.full_df = pd.concat([filler_df, self.full_df], axis=0, ignore_index=True)
|
||||
|
||||
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 = pd.merge(dataframe[to_keep],
|
||||
self.full_df, how='left', on='date')
|
||||
self.return_dataframe[self.full_df.columns] = (
|
||||
self.return_dataframe[self.full_df.columns].fillna(value=0))
|
||||
self.full_df = DataFrame()
|
||||
|
||||
return
|
||||
@@ -1058,9 +1026,7 @@ class FreqaiDataKitchen:
|
||||
backtest_timerange.startts = (
|
||||
backtest_timerange.startts - backtest_period_days * SECONDS_IN_DAY
|
||||
)
|
||||
start = datetime.fromtimestamp(backtest_timerange.startts, tz=timezone.utc)
|
||||
stop = datetime.fromtimestamp(backtest_timerange.stopts, tz=timezone.utc)
|
||||
full_timerange = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")
|
||||
full_timerange = backtest_timerange.timerange_str
|
||||
config_path = Path(self.config["config_files"][0])
|
||||
|
||||
if not self.full_path.is_dir():
|
||||
@@ -1327,22 +1293,22 @@ class FreqaiDataKitchen:
|
||||
self, append_df: DataFrame
|
||||
) -> None:
|
||||
"""
|
||||
Save prediction dataframe from backtesting to h5 file format
|
||||
Save prediction dataframe from backtesting to feather file format
|
||||
:param append_df: dataframe for backtesting period
|
||||
"""
|
||||
full_predictions_folder = Path(self.full_path / self.backtest_predictions_folder)
|
||||
if not full_predictions_folder.is_dir():
|
||||
full_predictions_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
append_df.to_hdf(self.backtesting_results_path, key='append_df', mode='w')
|
||||
append_df.to_feather(self.backtesting_results_path)
|
||||
|
||||
def get_backtesting_prediction(
|
||||
self
|
||||
) -> DataFrame:
|
||||
"""
|
||||
Get prediction dataframe from h5 file format
|
||||
Get prediction dataframe from feather file format
|
||||
"""
|
||||
append_df = pd.read_hdf(self.backtesting_results_path)
|
||||
append_df = pd.read_feather(self.backtesting_results_path)
|
||||
return append_df
|
||||
|
||||
def check_if_backtest_prediction_is_valid(
|
||||
@@ -1358,19 +1324,20 @@ class FreqaiDataKitchen:
|
||||
"""
|
||||
path_to_predictionfile = Path(self.full_path /
|
||||
self.backtest_predictions_folder /
|
||||
f"{self.model_filename}_prediction.h5")
|
||||
f"{self.model_filename}_prediction.feather")
|
||||
self.backtesting_results_path = path_to_predictionfile
|
||||
|
||||
file_exists = path_to_predictionfile.is_file()
|
||||
|
||||
if file_exists:
|
||||
append_df = self.get_backtesting_prediction()
|
||||
if len(append_df) == len_backtest_df:
|
||||
if len(append_df) == len_backtest_df and 'date' in append_df:
|
||||
logger.info(f"Found backtesting prediction file at {path_to_predictionfile}")
|
||||
return True
|
||||
else:
|
||||
logger.info("A new backtesting prediction file is required. "
|
||||
"(Number of predictions is different from dataframe length).")
|
||||
"(Number of predictions is different from dataframe length or "
|
||||
"old prediction file version).")
|
||||
return False
|
||||
else:
|
||||
logger.info(
|
||||
@@ -1378,17 +1345,6 @@ class FreqaiDataKitchen:
|
||||
)
|
||||
return False
|
||||
|
||||
def set_timerange_from_ready_models(self):
|
||||
backtesting_timerange, \
|
||||
assets_end_dates = (
|
||||
self.get_timerange_and_assets_end_dates_from_ready_models(self.full_path))
|
||||
|
||||
self.backtest_live_models_data = {
|
||||
"backtesting_timerange": backtesting_timerange,
|
||||
"assets_end_dates": assets_end_dates
|
||||
}
|
||||
return
|
||||
|
||||
def get_full_models_path(self, config: Config) -> Path:
|
||||
"""
|
||||
Returns default FreqAI model path
|
||||
@@ -1399,88 +1355,6 @@ class FreqaiDataKitchen:
|
||||
config["user_data_dir"] / "models" / str(freqai_config.get("identifier"))
|
||||
)
|
||||
|
||||
def get_timerange_and_assets_end_dates_from_ready_models(
|
||||
self, models_path: Path) -> Tuple[TimeRange, Dict[str, Any]]:
|
||||
"""
|
||||
Returns timerange information based on a FreqAI model directory
|
||||
:param models_path: FreqAI model path
|
||||
|
||||
:return: a Tuple with (Timerange calculated from directory and
|
||||
a Dict with pair and model end training dates info)
|
||||
"""
|
||||
all_models_end_dates = []
|
||||
assets_end_dates: Dict[str, Any] = self.get_assets_timestamps_training_from_ready_models(
|
||||
models_path)
|
||||
for key in assets_end_dates:
|
||||
for model_end_date in assets_end_dates[key]:
|
||||
if model_end_date not in all_models_end_dates:
|
||||
all_models_end_dates.append(model_end_date)
|
||||
|
||||
if len(all_models_end_dates) == 0:
|
||||
raise OperationalException(
|
||||
'At least 1 saved model is required to '
|
||||
'run backtest with the freqai-backtest-live-models option'
|
||||
)
|
||||
|
||||
if len(all_models_end_dates) == 1:
|
||||
logger.warning(
|
||||
"Only 1 model was found. Backtesting will run with the "
|
||||
"timerange from the end of the training date to the current date"
|
||||
)
|
||||
|
||||
finish_timestamp = int(datetime.now(tz=timezone.utc).timestamp())
|
||||
if len(all_models_end_dates) > 1:
|
||||
# After last model end date, use the same period from previous model
|
||||
# to finish the backtest
|
||||
all_models_end_dates.sort(reverse=True)
|
||||
finish_timestamp = all_models_end_dates[0] + \
|
||||
(all_models_end_dates[0] - all_models_end_dates[1])
|
||||
|
||||
all_models_end_dates.append(finish_timestamp)
|
||||
all_models_end_dates.sort()
|
||||
start_date = (datetime(*datetime.fromtimestamp(min(all_models_end_dates),
|
||||
timezone.utc).timetuple()[:3], tzinfo=timezone.utc))
|
||||
end_date = (datetime(*datetime.fromtimestamp(max(all_models_end_dates),
|
||||
timezone.utc).timetuple()[:3], tzinfo=timezone.utc))
|
||||
|
||||
# add 1 day to string timerange to ensure BT module will load all dataframe data
|
||||
end_date = end_date + timedelta(days=1)
|
||||
backtesting_timerange = TimeRange(
|
||||
'date', 'date', int(start_date.timestamp()), int(end_date.timestamp())
|
||||
)
|
||||
return backtesting_timerange, assets_end_dates
|
||||
|
||||
def get_assets_timestamps_training_from_ready_models(
|
||||
self, models_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Scan the models path and returns all assets end training dates (timestamp)
|
||||
:param models_path: FreqAI model path
|
||||
|
||||
:return: a Dict with asset and model end training dates info
|
||||
"""
|
||||
assets_end_dates: Dict[str, Any] = {}
|
||||
if not models_path.is_dir():
|
||||
raise OperationalException(
|
||||
'Model folders not found. Saved models are required '
|
||||
'to run backtest with the freqai-backtest-live-models option'
|
||||
)
|
||||
for model_dir in models_path.iterdir():
|
||||
if str(model_dir.name).startswith("sub-train"):
|
||||
model_end_date = int(model_dir.name.split("_")[1])
|
||||
asset = model_dir.name.split("_")[0].replace("sub-train-", "")
|
||||
model_file_name = (
|
||||
f"cb_{str(model_dir.name).replace('sub-train-', '').lower()}"
|
||||
"_model.joblib"
|
||||
)
|
||||
|
||||
model_path_file = Path(model_dir / model_file_name)
|
||||
if model_path_file.is_file():
|
||||
if asset not in assets_end_dates:
|
||||
assets_end_dates[asset] = []
|
||||
assets_end_dates[asset].append(model_end_date)
|
||||
|
||||
return assets_end_dates
|
||||
|
||||
def remove_special_chars_from_feature_names(self, dataframe: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Remove all special characters from feature strings (:)
|
||||
|
@@ -5,15 +5,17 @@ from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Tuple
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import psutil
|
||||
from numpy.typing import NDArray
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, Config
|
||||
from freqtrade.constants import Config
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_seconds
|
||||
@@ -67,6 +69,7 @@ class IFreqaiModel(ABC):
|
||||
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)
|
||||
# set current candle to arbitrary historical date
|
||||
self.current_candle: datetime = datetime.fromtimestamp(637887600, tz=timezone.utc)
|
||||
@@ -98,6 +101,9 @@ class IFreqaiModel(ABC):
|
||||
self.get_corr_dataframes: bool = True
|
||||
self._threads: List[threading.Thread] = []
|
||||
self._stop_event = threading.Event()
|
||||
self.metadata: Dict[str, Any] = self.dd.load_global_metadata_from_disk()
|
||||
self.data_provider: Optional[DataProvider] = None
|
||||
self.max_system_threads = max(int(psutil.cpu_count() * 2 - 2), 1)
|
||||
|
||||
record_params(config, self.full_path)
|
||||
|
||||
@@ -126,11 +132,13 @@ class IFreqaiModel(ABC):
|
||||
|
||||
self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE)
|
||||
self.dd.set_pair_dict_info(metadata)
|
||||
self.data_provider = strategy.dp
|
||||
|
||||
if self.live:
|
||||
self.inference_timer('start')
|
||||
self.dk = FreqaiDataKitchen(self.config, self.live, metadata["pair"])
|
||||
dk = self.start_live(dataframe, metadata, strategy, self.dk)
|
||||
dataframe = dk.remove_features_from_df(dk.return_dataframe)
|
||||
|
||||
# For backtesting, each pair enters and then gets trained for each window along the
|
||||
# sliding window defined by "train_period_days" (training window) and "live_retrain_hours"
|
||||
@@ -139,20 +147,24 @@ class IFreqaiModel(ABC):
|
||||
# the concatenated results for the full backtesting period back to the strategy.
|
||||
elif not self.follow_mode:
|
||||
self.dk = FreqaiDataKitchen(self.config, self.live, metadata["pair"])
|
||||
if self.dk.backtest_live_models:
|
||||
logger.info(
|
||||
f"Backtesting {len(self.dk.backtesting_timeranges)} timeranges (live models)")
|
||||
else:
|
||||
logger.info(f"Training {len(self.dk.training_timeranges)} timeranges")
|
||||
dataframe = self.dk.use_strategy_to_populate_indicators(
|
||||
strategy, prediction_dataframe=dataframe, pair=metadata["pair"]
|
||||
)
|
||||
dk = self.start_backtesting(dataframe, metadata, self.dk)
|
||||
if not self.config.get("freqai_backtest_live_models", False):
|
||||
logger.info(f"Training {len(self.dk.training_timeranges)} timeranges")
|
||||
dk = self.start_backtesting(dataframe, metadata, self.dk)
|
||||
dataframe = dk.remove_features_from_df(dk.return_dataframe)
|
||||
else:
|
||||
logger.info(
|
||||
"Backtesting using historic predictions (live models)")
|
||||
dk = self.start_backtesting_from_historic_predictions(
|
||||
dataframe, metadata, self.dk)
|
||||
dataframe = dk.return_dataframe
|
||||
|
||||
dataframe = dk.remove_features_from_df(dk.return_dataframe)
|
||||
self.clean_up()
|
||||
if self.live:
|
||||
self.inference_timer('stop', metadata["pair"])
|
||||
|
||||
return dataframe
|
||||
|
||||
def clean_up(self):
|
||||
@@ -164,6 +176,13 @@ class IFreqaiModel(ABC):
|
||||
self.model = None
|
||||
self.dk = None
|
||||
|
||||
def _on_stop(self):
|
||||
"""
|
||||
Callback for Subclasses to override to include logic for shutting down resources
|
||||
when SIGINT is sent.
|
||||
"""
|
||||
return
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Cleans up threads on Shutdown, set stop event. Join threads to wait
|
||||
@@ -172,6 +191,9 @@ class IFreqaiModel(ABC):
|
||||
logger.info("Stopping FreqAI")
|
||||
self._stop_event.set()
|
||||
|
||||
self.data_provider = None
|
||||
self._on_stop()
|
||||
|
||||
logger.info("Waiting on Training iteration")
|
||||
for _thread in self._threads:
|
||||
_thread.join()
|
||||
@@ -301,10 +323,11 @@ class IFreqaiModel(ABC):
|
||||
self.model = self.dd.load_data(pair, dk)
|
||||
|
||||
pred_df, do_preds = self.predict(dataframe_backtest, dk)
|
||||
append_df = dk.get_predictions_to_append(pred_df, do_preds)
|
||||
append_df = dk.get_predictions_to_append(pred_df, do_preds, dataframe_backtest)
|
||||
dk.append_predictions(append_df)
|
||||
dk.save_backtesting_prediction(append_df)
|
||||
|
||||
self.backtesting_fit_live_predictions(dk)
|
||||
dk.fill_predictions(dataframe)
|
||||
|
||||
return dk
|
||||
@@ -617,6 +640,8 @@ class IFreqaiModel(ABC):
|
||||
self.dd.historic_predictions[pair] = pred_df
|
||||
hist_preds_df = self.dd.historic_predictions[pair]
|
||||
|
||||
self.set_start_dry_live_date(strat_df)
|
||||
|
||||
for label in hist_preds_df.columns:
|
||||
if hist_preds_df[label].dtype == object:
|
||||
continue
|
||||
@@ -629,7 +654,7 @@ class IFreqaiModel(ABC):
|
||||
hist_preds_df['DI_values'] = 0
|
||||
|
||||
for return_str in dk.data['extra_returns_per_train']:
|
||||
hist_preds_df[return_str] = 0
|
||||
hist_preds_df[return_str] = dk.data['extra_returns_per_train'][return_str]
|
||||
|
||||
hist_preds_df['close_price'] = strat_df['close']
|
||||
hist_preds_df['date_pred'] = strat_df['date']
|
||||
@@ -657,7 +682,8 @@ class IFreqaiModel(ABC):
|
||||
for label in full_labels:
|
||||
if self.dd.historic_predictions[dk.pair][label].dtype == object:
|
||||
continue
|
||||
f = spy.stats.norm.fit(self.dd.historic_predictions[dk.pair][label].tail(num_candles))
|
||||
f = spy.stats.norm.fit(
|
||||
self.dd.historic_predictions[dk.pair][label].tail(num_candles))
|
||||
dk.data["labels_mean"][label], dk.data["labels_std"][label] = f[0], f[1]
|
||||
|
||||
return
|
||||
@@ -788,14 +814,8 @@ class IFreqaiModel(ABC):
|
||||
:return: if the data exists or not
|
||||
"""
|
||||
if self.config.get("freqai_backtest_live_models", False) and len(dataframe_backtest) == 0:
|
||||
tr_backtest_startts_str = datetime.fromtimestamp(
|
||||
tr_backtest.startts,
|
||||
tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT)
|
||||
tr_backtest_stopts_str = datetime.fromtimestamp(
|
||||
tr_backtest.stopts,
|
||||
tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT)
|
||||
logger.info(f"No data found for pair {pair} from {tr_backtest_startts_str} "
|
||||
f" from {tr_backtest_startts_str} to {tr_backtest_stopts_str}. "
|
||||
logger.info(f"No data found for pair {pair} from "
|
||||
f"from { tr_backtest.start_fmt} to {tr_backtest.stop_fmt}. "
|
||||
"Probably more than one training within the same candle period.")
|
||||
return False
|
||||
return True
|
||||
@@ -810,20 +830,88 @@ class IFreqaiModel(ABC):
|
||||
:param pair: the current pair
|
||||
:param total_trains: total trains (total number of slides for the sliding window)
|
||||
"""
|
||||
tr_train_startts_str = datetime.fromtimestamp(
|
||||
tr_train.startts,
|
||||
tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT)
|
||||
tr_train_stopts_str = datetime.fromtimestamp(
|
||||
tr_train.stopts,
|
||||
tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT)
|
||||
|
||||
if not self.config.get("freqai_backtest_live_models", False):
|
||||
logger.info(
|
||||
f"Training {pair}, {self.pair_it}/{self.total_pairs} pairs"
|
||||
f" from {tr_train_startts_str} "
|
||||
f"to {tr_train_stopts_str}, {train_it}/{total_trains} "
|
||||
f" from {tr_train.start_fmt} "
|
||||
f"to {tr_train.stop_fmt}, {train_it}/{total_trains} "
|
||||
"trains"
|
||||
)
|
||||
|
||||
def backtesting_fit_live_predictions(self, dk: FreqaiDataKitchen):
|
||||
"""
|
||||
Apply fit_live_predictions function in backtesting with a dummy historic_predictions
|
||||
The loop is required to simulate dry/live operation, as it is not possible to predict
|
||||
the type of logic implemented by the user.
|
||||
:param dk: datakitchen object
|
||||
"""
|
||||
fit_live_predictions_candles = self.freqai_info.get("fit_live_predictions_candles", 0)
|
||||
if fit_live_predictions_candles:
|
||||
logger.info("Applying fit_live_predictions in backtesting")
|
||||
label_columns = [col for col in dk.full_df.columns if (
|
||||
col.startswith("&") and
|
||||
not (col.startswith("&") and col.endswith("_mean")) and
|
||||
not (col.startswith("&") and col.endswith("_std")) and
|
||||
col not in self.dk.data["extra_returns_per_train"])
|
||||
]
|
||||
|
||||
for index in range(len(dk.full_df)):
|
||||
if index >= fit_live_predictions_candles:
|
||||
self.dd.historic_predictions[self.dk.pair] = (
|
||||
dk.full_df.iloc[index - fit_live_predictions_candles:index])
|
||||
self.fit_live_predictions(self.dk, self.dk.pair)
|
||||
for label in label_columns:
|
||||
if dk.full_df[label].dtype == object:
|
||||
continue
|
||||
if "labels_mean" in self.dk.data:
|
||||
dk.full_df.at[index, f"{label}_mean"] = (
|
||||
self.dk.data["labels_mean"][label])
|
||||
if "labels_std" in self.dk.data:
|
||||
dk.full_df.at[index, f"{label}_std"] = self.dk.data["labels_std"][label]
|
||||
|
||||
for extra_col in self.dk.data["extra_returns_per_train"]:
|
||||
dk.full_df.at[index, f"{extra_col}"] = (
|
||||
self.dk.data["extra_returns_per_train"][extra_col])
|
||||
|
||||
return
|
||||
|
||||
def update_metadata(self, metadata: Dict[str, Any]):
|
||||
"""
|
||||
Update global metadata and save the updated json file
|
||||
:param metadata: new global metadata dict
|
||||
"""
|
||||
self.dd.save_global_metadata_to_disk(metadata)
|
||||
self.metadata = metadata
|
||||
|
||||
def set_start_dry_live_date(self, live_dataframe: DataFrame):
|
||||
key_name = "start_dry_live_date"
|
||||
if key_name not in self.metadata:
|
||||
metadata = self.metadata
|
||||
metadata[key_name] = int(
|
||||
pd.to_datetime(live_dataframe.tail(1)["date"].values[0]).timestamp())
|
||||
self.update_metadata(metadata)
|
||||
|
||||
def start_backtesting_from_historic_predictions(
|
||||
self, dataframe: DataFrame, metadata: dict, dk: FreqaiDataKitchen
|
||||
) -> FreqaiDataKitchen:
|
||||
"""
|
||||
:param dataframe: DataFrame = strategy passed dataframe
|
||||
:param metadata: Dict = pair metadata
|
||||
:param dk: FreqaiDataKitchen = Data management/analysis tool associated to present pair only
|
||||
:return:
|
||||
FreqaiDataKitchen = Data management/analysis tool associated to present pair only
|
||||
"""
|
||||
pair = metadata["pair"]
|
||||
dk.return_dataframe = dataframe
|
||||
saved_dataframe = self.dd.historic_predictions[pair]
|
||||
columns_to_drop = list(set(saved_dataframe.columns).intersection(
|
||||
dk.return_dataframe.columns))
|
||||
dk.return_dataframe = dk.return_dataframe.drop(columns=list(columns_to_drop))
|
||||
dk.return_dataframe = pd.merge(
|
||||
dk.return_dataframe, saved_dataframe, how='left', left_on='date', right_on="date_pred")
|
||||
# dk.return_dataframe = dk.return_dataframe[saved_dataframe.columns].fillna(0)
|
||||
return dk
|
||||
|
||||
# Following methods which are overridden by user made prediction models.
|
||||
# See freqai/prediction_models/CatboostPredictionModel.py for an example.
|
||||
|
||||
|
141
freqtrade/freqai/prediction_models/ReinforcementLearner.py
Normal file
141
freqtrade/freqai/prediction_models/ReinforcementLearner.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import torch as th
|
||||
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv, Positions
|
||||
from freqtrade.freqai.RL.BaseReinforcementLearningModel import BaseReinforcementLearningModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReinforcementLearner(BaseReinforcementLearningModel):
|
||||
"""
|
||||
Reinforcement Learning Model prediction model.
|
||||
|
||||
Users can inherit from this class to make their own RL model with custom
|
||||
environment/training controls. Define the file as follows:
|
||||
|
||||
```
|
||||
from freqtrade.freqai.prediction_models.ReinforcementLearner import ReinforcementLearner
|
||||
|
||||
class MyCoolRLModel(ReinforcementLearner):
|
||||
```
|
||||
|
||||
Save the file to `user_data/freqaimodels`, then run it with:
|
||||
|
||||
freqtrade trade --freqaimodel MyCoolRLModel --config config.json --strategy SomeCoolStrat
|
||||
|
||||
Here the users can override any of the functions
|
||||
available in the `IFreqaiModel` inheritance tree. Most importantly for RL, this
|
||||
is where the user overrides `MyRLEnv` (see below), to define custom
|
||||
`calculate_reward()` function, or to override any other parts of the environment.
|
||||
|
||||
This class also allows users to override any other part of the IFreqaiModel tree.
|
||||
For example, the user can override `def fit()` or `def train()` or `def predict()`
|
||||
to take fine-tuned control over these processes.
|
||||
|
||||
Another common override may be `def data_cleaning_predict()` where the user can
|
||||
take fine-tuned control over the data handling pipeline.
|
||||
"""
|
||||
|
||||
def fit(self, data_dictionary: Dict[str, Any], dk: FreqaiDataKitchen, **kwargs):
|
||||
"""
|
||||
User customizable fit method
|
||||
:param data_dictionary: dict = common data dictionary containing all train/test
|
||||
features/labels/weights.
|
||||
:param dk: FreqaiDatakitchen = data kitchen for current pair.
|
||||
:return:
|
||||
model Any = trained model to be used for inference in dry/live/backtesting
|
||||
"""
|
||||
train_df = data_dictionary["train_features"]
|
||||
total_timesteps = self.freqai_info["rl_config"]["train_cycles"] * len(train_df)
|
||||
|
||||
policy_kwargs = dict(activation_fn=th.nn.ReLU,
|
||||
net_arch=self.net_arch)
|
||||
|
||||
if dk.pair not in self.dd.model_dictionary or not self.continual_learning:
|
||||
model = self.MODELCLASS(self.policy_type, self.train_env, policy_kwargs=policy_kwargs,
|
||||
tensorboard_log=Path(
|
||||
dk.full_path / "tensorboard" / dk.pair.split('/')[0]),
|
||||
**self.freqai_info['model_training_parameters']
|
||||
)
|
||||
else:
|
||||
logger.info('Continual training activated - starting training from previously '
|
||||
'trained agent.')
|
||||
model = self.dd.model_dictionary[dk.pair]
|
||||
model.set_env(self.train_env)
|
||||
|
||||
model.learn(
|
||||
total_timesteps=int(total_timesteps),
|
||||
callback=self.eval_callback
|
||||
)
|
||||
|
||||
if Path(dk.data_path / "best_model.zip").is_file():
|
||||
logger.info('Callback found a best model.')
|
||||
best_model = self.MODELCLASS.load(dk.data_path / "best_model")
|
||||
return best_model
|
||||
|
||||
logger.info('Couldnt find best model, using final model instead.')
|
||||
|
||||
return model
|
||||
|
||||
class MyRLEnv(Base5ActionRLEnv):
|
||||
"""
|
||||
User can override any function in BaseRLEnv and gym.Env. Here the user
|
||||
sets a custom reward based on profit and trade duration.
|
||||
"""
|
||||
|
||||
def calculate_reward(self, action: int) -> float:
|
||||
"""
|
||||
An example reward function. This is the one function that users will likely
|
||||
wish to inject their own creativity into.
|
||||
:param action: int = The action made by the agent for the current candle.
|
||||
:return:
|
||||
float = the reward to give to the agent for current step (used for optimization
|
||||
of weights in NN)
|
||||
"""
|
||||
# first, penalize if the action is not valid
|
||||
if not self._is_valid(action):
|
||||
return -2
|
||||
|
||||
pnl = self.get_unrealized_profit()
|
||||
factor = 100.
|
||||
|
||||
# reward agent for entering trades
|
||||
if (action in (Actions.Long_enter.value, Actions.Short_enter.value)
|
||||
and self._position == Positions.Neutral):
|
||||
return 25
|
||||
# discourage agent from not entering trades
|
||||
if action == Actions.Neutral.value and self._position == Positions.Neutral:
|
||||
return -1
|
||||
|
||||
max_trade_duration = self.rl_config.get('max_trade_duration_candles', 300)
|
||||
trade_duration = self._current_tick - self._last_trade_tick # type: ignore
|
||||
|
||||
if trade_duration <= max_trade_duration:
|
||||
factor *= 1.5
|
||||
elif trade_duration > max_trade_duration:
|
||||
factor *= 0.5
|
||||
|
||||
# discourage sitting in position
|
||||
if (self._position in (Positions.Short, Positions.Long) and
|
||||
action == Actions.Neutral.value):
|
||||
return -1 * trade_duration / max_trade_duration
|
||||
|
||||
# close long
|
||||
if action == Actions.Long_exit.value and self._position == Positions.Long:
|
||||
if pnl > self.profit_aim * self.rr:
|
||||
factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2)
|
||||
return float(pnl * factor)
|
||||
|
||||
# close short
|
||||
if action == Actions.Short_exit.value and self._position == Positions.Short:
|
||||
if pnl > self.profit_aim * self.rr:
|
||||
factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2)
|
||||
return float(pnl * factor)
|
||||
|
||||
return 0.
|
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import Any, Dict # , Tuple
|
||||
|
||||
# import numpy.typing as npt
|
||||
from pandas import DataFrame
|
||||
from stable_baselines3.common.callbacks import EvalCallback
|
||||
from stable_baselines3.common.vec_env import SubprocVecEnv
|
||||
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.freqai.prediction_models.ReinforcementLearner import ReinforcementLearner
|
||||
from freqtrade.freqai.RL.BaseReinforcementLearningModel import make_env
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReinforcementLearner_multiproc(ReinforcementLearner):
|
||||
"""
|
||||
Demonstration of how to build vectorized environments
|
||||
"""
|
||||
|
||||
def set_train_and_eval_environments(self, data_dictionary: Dict[str, Any],
|
||||
prices_train: DataFrame, prices_test: DataFrame,
|
||||
dk: FreqaiDataKitchen):
|
||||
"""
|
||||
User can override this if they are using a custom MyRLEnv
|
||||
:param data_dictionary: dict = common data dictionary containing train and test
|
||||
features/labels/weights.
|
||||
:param prices_train/test: DataFrame = dataframe comprised of the prices to be used in
|
||||
the environment during training
|
||||
or testing
|
||||
:param dk: FreqaiDataKitchen = the datakitchen for the current pair
|
||||
"""
|
||||
train_df = data_dictionary["train_features"]
|
||||
test_df = data_dictionary["test_features"]
|
||||
|
||||
env_id = "train_env"
|
||||
self.train_env = SubprocVecEnv([make_env(self.MyRLEnv, env_id, i, 1, train_df, prices_train,
|
||||
self.reward_params, self.CONV_WIDTH, monitor=True,
|
||||
config=self.config) for i
|
||||
in range(self.max_threads)])
|
||||
|
||||
eval_env_id = 'eval_env'
|
||||
self.eval_env = SubprocVecEnv([make_env(self.MyRLEnv, eval_env_id, i, 1,
|
||||
test_df, prices_test,
|
||||
self.reward_params, self.CONV_WIDTH, monitor=True,
|
||||
config=self.config) for i
|
||||
in range(self.max_threads)])
|
||||
self.eval_callback = EvalCallback(self.eval_env, deterministic=True,
|
||||
render=False, eval_freq=len(train_df),
|
||||
best_model_save_path=str(dk.data_path))
|
@@ -14,6 +14,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_drawer import FreqaiDataDrawer
|
||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist
|
||||
|
||||
@@ -229,8 +230,6 @@ def get_timerange_backtest_live_models(config: Config) -> str:
|
||||
"""
|
||||
dk = FreqaiDataKitchen(config)
|
||||
models_path = dk.get_full_models_path(config)
|
||||
timerange, _ = dk.get_timerange_and_assets_end_dates_from_ready_models(models_path)
|
||||
start_date = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
end_date = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
tr = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
||||
return tr
|
||||
dd = FreqaiDataDrawer(models_path, config)
|
||||
timerange = dd.get_timerange_from_live_historic_predictions()
|
||||
return timerange.timerange_str
|
||||
|
@@ -191,10 +191,10 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Check whether markets have to be reloaded and reload them when it's needed
|
||||
self.exchange.reload_markets()
|
||||
|
||||
self.update_closed_trades_without_assigned_fees()
|
||||
self.update_trades_without_assigned_fees()
|
||||
|
||||
# Query trades from persistence layer
|
||||
trades = Trade.get_open_trades()
|
||||
trades: List[Trade] = Trade.get_open_trades()
|
||||
|
||||
self.active_pair_whitelist = self._refresh_active_whitelist(trades)
|
||||
|
||||
@@ -354,7 +354,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
self._schedule.run_pending()
|
||||
|
||||
def update_closed_trades_without_assigned_fees(self) -> None:
|
||||
def update_trades_without_assigned_fees(self) -> None:
|
||||
"""
|
||||
Update closed trades without close fees assigned.
|
||||
Only acts when Orders are in the database, otherwise the last order-id is unknown.
|
||||
@@ -381,15 +381,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
trades = Trade.get_open_trades_without_assigned_fees()
|
||||
for trade in trades:
|
||||
if trade.is_open and not trade.fee_updated(trade.entry_side):
|
||||
order = trade.select_order(trade.entry_side, False)
|
||||
open_order = trade.select_order(trade.entry_side, True)
|
||||
if order and open_order is None:
|
||||
logger.info(
|
||||
f"Updating {trade.entry_side}-fee on trade {trade}"
|
||||
f"for order {order.order_id}."
|
||||
)
|
||||
self.update_trade_state(trade, order.order_id, send_msg=False)
|
||||
with self._exit_lock:
|
||||
if trade.is_open and not trade.fee_updated(trade.entry_side):
|
||||
order = trade.select_order(trade.entry_side, False)
|
||||
open_order = trade.select_order(trade.entry_side, True)
|
||||
if order and open_order is None:
|
||||
logger.info(
|
||||
f"Updating {trade.entry_side}-fee on trade {trade}"
|
||||
f"for order {order.order_id}."
|
||||
)
|
||||
self.update_trade_state(trade, order.order_id, send_msg=False)
|
||||
|
||||
def handle_insufficient_funds(self, trade: Trade):
|
||||
"""
|
||||
@@ -826,6 +827,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
co = self.exchange.cancel_stoploss_order_with_result(
|
||||
trade.stoploss_order_id, trade.pair, trade.amount)
|
||||
trade.update_order(co)
|
||||
# Reset stoploss order id.
|
||||
trade.stoploss_order_id = None
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
|
||||
return trade
|
||||
@@ -982,7 +985,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
# SELL / exit positions / close trades logic and methods
|
||||
#
|
||||
|
||||
def exit_positions(self, trades: List[Any]) -> int:
|
||||
def exit_positions(self, trades: List[Trade]) -> int:
|
||||
"""
|
||||
Tries to execute exit orders for open trades (positions)
|
||||
"""
|
||||
@@ -1010,7 +1013,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def handle_trade(self, trade: Trade) -> bool:
|
||||
"""
|
||||
Sells/exits_short the current pair if the threshold is reached and updates the trade record.
|
||||
Exits the current pair if the threshold is reached and updates the trade record.
|
||||
:return: True if trade has been sold/exited_short, False otherwise
|
||||
"""
|
||||
if not trade.is_open:
|
||||
@@ -1133,10 +1136,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value
|
||||
self.update_trade_state(trade, trade.stoploss_order_id, stoploss_order,
|
||||
stoploss_order=True)
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
reason='Auto lock')
|
||||
self._notify_exit(trade, "stoploss", True)
|
||||
self.handle_protections(trade.pair, trade.trade_direction)
|
||||
return True
|
||||
|
||||
if trade.open_order_id or not trade.is_open:
|
||||
@@ -1150,7 +1151,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
stoploss = (
|
||||
self.edge.stoploss(pair=trade.pair)
|
||||
if self.edge else
|
||||
self.strategy.stoploss / trade.leverage
|
||||
trade.stop_loss_pct / trade.leverage
|
||||
)
|
||||
if trade.is_short:
|
||||
stop_price = trade.open_rate * (1 - stoploss)
|
||||
@@ -1169,7 +1170,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
if self.create_stoploss_order(trade=trade, stop_price=trade.stoploss_or_liquidation):
|
||||
return False
|
||||
else:
|
||||
trade.stoploss_order_id = None
|
||||
logger.warning('Stoploss order was cancelled, but unable to recreate one.')
|
||||
|
||||
# Finally we check if stoploss on exchange should be moved up because of trailing.
|
||||
@@ -1595,11 +1595,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.close_rate_requested = limit
|
||||
trade.exit_reason = exit_reason
|
||||
|
||||
if not sub_trade_amt:
|
||||
# Lock pair for one candle to prevent immediate re-trading
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
reason='Auto lock')
|
||||
|
||||
self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj)
|
||||
# In case of market sell orders the order can be closed immediately
|
||||
if order.get('status', 'unknown') in ('closed', 'expired'):
|
||||
@@ -1809,6 +1804,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
self._notify_enter(trade, order, fill=True, sub_trade=sub_trade)
|
||||
|
||||
def handle_protections(self, pair: str, side: LongShort) -> None:
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
self.strategy.lock_pair(pair, datetime.now(timezone.utc), reason='Auto lock')
|
||||
prot_trig = self.protections.stop_per_pair(pair, side=side)
|
||||
if prot_trig:
|
||||
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
|
||||
|
@@ -10,7 +10,8 @@ from typing import Any, Dict, Iterator, List, Mapping, Union
|
||||
from typing.io import IO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pandas
|
||||
import orjson
|
||||
import pandas as pd
|
||||
import rapidjson
|
||||
|
||||
from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN
|
||||
@@ -256,29 +257,37 @@ def parse_db_uri_for_logging(uri: str):
|
||||
return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@')
|
||||
|
||||
|
||||
def dataframe_to_json(dataframe: pandas.DataFrame) -> str:
|
||||
def dataframe_to_json(dataframe: pd.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='split')
|
||||
# https://github.com/pandas-dev/pandas/issues/24889
|
||||
# https://github.com/pandas-dev/pandas/issues/40443
|
||||
# We need to convert to a dict to avoid mem leak
|
||||
def default(z):
|
||||
if isinstance(z, pd.Timestamp):
|
||||
return z.timestamp() * 1e3
|
||||
raise TypeError
|
||||
|
||||
return str(orjson.dumps(dataframe.to_dict(orient='split'), default=default), 'utf-8')
|
||||
|
||||
|
||||
def json_to_dataframe(data: str) -> pandas.DataFrame:
|
||||
def json_to_dataframe(data: str) -> pd.DataFrame:
|
||||
"""
|
||||
Deserialize JSON into a DataFrame
|
||||
:param data: A JSON string
|
||||
:returns: A pandas DataFrame from the JSON string
|
||||
"""
|
||||
dataframe = pandas.read_json(data, orient='split')
|
||||
dataframe = pd.read_json(data, orient='split')
|
||||
if 'date' in dataframe.columns:
|
||||
dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True)
|
||||
dataframe['date'] = pd.to_datetime(dataframe['date'], unit='ms', utc=True)
|
||||
|
||||
return dataframe
|
||||
|
||||
|
||||
def remove_entry_exit_signals(dataframe: pandas.DataFrame):
|
||||
def remove_entry_exit_signals(dataframe: pd.DataFrame):
|
||||
"""
|
||||
Remove Entry and Exit signals from a DataFrame
|
||||
|
||||
|
@@ -692,10 +692,11 @@ class Backtesting:
|
||||
trade.orders.append(order)
|
||||
return trade
|
||||
|
||||
def _get_exit_trade_entry(self, trade: LocalTrade, row: Tuple) -> Optional[LocalTrade]:
|
||||
def _get_exit_trade_entry(
|
||||
self, trade: LocalTrade, row: Tuple, is_first: bool) -> Optional[LocalTrade]:
|
||||
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
|
||||
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
if is_first and self.trading_mode == TradingMode.FUTURES:
|
||||
trade.funding_fees = self.exchange.calculate_funding_fees(
|
||||
self.futures_data[trade.pair],
|
||||
amount=trade.amount,
|
||||
@@ -704,32 +705,7 @@ class Backtesting:
|
||||
close_date=exit_candle_time,
|
||||
)
|
||||
|
||||
if self.timeframe_detail and trade.pair in self.detail_data:
|
||||
exit_candle_end = exit_candle_time + timedelta(minutes=self.timeframe_min)
|
||||
|
||||
detail_data = self.detail_data[trade.pair]
|
||||
detail_data = detail_data.loc[
|
||||
(detail_data['date'] >= exit_candle_time) &
|
||||
(detail_data['date'] < exit_candle_end)
|
||||
].copy()
|
||||
if len(detail_data) == 0:
|
||||
# Fall back to "regular" data if no detail data was found for this candle
|
||||
return self._get_exit_trade_entry_for_candle(trade, row)
|
||||
detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
|
||||
detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
|
||||
detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
|
||||
detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
|
||||
detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
|
||||
detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
|
||||
for det_row in detail_data[HEADERS].values.tolist():
|
||||
res = self._get_exit_trade_entry_for_candle(trade, det_row)
|
||||
if res:
|
||||
return res
|
||||
|
||||
return None
|
||||
|
||||
else:
|
||||
return self._get_exit_trade_entry_for_candle(trade, row)
|
||||
return self._get_exit_trade_entry_for_candle(trade, row)
|
||||
|
||||
def get_valid_price_and_stake(
|
||||
self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
|
||||
@@ -1074,7 +1050,7 @@ class Backtesting:
|
||||
|
||||
def backtest_loop(
|
||||
self, row: Tuple, pair: str, current_time: datetime, end_date: datetime,
|
||||
max_open_trades: int, open_trade_count_start: int) -> int:
|
||||
max_open_trades: int, open_trade_count_start: int, is_first: bool = True) -> int:
|
||||
"""
|
||||
NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
|
||||
|
||||
@@ -1092,9 +1068,11 @@ class Backtesting:
|
||||
# without positionstacking, we can only have one open trade per pair.
|
||||
# max_open_trades must be respected
|
||||
# don't open on the last row
|
||||
# We only open trades on the main candle, not on detail candles
|
||||
trade_dir = self.check_for_trade_entry(row)
|
||||
if (
|
||||
(self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
|
||||
and is_first
|
||||
and self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||
and current_time != end_date
|
||||
and trade_dir is not None
|
||||
@@ -1120,7 +1098,7 @@ class Backtesting:
|
||||
|
||||
# 4. Create exit orders (if any)
|
||||
if not trade.open_order_id:
|
||||
self._get_exit_trade_entry(trade, row) # Place exit order if necessary
|
||||
self._get_exit_trade_entry(trade, row, is_first) # Place exit order if necessary
|
||||
|
||||
# 5. Process exit orders.
|
||||
order = trade.select_order(trade.exit_side, is_open=True)
|
||||
@@ -1171,7 +1149,6 @@ class Backtesting:
|
||||
|
||||
self.progress.init_step(BacktestState.BACKTEST, int(
|
||||
(end_date - start_date) / timedelta(minutes=self.timeframe_min)))
|
||||
|
||||
# Loop timerange and get candle for each pair at that point in time
|
||||
while current_time <= end_date:
|
||||
open_trade_count_start = LocalTrade.bt_open_open_trade_count
|
||||
@@ -1185,9 +1162,37 @@ class Backtesting:
|
||||
row_index += 1
|
||||
indexes[pair] = row_index
|
||||
self.dataprovider._set_dataframe_max_index(row_index)
|
||||
current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
|
||||
if self.timeframe_detail and pair in self.detail_data:
|
||||
exit_candle_end = current_detail_time + timedelta(minutes=self.timeframe_min)
|
||||
|
||||
open_trade_count_start = self.backtest_loop(
|
||||
row, pair, current_time, end_date, max_open_trades, open_trade_count_start)
|
||||
detail_data = self.detail_data[pair]
|
||||
detail_data = detail_data.loc[
|
||||
(detail_data['date'] >= current_detail_time) &
|
||||
(detail_data['date'] < exit_candle_end)
|
||||
].copy()
|
||||
if len(detail_data) == 0:
|
||||
# Fall back to "regular" data if no detail data was found for this candle
|
||||
open_trade_count_start = self.backtest_loop(
|
||||
row, pair, current_time, end_date, max_open_trades,
|
||||
open_trade_count_start)
|
||||
detail_data.loc[:, 'enter_long'] = row[LONG_IDX]
|
||||
detail_data.loc[:, 'exit_long'] = row[ELONG_IDX]
|
||||
detail_data.loc[:, 'enter_short'] = row[SHORT_IDX]
|
||||
detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX]
|
||||
detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX]
|
||||
detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX]
|
||||
is_first = True
|
||||
current_time_det = current_time
|
||||
for det_row in detail_data[HEADERS].values.tolist():
|
||||
open_trade_count_start = self.backtest_loop(
|
||||
det_row, pair, current_time_det, end_date, max_open_trades,
|
||||
open_trade_count_start, is_first)
|
||||
current_time_det += timedelta(minutes=self.timeframe_detail_min)
|
||||
is_first = False
|
||||
else:
|
||||
open_trade_count_start = self.backtest_loop(
|
||||
row, pair, current_time, end_date, max_open_trades, open_trade_count_start)
|
||||
|
||||
# Move time one configured time_interval ahead.
|
||||
self.progress.increment()
|
||||
@@ -1286,8 +1291,7 @@ class Backtesting:
|
||||
def _get_min_cached_backtest_date(self):
|
||||
min_backtest_date = None
|
||||
backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
|
||||
if self.timerange.stopts == 0 or datetime.fromtimestamp(
|
||||
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc):
|
||||
if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc):
|
||||
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
|
||||
elif backtest_cache_age == 'day':
|
||||
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||
|
@@ -17,6 +17,7 @@ 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
|
||||
from freqtrade.optimize.hyperopt_epoch_filters import hyperopt_filter_epochs
|
||||
from freqtrade.optimize.optimize_reports import generate_wins_draws_losses
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -325,8 +326,10 @@ class HyperoptTools():
|
||||
|
||||
# New mode, using backtest result for metrics
|
||||
trials['results_metrics.winsdrawslosses'] = trials.apply(
|
||||
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
|
||||
f"{x['results_metrics.losses']:>4}", axis=1)
|
||||
lambda x: generate_wins_draws_losses(
|
||||
x['results_metrics.wins'], x['results_metrics.draws'],
|
||||
x['results_metrics.losses']
|
||||
), axis=1)
|
||||
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.winsdrawslosses',
|
||||
@@ -337,7 +340,7 @@ class HyperoptTools():
|
||||
'loss', 'is_initial_point', 'is_random', 'is_best']]
|
||||
|
||||
trials.columns = [
|
||||
'Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Best', 'Epoch', 'Trades', ' Win Draw Loss Win%', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'max_drawdown', 'max_drawdown_account',
|
||||
'max_drawdown_abs', 'Objective', 'is_initial_point', 'is_random', 'is_best'
|
||||
]
|
||||
@@ -467,9 +470,9 @@ class HyperoptTools():
|
||||
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_median',
|
||||
'results_metrics.profit_total',
|
||||
'Stake currency',
|
||||
'results_metrics.profit_total', 'Stake currency',
|
||||
'results_metrics.profit_total_abs', 'results_metrics.holding_avg',
|
||||
'results_metrics.trade_count_long', 'results_metrics.trade_count_short',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
perc_multi = 100
|
||||
|
||||
@@ -477,7 +480,9 @@ class HyperoptTools():
|
||||
trials = trials[base_metrics + param_metrics]
|
||||
|
||||
base_columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Median profit', 'Total profit',
|
||||
'Stake currency', 'Profit', 'Avg duration', 'Objective',
|
||||
'Stake currency', 'Profit', 'Avg duration',
|
||||
'Trade count long', 'Trade count short',
|
||||
'Objective',
|
||||
'is_initial_point', 'is_best']
|
||||
param_columns = list(results[0]['params_dict'].keys())
|
||||
trials.columns = base_columns + param_columns
|
||||
|
@@ -86,7 +86,7 @@ def _get_line_header(first_column: str, stake_currency: str,
|
||||
'Win Draw Loss Win%']
|
||||
|
||||
|
||||
def _generate_wins_draws_losses(wins, draws, losses):
|
||||
def generate_wins_draws_losses(wins, draws, losses):
|
||||
if wins > 0 and losses == 0:
|
||||
wl_ratio = '100'
|
||||
elif wins == 0:
|
||||
@@ -600,7 +600,7 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
|
||||
generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
|
||||
] for t in pair_results]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
@@ -626,7 +626,7 @@ def text_table_exit_reason(exit_reason_stats: List[Dict[str, Any]], stake_curren
|
||||
|
||||
output = [[
|
||||
t.get('exit_reason', t.get('sell_reason')), t['trades'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
|
||||
generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
|
||||
t['profit_mean_pct'], t['profit_sum_pct'],
|
||||
round_coin_value(t['profit_total_abs'], stake_currency, False),
|
||||
t['profit_total_pct'],
|
||||
@@ -656,7 +656,7 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr
|
||||
t['profit_total_abs'],
|
||||
t['profit_total_pct'],
|
||||
t['duration_avg'],
|
||||
_generate_wins_draws_losses(
|
||||
generate_wins_draws_losses(
|
||||
t['wins'],
|
||||
t['draws'],
|
||||
t['losses'])] for t in tag_results]
|
||||
@@ -715,7 +715,7 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
|
||||
generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
|
||||
for t, drawdown in zip(strategy_results, drawdown)]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
|
@@ -87,7 +87,7 @@ class PairLocks():
|
||||
Get the lock that expires the latest for the pair given.
|
||||
"""
|
||||
locks = PairLocks.get_pair_locks(pair, now, side=side)
|
||||
locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True)
|
||||
locks = sorted(locks, key=lambda lock: lock.lock_end_time, reverse=True)
|
||||
return locks[0] if locks else None
|
||||
|
||||
@staticmethod
|
||||
|
@@ -90,6 +90,13 @@ class Order(_DECL_BASE):
|
||||
def safe_filled(self) -> float:
|
||||
return self.filled if self.filled is not None else self.amount or 0.0
|
||||
|
||||
@property
|
||||
def safe_remaining(self) -> float:
|
||||
return (
|
||||
self.remaining if self.remaining is not None else
|
||||
self.amount - (self.filled or 0.0)
|
||||
)
|
||||
|
||||
@property
|
||||
def safe_fee_base(self) -> float:
|
||||
return self.ft_fee_base or 0.0
|
||||
|
@@ -81,8 +81,6 @@ async def validate_ws_token(
|
||||
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)
|
||||
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocketDisconnect
|
||||
from fastapi.websockets import WebSocket, WebSocketState
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.websockets import WebSocket
|
||||
from pydantic import ValidationError
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
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 import WebSocketChannel
|
||||
from freqtrade.rpc.api_server.ws.channel import ChannelManager
|
||||
from freqtrade.rpc.api_server.deps import get_message_stream, get_rpc
|
||||
from freqtrade.rpc.api_server.ws.channel import WebSocketChannel, create_channel
|
||||
from freqtrade.rpc.api_server.ws.message_stream import MessageStream
|
||||
from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema,
|
||||
WSRequestSchema, WSWhitelistMessage)
|
||||
from freqtrade.rpc.rpc import RPC
|
||||
@@ -22,23 +22,35 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def is_websocket_alive(ws: WebSocket) -> bool:
|
||||
async def channel_reader(channel: WebSocketChannel, rpc: RPC):
|
||||
"""
|
||||
Check if a FastAPI Websocket is still open
|
||||
Iterate over the messages from the channel and process the request
|
||||
"""
|
||||
if (
|
||||
ws.application_state == WebSocketState.CONNECTED and
|
||||
ws.client_state == WebSocketState.CONNECTED
|
||||
):
|
||||
return True
|
||||
return False
|
||||
async for message in channel:
|
||||
await _process_consumer_request(message, channel, rpc)
|
||||
|
||||
|
||||
async def channel_broadcaster(channel: WebSocketChannel, message_stream: MessageStream):
|
||||
"""
|
||||
Iterate over messages in the message stream and send them
|
||||
"""
|
||||
async for message, ts in message_stream:
|
||||
if channel.subscribed_to(message.get('type')):
|
||||
# Log a warning if this channel is behind
|
||||
# on the message stream by a lot
|
||||
if (time.time() - ts) > 60:
|
||||
logger.warning(f"Channel {channel} is behind MessageStream by 1 minute,"
|
||||
" this can cause a memory leak if you see this message"
|
||||
" often, consider reducing pair list size or amount of"
|
||||
" consumers.")
|
||||
|
||||
await channel.send(message, timeout=True)
|
||||
|
||||
|
||||
async def _process_consumer_request(
|
||||
request: Dict[str, Any],
|
||||
channel: WebSocketChannel,
|
||||
rpc: RPC,
|
||||
channel_manager: ChannelManager
|
||||
rpc: RPC
|
||||
):
|
||||
"""
|
||||
Validate and handle a request from a websocket consumer
|
||||
@@ -74,65 +86,29 @@ async def _process_consumer_request(
|
||||
|
||||
# Format response
|
||||
response = WSWhitelistMessage(data=whitelist)
|
||||
# Send it back
|
||||
await channel_manager.send_direct(channel, response.dict(exclude_none=True))
|
||||
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', 1500), 1500)
|
||||
# Limit the amount of candles per dataframe to 'limit' or 1500
|
||||
limit = min(data.get('limit', 1500), 1500) if data else None
|
||||
|
||||
# For every pair in the generator, send a separate message
|
||||
for message in rpc._ws_request_analyzed_df(limit):
|
||||
# Format response
|
||||
response = WSAnalyzedDFMessage(data=message)
|
||||
await channel_manager.send_direct(channel, response.dict(exclude_none=True))
|
||||
await channel.send(response.dict(exclude_none=True))
|
||||
|
||||
|
||||
@router.websocket("/message/ws")
|
||||
async def message_endpoint(
|
||||
ws: WebSocket,
|
||||
websocket: WebSocket,
|
||||
token: str = Depends(validate_ws_token),
|
||||
rpc: RPC = Depends(get_rpc),
|
||||
channel_manager=Depends(get_channel_manager),
|
||||
token: str = Depends(validate_ws_token)
|
||||
message_stream: MessageStream = Depends(get_message_stream)
|
||||
):
|
||||
"""
|
||||
Message WebSocket endpoint, facilitates sending RPC messages
|
||||
"""
|
||||
try:
|
||||
channel = await channel_manager.on_connect(ws)
|
||||
if await is_websocket_alive(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
|
||||
await _process_consumer_request(request, channel, rpc, channel_manager)
|
||||
|
||||
except (WebSocketDisconnect, WebSocketException):
|
||||
# Handle client disconnects
|
||||
logger.info(f"Consumer disconnected - {channel}")
|
||||
except RuntimeError:
|
||||
# Handle cases like -
|
||||
# RuntimeError('Cannot call "send" once a closed message has been sent')
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.info(f"Consumer connection failed - {channel}: {e}")
|
||||
logger.debug(e, exc_info=e)
|
||||
|
||||
except RuntimeError:
|
||||
# WebSocket was closed
|
||||
# Do nothing
|
||||
pass
|
||||
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)
|
||||
finally:
|
||||
if channel:
|
||||
await channel_manager.on_disconnect(ws)
|
||||
if token:
|
||||
async with create_channel(websocket) as channel:
|
||||
await channel.run_channel_tasks(
|
||||
channel_reader(channel, rpc),
|
||||
channel_broadcaster(channel, message_stream)
|
||||
)
|
||||
|
@@ -41,8 +41,8 @@ def get_exchange(config=Depends(get_config)):
|
||||
return ApiServer._exchange
|
||||
|
||||
|
||||
def get_channel_manager():
|
||||
return ApiServer._ws_channel_manager
|
||||
def get_message_stream():
|
||||
return ApiServer._message_stream
|
||||
|
||||
|
||||
def is_webserver_mode(config=Depends(get_config)):
|
||||
|
@@ -1,22 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from ipaddress import IPv4Address
|
||||
from threading import Thread
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
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.constants import Config
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer
|
||||
from freqtrade.rpc.api_server.ws import ChannelManager
|
||||
from freqtrade.rpc.api_server.ws_schemas import WSMessageSchemaType
|
||||
from freqtrade.rpc.api_server.ws.message_stream import MessageStream
|
||||
from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler
|
||||
|
||||
|
||||
@@ -50,10 +45,8 @@ class ApiServer(RPCHandler):
|
||||
_config: Config = {}
|
||||
# Exchange - only available in webserver mode.
|
||||
_exchange = None
|
||||
# websocket message queue stuff
|
||||
_ws_channel_manager: ChannelManager
|
||||
_ws_thread = None
|
||||
_ws_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
# websocket message stuff
|
||||
_message_stream: Optional[MessageStream] = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
@@ -71,15 +64,11 @@ class ApiServer(RPCHandler):
|
||||
return
|
||||
self._standalone: bool = standalone
|
||||
self._server = None
|
||||
self._ws_queue: Optional[ThreadedQueue] = None
|
||||
self._ws_background_task = None
|
||||
|
||||
ApiServer.__initialized = True
|
||||
|
||||
api_config = self._config['api_server']
|
||||
|
||||
ApiServer._ws_channel_manager = ChannelManager()
|
||||
|
||||
self.app = FastAPI(title="Freqtrade API",
|
||||
docs_url='/docs' if api_config.get('enable_openapi', False) else None,
|
||||
redoc_url=None,
|
||||
@@ -105,21 +94,9 @@ class ApiServer(RPCHandler):
|
||||
del ApiServer._rpc
|
||||
if self._server and not self._standalone:
|
||||
logger.info("Stopping API Server")
|
||||
# self._server.force_exit, self._server.should_exit = True, True
|
||||
self._server.cleanup()
|
||||
|
||||
if self._ws_thread and self._ws_loop:
|
||||
logger.info("Stopping API Server background tasks")
|
||||
|
||||
if self._ws_background_task:
|
||||
# Cancel the queue task
|
||||
self._ws_background_task.cancel()
|
||||
|
||||
self._ws_thread.join()
|
||||
|
||||
self._ws_thread = None
|
||||
self._ws_loop = None
|
||||
self._ws_background_task = None
|
||||
|
||||
@classmethod
|
||||
def shutdown(cls):
|
||||
cls.__initialized = False
|
||||
@@ -129,9 +106,11 @@ class ApiServer(RPCHandler):
|
||||
cls._rpc = None
|
||||
|
||||
def send_msg(self, msg: Dict[str, Any]) -> None:
|
||||
if self._ws_queue:
|
||||
sync_q = self._ws_queue.sync_q
|
||||
sync_q.put(msg)
|
||||
"""
|
||||
Publish the message to the message stream
|
||||
"""
|
||||
if ApiServer._message_stream:
|
||||
ApiServer._message_stream.publish(msg)
|
||||
|
||||
def handle_rpc_exception(self, request, exc):
|
||||
logger.exception(f"API Error calling: {exc}")
|
||||
@@ -170,51 +149,30 @@ class ApiServer(RPCHandler):
|
||||
)
|
||||
|
||||
app.add_exception_handler(RPCException, self.handle_rpc_exception)
|
||||
app.add_event_handler(
|
||||
event_type="startup",
|
||||
func=self._api_startup_event
|
||||
)
|
||||
app.add_event_handler(
|
||||
event_type="shutdown",
|
||||
func=self._api_shutdown_event
|
||||
)
|
||||
|
||||
def start_message_queue(self):
|
||||
if self._ws_thread:
|
||||
return
|
||||
async def _api_startup_event(self):
|
||||
"""
|
||||
Creates the MessageStream class on startup
|
||||
so it has access to the same event loop
|
||||
as uvicorn
|
||||
"""
|
||||
if not ApiServer._message_stream:
|
||||
ApiServer._message_stream = MessageStream()
|
||||
|
||||
# Create a new loop, as it'll be just for the background thread
|
||||
self._ws_loop = asyncio.new_event_loop()
|
||||
|
||||
# Start the thread
|
||||
self._ws_thread = Thread(target=self._ws_loop.run_forever)
|
||||
self._ws_thread.start()
|
||||
|
||||
# Finally, submit the coro to the thread
|
||||
self._ws_background_task = asyncio.run_coroutine_threadsafe(
|
||||
self._broadcast_queue_data(), loop=self._ws_loop)
|
||||
|
||||
async def _broadcast_queue_data(self) -> None:
|
||||
# Instantiate the queue in this coroutine so it's attached to our loop
|
||||
self._ws_queue = ThreadedQueue()
|
||||
async_queue = self._ws_queue.async_q
|
||||
|
||||
try:
|
||||
while True:
|
||||
logger.debug("Getting queue messages...")
|
||||
# Get data from queue
|
||||
message: WSMessageSchemaType = await async_queue.get()
|
||||
logger.debug(f"Found message of type: {message.get('type')}")
|
||||
async_queue.task_done()
|
||||
# Broadcast it
|
||||
await self._ws_channel_manager.broadcast(message)
|
||||
except asyncio.CancelledError:
|
||||
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()
|
||||
if self._ws_loop:
|
||||
self._ws_loop.stop()
|
||||
# Avoid adding more items to the queue if they aren't
|
||||
# going to get broadcasted.
|
||||
self._ws_queue = None
|
||||
async def _api_shutdown_event(self):
|
||||
"""
|
||||
Removes the MessageStream class on shutdown
|
||||
"""
|
||||
if ApiServer._message_stream:
|
||||
ApiServer._message_stream = None
|
||||
|
||||
def start_api(self):
|
||||
"""
|
||||
@@ -254,7 +212,6 @@ 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.")
|
||||
|
@@ -3,4 +3,5 @@
|
||||
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
|
||||
from freqtrade.rpc.api_server.ws.channel import ChannelManager, WebSocketChannel
|
||||
from freqtrade.rpc.api_server.ws.channel import WebSocketChannel
|
||||
from freqtrade.rpc.api_server.ws.message_stream import MessageStream
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from threading import RLock
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Deque, Dict, List, Optional, Type, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import WebSocket as FastAPIWebSocket
|
||||
from fastapi import WebSocketDisconnect
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy
|
||||
from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer,
|
||||
@@ -21,31 +23,27 @@ class WebSocketChannel:
|
||||
"""
|
||||
Object to help facilitate managing a websocket connection
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websocket: WebSocketType,
|
||||
channel_id: Optional[str] = None,
|
||||
drain_timeout: int = 3,
|
||||
throttle: float = 0.01,
|
||||
serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer
|
||||
):
|
||||
|
||||
self.channel_id = channel_id if channel_id else uuid4().hex[:8]
|
||||
|
||||
# The WebSocket object
|
||||
self._websocket = WebSocketProxy(websocket)
|
||||
|
||||
self.drain_timeout = drain_timeout
|
||||
self.throttle = throttle
|
||||
|
||||
self._subscriptions: List[str] = []
|
||||
# 32 is the size of the receiving queue in websockets package
|
||||
self.queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=32)
|
||||
self._relay_task = asyncio.create_task(self.relay())
|
||||
|
||||
# Internal event to signify a closed websocket
|
||||
self._closed = asyncio.Event()
|
||||
# The async tasks created for the channel
|
||||
self._channel_tasks: List[asyncio.Task] = []
|
||||
|
||||
# Deque for average send times
|
||||
self._send_times: Deque[float] = deque([], maxlen=10)
|
||||
# High limit defaults to 3 to start
|
||||
self._send_high_limit = 3
|
||||
|
||||
# The subscribed message types
|
||||
self._subscriptions: List[str] = []
|
||||
|
||||
# Wrap the WebSocket in the Serializing class
|
||||
self._wrapped_ws = serializer_cls(self._websocket)
|
||||
@@ -61,40 +59,58 @@ class WebSocketChannel:
|
||||
def remote_addr(self):
|
||||
return self._websocket.remote_addr
|
||||
|
||||
async def _send(self, data):
|
||||
"""
|
||||
Send data on the wrapped websocket
|
||||
"""
|
||||
await self._wrapped_ws.send(data)
|
||||
@property
|
||||
def avg_send_time(self):
|
||||
return sum(self._send_times) / len(self._send_times)
|
||||
|
||||
async def send(self, data) -> bool:
|
||||
def _calc_send_limit(self):
|
||||
"""
|
||||
Add the data to the queue to be sent.
|
||||
:returns: True if data added to queue, False otherwise
|
||||
Calculate the send high limit for this channel
|
||||
"""
|
||||
|
||||
# This block only runs if the queue is full, it will wait
|
||||
# until self.drain_timeout for the relay to drain the outgoing queue
|
||||
# We can't use asyncio.wait_for here because the queue may have been created with a
|
||||
# different eventloop
|
||||
start = time.time()
|
||||
while self.queue.full():
|
||||
await asyncio.sleep(1)
|
||||
if (time.time() - start) > self.drain_timeout:
|
||||
return False
|
||||
# Only update if we have enough data
|
||||
if len(self._send_times) == self._send_times.maxlen:
|
||||
# At least 1s or twice the average of send times, with a
|
||||
# maximum of 3 seconds per message
|
||||
self._send_high_limit = min(max(self.avg_send_time * 2, 1), 3)
|
||||
|
||||
# If for some reason the queue is still full, just return False
|
||||
async def send(
|
||||
self,
|
||||
message: Union[WSMessageSchemaType, Dict[str, Any]],
|
||||
timeout: bool = False
|
||||
):
|
||||
"""
|
||||
Send a message on the wrapped websocket. If the sending
|
||||
takes too long, it will raise a TimeoutError and
|
||||
disconnect the connection.
|
||||
|
||||
:param message: The message to send
|
||||
:param timeout: Enforce send high limit, defaults to False
|
||||
"""
|
||||
try:
|
||||
self.queue.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
return False
|
||||
_ = time.time()
|
||||
# If the send times out, it will raise
|
||||
# a TimeoutError and bubble up to the
|
||||
# message_endpoint to close the connection
|
||||
await asyncio.wait_for(
|
||||
self._wrapped_ws.send(message),
|
||||
timeout=self._send_high_limit if timeout else None
|
||||
)
|
||||
total_time = time.time() - _
|
||||
self._send_times.append(total_time)
|
||||
|
||||
# If we got here everything is ok
|
||||
return True
|
||||
self._calc_send_limit()
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"Connection for {self} timed out, disconnecting")
|
||||
raise
|
||||
|
||||
# Explicitly give control back to event loop as
|
||||
# websockets.send does not
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async def recv(self):
|
||||
"""
|
||||
Receive data on the wrapped websocket
|
||||
Receive a message on the wrapped websocket
|
||||
"""
|
||||
return await self._wrapped_ws.recv()
|
||||
|
||||
@@ -104,18 +120,28 @@ class WebSocketChannel:
|
||||
"""
|
||||
return await self._websocket.ping()
|
||||
|
||||
async def accept(self):
|
||||
"""
|
||||
Accept the underlying websocket connection,
|
||||
if the connection has been closed before we can
|
||||
accept, just close the channel.
|
||||
"""
|
||||
try:
|
||||
return await self._websocket.accept()
|
||||
except RuntimeError:
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Close the WebSocketChannel
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.raw_websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._closed.set()
|
||||
self._relay_task.cancel()
|
||||
|
||||
try:
|
||||
await self._websocket.close()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
"""
|
||||
@@ -139,99 +165,76 @@ class WebSocketChannel:
|
||||
"""
|
||||
return message_type in self._subscriptions
|
||||
|
||||
async def relay(self):
|
||||
async def run_channel_tasks(self, *tasks, **kwargs):
|
||||
"""
|
||||
Relay messages from the channel's queue and send them out. This is started
|
||||
as a task.
|
||||
Create and await on the channel tasks unless an exception
|
||||
was raised, then cancel them all.
|
||||
|
||||
:params *tasks: All coros or tasks to be run concurrently
|
||||
:param **kwargs: Any extra kwargs to pass to gather
|
||||
"""
|
||||
while not self._closed.is_set():
|
||||
message = await self.queue.get()
|
||||
|
||||
if not self.is_closed():
|
||||
# Wrap the coros into tasks if they aren't already
|
||||
self._channel_tasks = [
|
||||
task if isinstance(task, asyncio.Task) else asyncio.create_task(task)
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
try:
|
||||
await self._send(message)
|
||||
self.queue.task_done()
|
||||
return await asyncio.gather(*self._channel_tasks, **kwargs)
|
||||
except Exception:
|
||||
# If an exception occurred, cancel the rest of the tasks
|
||||
await self.cancel_channel_tasks()
|
||||
|
||||
# Limit messages per sec.
|
||||
# Could cause problems with queue size if too low, and
|
||||
# problems with network traffik if too high.
|
||||
# 0.01 = 100/s
|
||||
await asyncio.sleep(self.throttle)
|
||||
except RuntimeError:
|
||||
# The connection was closed, just exit the task
|
||||
return
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
def __init__(self):
|
||||
self.channels = dict()
|
||||
self._lock = RLock() # Re-entrant Lock
|
||||
|
||||
async def on_connect(self, websocket: WebSocketType):
|
||||
async def cancel_channel_tasks(self):
|
||||
"""
|
||||
Wrap websocket connection into Channel and add to list
|
||||
|
||||
:param websocket: The WebSocket object to attach to the Channel
|
||||
Cancel and wait on all channel tasks
|
||||
"""
|
||||
if isinstance(websocket, FastAPIWebSocket):
|
||||
for task in self._channel_tasks:
|
||||
task.cancel()
|
||||
|
||||
# Wait for tasks to finish cancelling
|
||||
try:
|
||||
await websocket.accept()
|
||||
except RuntimeError:
|
||||
# The connection was closed before we could accept it
|
||||
return
|
||||
await task
|
||||
except (
|
||||
asyncio.CancelledError,
|
||||
asyncio.TimeoutError,
|
||||
WebSocketDisconnect,
|
||||
ConnectionClosed,
|
||||
RuntimeError
|
||||
):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.info(f"Encountered unknown exception: {e}", exc_info=e)
|
||||
|
||||
ws_channel = WebSocketChannel(websocket)
|
||||
self._channel_tasks = []
|
||||
|
||||
with self._lock:
|
||||
self.channels[websocket] = ws_channel
|
||||
|
||||
return ws_channel
|
||||
|
||||
async def on_disconnect(self, websocket: WebSocketType):
|
||||
async def __aiter__(self):
|
||||
"""
|
||||
Call close on the channel if it's not, and remove from channel list
|
||||
Generator for received messages
|
||||
"""
|
||||
# We can not catch any errors here as websocket.recv is
|
||||
# the first to catch any disconnects and bubble it up
|
||||
# so the connection is garbage collected right away
|
||||
while not self.is_closed():
|
||||
yield await self.recv()
|
||||
|
||||
:param websocket: The WebSocket objet attached to the Channel
|
||||
"""
|
||||
with self._lock:
|
||||
channel = self.channels.get(websocket)
|
||||
if channel:
|
||||
logger.info(f"Disconnecting channel {channel}")
|
||||
if not channel.is_closed():
|
||||
await channel.close()
|
||||
|
||||
del self.channels[websocket]
|
||||
@asynccontextmanager
|
||||
async def create_channel(
|
||||
websocket: WebSocketType,
|
||||
**kwargs
|
||||
) -> AsyncIterator[WebSocketChannel]:
|
||||
"""
|
||||
Context manager for safely opening and closing a WebSocketChannel
|
||||
"""
|
||||
channel = WebSocketChannel(websocket, **kwargs)
|
||||
try:
|
||||
await channel.accept()
|
||||
logger.info(f"Connected to channel - {channel}")
|
||||
|
||||
async def disconnect_all(self):
|
||||
"""
|
||||
Disconnect all Channels
|
||||
"""
|
||||
with self._lock:
|
||||
for websocket in self.channels.copy().keys():
|
||||
await self.on_disconnect(websocket)
|
||||
|
||||
async def broadcast(self, message: WSMessageSchemaType):
|
||||
"""
|
||||
Broadcast a message on all Channels
|
||||
|
||||
:param message: The message to send
|
||||
"""
|
||||
with self._lock:
|
||||
for channel in self.channels.copy().values():
|
||||
if channel.subscribed_to(message.get('type')):
|
||||
await self.send_direct(channel, message)
|
||||
|
||||
async def send_direct(
|
||||
self, channel: WebSocketChannel, message: Union[WSMessageSchemaType, Dict[str, Any]]):
|
||||
"""
|
||||
Send a message directly through direct_channel only
|
||||
|
||||
:param direct_channel: The WebSocketChannel object to send the message through
|
||||
:param message: The message to send
|
||||
"""
|
||||
if not await channel.send(message):
|
||||
await self.on_disconnect(channel.raw_websocket)
|
||||
|
||||
def has_channels(self):
|
||||
"""
|
||||
Flag for more than 0 channels
|
||||
"""
|
||||
return len(self.channels) > 0
|
||||
yield channel
|
||||
finally:
|
||||
await channel.close()
|
||||
logger.info(f"Disconnected from channel - {channel}")
|
||||
|
31
freqtrade/rpc/api_server/ws/message_stream.py
Normal file
31
freqtrade/rpc/api_server/ws/message_stream.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
class MessageStream:
|
||||
"""
|
||||
A message stream for consumers to subscribe to,
|
||||
and for producers to publish to.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._waiter = self._loop.create_future()
|
||||
|
||||
def publish(self, message):
|
||||
"""
|
||||
Publish a message to this MessageStream
|
||||
|
||||
:param message: The message to publish
|
||||
"""
|
||||
waiter, self._waiter = self._waiter, self._loop.create_future()
|
||||
waiter.set_result((message, time.time(), self._waiter))
|
||||
|
||||
async def __aiter__(self):
|
||||
"""
|
||||
Iterate over the messages in the message stream
|
||||
"""
|
||||
waiter = self._waiter
|
||||
while True:
|
||||
# Shield the future from being cancelled by a task waiting on it
|
||||
message, ts, waiter = await asyncio.shield(waiter)
|
||||
yield message, ts
|
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
import orjson
|
||||
import rapidjson
|
||||
@@ -7,6 +8,7 @@ from pandas import DataFrame
|
||||
|
||||
from freqtrade.misc import dataframe_to_json, json_to_dataframe
|
||||
from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy
|
||||
from freqtrade.rpc.api_server.ws_schemas import WSMessageSchemaType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,17 +26,13 @@ class WebSocketSerializer(ABC):
|
||||
def _deserialize(self, data):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def send(self, data: bytes):
|
||||
async def send(self, data: Union[WSMessageSchemaType, Dict[str, Any]]):
|
||||
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)
|
||||
|
||||
|
||||
class HybridJSONWebSocketSerializer(WebSocketSerializer):
|
||||
def _serialize(self, data) -> str:
|
||||
|
@@ -31,6 +31,7 @@ class Producer(TypedDict):
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
secure: bool
|
||||
ws_token: str
|
||||
|
||||
|
||||
@@ -180,7 +181,8 @@ class ExternalMessageConsumer:
|
||||
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}"
|
||||
scheme = 'wss' if producer.get('secure', False) else 'ws'
|
||||
ws_url = f"{scheme}://{host}:{port}/api/v1/message/ws?token={token}"
|
||||
|
||||
# This will raise InvalidURI if the url is bad
|
||||
async with websockets.connect(
|
||||
|
@@ -218,9 +218,10 @@ class RPC:
|
||||
stoploss_current_dist_pct=round(stoploss_current_dist_ratio * 100, 2),
|
||||
stoploss_entry_dist=stoploss_entry_dist,
|
||||
stoploss_entry_dist_ratio=round(stoploss_entry_dist_ratio, 8),
|
||||
open_order='({} {} rem={:.8f})'.format(
|
||||
order.order_type, order.side, order.remaining
|
||||
) if order else None,
|
||||
open_order=(
|
||||
f'({order.order_type} {order.side} rem={order.safe_remaining:.8f})' if
|
||||
order else None
|
||||
),
|
||||
))
|
||||
results.append(trade_dict)
|
||||
return results
|
||||
@@ -739,6 +740,24 @@ class RPC:
|
||||
self._freqtrade.wallets.update()
|
||||
return {'result': f'Created sell order for trade {trade_id}.'}
|
||||
|
||||
def _force_entry_validations(self, pair: str, order_side: SignalDirection):
|
||||
if not self._freqtrade.config.get('force_entry_enable', False):
|
||||
raise RPCException('Force_entry not enabled.')
|
||||
|
||||
if self._freqtrade.state != State.RUNNING:
|
||||
raise RPCException('trader is not running')
|
||||
|
||||
if order_side == SignalDirection.SHORT and self._freqtrade.trading_mode == TradingMode.SPOT:
|
||||
raise RPCException("Can't go short on Spot markets.")
|
||||
|
||||
if pair not in self._freqtrade.exchange.get_markets(tradable_only=True):
|
||||
raise RPCException('Symbol does not exist or market is not active.')
|
||||
# Check if pair quote currency equals to the stake currency.
|
||||
stake_currency = self._freqtrade.config.get('stake_currency')
|
||||
if not self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency:
|
||||
raise RPCException(
|
||||
f'Wrong pair selected. Only pairs with stake-currency {stake_currency} allowed.')
|
||||
|
||||
def _rpc_force_entry(self, pair: str, price: Optional[float], *,
|
||||
order_type: Optional[str] = None,
|
||||
order_side: SignalDirection = SignalDirection.LONG,
|
||||
@@ -749,21 +768,8 @@ class RPC:
|
||||
Handler for forcebuy <asset> <price>
|
||||
Buys a pair trade at the given or current price
|
||||
"""
|
||||
self._force_entry_validations(pair, order_side)
|
||||
|
||||
if not self._freqtrade.config.get('force_entry_enable', False):
|
||||
raise RPCException('Force_entry not enabled.')
|
||||
|
||||
if self._freqtrade.state != State.RUNNING:
|
||||
raise RPCException('trader is not running')
|
||||
|
||||
if order_side == SignalDirection.SHORT and self._freqtrade.trading_mode == TradingMode.SPOT:
|
||||
raise RPCException("Can't go short on Spot markets.")
|
||||
|
||||
# Check if pair quote currency equals to the stake currency.
|
||||
stake_currency = self._freqtrade.config.get('stake_currency')
|
||||
if not self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency:
|
||||
raise RPCException(
|
||||
f'Wrong pair selected. Only pairs with stake-currency {stake_currency} allowed.')
|
||||
# check if valid pair
|
||||
|
||||
# check if pair already has an open pair
|
||||
@@ -773,6 +779,9 @@ class RPC:
|
||||
is_short = trade.is_short
|
||||
if not self._freqtrade.strategy.position_adjustment_enable:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id}')
|
||||
if trade.open_order_id is not None:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id} '
|
||||
f'and has open order {trade.open_order_id}')
|
||||
else:
|
||||
if Trade.get_open_trade_count() >= self._config['max_open_trades']:
|
||||
raise RPCException("Maximum number of trades is reached.")
|
||||
@@ -785,17 +794,18 @@ class RPC:
|
||||
if not order_type:
|
||||
order_type = self._freqtrade.strategy.order_types.get(
|
||||
'force_entry', self._freqtrade.strategy.order_types['entry'])
|
||||
if self._freqtrade.execute_entry(pair, stake_amount, price,
|
||||
ordertype=order_type, trade=trade,
|
||||
is_short=is_short,
|
||||
enter_tag=enter_tag,
|
||||
leverage_=leverage,
|
||||
):
|
||||
Trade.commit()
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
return trade
|
||||
else:
|
||||
raise RPCException(f'Failed to enter position for {pair}.')
|
||||
with self._freqtrade._exit_lock:
|
||||
if self._freqtrade.execute_entry(pair, stake_amount, price,
|
||||
ordertype=order_type, trade=trade,
|
||||
is_short=is_short,
|
||||
enter_tag=enter_tag,
|
||||
leverage_=leverage,
|
||||
):
|
||||
Trade.commit()
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
return trade
|
||||
else:
|
||||
raise RPCException(f'Failed to enter position for {pair}.')
|
||||
|
||||
def _rpc_delete(self, trade_id: int) -> Dict[str, Union[str, int]]:
|
||||
"""
|
||||
|
@@ -79,6 +79,8 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
|
||||
)
|
||||
try:
|
||||
return command_handler(self, *args, **kwargs)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
except BaseException:
|
||||
logger.exception('Exception occurred within Telegram module')
|
||||
|
||||
@@ -538,72 +540,67 @@ class Telegram(RPCHandler):
|
||||
handler for `/status` and `/status <id>`.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Check if there's at least one numerical ID provided.
|
||||
# If so, try to get only these trades.
|
||||
trade_ids = []
|
||||
if context.args and len(context.args) > 0:
|
||||
trade_ids = [int(i) for i in context.args if i.isnumeric()]
|
||||
|
||||
# Check if there's at least one numerical ID provided.
|
||||
# If so, try to get only these trades.
|
||||
trade_ids = []
|
||||
if context.args and len(context.args) > 0:
|
||||
trade_ids = [int(i) for i in context.args if i.isnumeric()]
|
||||
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
|
||||
position_adjust = self._config.get('position_adjustment_enable', False)
|
||||
max_entries = self._config.get('max_entry_position_adjustment', -1)
|
||||
for r in results:
|
||||
r['open_date_hum'] = arrow.get(r['open_date']).humanize()
|
||||
r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']])
|
||||
r['exit_reason'] = r.get('exit_reason', "")
|
||||
lines = [
|
||||
"*Trade ID:* `{trade_id}`" +
|
||||
(" `(since {open_date_hum})`" if r['is_open'] else ""),
|
||||
"*Current Pair:* {pair}",
|
||||
"*Direction:* " + ("`Short`" if r.get('is_short') else "`Long`"),
|
||||
"*Leverage:* `{leverage}`" if r.get('leverage') else "",
|
||||
"*Amount:* `{amount} ({stake_amount} {quote_currency})`",
|
||||
"*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "",
|
||||
"*Exit Reason:* `{exit_reason}`" if r['exit_reason'] else "",
|
||||
]
|
||||
|
||||
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
|
||||
position_adjust = self._config.get('position_adjustment_enable', False)
|
||||
max_entries = self._config.get('max_entry_position_adjustment', -1)
|
||||
for r in results:
|
||||
r['open_date_hum'] = arrow.get(r['open_date']).humanize()
|
||||
r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']])
|
||||
r['exit_reason'] = r.get('exit_reason', "")
|
||||
lines = [
|
||||
"*Trade ID:* `{trade_id}`" +
|
||||
(" `(since {open_date_hum})`" if r['is_open'] else ""),
|
||||
"*Current Pair:* {pair}",
|
||||
"*Direction:* " + ("`Short`" if r.get('is_short') else "`Long`"),
|
||||
"*Leverage:* `{leverage}`" if r.get('leverage') else "",
|
||||
"*Amount:* `{amount} ({stake_amount} {quote_currency})`",
|
||||
"*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "",
|
||||
"*Exit Reason:* `{exit_reason}`" if r['exit_reason'] else "",
|
||||
]
|
||||
if position_adjust:
|
||||
max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "")
|
||||
lines.append("*Number of Entries:* `{num_entries}`" + max_buy_str)
|
||||
|
||||
if position_adjust:
|
||||
max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "")
|
||||
lines.append("*Number of Entries:* `{num_entries}`" + max_buy_str)
|
||||
lines.extend([
|
||||
"*Open Rate:* `{open_rate:.8f}`",
|
||||
"*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "",
|
||||
"*Open Date:* `{open_date}`",
|
||||
"*Close Date:* `{close_date}`" if r['close_date'] else "",
|
||||
"*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "",
|
||||
("*Current Profit:* " if r['is_open'] else "*Close Profit: *")
|
||||
+ "`{profit_ratio:.2%}`",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"*Open Rate:* `{open_rate:.8f}`",
|
||||
"*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "",
|
||||
"*Open Date:* `{open_date}`",
|
||||
"*Close Date:* `{close_date}`" if r['close_date'] else "",
|
||||
"*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "",
|
||||
("*Current Profit:* " if r['is_open'] else "*Close Profit: *")
|
||||
+ "`{profit_ratio:.2%}`",
|
||||
])
|
||||
if r['is_open']:
|
||||
if r.get('realized_profit'):
|
||||
lines.append("*Realized Profit:* `{realized_profit:.8f}`")
|
||||
if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
|
||||
and r['initial_stop_loss_ratio'] is not None):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
||||
"`({initial_stop_loss_ratio:.2%})`")
|
||||
|
||||
if r['is_open']:
|
||||
if r.get('realized_profit'):
|
||||
lines.append("*Realized Profit:* `{realized_profit:.8f}`")
|
||||
if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
|
||||
and r['initial_stop_loss_ratio'] is not None):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
||||
"`({initial_stop_loss_ratio:.2%})`")
|
||||
# Adding stoploss and stoploss percentage only if it is not None
|
||||
lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " +
|
||||
("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
|
||||
lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` "
|
||||
"`({stoploss_current_dist_ratio:.2%})`")
|
||||
if r['open_order']:
|
||||
lines.append(
|
||||
"*Open Order:* `{open_order}`"
|
||||
+ "- `{exit_order_status}`" if r['exit_order_status'] else "")
|
||||
|
||||
# Adding stoploss and stoploss percentage only if it is not None
|
||||
lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " +
|
||||
("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
|
||||
lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` "
|
||||
"`({stoploss_current_dist_ratio:.2%})`")
|
||||
if r['open_order']:
|
||||
lines.append(
|
||||
"*Open Order:* `{open_order}`"
|
||||
+ "- `{exit_order_status}`" if r['exit_order_status'] else "")
|
||||
|
||||
lines_detail = self._prepare_order_details(
|
||||
r['orders'], r['quote_currency'], r['is_open'])
|
||||
lines.extend(lines_detail if lines_detail else "")
|
||||
self.__send_status_msg(lines, r)
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
lines_detail = self._prepare_order_details(
|
||||
r['orders'], r['quote_currency'], r['is_open'])
|
||||
lines.extend(lines_detail if lines_detail else "")
|
||||
self.__send_status_msg(lines, r)
|
||||
|
||||
def __send_status_msg(self, lines: List[str], r: Dict[str, Any]) -> None:
|
||||
"""
|
||||
@@ -630,37 +627,34 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
fiat_currency = self._config.get('fiat_display_currency', '')
|
||||
statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], fiat_currency)
|
||||
fiat_currency = self._config.get('fiat_display_currency', '')
|
||||
statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], fiat_currency)
|
||||
|
||||
show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
|
||||
max_trades_per_msg = 50
|
||||
"""
|
||||
Calculate the number of messages of 50 trades per message
|
||||
0.99 is used to make sure that there are no extra (empty) messages
|
||||
As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
|
||||
"""
|
||||
messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
|
||||
for i in range(0, messages_count):
|
||||
trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
|
||||
if show_total and i == messages_count - 1:
|
||||
# append total line
|
||||
trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
|
||||
show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
|
||||
max_trades_per_msg = 50
|
||||
"""
|
||||
Calculate the number of messages of 50 trades per message
|
||||
0.99 is used to make sure that there are no extra (empty) messages
|
||||
As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
|
||||
"""
|
||||
messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
|
||||
for i in range(0, messages_count):
|
||||
trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
|
||||
if show_total and i == messages_count - 1:
|
||||
# append total line
|
||||
trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
|
||||
|
||||
message = tabulate(trades,
|
||||
headers=head,
|
||||
tablefmt='simple')
|
||||
if show_total and i == messages_count - 1:
|
||||
# insert separators line between Total
|
||||
lines = message.split("\n")
|
||||
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
|
||||
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_status_table",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
message = tabulate(trades,
|
||||
headers=head,
|
||||
tablefmt='simple')
|
||||
if show_total and i == messages_count - 1:
|
||||
# insert separators line between Total
|
||||
lines = message.split("\n")
|
||||
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
|
||||
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_status_table",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None:
|
||||
@@ -686,35 +680,32 @@ class Telegram(RPCHandler):
|
||||
timescale = int(context.args[0]) if context.args else val.default
|
||||
except (TypeError, ValueError, IndexError):
|
||||
timescale = val.default
|
||||
try:
|
||||
stats = self._rpc._rpc_timeunit_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur,
|
||||
unit
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[f"{period['date']} ({period['trade_count']})",
|
||||
f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}",
|
||||
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
|
||||
f"{period['rel_profit']:.2%}",
|
||||
] for period in stats['data']],
|
||||
headers=[
|
||||
f"{val.header} (count)",
|
||||
f'{stake_cur}',
|
||||
f'{fiat_disp_cur}',
|
||||
'Profit %',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = (
|
||||
f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
|
||||
f'<pre>{stats_tab}</pre>'
|
||||
)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path=val.callback, query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
stats = self._rpc._rpc_timeunit_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur,
|
||||
unit
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[f"{period['date']} ({period['trade_count']})",
|
||||
f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}",
|
||||
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
|
||||
f"{period['rel_profit']:.2%}",
|
||||
] for period in stats['data']],
|
||||
headers=[
|
||||
f"{val.header} (count)",
|
||||
f'{stake_cur}',
|
||||
f'{fiat_disp_cur}',
|
||||
'Profit %',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = (
|
||||
f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
|
||||
f'<pre>{stats_tab}</pre>'
|
||||
)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path=val.callback, query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _daily(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -878,79 +869,76 @@ class Telegram(RPCHandler):
|
||||
@authorized_only
|
||||
def _balance(self, update: Update, context: CallbackContext) -> None:
|
||||
""" Handler for /balance """
|
||||
try:
|
||||
result = self._rpc._rpc_balance(self._config['stake_currency'],
|
||||
self._config.get('fiat_display_currency', ''))
|
||||
result = self._rpc._rpc_balance(self._config['stake_currency'],
|
||||
self._config.get('fiat_display_currency', ''))
|
||||
|
||||
balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0)
|
||||
if not balance_dust_level:
|
||||
balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0)
|
||||
balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0)
|
||||
if not balance_dust_level:
|
||||
balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0)
|
||||
|
||||
output = ''
|
||||
if self._config['dry_run']:
|
||||
output += "*Warning:* Simulated balances in Dry Mode.\n"
|
||||
starting_cap = round_coin_value(
|
||||
result['starting_capital'], self._config['stake_currency'])
|
||||
output += f"Starting capital: `{starting_cap}`"
|
||||
starting_cap_fiat = round_coin_value(
|
||||
result['starting_capital_fiat'], self._config['fiat_display_currency']
|
||||
) if result['starting_capital_fiat'] > 0 else ''
|
||||
output += (f" `, {starting_cap_fiat}`.\n"
|
||||
) if result['starting_capital_fiat'] > 0 else '.\n'
|
||||
output = ''
|
||||
if self._config['dry_run']:
|
||||
output += "*Warning:* Simulated balances in Dry Mode.\n"
|
||||
starting_cap = round_coin_value(
|
||||
result['starting_capital'], self._config['stake_currency'])
|
||||
output += f"Starting capital: `{starting_cap}`"
|
||||
starting_cap_fiat = round_coin_value(
|
||||
result['starting_capital_fiat'], self._config['fiat_display_currency']
|
||||
) if result['starting_capital_fiat'] > 0 else ''
|
||||
output += (f" `, {starting_cap_fiat}`.\n"
|
||||
) if result['starting_capital_fiat'] > 0 else '.\n'
|
||||
|
||||
total_dust_balance = 0
|
||||
total_dust_currencies = 0
|
||||
for curr in result['currencies']:
|
||||
curr_output = ''
|
||||
if curr['est_stake'] > balance_dust_level:
|
||||
if curr['is_position']:
|
||||
curr_output = (
|
||||
f"*{curr['currency']}:*\n"
|
||||
f"\t`{curr['side']}: {curr['position']:.8f}`\n"
|
||||
f"\t`Leverage: {curr['leverage']:.1f}`\n"
|
||||
f"\t`Est. {curr['stake']}: "
|
||||
f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
|
||||
else:
|
||||
curr_output = (
|
||||
f"*{curr['currency']}:*\n"
|
||||
f"\t`Available: {curr['free']:.8f}`\n"
|
||||
f"\t`Balance: {curr['balance']:.8f}`\n"
|
||||
f"\t`Pending: {curr['used']:.8f}`\n"
|
||||
f"\t`Est. {curr['stake']}: "
|
||||
f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
|
||||
elif curr['est_stake'] <= balance_dust_level:
|
||||
total_dust_balance += curr['est_stake']
|
||||
total_dust_currencies += 1
|
||||
|
||||
# Handle overflowing message length
|
||||
if len(output + curr_output) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output)
|
||||
output = curr_output
|
||||
total_dust_balance = 0
|
||||
total_dust_currencies = 0
|
||||
for curr in result['currencies']:
|
||||
curr_output = ''
|
||||
if curr['est_stake'] > balance_dust_level:
|
||||
if curr['is_position']:
|
||||
curr_output = (
|
||||
f"*{curr['currency']}:*\n"
|
||||
f"\t`{curr['side']}: {curr['position']:.8f}`\n"
|
||||
f"\t`Leverage: {curr['leverage']:.1f}`\n"
|
||||
f"\t`Est. {curr['stake']}: "
|
||||
f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
|
||||
else:
|
||||
output += curr_output
|
||||
curr_output = (
|
||||
f"*{curr['currency']}:*\n"
|
||||
f"\t`Available: {curr['free']:.8f}`\n"
|
||||
f"\t`Balance: {curr['balance']:.8f}`\n"
|
||||
f"\t`Pending: {curr['used']:.8f}`\n"
|
||||
f"\t`Est. {curr['stake']}: "
|
||||
f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
|
||||
elif curr['est_stake'] <= balance_dust_level:
|
||||
total_dust_balance += curr['est_stake']
|
||||
total_dust_currencies += 1
|
||||
|
||||
if total_dust_balance > 0:
|
||||
output += (
|
||||
f"*{total_dust_currencies} Other "
|
||||
f"{plural(total_dust_currencies, 'Currency', 'Currencies')} "
|
||||
f"(< {balance_dust_level} {result['stake']}):*\n"
|
||||
f"\t`Est. {result['stake']}: "
|
||||
f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n")
|
||||
tc = result['trade_count'] > 0
|
||||
stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
|
||||
fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
|
||||
# Handle overflowing message length
|
||||
if len(output + curr_output) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output)
|
||||
output = curr_output
|
||||
else:
|
||||
output += curr_output
|
||||
|
||||
output += ("\n*Estimated Value*:\n"
|
||||
f"\t`{result['stake']}: "
|
||||
f"{round_coin_value(result['total'], result['stake'], False)}`"
|
||||
f"{stake_improve}\n"
|
||||
f"\t`{result['symbol']}: "
|
||||
f"{round_coin_value(result['value'], result['symbol'], False)}`"
|
||||
f"{fiat_val}\n")
|
||||
self._send_msg(output, reload_able=True, callback_path="update_balance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
if total_dust_balance > 0:
|
||||
output += (
|
||||
f"*{total_dust_currencies} Other "
|
||||
f"{plural(total_dust_currencies, 'Currency', 'Currencies')} "
|
||||
f"(< {balance_dust_level} {result['stake']}):*\n"
|
||||
f"\t`Est. {result['stake']}: "
|
||||
f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n")
|
||||
tc = result['trade_count'] > 0
|
||||
stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
|
||||
fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
|
||||
|
||||
output += ("\n*Estimated Value*:\n"
|
||||
f"\t`{result['stake']}: "
|
||||
f"{round_coin_value(result['total'], result['stake'], False)}`"
|
||||
f"{stake_improve}\n"
|
||||
f"\t`{result['symbol']}: "
|
||||
f"{round_coin_value(result['value'], result['symbol'], False)}`"
|
||||
f"{fiat_val}\n")
|
||||
self._send_msg(output, reload_able=True, callback_path="update_balance",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _start(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1125,26 +1113,23 @@ class Telegram(RPCHandler):
|
||||
nrecent = int(context.args[0]) if context.args else 10
|
||||
except (TypeError, ValueError, IndexError):
|
||||
nrecent = 10
|
||||
try:
|
||||
trades = self._rpc._rpc_trade_history(
|
||||
nrecent
|
||||
)
|
||||
trades_tab = tabulate(
|
||||
[[arrow.get(trade['close_date']).humanize(),
|
||||
trade['pair'] + " (#" + str(trade['trade_id']) + ")",
|
||||
f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"]
|
||||
for trade in trades['trades']],
|
||||
headers=[
|
||||
'Close Date',
|
||||
'Pair (ID)',
|
||||
f'Profit ({stake_cur})',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = (f"<b>{min(trades['trades_count'], nrecent)} recent trades</b>:\n"
|
||||
+ (f"<pre>{trades_tab}</pre>" if trades['trades_count'] > 0 else ''))
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
trades = self._rpc._rpc_trade_history(
|
||||
nrecent
|
||||
)
|
||||
trades_tab = tabulate(
|
||||
[[arrow.get(trade['close_date']).humanize(),
|
||||
trade['pair'] + " (#" + str(trade['trade_id']) + ")",
|
||||
f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"]
|
||||
for trade in trades['trades']],
|
||||
headers=[
|
||||
'Close Date',
|
||||
'Pair (ID)',
|
||||
f'Profit ({stake_cur})',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = (f"<b>{min(trades['trades_count'], nrecent)} recent trades</b>:\n"
|
||||
+ (f"<pre>{trades_tab}</pre>" if trades['trades_count'] > 0 else ''))
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
@authorized_only
|
||||
def _delete_trade(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1155,18 +1140,14 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
if not context.args or len(context.args) == 0:
|
||||
raise RPCException("Trade-id not set.")
|
||||
trade_id = int(context.args[0])
|
||||
msg = self._rpc._rpc_delete(trade_id)
|
||||
self._send_msg((
|
||||
f"`{msg['result_msg']}`\n"
|
||||
'Please make sure to take care of this asset on the exchange manually.'
|
||||
))
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
if not context.args or len(context.args) == 0:
|
||||
raise RPCException("Trade-id not set.")
|
||||
trade_id = int(context.args[0])
|
||||
msg = self._rpc._rpc_delete(trade_id)
|
||||
self._send_msg((
|
||||
f"`{msg['result_msg']}`\n"
|
||||
'Please make sure to take care of this asset on the exchange manually.'
|
||||
))
|
||||
|
||||
@authorized_only
|
||||
def _performance(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1177,27 +1158,24 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
trades = self._rpc._rpc_performance()
|
||||
output = "<b>Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['pair']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
trades = self._rpc._rpc_performance()
|
||||
output = "<b>Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['pair']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_performance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_performance",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1208,31 +1186,28 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
|
||||
trades = self._rpc._rpc_enter_tag_performance(pair)
|
||||
output = "<b>Entry Tag Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['enter_tag']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
trades = self._rpc._rpc_enter_tag_performance(pair)
|
||||
output = "<b>Entry Tag Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['enter_tag']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_enter_tag_performance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_enter_tag_performance",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1243,31 +1218,28 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
|
||||
trades = self._rpc._rpc_exit_reason_performance(pair)
|
||||
output = "<b>Exit Reason Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['exit_reason']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
trades = self._rpc._rpc_exit_reason_performance(pair)
|
||||
output = "<b>Exit Reason Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['exit_reason']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit_ratio']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_exit_reason_performance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_exit_reason_performance",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1278,31 +1250,28 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
pair = None
|
||||
if context.args and isinstance(context.args[0], str):
|
||||
pair = context.args[0]
|
||||
|
||||
trades = self._rpc._rpc_mix_tag_performance(pair)
|
||||
output = "<b>Mix Tag Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['mix_tag']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
trades = self._rpc._rpc_mix_tag_performance(pair)
|
||||
output = "<b>Mix Tag Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['mix_tag']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit']:.2%}) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
else:
|
||||
output += stat_line
|
||||
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_mix_tag_performance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_mix_tag_performance",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _count(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1313,18 +1282,15 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
counts = self._rpc._rpc_count()
|
||||
message = tabulate({k: [v] for k, v in counts.items()},
|
||||
headers=['current', 'max', 'total stake'],
|
||||
tablefmt='simple')
|
||||
message = "<pre>{}</pre>".format(message)
|
||||
logger.debug(message)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_count",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
counts = self._rpc._rpc_count()
|
||||
message = tabulate({k: [v] for k, v in counts.items()},
|
||||
headers=['current', 'max', 'total stake'],
|
||||
tablefmt='simple')
|
||||
message = "<pre>{}</pre>".format(message)
|
||||
logger.debug(message)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML,
|
||||
reload_able=True, callback_path="update_count",
|
||||
query=update.callback_query)
|
||||
|
||||
@authorized_only
|
||||
def _locks(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1372,22 +1338,19 @@ class Telegram(RPCHandler):
|
||||
Handler for /whitelist
|
||||
Shows the currently active whitelist
|
||||
"""
|
||||
try:
|
||||
whitelist = self._rpc._rpc_whitelist()
|
||||
whitelist = self._rpc._rpc_whitelist()
|
||||
|
||||
if context.args:
|
||||
if "sorted" in context.args:
|
||||
whitelist['whitelist'] = sorted(whitelist['whitelist'])
|
||||
if "baseonly" in context.args:
|
||||
whitelist['whitelist'] = [pair.split("/")[0] for pair in whitelist['whitelist']]
|
||||
if context.args:
|
||||
if "sorted" in context.args:
|
||||
whitelist['whitelist'] = sorted(whitelist['whitelist'])
|
||||
if "baseonly" in context.args:
|
||||
whitelist['whitelist'] = [pair.split("/")[0] for pair in whitelist['whitelist']]
|
||||
|
||||
message = f"Using whitelist `{whitelist['method']}` with {whitelist['length']} pairs\n"
|
||||
message += f"`{', '.join(whitelist['whitelist'])}`"
|
||||
message = f"Using whitelist `{whitelist['method']}` with {whitelist['length']} pairs\n"
|
||||
message += f"`{', '.join(whitelist['whitelist'])}`"
|
||||
|
||||
logger.debug(message)
|
||||
self._send_msg(message)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
logger.debug(message)
|
||||
self._send_msg(message)
|
||||
|
||||
@authorized_only
|
||||
def _blacklist(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1425,30 +1388,27 @@ class Telegram(RPCHandler):
|
||||
Shows the latest logs
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
limit = int(context.args[0]) if context.args else 10
|
||||
except (TypeError, ValueError, IndexError):
|
||||
limit = 10
|
||||
logs = RPC._rpc_get_logs(limit)['logs']
|
||||
msgs = ''
|
||||
msg_template = "*{}* {}: {} \\- `{}`"
|
||||
for logrec in logs:
|
||||
msg = msg_template.format(escape_markdown(logrec[0], version=2),
|
||||
escape_markdown(logrec[2], version=2),
|
||||
escape_markdown(logrec[3], version=2),
|
||||
escape_markdown(logrec[4], version=2))
|
||||
if len(msgs + msg) + 10 >= MAX_MESSAGE_LENGTH:
|
||||
# Send message immediately if it would become too long
|
||||
self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
|
||||
msgs = msg + '\n'
|
||||
else:
|
||||
# Append message to messages to send
|
||||
msgs += msg + '\n'
|
||||
|
||||
if msgs:
|
||||
limit = int(context.args[0]) if context.args else 10
|
||||
except (TypeError, ValueError, IndexError):
|
||||
limit = 10
|
||||
logs = RPC._rpc_get_logs(limit)['logs']
|
||||
msgs = ''
|
||||
msg_template = "*{}* {}: {} \\- `{}`"
|
||||
for logrec in logs:
|
||||
msg = msg_template.format(escape_markdown(logrec[0], version=2),
|
||||
escape_markdown(logrec[2], version=2),
|
||||
escape_markdown(logrec[3], version=2),
|
||||
escape_markdown(logrec[4], version=2))
|
||||
if len(msgs + msg) + 10 >= MAX_MESSAGE_LENGTH:
|
||||
# Send message immediately if it would become too long
|
||||
self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
msgs = msg + '\n'
|
||||
else:
|
||||
# Append message to messages to send
|
||||
msgs += msg + '\n'
|
||||
|
||||
if msgs:
|
||||
self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
|
||||
|
||||
@authorized_only
|
||||
def _edge(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1456,21 +1416,17 @@ class Telegram(RPCHandler):
|
||||
Handler for /edge
|
||||
Shows information related to Edge
|
||||
"""
|
||||
try:
|
||||
edge_pairs = self._rpc._rpc_edge()
|
||||
if not edge_pairs:
|
||||
message = '<b>Edge only validated following pairs:</b>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
edge_pairs = self._rpc._rpc_edge()
|
||||
if not edge_pairs:
|
||||
message = '<b>Edge only validated following pairs:</b>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
for chunk in chunks(edge_pairs, 25):
|
||||
edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
|
||||
message = (f'<b>Edge only validated following pairs:</b>\n'
|
||||
f'<pre>{edge_pairs_tab}</pre>')
|
||||
for chunk in chunks(edge_pairs, 25):
|
||||
edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
|
||||
message = (f'<b>Edge only validated following pairs:</b>\n'
|
||||
f'<pre>{edge_pairs_tab}</pre>')
|
||||
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
@authorized_only
|
||||
def _help(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1551,12 +1507,9 @@ class Telegram(RPCHandler):
|
||||
Handler for /health
|
||||
Shows the last process timestamp
|
||||
"""
|
||||
try:
|
||||
health = self._rpc._health()
|
||||
message = f"Last process: `{health['last_process_loc']}`"
|
||||
self._send_msg(message)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
health = self._rpc._health()
|
||||
message = f"Last process: `{health['last_process_loc']}`"
|
||||
self._send_msg(message)
|
||||
|
||||
@authorized_only
|
||||
def _version(self, update: Update, context: CallbackContext) -> None:
|
||||
|
@@ -19,7 +19,7 @@ class FreqaiExampleHybridStrategy(IStrategy):
|
||||
|
||||
Launching this strategy would be:
|
||||
|
||||
freqtrade trade --strategy FreqaiExampleHyridStrategy --strategy-path freqtrade/templates
|
||||
freqtrade trade --strategy FreqaiExampleHybridStrategy --strategy-path freqtrade/templates
|
||||
--freqaimodel CatboostClassifier --config config_examples/config_freqai.example.json
|
||||
|
||||
or the user simply adds this to their config:
|
||||
@@ -86,7 +86,7 @@ class FreqaiExampleHybridStrategy(IStrategy):
|
||||
process_only_new_candles = True
|
||||
stoploss = -0.05
|
||||
use_exit_signal = True
|
||||
startup_candle_count: int = 300
|
||||
startup_candle_count: int = 30
|
||||
can_short = True
|
||||
|
||||
# Hyperoptable parameters
|
||||
|
@@ -328,7 +328,7 @@
|
||||
"# Show graph inline\n",
|
||||
"# graph.show()\n",
|
||||
"\n",
|
||||
"# Render graph in a seperate window\n",
|
||||
"# Render graph in a separate window\n",
|
||||
"graph.show(renderer=\"browser\")\n"
|
||||
]
|
||||
},
|
||||
|
Reference in New Issue
Block a user