Merge branch 'develop' into feat_readjust_entry
This commit is contained in:
@@ -72,7 +72,8 @@ ARGS_LIST_DATA = ["exchange", "dataformat_ohlcv", "pairs", "trading_mode"]
|
||||
|
||||
ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "new_pairs_days", "include_inactive",
|
||||
"timerange", "download_trades", "exchange", "timeframes",
|
||||
"erase", "dataformat_ohlcv", "dataformat_trades", "trading_mode"]
|
||||
"erase", "dataformat_ohlcv", "dataformat_trades", "trading_mode",
|
||||
"prepend_data"]
|
||||
|
||||
ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
|
||||
"db_url", "trade_source", "export", "exportfilename",
|
||||
|
@@ -443,6 +443,11 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
default=['1m', '5m'],
|
||||
nargs='+',
|
||||
),
|
||||
"prepend_data": Arg(
|
||||
'--prepend',
|
||||
help='Allow data prepending.',
|
||||
action='store_true',
|
||||
),
|
||||
"erase": Arg(
|
||||
'--erase',
|
||||
help='Clean all existing data for the selected exchange/pairs/timeframes.',
|
||||
|
@@ -85,6 +85,7 @@ def start_download_data(args: Dict[str, Any]) -> None:
|
||||
new_pairs_days=config['new_pairs_days'],
|
||||
erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'],
|
||||
trading_mode=config.get('trading_mode', 'spot'),
|
||||
prepend=config.get('prepend_data', False)
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
@@ -22,6 +22,6 @@ def setup_utils_configuration(args: Dict[str, Any], method: RunMode) -> Dict[str
|
||||
|
||||
# Ensure these modes are using Dry-run
|
||||
config['dry_run'] = True
|
||||
validate_config_consistency(config)
|
||||
validate_config_consistency(config, preliminary=True)
|
||||
|
||||
return config
|
||||
|
@@ -39,7 +39,7 @@ def _extend_validator(validator_class):
|
||||
FreqtradeValidator = _extend_validator(Draft4Validator)
|
||||
|
||||
|
||||
def validate_config_schema(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def validate_config_schema(conf: Dict[str, Any], preliminary: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate the configuration follow the Config Schema
|
||||
:param conf: Config in JSON format
|
||||
@@ -49,7 +49,10 @@ def validate_config_schema(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if conf.get('runmode', RunMode.OTHER) in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
conf_schema['required'] = constants.SCHEMA_TRADE_REQUIRED
|
||||
elif conf.get('runmode', RunMode.OTHER) in (RunMode.BACKTEST, RunMode.HYPEROPT):
|
||||
conf_schema['required'] = constants.SCHEMA_BACKTEST_REQUIRED
|
||||
if preliminary:
|
||||
conf_schema['required'] = constants.SCHEMA_BACKTEST_REQUIRED
|
||||
else:
|
||||
conf_schema['required'] = constants.SCHEMA_BACKTEST_REQUIRED_FINAL
|
||||
else:
|
||||
conf_schema['required'] = constants.SCHEMA_MINIMAL_REQUIRED
|
||||
try:
|
||||
@@ -64,7 +67,7 @@ def validate_config_schema(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def validate_config_consistency(conf: Dict[str, Any]) -> None:
|
||||
def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False) -> None:
|
||||
"""
|
||||
Validate the configuration consistency.
|
||||
Should be ran after loading both configuration and strategy,
|
||||
@@ -85,7 +88,7 @@ def validate_config_consistency(conf: Dict[str, Any]) -> None:
|
||||
|
||||
# validate configuration before returning
|
||||
logger.info('Validating configuration ...')
|
||||
validate_config_schema(conf)
|
||||
validate_config_schema(conf, preliminary=preliminary)
|
||||
|
||||
|
||||
def _validate_unlimited_amount(conf: Dict[str, Any]) -> None:
|
||||
|
@@ -393,6 +393,8 @@ class Configuration:
|
||||
self._args_to_config(config, argname='trade_source',
|
||||
logstring='Using trades from: {}')
|
||||
|
||||
self._args_to_config(config, argname='prepend_data',
|
||||
logstring='Prepend detected. Allowing data prepending.')
|
||||
self._args_to_config(config, argname='erase',
|
||||
logstring='Erase detected. Deleting existing data.')
|
||||
|
||||
|
@@ -462,6 +462,10 @@ SCHEMA_BACKTEST_REQUIRED = [
|
||||
'dataformat_ohlcv',
|
||||
'dataformat_trades',
|
||||
]
|
||||
SCHEMA_BACKTEST_REQUIRED_FINAL = SCHEMA_BACKTEST_REQUIRED + [
|
||||
'stoploss',
|
||||
'minimal_roi',
|
||||
]
|
||||
|
||||
SCHEMA_MINIMAL_REQUIRED = [
|
||||
'exchange',
|
||||
|
@@ -5,7 +5,7 @@ import logging
|
||||
from copy import copy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -400,168 +400,3 @@ def extract_trades_of_period(dataframe: pd.DataFrame, trades: pd.DataFrame,
|
||||
trades = trades.loc[(trades['open_date'] >= trades_start) &
|
||||
(trades['close_date'] <= trades_stop)]
|
||||
return trades
|
||||
|
||||
|
||||
def calculate_market_change(data: Dict[str, pd.DataFrame], column: str = "close") -> float:
|
||||
"""
|
||||
Calculate market change based on "column".
|
||||
Calculation is done by taking the first non-null and the last non-null element of each column
|
||||
and calculating the pctchange as "(last - first) / first".
|
||||
Then the results per pair are combined as mean.
|
||||
|
||||
:param data: Dict of Dataframes, dict key should be pair.
|
||||
:param column: Column in the original dataframes to use
|
||||
:return:
|
||||
"""
|
||||
tmp_means = []
|
||||
for pair, df in data.items():
|
||||
start = df[column].dropna().iloc[0]
|
||||
end = df[column].dropna().iloc[-1]
|
||||
tmp_means.append((end - start) / start)
|
||||
|
||||
return float(np.mean(tmp_means))
|
||||
|
||||
|
||||
def combine_dataframes_with_mean(data: Dict[str, pd.DataFrame],
|
||||
column: str = "close") -> pd.DataFrame:
|
||||
"""
|
||||
Combine multiple dataframes "column"
|
||||
:param data: Dict of Dataframes, dict key should be pair.
|
||||
:param column: Column in the original dataframes to use
|
||||
:return: DataFrame with the column renamed to the dict key, and a column
|
||||
named mean, containing the mean of all pairs.
|
||||
:raise: ValueError if no data is provided.
|
||||
"""
|
||||
df_comb = pd.concat([data[pair].set_index('date').rename(
|
||||
{column: pair}, axis=1)[pair] for pair in data], axis=1)
|
||||
|
||||
df_comb['mean'] = df_comb.mean(axis=1)
|
||||
|
||||
return df_comb
|
||||
|
||||
|
||||
def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
|
||||
timeframe: str) -> pd.DataFrame:
|
||||
"""
|
||||
Adds a column `col_name` with the cumulative profit for the given trades array.
|
||||
:param df: DataFrame with date index
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_abs)
|
||||
:param col_name: Column name that will be assigned the results
|
||||
:param timeframe: Timeframe used during the operations
|
||||
:return: Returns df with one additional column, col_name, containing the cumulative profit.
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
timeframe_minutes = timeframe_to_minutes(timeframe)
|
||||
# Resample to timeframe to make sure trades match candles
|
||||
_trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_date'
|
||||
)[['profit_abs']].sum()
|
||||
df.loc[:, col_name] = _trades_sum['profit_abs'].cumsum()
|
||||
# Set first value to 0
|
||||
df.loc[df.iloc[0].name, col_name] = 0
|
||||
# FFill to get continuous
|
||||
df[col_name] = df[col_name].ffill()
|
||||
return df
|
||||
|
||||
|
||||
def _calc_drawdown_series(profit_results: pd.DataFrame, *, date_col: str, value_col: str
|
||||
) -> pd.DataFrame:
|
||||
max_drawdown_df = pd.DataFrame()
|
||||
max_drawdown_df['cumulative'] = profit_results[value_col].cumsum()
|
||||
max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax()
|
||||
max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value']
|
||||
max_drawdown_df['date'] = profit_results.loc[:, date_col]
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_ratio'
|
||||
):
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_ratio')
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue) with absolute max drawdown,
|
||||
high and low time and high and low value.
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_abs', starting_balance: float = 0
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]:
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_abs')
|
||||
:param starting_balance: Portfolio starting balance - properly calculate relative drawdown.
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue, relative_drawdown)
|
||||
with absolute max drawdown, high and low time and high and low value,
|
||||
and the relative account drawdown
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
idxmin = max_drawdown_df['drawdown'].idxmin()
|
||||
if idxmin == 0:
|
||||
raise ValueError("No losing trade, therefore no drawdown.")
|
||||
high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col]
|
||||
low_date = profit_results.loc[idxmin, date_col]
|
||||
high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin]
|
||||
['high_value'].idxmax(), 'cumulative']
|
||||
low_val = max_drawdown_df.loc[idxmin, 'cumulative']
|
||||
max_drawdown_rel = 0.0
|
||||
if high_val + starting_balance != 0:
|
||||
max_drawdown_rel = (high_val - low_val) / (high_val + starting_balance)
|
||||
|
||||
return (
|
||||
abs(min(max_drawdown_df['drawdown'])),
|
||||
high_date,
|
||||
low_date,
|
||||
high_val,
|
||||
low_val,
|
||||
max_drawdown_rel
|
||||
)
|
||||
|
||||
|
||||
def calculate_csum(trades: pd.DataFrame, starting_balance: float = 0) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate min/max cumsum of trades, to show if the wallet/stake amount ratio is sane
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_percent)
|
||||
:param starting_balance: Add starting balance to results, to show the wallets high / low points
|
||||
:return: Tuple (float, float) with cumsum of profit_abs
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
|
||||
csum_df = pd.DataFrame()
|
||||
csum_df['sum'] = trades['profit_abs'].cumsum()
|
||||
csum_min = csum_df['sum'].min() + starting_balance
|
||||
csum_max = csum_df['sum'].max() + starting_balance
|
||||
|
||||
return csum_min, csum_max
|
||||
|
||||
|
||||
def calculate_cagr(days_passed: int, starting_balance: float, final_balance: float) -> float:
|
||||
"""
|
||||
Calculate CAGR
|
||||
:param days_passed: Days passed between start and ending balance
|
||||
:param starting_balance: Starting balance
|
||||
:param final_balance: Final balance to calculate CAGR against
|
||||
:return: CAGR
|
||||
"""
|
||||
return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1
|
||||
|
@@ -139,8 +139,9 @@ def _load_cached_data_for_updating(
|
||||
timeframe: str,
|
||||
timerange: Optional[TimeRange],
|
||||
data_handler: IDataHandler,
|
||||
candle_type: CandleType
|
||||
) -> Tuple[DataFrame, Optional[int]]:
|
||||
candle_type: CandleType,
|
||||
prepend: bool = False,
|
||||
) -> Tuple[DataFrame, Optional[int], Optional[int]]:
|
||||
"""
|
||||
Load cached data to download more data.
|
||||
If timerange is passed in, checks whether data from an before the stored data will be
|
||||
@@ -150,9 +151,12 @@ def _load_cached_data_for_updating(
|
||||
Note: Only used by download_pair_history().
|
||||
"""
|
||||
start = None
|
||||
end = None
|
||||
if timerange:
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
if timerange.stoptype == 'date':
|
||||
end = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
|
||||
# Intentionally don't pass timerange in - since we need to load the full dataset.
|
||||
data = data_handler.ohlcv_load(pair, timeframe=timeframe,
|
||||
@@ -160,14 +164,17 @@ def _load_cached_data_for_updating(
|
||||
drop_incomplete=True, warn_no_data=False,
|
||||
candle_type=candle_type)
|
||||
if not data.empty:
|
||||
if start and start < data.iloc[0]['date']:
|
||||
if not prepend and start and start < data.iloc[0]['date']:
|
||||
# Earlier data than existing data requested, redownload all
|
||||
data = DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS)
|
||||
else:
|
||||
start = data.iloc[-1]['date']
|
||||
|
||||
if prepend:
|
||||
end = data.iloc[0]['date']
|
||||
else:
|
||||
start = data.iloc[-1]['date']
|
||||
start_ms = int(start.timestamp() * 1000) if start else None
|
||||
return data, start_ms
|
||||
end_ms = int(end.timestamp() * 1000) if end else None
|
||||
return data, start_ms, end_ms
|
||||
|
||||
|
||||
def _download_pair_history(pair: str, *,
|
||||
@@ -180,6 +187,7 @@ def _download_pair_history(pair: str, *,
|
||||
timerange: Optional[TimeRange] = None,
|
||||
candle_type: CandleType,
|
||||
erase: bool = False,
|
||||
prepend: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Download latest candles from the exchange for the pair and timeframe passed in parameters
|
||||
@@ -187,8 +195,6 @@ def _download_pair_history(pair: str, *,
|
||||
exists in a cache. If timerange starts earlier than the data in the cache,
|
||||
the full data will be redownloaded
|
||||
|
||||
Based on @Rybolov work: https://github.com/rybolov/freqtrade-data
|
||||
|
||||
:param pair: pair to download
|
||||
:param timeframe: Timeframe (e.g "5m")
|
||||
:param timerange: range of time to download
|
||||
@@ -203,14 +209,17 @@ def _download_pair_history(pair: str, *,
|
||||
if data_handler.ohlcv_purge(pair, timeframe, candle_type=candle_type):
|
||||
logger.info(f'Deleting existing data for pair {pair}, {timeframe}, {candle_type}.')
|
||||
|
||||
logger.info(
|
||||
f'Download history data for pair: "{pair}" ({process}), timeframe: {timeframe}, '
|
||||
f'candle type: {candle_type} and store in {datadir}.'
|
||||
)
|
||||
data, since_ms, until_ms = _load_cached_data_for_updating(
|
||||
pair, timeframe, timerange,
|
||||
data_handler=data_handler,
|
||||
candle_type=candle_type,
|
||||
prepend=prepend)
|
||||
|
||||
data, since_ms = _load_cached_data_for_updating(pair, timeframe, timerange,
|
||||
data_handler=data_handler,
|
||||
candle_type=candle_type)
|
||||
logger.info(f'({process}) - Download history data for "{pair}", {timeframe}, '
|
||||
f'{candle_type} and store in {datadir}.'
|
||||
f'From {format_ms_time(since_ms) if since_ms else "start"} to '
|
||||
f'{format_ms_time(until_ms) if until_ms else "now"}'
|
||||
)
|
||||
|
||||
logger.debug("Current Start: %s",
|
||||
f"{data.iloc[0]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None')
|
||||
@@ -225,6 +234,7 @@ def _download_pair_history(pair: str, *,
|
||||
days=-new_pairs_days).int_timestamp * 1000,
|
||||
is_new_pair=data.empty,
|
||||
candle_type=candle_type,
|
||||
until_ms=until_ms if until_ms else None
|
||||
)
|
||||
# TODO: Maybe move parsing to exchange class (?)
|
||||
new_dataframe = ohlcv_to_dataframe(new_data, timeframe, pair,
|
||||
@@ -257,6 +267,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
|
||||
timerange: Optional[TimeRange] = None,
|
||||
new_pairs_days: int = 30, erase: bool = False,
|
||||
data_format: str = None,
|
||||
prepend: bool = False,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Refresh stored ohlcv data for backtesting and hyperopt operations.
|
||||
@@ -280,7 +291,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
|
||||
timerange=timerange, data_handler=data_handler,
|
||||
timeframe=str(timeframe), new_pairs_days=new_pairs_days,
|
||||
candle_type=candle_type,
|
||||
erase=erase)
|
||||
erase=erase, prepend=prepend)
|
||||
if trading_mode == 'futures':
|
||||
# Predefined candletype (and timeframe) depending on exchange
|
||||
# Downloads what is necessary to backtest based on futures data.
|
||||
@@ -294,7 +305,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
|
||||
timerange=timerange, data_handler=data_handler,
|
||||
timeframe=str(tf_mark), new_pairs_days=new_pairs_days,
|
||||
candle_type=funding_candle_type,
|
||||
erase=erase)
|
||||
erase=erase, prepend=prepend)
|
||||
|
||||
return pairs_not_available
|
||||
|
||||
@@ -312,8 +323,9 @@ def _download_trades_history(exchange: Exchange,
|
||||
try:
|
||||
|
||||
until = None
|
||||
if (timerange and timerange.starttype == 'date'):
|
||||
since = timerange.startts * 1000
|
||||
if timerange:
|
||||
if timerange.starttype == 'date':
|
||||
since = timerange.startts * 1000
|
||||
if timerange.stoptype == 'date':
|
||||
until = timerange.stopts * 1000
|
||||
else:
|
||||
|
173
freqtrade/data/metrics.py
Normal file
173
freqtrade/data/metrics.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import logging
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_market_change(data: Dict[str, pd.DataFrame], column: str = "close") -> float:
|
||||
"""
|
||||
Calculate market change based on "column".
|
||||
Calculation is done by taking the first non-null and the last non-null element of each column
|
||||
and calculating the pctchange as "(last - first) / first".
|
||||
Then the results per pair are combined as mean.
|
||||
|
||||
:param data: Dict of Dataframes, dict key should be pair.
|
||||
:param column: Column in the original dataframes to use
|
||||
:return:
|
||||
"""
|
||||
tmp_means = []
|
||||
for pair, df in data.items():
|
||||
start = df[column].dropna().iloc[0]
|
||||
end = df[column].dropna().iloc[-1]
|
||||
tmp_means.append((end - start) / start)
|
||||
|
||||
return float(np.mean(tmp_means))
|
||||
|
||||
|
||||
def combine_dataframes_with_mean(data: Dict[str, pd.DataFrame],
|
||||
column: str = "close") -> pd.DataFrame:
|
||||
"""
|
||||
Combine multiple dataframes "column"
|
||||
:param data: Dict of Dataframes, dict key should be pair.
|
||||
:param column: Column in the original dataframes to use
|
||||
:return: DataFrame with the column renamed to the dict key, and a column
|
||||
named mean, containing the mean of all pairs.
|
||||
:raise: ValueError if no data is provided.
|
||||
"""
|
||||
df_comb = pd.concat([data[pair].set_index('date').rename(
|
||||
{column: pair}, axis=1)[pair] for pair in data], axis=1)
|
||||
|
||||
df_comb['mean'] = df_comb.mean(axis=1)
|
||||
|
||||
return df_comb
|
||||
|
||||
|
||||
def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
|
||||
timeframe: str) -> pd.DataFrame:
|
||||
"""
|
||||
Adds a column `col_name` with the cumulative profit for the given trades array.
|
||||
:param df: DataFrame with date index
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_abs)
|
||||
:param col_name: Column name that will be assigned the results
|
||||
:param timeframe: Timeframe used during the operations
|
||||
:return: Returns df with one additional column, col_name, containing the cumulative profit.
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
timeframe_minutes = timeframe_to_minutes(timeframe)
|
||||
# Resample to timeframe to make sure trades match candles
|
||||
_trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_date'
|
||||
)[['profit_abs']].sum()
|
||||
df.loc[:, col_name] = _trades_sum['profit_abs'].cumsum()
|
||||
# Set first value to 0
|
||||
df.loc[df.iloc[0].name, col_name] = 0
|
||||
# FFill to get continuous
|
||||
df[col_name] = df[col_name].ffill()
|
||||
return df
|
||||
|
||||
|
||||
def _calc_drawdown_series(profit_results: pd.DataFrame, *, date_col: str, value_col: str
|
||||
) -> pd.DataFrame:
|
||||
max_drawdown_df = pd.DataFrame()
|
||||
max_drawdown_df['cumulative'] = profit_results[value_col].cumsum()
|
||||
max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax()
|
||||
max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value']
|
||||
max_drawdown_df['date'] = profit_results.loc[:, date_col]
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_ratio'
|
||||
):
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_ratio')
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue) with absolute max drawdown,
|
||||
high and low time and high and low value.
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_abs', starting_balance: float = 0
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]:
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_abs')
|
||||
:param starting_balance: Portfolio starting balance - properly calculate relative drawdown.
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue, relative_drawdown)
|
||||
with absolute max drawdown, high and low time and high and low value,
|
||||
and the relative account drawdown
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
idxmin = max_drawdown_df['drawdown'].idxmin()
|
||||
if idxmin == 0:
|
||||
raise ValueError("No losing trade, therefore no drawdown.")
|
||||
high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col]
|
||||
low_date = profit_results.loc[idxmin, date_col]
|
||||
high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin]
|
||||
['high_value'].idxmax(), 'cumulative']
|
||||
low_val = max_drawdown_df.loc[idxmin, 'cumulative']
|
||||
max_drawdown_rel = 0.0
|
||||
if high_val + starting_balance != 0:
|
||||
max_drawdown_rel = (high_val - low_val) / (high_val + starting_balance)
|
||||
|
||||
return (
|
||||
abs(min(max_drawdown_df['drawdown'])),
|
||||
high_date,
|
||||
low_date,
|
||||
high_val,
|
||||
low_val,
|
||||
max_drawdown_rel
|
||||
)
|
||||
|
||||
|
||||
def calculate_csum(trades: pd.DataFrame, starting_balance: float = 0) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate min/max cumsum of trades, to show if the wallet/stake amount ratio is sane
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_percent)
|
||||
:param starting_balance: Add starting balance to results, to show the wallets high / low points
|
||||
:return: Tuple (float, float) with cumsum of profit_abs
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
|
||||
csum_df = pd.DataFrame()
|
||||
csum_df['sum'] = trades['profit_abs'].cumsum()
|
||||
csum_min = csum_df['sum'].min() + starting_balance
|
||||
csum_max = csum_df['sum'].max() + starting_balance
|
||||
|
||||
return csum_min, csum_max
|
||||
|
||||
|
||||
def calculate_cagr(days_passed: int, starting_balance: float, final_balance: float) -> float:
|
||||
"""
|
||||
Calculate CAGR
|
||||
:param days_passed: Days passed between start and ending balance
|
||||
:param starting_balance: Starting balance
|
||||
:param final_balance: Final balance to calculate CAGR against
|
||||
:return: CAGR
|
||||
"""
|
||||
return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1
|
@@ -95,6 +95,7 @@ class Binance(Exchange):
|
||||
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
|
||||
since_ms: int, candle_type: CandleType,
|
||||
is_new_pair: bool = False, raise_: bool = False,
|
||||
until_ms: int = None
|
||||
) -> Tuple[str, str, str, List]:
|
||||
"""
|
||||
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
|
||||
@@ -115,7 +116,8 @@ class Binance(Exchange):
|
||||
since_ms=since_ms,
|
||||
is_new_pair=is_new_pair,
|
||||
raise_=raise_,
|
||||
candle_type=candle_type
|
||||
candle_type=candle_type,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
|
||||
def funding_fee_cutoff(self, open_date: datetime):
|
||||
|
@@ -1645,7 +1645,8 @@ class Exchange:
|
||||
|
||||
def get_historic_ohlcv(self, pair: str, timeframe: str,
|
||||
since_ms: int, candle_type: CandleType,
|
||||
is_new_pair: bool = False) -> List:
|
||||
is_new_pair: bool = False,
|
||||
until_ms: int = None) -> List:
|
||||
"""
|
||||
Get candle history using asyncio and returns the list of candles.
|
||||
Handles all async work for this.
|
||||
@@ -1653,13 +1654,14 @@ class Exchange:
|
||||
:param pair: Pair to download
|
||||
:param timeframe: Timeframe to get data for
|
||||
:param since_ms: Timestamp in milliseconds to get history from
|
||||
:param until_ms: Timestamp in milliseconds to get history up to
|
||||
:param candle_type: '', mark, index, premiumIndex, or funding_rate
|
||||
:return: List with candle (OHLCV) data
|
||||
"""
|
||||
pair, _, _, data = self.loop.run_until_complete(
|
||||
self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe,
|
||||
since_ms=since_ms, is_new_pair=is_new_pair,
|
||||
candle_type=candle_type))
|
||||
since_ms=since_ms, until_ms=until_ms,
|
||||
is_new_pair=is_new_pair, candle_type=candle_type))
|
||||
logger.info(f"Downloaded data for {pair} with length {len(data)}.")
|
||||
return data
|
||||
|
||||
@@ -1680,6 +1682,7 @@ class Exchange:
|
||||
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
|
||||
since_ms: int, candle_type: CandleType,
|
||||
is_new_pair: bool = False, raise_: bool = False,
|
||||
until_ms: int = None
|
||||
) -> Tuple[str, str, str, List]:
|
||||
"""
|
||||
Download historic ohlcv
|
||||
@@ -1695,7 +1698,7 @@ class Exchange:
|
||||
)
|
||||
input_coroutines = [self._async_get_candle_history(
|
||||
pair, timeframe, candle_type, since) for since in
|
||||
range(since_ms, arrow.utcnow().int_timestamp * 1000, one_call)]
|
||||
range(since_ms, until_ms or (arrow.utcnow().int_timestamp * 1000), one_call)]
|
||||
|
||||
data: List = []
|
||||
# Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
|
||||
|
@@ -402,7 +402,10 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.info("No currency pair in active pair whitelist, "
|
||||
"but checking to exit open trades.")
|
||||
return trades_created
|
||||
if PairLocks.is_global_lock():
|
||||
if PairLocks.is_global_lock(side='*'):
|
||||
# This only checks for total locks (both sides).
|
||||
# per-side locks will be evaluated by `is_pair_locked` within create_trade,
|
||||
# once the direction for the trade is clear.
|
||||
lock = PairLocks.get_pair_longest_lock('*')
|
||||
if lock:
|
||||
self.log_once(f"Global pairlock active until "
|
||||
@@ -436,16 +439,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe)
|
||||
nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
|
||||
if self.strategy.is_pair_locked(pair, nowtime):
|
||||
lock = PairLocks.get_pair_longest_lock(pair, nowtime)
|
||||
if lock:
|
||||
self.log_once(f"Pair {pair} is still locked until "
|
||||
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} "
|
||||
f"due to {lock.reason}.",
|
||||
logger.info)
|
||||
else:
|
||||
self.log_once(f"Pair {pair} is still locked.", logger.info)
|
||||
return False
|
||||
|
||||
# get_free_open_trades is checked before create_trade is called
|
||||
# but it is still used here to prevent opening too many trades within one iteration
|
||||
@@ -461,6 +454,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
)
|
||||
|
||||
if signal:
|
||||
if self.strategy.is_pair_locked(pair, candle_date=nowtime, side=signal):
|
||||
lock = PairLocks.get_pair_longest_lock(pair, nowtime, signal)
|
||||
if lock:
|
||||
self.log_once(f"Pair {pair} {lock.side} is locked until "
|
||||
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} "
|
||||
f"due to {lock.reason}.",
|
||||
logger.info)
|
||||
else:
|
||||
self.log_once(f"Pair {pair} is currently locked.", logger.info)
|
||||
return False
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge)
|
||||
|
||||
bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {})
|
||||
@@ -1653,21 +1656,21 @@ class FreqtradeBot(LoggingMixin):
|
||||
if not trade.is_open:
|
||||
if send_msg and not stoploss_order and not trade.open_order_id:
|
||||
self._notify_exit(trade, '', True)
|
||||
self.handle_protections(trade.pair)
|
||||
self.handle_protections(trade.pair, trade.trade_direction)
|
||||
elif send_msg and not trade.open_order_id:
|
||||
# Enter fill
|
||||
self._notify_enter(trade, order, fill=True)
|
||||
|
||||
return False
|
||||
|
||||
def handle_protections(self, pair: str) -> None:
|
||||
prot_trig = self.protections.stop_per_pair(pair)
|
||||
def handle_protections(self, pair: str, side: LongShort) -> None:
|
||||
prot_trig = self.protections.stop_per_pair(pair, side=side)
|
||||
if prot_trig:
|
||||
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
|
||||
msg.update(prot_trig.to_json())
|
||||
self.rpc.send_msg(msg)
|
||||
|
||||
prot_trig_glb = self.protections.global_stop()
|
||||
prot_trig_glb = self.protections.global_stop(side=side)
|
||||
if prot_trig_glb:
|
||||
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
|
||||
msg.update(prot_trig_glb.to_json())
|
||||
|
@@ -867,10 +867,11 @@ class Backtesting:
|
||||
return 'short'
|
||||
return None
|
||||
|
||||
def run_protections(self, enable_protections, pair: str, current_time: datetime):
|
||||
def run_protections(
|
||||
self, enable_protections, pair: str, current_time: datetime, side: LongShort):
|
||||
if enable_protections:
|
||||
self.protections.stop_per_pair(pair, current_time)
|
||||
self.protections.global_stop(current_time)
|
||||
self.protections.stop_per_pair(pair, current_time, side)
|
||||
self.protections.global_stop(current_time, side)
|
||||
|
||||
def manage_open_orders(self, trade: LocalTrade, current_time, row: Tuple) -> bool:
|
||||
"""
|
||||
@@ -1030,7 +1031,7 @@ class Backtesting:
|
||||
and self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||
and current_time != end_date
|
||||
and trade_dir is not None
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
|
||||
):
|
||||
trade = self._enter_trade(pair, row, trade_dir)
|
||||
if trade:
|
||||
@@ -1068,7 +1069,8 @@ class Backtesting:
|
||||
LocalTrade.close_bt_trade(trade)
|
||||
trades.append(trade)
|
||||
self.wallets.update()
|
||||
self.run_protections(enable_protections, pair, current_time)
|
||||
self.run_protections(
|
||||
enable_protections, pair, current_time, trade.trade_direction)
|
||||
|
||||
# Move time one configured time_interval ahead.
|
||||
self.progress.increment()
|
||||
@@ -1092,7 +1094,7 @@ class Backtesting:
|
||||
timerange: TimeRange):
|
||||
self.progress.init_step(BacktestState.ANALYZE, 0)
|
||||
|
||||
logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
|
||||
logger.info(f"Running backtesting for Strategy {strat.get_strategy_name()}")
|
||||
backtest_start_time = datetime.now(timezone.utc)
|
||||
self._set_strategy(strat)
|
||||
|
||||
|
@@ -10,7 +10,7 @@ from typing import Any, Dict
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.data.btanalysis import calculate_max_drawdown
|
||||
from freqtrade.data.metrics import calculate_max_drawdown
|
||||
from freqtrade.optimize.hyperopt import IHyperOptLoss
|
||||
|
||||
|
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.data.btanalysis import calculate_max_drawdown
|
||||
from freqtrade.data.metrics import calculate_max_drawdown
|
||||
from freqtrade.optimize.hyperopt import IHyperOptLoss
|
||||
|
||||
|
@@ -9,7 +9,7 @@ individual needs.
|
||||
"""
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.data.btanalysis import calculate_max_drawdown
|
||||
from freqtrade.data.metrics import calculate_max_drawdown
|
||||
from freqtrade.optimize.hyperopt import IHyperOptLoss
|
||||
|
||||
|
@@ -9,8 +9,8 @@ from pandas import DataFrame, to_datetime
|
||||
from tabulate import tabulate
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.btanalysis import (calculate_cagr, calculate_csum, calculate_market_change,
|
||||
calculate_max_drawdown)
|
||||
from freqtrade.data.metrics import (calculate_cagr, calculate_csum, calculate_market_change,
|
||||
calculate_max_drawdown)
|
||||
from freqtrade.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value
|
||||
from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
|
||||
|
||||
|
@@ -9,7 +9,7 @@ from freqtrade.exceptions import OperationalException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_table_names_for_table(inspector, tabletype):
|
||||
def get_table_names_for_table(inspector, tabletype) -> List[str]:
|
||||
return [t for t in inspector.get_table_names() if t.startswith(tabletype)]
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def get_column_def(columns: List, column: str, default: str) -> str:
|
||||
return default if not has_column(columns, column) else column
|
||||
|
||||
|
||||
def get_backup_name(tabs, backup_prefix: str):
|
||||
def get_backup_name(tabs: List[str], backup_prefix: str):
|
||||
table_back_name = backup_prefix
|
||||
for i, table_back_name in enumerate(tabs):
|
||||
table_back_name = f'{backup_prefix}{i}'
|
||||
@@ -56,6 +56,16 @@ def set_sequence_ids(engine, order_id, trade_id):
|
||||
connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}"))
|
||||
|
||||
|
||||
def drop_index_on_table(engine, inspector, table_bak_name):
|
||||
with engine.begin() as connection:
|
||||
# drop indexes on backup table in new session
|
||||
for index in inspector.get_indexes(table_bak_name):
|
||||
if engine.name == 'mysql':
|
||||
connection.execute(text(f"drop index {index['name']} on {table_bak_name}"))
|
||||
else:
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
|
||||
|
||||
def migrate_trades_and_orders_table(
|
||||
decl_base, inspector, engine,
|
||||
trade_back_name: str, cols: List,
|
||||
@@ -116,13 +126,7 @@ def migrate_trades_and_orders_table(
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table trades rename to {trade_back_name}"))
|
||||
|
||||
with engine.begin() as connection:
|
||||
# drop indexes on backup table in new session
|
||||
for index in inspector.get_indexes(trade_back_name):
|
||||
if engine.name == 'mysql':
|
||||
connection.execute(text(f"drop index {index['name']} on {trade_back_name}"))
|
||||
else:
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
drop_index_on_table(engine, inspector, trade_back_name)
|
||||
|
||||
order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name)
|
||||
|
||||
@@ -205,6 +209,31 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List):
|
||||
"""))
|
||||
|
||||
|
||||
def migrate_pairlocks_table(
|
||||
decl_base, inspector, engine,
|
||||
pairlock_back_name: str, cols: List):
|
||||
|
||||
# Schema migration necessary
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table pairlocks rename to {pairlock_back_name}"))
|
||||
|
||||
drop_index_on_table(engine, inspector, pairlock_back_name)
|
||||
|
||||
side = get_column_def(cols, 'side', "'*'")
|
||||
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
# Copy data back - following the correct schema
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"""insert into pairlocks
|
||||
(id, pair, side, reason, lock_time,
|
||||
lock_end_time, active)
|
||||
select id, pair, {side} side, reason, lock_time,
|
||||
lock_end_time, active
|
||||
from {pairlock_back_name}
|
||||
"""))
|
||||
|
||||
|
||||
def set_sqlite_to_wal(engine):
|
||||
if engine.name == 'sqlite' and str(engine.url) != 'sqlite://':
|
||||
# Set Mode to
|
||||
@@ -220,10 +249,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
|
||||
cols_trades = inspector.get_columns('trades')
|
||||
cols_orders = inspector.get_columns('orders')
|
||||
cols_pairlocks = inspector.get_columns('pairlocks')
|
||||
tabs = get_table_names_for_table(inspector, 'trades')
|
||||
table_back_name = get_backup_name(tabs, 'trades_bak')
|
||||
order_tabs = get_table_names_for_table(inspector, 'orders')
|
||||
order_table_bak_name = get_backup_name(order_tabs, 'orders_bak')
|
||||
pairlock_tabs = get_table_names_for_table(inspector, 'pairlocks')
|
||||
pairlock_table_bak_name = get_backup_name(pairlock_tabs, 'pairlocks_bak')
|
||||
|
||||
# Check if migration necessary
|
||||
# Migrates both trades and orders table!
|
||||
@@ -236,6 +268,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
decl_base, inspector, engine, table_back_name, cols_trades,
|
||||
order_table_bak_name, cols_orders)
|
||||
|
||||
if not has_column(cols_pairlocks, 'side'):
|
||||
logger.info(f"Running database migration for pairlocks - "
|
||||
f"backup: {pairlock_table_bak_name}")
|
||||
|
||||
migrate_pairlocks_table(
|
||||
decl_base, inspector, engine, pairlock_table_bak_name, cols_pairlocks
|
||||
)
|
||||
if 'orders' not in previous_tables and 'trades' in previous_tables:
|
||||
raise OperationalException(
|
||||
"Your database seems to be very old. "
|
||||
|
@@ -7,13 +7,13 @@ from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String,
|
||||
create_engine, desc, func, inspect)
|
||||
create_engine, desc, func, inspect, or_)
|
||||
from sqlalchemy.exc import NoSuchModuleError
|
||||
from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.schema import UniqueConstraint
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, LongShort
|
||||
from freqtrade.enums import ExitType, TradingMode
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.leverage import interest
|
||||
@@ -393,7 +393,7 @@ class LocalTrade():
|
||||
return "sell"
|
||||
|
||||
@property
|
||||
def trade_direction(self) -> str:
|
||||
def trade_direction(self) -> LongShort:
|
||||
if self.is_short:
|
||||
return "short"
|
||||
else:
|
||||
@@ -1426,6 +1426,8 @@ class PairLock(_DECL_BASE):
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
# lock direction - long, short or * (for both)
|
||||
side = Column(String(25), nullable=False, default="*")
|
||||
reason = Column(String(255), nullable=True)
|
||||
# Time the pair was locked (start time)
|
||||
lock_time = Column(DateTime, nullable=False)
|
||||
@@ -1437,11 +1439,12 @@ class PairLock(_DECL_BASE):
|
||||
def __repr__(self):
|
||||
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, '
|
||||
f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})')
|
||||
return (
|
||||
f'PairLock(id={self.id}, pair={self.pair}, side={self.side}, lock_time={lock_time}, '
|
||||
f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})')
|
||||
|
||||
@staticmethod
|
||||
def query_pair_locks(pair: Optional[str], now: datetime) -> Query:
|
||||
def query_pair_locks(pair: Optional[str], now: datetime, side: str = '*') -> Query:
|
||||
"""
|
||||
Get all currently active locks for this pair
|
||||
:param pair: Pair to check for. Returns all current locks if pair is empty
|
||||
@@ -1452,6 +1455,11 @@ class PairLock(_DECL_BASE):
|
||||
PairLock.active.is_(True), ]
|
||||
if pair:
|
||||
filters.append(PairLock.pair == pair)
|
||||
if side != '*':
|
||||
filters.append(or_(PairLock.side == side, PairLock.side == '*'))
|
||||
else:
|
||||
filters.append(PairLock.side == '*')
|
||||
|
||||
return PairLock.query.filter(
|
||||
*filters
|
||||
)
|
||||
@@ -1466,5 +1474,6 @@ class PairLock(_DECL_BASE):
|
||||
'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
|
||||
).timestamp() * 1000),
|
||||
'reason': self.reason,
|
||||
'side': self.side,
|
||||
'active': self.active,
|
||||
}
|
||||
|
@@ -31,7 +31,7 @@ class PairLocks():
|
||||
|
||||
@staticmethod
|
||||
def lock_pair(pair: str, until: datetime, reason: str = None, *,
|
||||
now: datetime = None) -> PairLock:
|
||||
now: datetime = None, side: str = '*') -> PairLock:
|
||||
"""
|
||||
Create PairLock from now to "until".
|
||||
Uses database by default, unless PairLocks.use_db is set to False,
|
||||
@@ -40,12 +40,14 @@ class PairLocks():
|
||||
:param until: End time of the lock. Will be rounded up to the next candle.
|
||||
:param reason: Reason string that will be shown as reason for the lock
|
||||
:param now: Current timestamp. Used to determine lock start time.
|
||||
:param side: Side to lock pair, can be 'long', 'short' or '*'
|
||||
"""
|
||||
lock = PairLock(
|
||||
pair=pair,
|
||||
lock_time=now or datetime.now(timezone.utc),
|
||||
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
|
||||
reason=reason,
|
||||
side=side,
|
||||
active=True
|
||||
)
|
||||
if PairLocks.use_db:
|
||||
@@ -56,7 +58,8 @@ class PairLocks():
|
||||
return lock
|
||||
|
||||
@staticmethod
|
||||
def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List[PairLock]:
|
||||
def get_pair_locks(
|
||||
pair: Optional[str], now: Optional[datetime] = None, side: str = '*') -> List[PairLock]:
|
||||
"""
|
||||
Get all currently active locks for this pair
|
||||
:param pair: Pair to check for. Returns all current locks if pair is empty
|
||||
@@ -67,26 +70,28 @@ class PairLocks():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if PairLocks.use_db:
|
||||
return PairLock.query_pair_locks(pair, now).all()
|
||||
return PairLock.query_pair_locks(pair, now, side).all()
|
||||
else:
|
||||
locks = [lock for lock in PairLocks.locks if (
|
||||
lock.lock_end_time >= now
|
||||
and lock.active is True
|
||||
and (pair is None or lock.pair == pair)
|
||||
and (lock.side == '*' or lock.side == side)
|
||||
)]
|
||||
return locks
|
||||
|
||||
@staticmethod
|
||||
def get_pair_longest_lock(pair: str, now: Optional[datetime] = None) -> Optional[PairLock]:
|
||||
def get_pair_longest_lock(
|
||||
pair: str, now: Optional[datetime] = None, side: str = '*') -> Optional[PairLock]:
|
||||
"""
|
||||
Get the lock that expires the latest for the pair given.
|
||||
"""
|
||||
locks = PairLocks.get_pair_locks(pair, now)
|
||||
locks = PairLocks.get_pair_locks(pair, now, side=side)
|
||||
locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True)
|
||||
return locks[0] if locks else None
|
||||
|
||||
@staticmethod
|
||||
def unlock_pair(pair: str, now: Optional[datetime] = None) -> None:
|
||||
def unlock_pair(pair: str, now: Optional[datetime] = None, side: str = '*') -> None:
|
||||
"""
|
||||
Release all locks for this pair.
|
||||
:param pair: Pair to unlock
|
||||
@@ -97,7 +102,7 @@ class PairLocks():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(f"Releasing all locks for {pair}.")
|
||||
locks = PairLocks.get_pair_locks(pair, now)
|
||||
locks = PairLocks.get_pair_locks(pair, now, side=side)
|
||||
for lock in locks:
|
||||
lock.active = False
|
||||
if PairLocks.use_db:
|
||||
@@ -134,7 +139,7 @@ class PairLocks():
|
||||
lock.active = False
|
||||
|
||||
@staticmethod
|
||||
def is_global_lock(now: Optional[datetime] = None) -> bool:
|
||||
def is_global_lock(now: Optional[datetime] = None, side: str = '*') -> bool:
|
||||
"""
|
||||
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
||||
defaults to datetime.now(timezone.utc)
|
||||
@@ -142,10 +147,10 @@ class PairLocks():
|
||||
if not now:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
return len(PairLocks.get_pair_locks('*', now)) > 0
|
||||
return len(PairLocks.get_pair_locks('*', now, side)) > 0
|
||||
|
||||
@staticmethod
|
||||
def is_pair_locked(pair: str, now: Optional[datetime] = None) -> bool:
|
||||
def is_pair_locked(pair: str, now: Optional[datetime] = None, side: str = '*') -> bool:
|
||||
"""
|
||||
:param pair: Pair to check for
|
||||
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
||||
@@ -154,7 +159,10 @@ class PairLocks():
|
||||
if not now:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
return len(PairLocks.get_pair_locks(pair, now)) > 0 or PairLocks.is_global_lock(now)
|
||||
return (
|
||||
len(PairLocks.get_pair_locks(pair, now, side)) > 0
|
||||
or PairLocks.is_global_lock(now, side)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_locks() -> List[PairLock]:
|
||||
|
@@ -5,12 +5,13 @@ from typing import Any, Dict, List, Optional
|
||||
import pandas as pd
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.data.btanalysis import (analyze_trade_parallelism, calculate_max_drawdown,
|
||||
calculate_underwater, combine_dataframes_with_mean,
|
||||
create_cum_profit, extract_trades_of_period, load_trades)
|
||||
from freqtrade.data.btanalysis import (analyze_trade_parallelism, extract_trades_of_period,
|
||||
load_trades)
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.data.history import get_timerange, load_data
|
||||
from freqtrade.data.metrics import (calculate_max_drawdown, calculate_underwater,
|
||||
combine_dataframes_with_mean, create_cum_profit)
|
||||
from freqtrade.enums import CandleType
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_prev_date, timeframe_to_seconds
|
||||
|
@@ -5,6 +5,7 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.persistence import PairLocks
|
||||
from freqtrade.persistence.models import PairLock
|
||||
from freqtrade.plugins.protections import IProtection
|
||||
@@ -44,28 +45,31 @@ class ProtectionManager():
|
||||
"""
|
||||
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
||||
|
||||
def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]:
|
||||
def global_stop(self, now: Optional[datetime] = None,
|
||||
side: LongShort = 'long') -> Optional[PairLock]:
|
||||
if not now:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = None
|
||||
for protection_handler in self._protection_handlers:
|
||||
if protection_handler.has_global_stop:
|
||||
lock, until, reason = protection_handler.global_stop(now)
|
||||
|
||||
# Early stopping - first positive result blocks further trades
|
||||
if lock and until:
|
||||
if not PairLocks.is_global_lock(until):
|
||||
result = PairLocks.lock_pair('*', until, reason, now=now)
|
||||
lock = protection_handler.global_stop(date_now=now, side=side)
|
||||
if lock and lock.until:
|
||||
if not PairLocks.is_global_lock(lock.until, side=lock.lock_side):
|
||||
result = PairLocks.lock_pair(
|
||||
'*', lock.until, lock.reason, now=now, side=lock.lock_side)
|
||||
return result
|
||||
|
||||
def stop_per_pair(self, pair, now: Optional[datetime] = None) -> Optional[PairLock]:
|
||||
def stop_per_pair(self, pair, now: Optional[datetime] = None,
|
||||
side: LongShort = 'long') -> Optional[PairLock]:
|
||||
if not now:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = None
|
||||
for protection_handler in self._protection_handlers:
|
||||
if protection_handler.has_local_stop:
|
||||
lock, until, reason = protection_handler.stop_per_pair(pair, now)
|
||||
if lock and until:
|
||||
if not PairLocks.is_pair_locked(pair, until):
|
||||
result = PairLocks.lock_pair(pair, until, reason, now=now)
|
||||
lock = protection_handler.stop_per_pair(
|
||||
pair=pair, date_now=now, side=side)
|
||||
if lock and lock.until:
|
||||
if not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side):
|
||||
result = PairLocks.lock_pair(
|
||||
pair, lock.until, lock.reason, now=now, side=lock.lock_side)
|
||||
return result
|
||||
|
@@ -1,7 +1,9 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
|
||||
@@ -26,7 +28,7 @@ class CooldownPeriod(IProtection):
|
||||
"""
|
||||
return (f"{self.name} - Cooldown period of {self.stop_duration_str}.")
|
||||
|
||||
def _cooldown_period(self, pair: str, date_now: datetime, ) -> ProtectionReturn:
|
||||
def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Get last trade for this pair
|
||||
"""
|
||||
@@ -45,11 +47,15 @@ class CooldownPeriod(IProtection):
|
||||
self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
|
||||
until = self.calculate_lock_end([trade], self._stop_duration)
|
||||
|
||||
return True, until, self._reason()
|
||||
return ProtectionReturn(
|
||||
lock=True,
|
||||
until=until,
|
||||
reason=self._reason(),
|
||||
)
|
||||
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for all pairs
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
@@ -57,9 +63,10 @@ class CooldownPeriod(IProtection):
|
||||
If true, all pairs will be locked with <reason> until <until>
|
||||
"""
|
||||
# Not implemented for cooldown period.
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
|
||||
def stop_per_pair(
|
||||
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for this pair
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
|
@@ -1,9 +1,11 @@
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
@@ -12,7 +14,13 @@ from freqtrade.persistence import LocalTrade
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str]]
|
||||
|
||||
@dataclass
|
||||
class ProtectionReturn:
|
||||
lock: bool
|
||||
until: datetime
|
||||
reason: Optional[str]
|
||||
lock_side: str = '*'
|
||||
|
||||
|
||||
class IProtection(LoggingMixin, ABC):
|
||||
@@ -80,14 +88,15 @@ class IProtection(LoggingMixin, ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for all pairs
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
|
||||
def stop_per_pair(
|
||||
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for this pair
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
|
@@ -1,8 +1,9 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
|
||||
@@ -35,7 +36,7 @@ class LowProfitPairs(IProtection):
|
||||
return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, '
|
||||
f'locking for {self.stop_duration_str}.')
|
||||
|
||||
def _low_profit(self, date_now: datetime, pair: str) -> ProtectionReturn:
|
||||
def _low_profit(self, date_now: datetime, pair: str) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Evaluate recent trades for pair
|
||||
"""
|
||||
@@ -51,7 +52,7 @@ class LowProfitPairs(IProtection):
|
||||
# trades = Trade.get_trades(filters).all()
|
||||
if len(trades) < self._trade_limit:
|
||||
# Not enough trades in the relevant period
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
profit = sum(trade.close_profit for trade in trades if trade.close_profit)
|
||||
if profit < self._required_profit:
|
||||
@@ -60,20 +61,25 @@ class LowProfitPairs(IProtection):
|
||||
f"within {self._lookback_period} minutes.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
|
||||
return True, until, self._reason(profit)
|
||||
return ProtectionReturn(
|
||||
lock=True,
|
||||
until=until,
|
||||
reason=self._reason(profit),
|
||||
)
|
||||
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for all pairs
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
:return: Tuple of [bool, until, reason].
|
||||
If true, all pairs will be locked with <reason> until <until>
|
||||
"""
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
|
||||
def stop_per_pair(
|
||||
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for this pair
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
|
@@ -1,11 +1,12 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from freqtrade.data.btanalysis import calculate_max_drawdown
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.data.metrics import calculate_max_drawdown
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
|
||||
@@ -39,7 +40,7 @@ class MaxDrawdown(IProtection):
|
||||
return (f'{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, '
|
||||
f'locking for {self.stop_duration_str}.')
|
||||
|
||||
def _max_drawdown(self, date_now: datetime) -> ProtectionReturn:
|
||||
def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Evaluate recent trades for drawdown ...
|
||||
"""
|
||||
@@ -51,14 +52,14 @@ class MaxDrawdown(IProtection):
|
||||
|
||||
if len(trades) < self._trade_limit:
|
||||
# Not enough trades in the relevant period
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
# Drawdown is always positive
|
||||
try:
|
||||
# TODO: This should use absolute profit calculation, considering account balance.
|
||||
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||
except ValueError:
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
if drawdown > self._max_allowed_drawdown:
|
||||
self.log_once(
|
||||
@@ -66,11 +67,15 @@ class MaxDrawdown(IProtection):
|
||||
f" within {self.lookback_period_str}.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
|
||||
return True, until, self._reason(drawdown)
|
||||
return ProtectionReturn(
|
||||
lock=True,
|
||||
until=until,
|
||||
reason=self._reason(drawdown),
|
||||
)
|
||||
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for all pairs
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
@@ -79,11 +84,12 @@ class MaxDrawdown(IProtection):
|
||||
"""
|
||||
return self._max_drawdown(date_now)
|
||||
|
||||
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
|
||||
def stop_per_pair(
|
||||
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for this pair
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
:return: Tuple of [bool, until, reason].
|
||||
If true, this pair will be locked with <reason> until <until>
|
||||
"""
|
||||
return False, None, None
|
||||
return None
|
||||
|
@@ -1,8 +1,9 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.enums import ExitType
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
@@ -21,6 +22,7 @@ class StoplossGuard(IProtection):
|
||||
|
||||
self._trade_limit = protection_config.get('trade_limit', 10)
|
||||
self._disable_global_stop = protection_config.get('only_per_pair', False)
|
||||
self._only_per_side = protection_config.get('only_per_side', False)
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
@@ -36,7 +38,8 @@ class StoplossGuard(IProtection):
|
||||
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
|
||||
f'locking for {self._stop_duration} min.')
|
||||
|
||||
def _stoploss_guard(self, date_now: datetime, pair: str = None) -> ProtectionReturn:
|
||||
def _stoploss_guard(
|
||||
self, date_now: datetime, pair: Optional[str], side: str) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Evaluate recent trades
|
||||
"""
|
||||
@@ -48,15 +51,24 @@ class StoplossGuard(IProtection):
|
||||
ExitType.STOPLOSS_ON_EXCHANGE.value)
|
||||
and trade.close_profit and trade.close_profit < 0)]
|
||||
|
||||
if self._only_per_side:
|
||||
# Long or short trades only
|
||||
trades = [trade for trade in trades if trade.trade_direction == side]
|
||||
|
||||
if len(trades) < self._trade_limit:
|
||||
return False, None, None
|
||||
return None
|
||||
|
||||
self.log_once(f"Trading stopped due to {self._trade_limit} "
|
||||
f"stoplosses within {self._lookback_period} minutes.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
return True, until, self._reason()
|
||||
return ProtectionReturn(
|
||||
lock=True,
|
||||
until=until,
|
||||
reason=self._reason(),
|
||||
lock_side=(side if self._only_per_side else '*')
|
||||
)
|
||||
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for all pairs
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
@@ -64,14 +76,15 @@ class StoplossGuard(IProtection):
|
||||
If true, all pairs will be locked with <reason> until <until>
|
||||
"""
|
||||
if self._disable_global_stop:
|
||||
return False, None, None
|
||||
return self._stoploss_guard(date_now, None)
|
||||
return None
|
||||
return self._stoploss_guard(date_now, None, side)
|
||||
|
||||
def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
|
||||
def stop_per_pair(
|
||||
self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Stops trading (position entering) for this pair
|
||||
This must evaluate to true for the whole period of the "cooldown period".
|
||||
:return: Tuple of [bool, until, reason].
|
||||
If true, this pair will be locked with <reason> until <until>
|
||||
"""
|
||||
return self._stoploss_guard(date_now, pair)
|
||||
return self._stoploss_guard(date_now, pair, side)
|
||||
|
@@ -23,7 +23,7 @@ class HyperOptLossResolver(IResolver):
|
||||
object_type = IHyperOptLoss
|
||||
object_type_str = "HyperoptLoss"
|
||||
user_subdir = USERPATH_HYPEROPTS
|
||||
initial_search_path = Path(__file__).parent.parent.joinpath('optimize').resolve()
|
||||
initial_search_path = Path(__file__).parent.parent.joinpath('optimize/hyperopt_loss').resolve()
|
||||
|
||||
@staticmethod
|
||||
def load_hyperoptloss(config: Dict) -> IHyperOptLoss:
|
||||
|
@@ -84,6 +84,7 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
lastconfig['enable_protections'] = btconfig.get('enable_protections')
|
||||
lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet')
|
||||
|
||||
ApiServer._bt.strategylist = [strat]
|
||||
ApiServer._bt.results = {}
|
||||
ApiServer._bt.load_prior_backtest()
|
||||
|
||||
|
@@ -291,6 +291,7 @@ class LockModel(BaseModel):
|
||||
lock_time: str
|
||||
lock_timestamp: int
|
||||
pair: str
|
||||
side: str
|
||||
reason: str
|
||||
|
||||
|
||||
|
@@ -573,7 +573,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
return self.__class__.__name__
|
||||
|
||||
def lock_pair(self, pair: str, until: datetime, reason: str = None) -> None:
|
||||
def lock_pair(self, pair: str, until: datetime, reason: str = None, side: str = '*') -> None:
|
||||
"""
|
||||
Locks pair until a given timestamp happens.
|
||||
Locked pairs are not analyzed, and are prevented from opening new trades.
|
||||
@@ -583,8 +583,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param until: datetime in UTC until the pair should be blocked from opening new trades.
|
||||
Needs to be timezone aware `datetime.now(timezone.utc)`
|
||||
:param reason: Optional string explaining why the pair was locked.
|
||||
:param side: Side to check, can be long, short or '*'
|
||||
"""
|
||||
PairLocks.lock_pair(pair, until, reason)
|
||||
PairLocks.lock_pair(pair, until, reason, side=side)
|
||||
|
||||
def unlock_pair(self, pair: str) -> None:
|
||||
"""
|
||||
@@ -604,7 +605,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
PairLocks.unlock_reason(reason, datetime.now(timezone.utc))
|
||||
|
||||
def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool:
|
||||
def is_pair_locked(self, pair: str, *, candle_date: datetime = None, side: str = '*') -> bool:
|
||||
"""
|
||||
Checks if a pair is currently locked
|
||||
The 2nd, optional parameter ensures that locks are applied until the new candle arrives,
|
||||
@@ -612,15 +613,16 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
of 2 seconds for an entry order to happen on an old signal.
|
||||
:param pair: "Pair to check"
|
||||
:param candle_date: Date of the last candle. Optional, defaults to current date
|
||||
:param side: Side to check, can be long, short or '*'
|
||||
:returns: locking state of the pair in question.
|
||||
"""
|
||||
|
||||
if not candle_date:
|
||||
# Simple call ...
|
||||
return PairLocks.is_pair_locked(pair)
|
||||
return PairLocks.is_pair_locked(pair, side=side)
|
||||
else:
|
||||
lock_time = timeframe_to_next_date(self.timeframe, candle_date)
|
||||
return PairLocks.is_pair_locked(pair, lock_time)
|
||||
return PairLocks.is_pair_locked(pair, lock_time, side=side)
|
||||
|
||||
def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
|
Reference in New Issue
Block a user