Merge branch 'develop' into feat/short

This commit is contained in:
Matthias
2022-01-22 17:25:21 +01:00
77 changed files with 1886 additions and 252 deletions

View File

@@ -24,7 +24,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
"enable_protections", "dry_run_wallet", "timeframe_detail",
"strategy_list", "export", "exportfilename",
"backtest_breakdown"]
"backtest_breakdown", "backtest_cache"]
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
"position_stacking", "use_max_market_positions",

View File

@@ -86,7 +86,7 @@ def ask_user_config() -> Dict[str, Any]:
{
"type": "select",
"name": "timeframe_in_config",
"message": "Tim",
"message": "Time",
"choices": ["Have the strategy define timeframe.", "Override in configuration."]
},
{

View File

@@ -205,6 +205,12 @@ AVAILABLE_CLI_OPTIONS = {
nargs='+',
choices=constants.BACKTEST_BREAKDOWNS
),
"backtest_cache": Arg(
'--cache',
help='Load a cached backtest result no older than specified age (default: %(default)s).',
default=constants.BACKTEST_CACHE_DEFAULT,
choices=constants.BACKTEST_CACHE_AGE,
),
# Edge
"stoploss_range": Arg(
'--stoplosses',

View File

@@ -276,6 +276,9 @@ class Configuration:
self._args_to_config(config, argname='backtest_breakdown',
logstring='Parameter --breakdown detected ...')
self._args_to_config(config, argname='backtest_cache',
logstring='Parameter --cache={} detected ...')
self._args_to_config(config, argname='disableparamexport',
logstring='Parameter --disableparamexport detected: {} ...')

View File

@@ -36,6 +36,8 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
BACKTEST_BREAKDOWNS = ['day', 'week', 'month']
BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month']
BACKTEST_CACHE_DEFAULT = 'day'
DRY_RUN_WALLET = 1000
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons

View File

@@ -2,6 +2,8 @@
Helpers when analyzing backtest data
"""
import logging
from copy import copy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
@@ -10,7 +12,7 @@ import pandas as pd
from freqtrade.constants import LAST_BT_RESULT_FN
from freqtrade.exceptions import OperationalException
from freqtrade.misc import json_load
from freqtrade.misc import get_backtest_metadata_filename, json_load
from freqtrade.persistence import LocalTrade, Trade, init_db
@@ -100,10 +102,30 @@ def get_latest_hyperopt_file(directory: Union[Path, str], predef_filename: str =
if isinstance(directory, str):
directory = Path(directory)
if predef_filename:
if Path(predef_filename).is_absolute():
raise OperationalException(
"--hyperopt-filename expects only the filename, not an absolute path.")
return directory / predef_filename
return directory / get_latest_hyperopt_filename(directory)
def load_backtest_metadata(filename: Union[Path, str]) -> Dict[str, Any]:
"""
Read metadata dictionary from backtest results file without reading and deserializing entire
file.
:param filename: path to backtest results file.
:return: metadata dict or None if metadata is not present.
"""
filename = get_backtest_metadata_filename(filename)
try:
with filename.open() as fp:
return json_load(fp)
except FileNotFoundError:
return {}
except Exception as e:
raise OperationalException('Unexpected error while loading backtest metadata.') from e
def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
"""
Load backtest statistics file.
@@ -120,9 +142,80 @@ def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
with filename.open() as file:
data = json_load(file)
# Legacy list format does not contain metadata.
if isinstance(data, dict):
data['metadata'] = load_backtest_metadata(filename)
return data
def _load_and_merge_backtest_result(strategy_name: str, filename: Path, results: Dict[str, Any]):
bt_data = load_backtest_stats(filename)
for k in ('metadata', 'strategy'):
results[k][strategy_name] = bt_data[k][strategy_name]
comparison = bt_data['strategy_comparison']
for i in range(len(comparison)):
if comparison[i]['key'] == strategy_name:
results['strategy_comparison'].append(comparison[i])
break
def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str],
min_backtest_date: datetime = None) -> Dict[str, Any]:
"""
Find existing backtest stats that match specified run IDs and load them.
:param dirname: pathlib.Path object, or string pointing to the file.
:param run_ids: {strategy_name: id_string} dictionary.
:param min_backtest_date: do not load a backtest older than specified date.
:return: results dict.
"""
# Copy so we can modify this dict without affecting parent scope.
run_ids = copy(run_ids)
dirname = Path(dirname)
results: Dict[str, Any] = {
'metadata': {},
'strategy': {},
'strategy_comparison': [],
}
# Weird glob expression here avoids including .meta.json files.
for filename in reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json'))):
metadata = load_backtest_metadata(filename)
if not metadata:
# Files are sorted from newest to oldest. When file without metadata is encountered it
# is safe to assume older files will also not have any metadata.
break
for strategy_name, run_id in list(run_ids.items()):
strategy_metadata = metadata.get(strategy_name, None)
if not strategy_metadata:
# This strategy is not present in analyzed backtest.
continue
if min_backtest_date is not None:
try:
backtest_date = strategy_metadata['backtest_start_time']
except KeyError:
# TODO: this can be removed starting from feb 2022
# The metadata-file without start_time was only available in develop
# and was never included in an official release.
# Older metadata format without backtest time, too old to consider.
return results
backtest_date = datetime.fromtimestamp(backtest_date, tz=timezone.utc)
if backtest_date < min_backtest_date:
# Do not use a cached result for this strategy as first result is too old.
del run_ids[strategy_name]
continue
if strategy_metadata['run_id'] == run_id:
del run_ids[strategy_name]
_load_and_merge_backtest_result(strategy_name, filename, results)
if len(run_ids) == 0:
break
return results
def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = None) -> pd.DataFrame:
"""
Load backtest data file.

View File

@@ -752,8 +752,9 @@ class Exchange:
'cost': _amount * rate,
'type': ordertype,
'side': side,
'filled': 0,
'remaining': _amount,
'datetime': arrow.utcnow().isoformat(),
'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'timestamp': arrow.utcnow().int_timestamp * 1000,
'status': "closed" if ordertype == "market" else "open",
'fee': None,
@@ -768,6 +769,7 @@ class Exchange:
average = self.get_dry_market_fill_price(pair, side, amount, rate)
dry_order.update({
'average': average,
'filled': _amount,
'cost': (dry_order['amount'] * average) / leverage
})
dry_order = self.add_dry_order_fee(pair, dry_order)

View File

@@ -17,7 +17,7 @@ from freqtrade.configuration import validate_config_consistency
from freqtrade.data.converter import order_book_to_dataframe
from freqtrade.data.dataprovider import DataProvider
from freqtrade.edge import Edge
from freqtrade.enums import (Collateral, RPCMessageType, SellType, SignalDirection, State,
from freqtrade.enums import (Collateral, RPCMessageType, RunMode, SellType, SignalDirection, State,
TradingMode)
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
InvalidOrderException, PricingError)
@@ -202,6 +202,11 @@ class FreqtradeBot(LoggingMixin):
# First process current opened trades (positions)
self.exit_positions(trades)
# Check if we need to adjust our current positions before attempting to buy new trades.
if self.strategy.position_adjustment_enable:
with self._exit_lock:
self.process_open_trade_positions()
# Then looking for buy opportunities
if self.get_free_open_trades():
self.enter_positions()
@@ -328,7 +333,8 @@ class FreqtradeBot(LoggingMixin):
for trade in trades:
if trade.is_open and not trade.fee_updated(trade.enter_side):
order = trade.select_order(trade.enter_side, False)
if order:
open_order = trade.select_order(trade.enter_side, True)
if order and open_order is None:
logger.info(
f"Updating {trade.enter_side}-fee on trade {trade}"
f"for order {order.order_id}."
@@ -500,6 +506,53 @@ class FreqtradeBot(LoggingMixin):
else:
return False
#
# BUY / increase positions / DCA logic and methods
#
def process_open_trade_positions(self):
"""
Tries to execute additional buy or sell orders for open trades (positions)
"""
# Walk through each pair and check if it needs changes
for trade in Trade.get_open_trades():
# If there is any open orders, wait for them to finish.
if trade.open_order_id is None:
try:
self.check_and_call_adjust_trade_position(trade)
except DependencyException as exception:
logger.warning('Unable to adjust position of trade for %s: %s',
trade.pair, exception)
def check_and_call_adjust_trade_position(self, trade: Trade):
"""
Check the implemented trading strategy for adjustment command.
If the strategy triggers the adjustment, a new order gets issued.
Once that completes, the existing trade is modified to match new data.
"""
# TODO-lev: Check what changes are necessary for DCA in relation to shorts.
current_rate = self.exchange.get_rate(trade.pair, refresh=True, side="buy")
current_profit = trade.calc_profit_ratio(current_rate)
min_stake_amount = self.exchange.get_min_pair_stake_amount(trade.pair,
current_rate,
self.strategy.stoploss)
max_stake_amount = self.wallets.get_available_stake_amount()
logger.debug(f"Calling adjust_trade_position for pair {trade.pair}")
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)(
trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_rate,
current_profit=current_profit, min_stake=min_stake_amount, max_stake=max_stake_amount)
if stake_amount is not None and stake_amount > 0.0:
# We should increase our position
self.execute_entry(trade.pair, stake_amount, trade=trade)
if stake_amount is not None and stake_amount < 0.0:
# We should decrease our position
# TODO: Selling part of the trade not implemented yet.
logger.error(f"Unable to decrease trade position / sell partially"
f" for pair {trade.pair}, feature not implemented.")
def _check_depth_of_market(
self,
pair: str,
@@ -578,7 +631,8 @@ class FreqtradeBot(LoggingMixin):
*,
is_short: bool = False,
ordertype: Optional[str] = None,
enter_tag: Optional[str] = None
enter_tag: Optional[str] = None,
trade: Optional[Trade] = None,
) -> bool:
"""
Executes a limit buy for the given pair
@@ -591,43 +645,10 @@ class FreqtradeBot(LoggingMixin):
[side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long']
trade_side = 'short' if is_short else 'long'
pos_adjust = trade is not None
if price:
enter_limit_requested = price
else:
# Calculate price
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side)
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=proposed_enter_rate)(
pair=pair, current_time=datetime.now(timezone.utc),
proposed_rate=proposed_enter_rate)
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
if not enter_limit_requested:
raise PricingError(f'Could not determine {side} price.')
# Min-stake-amount should actually include Leverage - this way our "minimal"
# stake- amount might be higher than necessary.
# We do however also need min-stake to determine leverage, therefore this is ignored as
# edge-case for now.
min_stake_amount = self.exchange.get_min_pair_stake_amount(
pair,
enter_limit_requested,
self.strategy.stoploss,
)
if not self.edge:
max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=datetime.now(timezone.utc),
current_rate=enter_limit_requested, proposed_stake=stake_amount,
min_stake=min_stake_amount, max_stake=max_stake_amount,
side=trade_side
)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
enter_limit_requested, stake_amount = self.get_valid_enter_price_and_stake(
pair, price, stake_amount, side, trade_side, trade)
if not stake_amount:
return False
@@ -643,16 +664,19 @@ class FreqtradeBot(LoggingMixin):
) if self.trading_mode != TradingMode.SPOT else 1.0
# Cap leverage between 1.0 and max_leverage.
leverage = min(max(leverage, 1.0), max_leverage)
logger.info(
f"{name} signal found: about create a new trade for {pair} with stake_amount: "
f"{stake_amount} ..."
)
if pos_adjust:
logger.info(f"Position adjust: about to create a new order for {pair} with stake: "
f"{stake_amount} for {trade}")
else:
logger.info(
f"{name} signal found: about create a new trade for {pair} with stake_amount: "
f"{stake_amount} ...")
amount = (stake_amount / enter_limit_requested) * leverage
order_type = ordertype or self.strategy.order_types['buy']
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
if not pos_adjust and not strategy_safe_wrapper(
self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested,
time_in_force=time_in_force, current_time=datetime.now(timezone.utc),
side=trade_side
@@ -672,6 +696,7 @@ class FreqtradeBot(LoggingMixin):
order_obj = Order.parse_from_ccxt_object(order, pair, side)
order_id = order['id']
order_status = order.get('status', None)
logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.")
# we assume the order is executed at the price requested
enter_limit_filled_price = enter_limit_requested
@@ -717,39 +742,54 @@ class FreqtradeBot(LoggingMixin):
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
open_date = datetime.now(timezone.utc)
funding_fees = self.exchange.get_funding_fees(pair, amount, open_date)
# This is a new trade
if trade is None:
trade = Trade(
pair=pair,
stake_amount=stake_amount,
amount=amount,
is_open=True,
amount_requested=amount_requested,
fee_open=fee,
fee_close=fee,
open_rate=enter_limit_filled_price,
open_rate_requested=enter_limit_requested,
open_date=open_date,
exchange=self.exchange.id,
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
enter_tag=enter_tag,
timeframe=timeframe_to_minutes(self.config['timeframe']),
leverage=leverage,
is_short=is_short,
interest_rate=interest_rate,
isolated_liq=isolated_liq,
trading_mode=self.trading_mode,
funding_fees=funding_fees
)
else:
# This is additional buy, we reset fee_open_currency so timeout checking can work
trade.is_open = True
trade.fee_open_currency = None
trade.open_rate_requested = enter_limit_requested
trade.open_order_id = order_id
trade = Trade(
pair=pair,
stake_amount=stake_amount,
amount=amount,
is_open=True,
amount_requested=amount_requested,
fee_open=fee,
fee_close=fee,
open_rate=enter_limit_filled_price,
open_rate_requested=enter_limit_requested,
open_date=open_date,
exchange=self.exchange.id,
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
enter_tag=enter_tag,
timeframe=timeframe_to_minutes(self.config['timeframe']),
leverage=leverage,
is_short=is_short,
interest_rate=interest_rate,
isolated_liq=isolated_liq,
trading_mode=self.trading_mode,
funding_fees=funding_fees
)
trade.orders.append(order_obj)
trade.recalc_trade_from_orders()
Trade.query.session.add(trade)
Trade.commit()
# Updating wallets
self.wallets.update()
self._notify_enter(trade, order_type)
self._notify_enter(trade, order, order_type)
if pos_adjust:
if order_status == 'closed':
logger.info(f"DCA order closed, trade should be up to date: {trade}")
trade = self.cancel_stoploss_on_exchange(trade)
else:
logger.info(f"DCA order {order_status}, will wait for resolution: {trade}")
# Update fees if order is closed
if order_status == 'closed':
@@ -757,7 +797,59 @@ class FreqtradeBot(LoggingMixin):
return True
def _notify_enter(self, trade: Trade, order_type: Optional[str] = None,
def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade:
# First cancelling stoploss on exchange ...
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
try:
logger.info(f"Canceling stoploss on exchange for {trade}")
co = self.exchange.cancel_stoploss_order_with_result(
trade.stoploss_order_id, trade.pair, trade.amount)
trade.update_order(co)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
return trade
def get_valid_enter_price_and_stake(
self, pair: str, price: Optional[float], stake_amount: float,
side: str, trade_side: str,
trade: Optional[Trade]) -> Tuple[float, float]:
if price:
enter_limit_requested = price
else:
# Calculate price
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side)
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=proposed_enter_rate)(
pair=pair, current_time=datetime.now(timezone.utc),
proposed_rate=proposed_enter_rate)
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
if not enter_limit_requested:
raise PricingError(f'Could not determine {side} price.')
# Min-stake-amount should actually include Leverage - this way our "minimal"
# stake- amount might be higher than necessary.
# We do however also need min-stake to determine leverage, therefore this is ignored as
# edge-case for now.
min_stake_amount = self.exchange.get_min_pair_stake_amount(
pair, enter_limit_requested, self.strategy.stoploss,)
if not self.edge and trade is None:
max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=datetime.now(timezone.utc),
current_rate=enter_limit_requested, proposed_stake=stake_amount,
min_stake=min_stake_amount, max_stake=max_stake_amount,
side=trade_side
)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
return enter_limit_requested, stake_amount
def _notify_enter(self, trade: Trade, order: Dict, order_type: Optional[str] = None,
fill: bool = False) -> None:
"""
Sends rpc notification when a entry order occurred.
@@ -766,6 +858,13 @@ class FreqtradeBot(LoggingMixin):
msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL
else:
msg_type = RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY
open_rate = safe_value_fallback(order, 'average', 'price')
if open_rate is None:
open_rate = trade.open_rate
current_rate = trade.open_rate_requested
if self.dataprovider.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.enter_side)
msg = {
'trade_id': trade.id,
@@ -776,15 +875,15 @@ class FreqtradeBot(LoggingMixin):
'pair': trade.pair,
'leverage': trade.leverage if trade.leverage else None,
'direction': 'Short' if trade.is_short else 'Long',
'limit': trade.open_rate, # Deprecated (?)
'open_rate': trade.open_rate,
'limit': open_rate, # Deprecated (?)
'open_rate': open_rate,
'order_type': order_type,
'stake_amount': trade.stake_amount,
'stake_currency': self.config['stake_currency'],
'fiat_currency': self.config.get('fiat_display_currency', None),
'amount': trade.amount,
'amount': safe_value_fallback(order, 'filled', 'amount') or trade.amount,
'open_date': trade.open_date or datetime.utcnow(),
'current_rate': trade.open_rate_requested,
'current_rate': current_rate,
}
# Send the message
@@ -1163,14 +1262,17 @@ class FreqtradeBot(LoggingMixin):
# Using filled to determine the filled amount
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
logger.info(
'%s order fully cancelled. Removing %s from database.',
side, trade
)
# if trade is not partially completed, just delete the trade
trade.delete()
was_trade_fully_canceled = True
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
# if trade is not partially completed and it's the only order, just delete the trade
if len(trade.orders) <= 1:
trade.delete()
was_trade_fully_canceled = True
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
else:
# FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
self.update_trade_state(trade, trade.open_order_id, corder)
trade.open_order_id = None
logger.info(f'Partial {side} order timeout for {trade}.')
else:
# if trade is partially complete, edit the stake details for the trade
# and close the order
@@ -1303,13 +1405,7 @@ class FreqtradeBot(LoggingMixin):
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
# First cancelling stoploss on exchange ...
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
try:
co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id,
trade.pair, trade.amount)
trade.update_order(co)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
trade = self.cancel_stoploss_on_exchange(trade)
order_type = ordertype or self.strategy.order_types[exit_type]
if sell_reason.sell_type == SellType.EMERGENCY_SELL:
@@ -1476,7 +1572,7 @@ class FreqtradeBot(LoggingMixin):
return False
# Update trade with order values
logger.info('Found open order for %s', trade)
logger.info(f'Found open order for {trade}')
try:
order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id,
trade.pair,
@@ -1492,29 +1588,26 @@ class FreqtradeBot(LoggingMixin):
# Handling of this will happen in check_handle_timedout.
return True
# Try update amount (binance-fix)
try:
new_amount = self.get_real_amount(trade, order)
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
trade.recalc_open_trade_value()
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
order = self.handle_order_fee(trade, order)
trade.update(order)
trade.recalc_trade_from_orders()
Trade.commit()
# Updating wallets when order is closed
if order['status'] in constants.NON_OPEN_EXCHANGE_STATES:
# If a buy order was closed, force update on stoploss on exchange
if order.get('side', None) == 'buy':
trade = self.cancel_stoploss_on_exchange(trade)
# Updating wallets when order is closed
self.wallets.update()
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.wallets.update()
elif send_msg and not trade.open_order_id:
# Buy fill
self._notify_enter(trade, fill=True)
self._notify_enter(trade, order, fill=True)
return False
@@ -1551,6 +1644,18 @@ class FreqtradeBot(LoggingMixin):
return real_amount
return amount
def handle_order_fee(self, trade: Trade, order: Dict[str, Any]) -> Dict[str, Any]:
# Try update amount (binance-fix)
try:
new_amount = self.get_real_amount(trade, order)
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
return order
def get_real_amount(self, trade: Trade, order: Dict) -> float:
"""
Detect and update trade fee.

View File

@@ -7,11 +7,25 @@ from typing import Any, Dict
from freqtrade.exceptions import OperationalException
class FTBufferingHandler(BufferingHandler):
def flush(self):
"""
Override Flush behaviour - we keep half of the configured capacity
otherwise, we have moments with "empty" logs.
"""
self.acquire()
try:
# Keep half of the records in buffer.
self.buffer = self.buffer[-int(self.capacity / 2):]
finally:
self.release()
logger = logging.getLogger(__name__)
LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
# Initialize bufferhandler - will be used for /log endpoints
bufferHandler = BufferingHandler(1000)
bufferHandler = FTBufferingHandler(1000)
bufferHandler.setFormatter(Formatter(LOGFORMAT))

View File

@@ -2,11 +2,13 @@
Various tool function for Freqtrade and scripts
"""
import gzip
import hashlib
import logging
import re
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, List
from typing import Any, Iterator, List, Union
from typing.io import IO
from urllib.parse import urlparse
@@ -228,3 +230,34 @@ def parse_db_uri_for_logging(uri: str):
return uri
pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0]
return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@')
def get_strategy_run_id(strategy) -> str:
"""
Generate unique identification hash for a backtest run. Identical config and strategy file will
always return an identical hash.
:param strategy: strategy object.
:return: hex string id.
"""
digest = hashlib.sha1()
config = deepcopy(strategy.config)
# Options that have no impact on results of individual backtest.
not_important_keys = ('strategy_list', 'original_config', 'telegram', 'api_server')
for k in not_important_keys:
if k in config:
del config[k]
# Explicitly allow NaN values (e.g. max_open_trades).
# as it does not matter for getting the hash.
digest.update(rapidjson.dumps(config, default=str,
number_mode=rapidjson.NM_NAN).encode('utf-8'))
with open(strategy.__file__, 'rb') as fp:
digest.update(fp.read())
return digest.hexdigest().lower()
def get_backtest_metadata_filename(filename: Union[Path, str]) -> Path:
"""Return metadata filename for specified backtest results file."""
filename = Path(filename)
return filename.parent / Path(f'{filename.stem}.meta{filename.suffix}')

View File

@@ -12,20 +12,22 @@ from typing import Any, Dict, List, Optional, Tuple
from numpy import nan
from pandas import DataFrame
from freqtrade import constants
from freqtrade.configuration import TimeRange, validate_config_consistency
from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.data import history
from freqtrade.data.btanalysis import trade_list_to_dataframe
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
from freqtrade.data.converter import trim_dataframe, trim_dataframes
from freqtrade.data.dataprovider import DataProvider
from freqtrade.enums import BacktestState, CandleType, SellType, TradingMode
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.misc import get_strategy_run_id
from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.bt_progress import BTProgress
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
store_backtest_stats)
from freqtrade.persistence import LocalTrade, PairLocks, Trade
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
@@ -63,9 +65,10 @@ class Backtesting:
LoggingMixin.show_output = False
self.config = config
self.results: Optional[Dict[str, Any]] = None
self.results: Dict[str, Any] = {}
config['dry_run'] = True
self.run_ids: Dict[str, str] = {}
self.strategylist: List[IStrategy] = []
self.all_results: Dict[str, Dict] = {}
self._exchange_name = self.config['exchange']['name']
@@ -373,12 +376,37 @@ class Backtesting:
else:
return sell_row[OPEN_IDX]
def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
) -> LocalTrade:
current_profit = trade.calc_profit_ratio(row[OPEN_IDX])
min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1)
max_stake = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)(
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
current_profit=current_profit, min_stake=min_stake, max_stake=max_stake)
# Check if we should increase our position
if stake_amount is not None and stake_amount > 0.0:
pos_trade = self._enter_trade(
trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade)
if pos_trade is not None:
return pos_trade
return trade
def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
# TODO-lev: add interest / funding fees to trade object ->
# Must be done either here, or one level higher ->
# (if we don't want to do it at "detail" level)
# Check if we need to adjust our current positions
if self.strategy.position_adjustment_enable:
trade = self._get_adjust_trade_entry_for_candle(trade, sell_row)
sell_candle_time = sell_row[DATE_IDX].to_pydatetime()
enter = sell_row[SHORT_IDX] if trade.is_short else sell_row[LONG_IDX]
exit_ = sell_row[ESHORT_IDX] if trade.is_short else sell_row[ELONG_IDX]
@@ -462,17 +490,14 @@ class Backtesting:
else:
return self._get_sell_trade_entry_for_candle(trade, sell_row)
def _enter_trade(self, pair: str, row: List, direction: str) -> Optional[LocalTrade]:
try:
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
except DependencyException:
return None
def _enter_trade(self, pair: str, row: Tuple, direction: str,
stake_amount: Optional[float] = None,
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
current_time = row[DATE_IDX].to_pydatetime()
# let's call the custom entry price, using the open price as default price
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=row[OPEN_IDX])(
pair=pair, current_time=row[DATE_IDX].to_pydatetime(),
pair=pair, current_time=current_time,
proposed_rate=row[OPEN_IDX]) # default value is the open rate
# Move rate to within the candle's low/high rate
@@ -481,15 +506,25 @@ class Backtesting:
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0
max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=current_time, current_rate=propose_rate,
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount,
side=direction)
pos_adjust = trade is not None
if not pos_adjust:
try:
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
except DependencyException:
return trade
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=current_time, current_rate=propose_rate,
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount,
side=direction)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
if not stake_amount:
return None
# In case of pos adjust, still return the original trade
# If not pos adjust, trade is None
return trade
max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
@@ -506,31 +541,54 @@ class Backtesting:
order_type = self.strategy.order_types['buy']
time_in_force = self.strategy.order_time_in_force['sell']
# Confirm trade entry:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
time_in_force=time_in_force, current_time=current_time,
side=direction):
return None
if not pos_adjust:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
time_in_force=time_in_force, current_time=current_time,
side=direction):
return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
# Enter trade
has_enter_tag = len(row) >= ENTER_TAG_IDX + 1
trade = LocalTrade(
pair=pair,
open_rate=row[OPEN_IDX],
open_date=current_time,
stake_amount=stake_amount,
amount=round((stake_amount / propose_rate) * leverage, 8),
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
enter_tag=row[ENTER_TAG_IDX] if has_enter_tag else None,
exchange=self._exchange_name,
is_short=(direction == 'short'),
leverage=leverage,
amount = round(stake_amount / propose_rate, 8)
if trade is None:
# Enter trade
has_buy_tag = len(row) >= ENTER_TAG_IDX + 1
trade = LocalTrade(
pair=pair,
open_rate=propose_rate,
open_date=current_time,
stake_amount=stake_amount,
amount=round((stake_amount / propose_rate) * leverage, 8),
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
enter_tag=row[ENTER_TAG_IDX] if has_buy_tag else None,
exchange=self._exchange_name,
is_short=(direction == 'short'),
leverage=leverage,
orders=[]
)
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
order = Order(
ft_is_open=False,
ft_pair=trade.pair,
symbol=trade.pair,
ft_order_side="buy",
side="buy",
order_type="market",
status="closed",
price=propose_rate,
average=propose_rate,
amount=amount,
filled=amount,
cost=stake_amount + trade.fee_open
)
return trade
return None
trade.orders.append(order)
if pos_adjust:
trade.recalc_trade_from_orders()
return trade
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
@@ -734,6 +792,7 @@ class Backtesting:
)
backtest_end_time = datetime.now(timezone.utc)
results.update({
'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()),
})
@@ -741,6 +800,33 @@ class Backtesting:
return min_date, max_date
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):
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)
elif backtest_cache_age == 'week':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
elif backtest_cache_age == 'month':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
return min_backtest_date
def load_prior_backtest(self):
self.run_ids = {
strategy.get_strategy_name(): get_strategy_run_id(strategy)
for strategy in self.strategylist
}
# Load previous result that will be updated incrementally.
# This can be circumvented in certain instances in combination with downloading more data
min_backtest_date = self._get_min_cached_backtest_date()
if min_backtest_date is not None:
self.results = find_existing_backtest_stats(
self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
def start(self) -> None:
"""
Run backtesting end-to-end
@@ -752,15 +838,38 @@ class Backtesting:
self.load_bt_data_detail()
logger.info("Dataload complete. Calculating indicators")
for strat in self.strategylist:
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
if len(self.strategylist) > 0:
self.load_prior_backtest()
self.results = generate_backtest_stats(data, self.all_results,
min_date=min_date, max_date=max_date)
for strat in self.strategylist:
if self.results and strat.get_strategy_name() in self.results['strategy']:
# When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
continue
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
# Update old results with new ones.
if len(self.all_results) > 0:
results = generate_backtest_stats(
data, self.all_results, min_date=min_date, max_date=max_date)
if self.results:
self.results['metadata'].update(results['metadata'])
self.results['strategy'].update(results['strategy'])
self.results['strategy_comparison'].extend(results['strategy_comparison'])
else:
self.results = results
if self.config.get('export', 'none') == 'trades':
store_backtest_stats(self.config['exportfilename'], self.results)
# Results may be mixed up now. Sort them so they follow --strategy-list order.
if 'strategy_list' in self.config and len(self.results) > 0:
self.results['strategy_comparison'] = sorted(
self.results['strategy_comparison'],
key=lambda c: self.config['strategy_list'].index(c['key']))
self.results['strategy'] = dict(
sorted(self.results['strategy'].items(),
key=lambda kv: self.config['strategy_list'].index(kv[0])))
if len(self.strategylist) > 0:
# Show backtest results
show_backtest_results(self.config, self.results)

View File

@@ -34,7 +34,7 @@ class EdgeCli:
self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
self.strategy = StrategyResolver.load_strategy(self.config)
self.strategy.dp = DataProvider(config, None)
self.strategy.dp = DataProvider(config, self.exchange)
validate_config_consistency(self.config)

View File

@@ -137,6 +137,7 @@ class HyperoptTools():
}
if not HyperoptTools._test_hyperopt_results_exist(results_file):
# No file found.
logger.warning(f"Hyperopt file {results_file} not found.")
return [], 0
epochs = []

View File

@@ -11,7 +11,8 @@ from tabulate import tabulate
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change,
calculate_max_drawdown)
from freqtrade.misc import decimals_per_coin, file_dump_json, round_coin_value
from freqtrade.misc import (decimals_per_coin, file_dump_json, get_backtest_metadata_filename,
round_coin_value)
logger = logging.getLogger(__name__)
@@ -33,6 +34,11 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
recordfilename.parent,
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
).with_suffix(recordfilename.suffix)
# Store metadata separately.
file_dump_json(get_backtest_metadata_filename(filename), stats['metadata'])
del stats['metadata']
file_dump_json(filename, stats)
latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN)
@@ -515,16 +521,26 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
:param max_date: Backtest end date
:return: Dictionary containing results per strategy and a strategy summary.
"""
result: Dict[str, Any] = {'strategy': {}}
result: Dict[str, Any] = {
'metadata': {},
'strategy': {},
'strategy_comparison': [],
}
market_change = calculate_market_change(btdata, 'close')
metadata = {}
pairlist = list(btdata.keys())
for strategy, content in all_results.items():
strat_stats = generate_strategy_stats(pairlist, strategy, content,
min_date, max_date, market_change=market_change)
metadata[strategy] = {
'run_id': content['run_id'],
'backtest_start_time': content['backtest_start_time'],
}
result['strategy'][strategy] = strat_stats
strategy_results = generate_strategy_comparison(bt_stats=result['strategy'])
result['metadata'] = metadata
result['strategy_comparison'] = strategy_results
return result

View File

@@ -559,11 +559,11 @@ class LocalTrade():
self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
if 'leverage' in order:
self.leverage = order['leverage']
self.recalc_open_trade_value()
if self.is_open:
payment = "SELL" if self.is_short else "BUY"
logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.')
self.open_order_id = None
self.recalc_trade_from_orders()
elif order_type in ('market', 'limit') and self.exit_side == order['side']:
if self.is_open:
payment = "BUY" if self.is_short else "SELL"
@@ -795,6 +795,37 @@ class LocalTrade():
return float(f"{profit_ratio:.8f}")
def recalc_trade_from_orders(self):
# We need at least 2 orders for averaging amounts and rates.
if len(self.orders) < 2:
# Just in case, still recalc open trade value
self.recalc_open_trade_value()
return
total_amount = 0.0
total_stake = 0.0
for temp_order in self.orders:
if (temp_order.ft_is_open or
(temp_order.ft_order_side != self.enter_side) or
(temp_order.status not in NON_OPEN_EXCHANGE_STATES)):
continue
tmp_amount = temp_order.amount
if temp_order.filled is not None:
tmp_amount = temp_order.filled
if tmp_amount > 0.0 and temp_order.average is not None:
total_amount += tmp_amount
total_stake += temp_order.average * tmp_amount
if total_amount > 0:
self.open_rate = total_stake / total_amount
self.stake_amount = total_stake
self.amount = total_amount
self.fee_open_cost = self.fee_open * self.stake_amount
self.recalc_open_trade_value()
if self.stop_loss_pct is not None and self.open_rate is not None:
self.adjust_stop_loss(self.open_rate, self.stop_loss_pct)
def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]:
"""
Finds latest order for this orderside and status
@@ -810,6 +841,34 @@ class LocalTrade():
else:
return None
def select_filled_orders(self, order_side: str) -> List['Order']:
"""
Finds filled orders for this orderside.
:param order_side: Side of the order (either 'buy' or 'sell')
:return: array of Order objects
"""
return [o for o in self.orders if o.ft_order_side == order_side and
o.ft_is_open is False and
(o.filled or 0) > 0 and
o.status in NON_OPEN_EXCHANGE_STATES]
@property
def nr_of_successful_buys(self) -> int:
"""
Helper function to count the number of buy orders that have been filled.
:return: int count of buy orders that have been filled for this trade.
"""
return len(self.select_filled_orders('buy'))
@property
def nr_of_successful_sells(self) -> int:
"""
Helper function to count the number of sell orders that have been filled.
:return: int count of sell orders that have been filled for this trade.
"""
return len(self.select_filled_orders('sell'))
@staticmethod
def get_trades_proxy(*, pair: str = None, is_open: bool = None,
open_date: datetime = None, close_date: datetime = None,
@@ -897,7 +956,7 @@ class Trade(_DECL_BASE, LocalTrade):
id = Column(Integer, primary_key=True)
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan", lazy="joined")
exchange = Column(String(25), nullable=False)
pair = Column(String(25), nullable=False, index=True)

View File

@@ -235,10 +235,12 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
# Trades can be empty
if trades is not None and len(trades) > 0:
# Create description for sell summarizing the trade
trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, "
f"{row['sell_reason']}, "
f"{row['trade_duration']} min",
axis=1)
trades['desc'] = trades.apply(
lambda row: f"{row['profit_ratio']:.2%}, " +
(f"{row['enter_tag']}, " if row['enter_tag'] is not None else "") +
f"{row['sell_reason']}, " +
f"{row['trade_duration']} min",
axis=1)
trade_buys = go.Scatter(
x=trades["open_date"],
y=trades["open_rate"],

View File

@@ -47,7 +47,7 @@ class SpreadFilter(IPairList):
spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio:
self.log_once(f"Removed {pair} from whitelist, because spread "
f"{spread * 100:.3%} > {self._max_spread_ratio:.3%}",
f"{spread:.3%} > {self._max_spread_ratio:.3%}",
logger.info)
return False
else:

View File

@@ -96,7 +96,8 @@ class StrategyResolver(IResolver):
("ignore_roi_if_buy_signal", False),
("sell_profit_offset", 0.0),
("disable_dataframe_checks", False),
("ignore_buying_expired_candle_after", 0)
("ignore_buying_expired_candle_after", 0),
("position_adjustment_enable", False)
]
for attribute, default in attributes:
StrategyResolver._override_attribute_helper(strategy, config,

View File

@@ -39,7 +39,8 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
# Start backtesting
# Initialize backtesting object
def run_backtest():
from freqtrade.optimize.optimize_reports import generate_backtest_stats
from freqtrade.optimize.optimize_reports import (generate_backtest_stats,
store_backtest_stats)
from freqtrade.resolvers import StrategyResolver
asyncio.set_event_loop(asyncio.new_event_loop())
try:
@@ -76,13 +77,25 @@ 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.abort = False
min_date, max_date = ApiServer._bt.backtest_one_strategy(
strat, ApiServer._bt_data, ApiServer._bt_timerange)
ApiServer._bt.results = {}
ApiServer._bt.load_prior_backtest()
ApiServer._bt.abort = False
if (ApiServer._bt.results and
strat.get_strategy_name() in ApiServer._bt.results['strategy']):
# When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
else:
min_date, max_date = ApiServer._bt.backtest_one_strategy(
strat, ApiServer._bt_data, ApiServer._bt_timerange)
ApiServer._bt.results = generate_backtest_stats(
ApiServer._bt_data, ApiServer._bt.all_results,
min_date=min_date, max_date=max_date)
if btconfig.get('export', 'none') == 'trades':
store_backtest_stats(btconfig['exportfilename'], ApiServer._bt.results)
ApiServer._bt.results = generate_backtest_stats(
ApiServer._bt_data, ApiServer._bt.all_results,
min_date=min_date, max_date=max_date)
logger.info("Backtest finished.")
except DependencyException as e:

View File

@@ -281,6 +281,7 @@ class ForceBuyPayload(BaseModel):
pair: str
price: Optional[float]
ordertype: Optional[OrderTypeValues]
stakeamount: Optional[float]
class ForceSellPayload(BaseModel):

View File

@@ -21,7 +21,7 @@ from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, Blac
Stats, StatusMsg, StrategyListResponse,
StrategyResponse, SysInfo, Version,
WhitelistResponse)
from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional
from freqtrade.rpc.api_server.deps import get_config, get_exchange, get_rpc, get_rpc_optional
from freqtrade.rpc.rpc import RPCException
@@ -32,7 +32,8 @@ logger = logging.getLogger(__name__)
# Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen.
# 1.11: forcebuy and forcesell accept ordertype
# 1.12: add blacklist delete endpoint
API_VERSION = 1.12
# 1.13: forcebuy supports stake_amount
API_VERSION = 1.13
# Public API, requires no auth.
router_public = APIRouter()
@@ -135,7 +136,9 @@ def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(g
@router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading'])
def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
ordertype = payload.ordertype.value if payload.ordertype else None
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype)
stake_amount = payload.stakeamount if payload.stakeamount else None
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype, stake_amount)
if trade:
return ForceBuyResponse.parse_obj(trade.to_json())
@@ -218,12 +221,14 @@ def pair_candles(pair: str, timeframe: str, limit: Optional[int], rpc: RPC = Dep
@router.get('/pair_history', response_model=PairHistory, tags=['candle data'])
def pair_history(pair: str, timeframe: str, timerange: str, strategy: str,
config=Depends(get_config)):
config=Depends(get_config), exchange=Depends(get_exchange)):
# The initial call to this endpoint can be slow, as it may need to initialize
# the exchange class.
config = deepcopy(config)
config.update({
'strategy': strategy,
})
return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange)
return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange, exchange)
@router.get('/plot_config', response_model=PlotConfig, tags=['candle data'])

View File

@@ -1,5 +1,7 @@
from typing import Any, Dict, Iterator, Optional
from fastapi import Depends
from freqtrade.persistence import Trade
from freqtrade.rpc.rpc import RPC, RPCException
@@ -28,3 +30,11 @@ def get_config() -> Dict[str, Any]:
def get_api_config() -> Dict[str, Any]:
return ApiServer._config['api_server']
def get_exchange(config=Depends(get_config)):
if not ApiServer._exchange:
from freqtrade.resolvers import ExchangeResolver
ApiServer._exchange = ExchangeResolver.load_exchange(
config['exchange']['name'], config)
return ApiServer._exchange

View File

@@ -41,6 +41,8 @@ class ApiServer(RPCHandler):
_has_rpc: bool = False
_bgtask_running: bool = False
_config: Dict[str, Any] = {}
# Exchange - only available in webserver mode.
_exchange = None
def __new__(cls, *args, **kwargs):
"""

View File

@@ -243,19 +243,25 @@ class RPC:
profit_str += f" ({fiat_profit:.2f})"
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
else fiat_profit_sum + fiat_profit
trades_list.append([
detail_trade = [
f'{trade.id} {direction_str}',
trade.pair + ('*' if (trade.open_order_id is not None
and trade.close_rate_requested is None) else '')
+ ('**' if (trade.close_rate_requested is not None) else ''),
+ ('**' if (trade.close_rate_requested is not None) else ''),
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
profit_str
])
]
if self._config.get('position_adjustment_enable', False):
filled_buys = trade.select_filled_orders('buy')
detail_trade.append(str(len(filled_buys)))
trades_list.append(detail_trade)
profitcol = "Profit"
if self._fiat_converter:
profitcol += " (" + fiat_display_currency + ")"
columns = ['ID L/S', 'Pair', 'Since', profitcol]
if self._config.get('position_adjustment_enable', False):
columns.append('# Buys')
return trades_list, columns, fiat_profit_sum
def _rpc_daily_profit(
@@ -598,6 +604,7 @@ class RPC:
value = self._fiat_converter.convert_amount(
total, stake_currency, fiat_display_currency) if self._fiat_converter else 0
trade_count = len(Trade.get_trades_proxy())
starting_capital_ratio = 0.0
starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0
starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0
@@ -614,6 +621,7 @@ class RPC:
'starting_capital_fiat': starting_cap_fiat,
'starting_capital_fiat_ratio': starting_cap_fiat_ratio,
'starting_capital_fiat_pct': round(starting_cap_fiat_ratio * 100, 2),
'trade_count': trade_count,
'note': 'Simulated balances' if self._freqtrade.config['dry_run'] else ''
}
@@ -705,8 +713,8 @@ class RPC:
self._freqtrade.wallets.update()
return {'result': f'Created sell order for trade {trade_id}.'}
def _rpc_forcebuy(self, pair: str, price: Optional[float],
order_type: Optional[str] = None) -> Optional[Trade]:
def _rpc_forcebuy(self, pair: str, price: Optional[float], order_type: Optional[str] = None,
stake_amount: Optional[float] = None) -> Optional[Trade]:
"""
Handler for forcebuy <asset> <price>
Buys a pair trade at the given or current price
@@ -728,16 +736,19 @@ class RPC:
# check if pair already has an open pair
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
if trade:
raise RPCException(f'position for {pair} already open - id: {trade.id}')
if not self._freqtrade.strategy.position_adjustment_enable:
raise RPCException(f'position for {pair} already open - id: {trade.id}')
# gen stake amount
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
if not stake_amount:
# gen stake amount
stake_amount = self._freqtrade.wallets.get_trade_stake_amount(pair)
# execute buy
if not order_type:
order_type = self._freqtrade.strategy.order_types.get(
'forcebuy', self._freqtrade.strategy.order_types['buy'])
if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type):
if self._freqtrade.execute_entry(pair, stake_amount, price,
ordertype=order_type, trade=trade):
Trade.commit()
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
return trade
@@ -992,7 +1003,7 @@ class RPC:
@staticmethod
def _rpc_analysed_history_full(config, pair: str, timeframe: str,
timerange: str) -> Dict[str, Any]:
timerange: str, exchange) -> Dict[str, Any]:
timerange_parsed = TimeRange.parse_timerange(timerange)
_data = load_data(
@@ -1007,7 +1018,7 @@ class RPC:
from freqtrade.data.dataprovider import DataProvider
from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategy = StrategyResolver.load_strategy(config)
strategy.dp = DataProvider(config, exchange=None, pairlists=None)
strategy.dp = DataProvider(config, exchange=exchange, pairlists=None)
df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair})

View File

@@ -85,12 +85,14 @@ class RPCManager:
timeframe = config['timeframe']
exchange_name = config['exchange']['name']
strategy_name = config.get('strategy', '')
pos_adjust_enabled = 'On' if config['position_adjustment_enable'] else 'Off'
self.send_msg({
'type': RPCMessageType.STARTUP,
'status': f'*Exchange:* `{exchange_name}`\n'
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
f'*Minimum ROI:* `{minimal_roi}`\n'
f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n'
f'*Position adjustment:* `{pos_adjust_enabled}`\n'
f'*Timeframe:* `{timeframe}`\n'
f'*Strategy:* `{strategy_name}`'
})

View File

@@ -781,14 +781,17 @@ class Telegram(RPCHandler):
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" `({result['starting_capital_ratio']:.2%})`\n"
f"{stake_improve}\n"
f"\t`{result['symbol']}: "
f"{round_coin_value(result['value'], result['symbol'], False)}`"
f" `({result['starting_capital_fiat_ratio']:.2%})`\n")
f"{fiat_val}\n")
self._send_msg(output, reload_able=True, callback_path="update_balance",
query=update.callback_query)
except RPCException as e:

View File

@@ -106,6 +106,9 @@ class IStrategy(ABC, HyperStrategyMixin):
sell_profit_offset: float
ignore_roi_if_buy_signal: bool
# Position adjustment is disabled by default
position_adjustment_enable: bool = False
# Number of seconds after which the candle will no longer result in a buy on expired candles
ignore_buying_expired_candle_after: int = 0
@@ -383,6 +386,28 @@ class IStrategy(ABC, HyperStrategyMixin):
"""
return proposed_stake
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, min_stake: float,
max_stake: float, **kwargs) -> Optional[float]:
"""
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
This means extra buy orders with additional fees.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns None
:param trade: trade object.
:param current_time: datetime object, containing the current datetime
:param current_rate: Current buy rate.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param min_stake: Minimal stake size allowed by exchange.
:param max_stake: Balance available for trading.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: Stake amount to adjust your trade
"""
return None
def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, side: str,
**kwargs) -> float:
@@ -687,6 +712,8 @@ class IStrategy(ABC, HyperStrategyMixin):
enter = latest[SignalType.ENTER_LONG.value] == 1
exit_ = latest.get(SignalType.EXIT_LONG.value, 0) == 1
exit_tag = latest.get(SignalTagType.EXIT_TAG.value, None)
# Tags can be None, which does not resolve to False.
exit_tag = exit_tag if isinstance(exit_tag, str) else None
logger.debug(f"exit-trigger: {latest['date']} (pair={pair}) "
f"enter={enter} exit={exit_}")
@@ -726,6 +753,8 @@ class IStrategy(ABC, HyperStrategyMixin):
enter_signal = SignalDirection.SHORT
enter_tag_value = latest.get(SignalTagType.ENTER_TAG.value, None)
enter_tag_value = enter_tag_value if isinstance(enter_tag_value, str) else None
timeframe_seconds = timeframe_to_seconds(timeframe)
if self.ignore_expired_candle(

View File

@@ -15,7 +15,8 @@
"cancel_open_orders_on_exit": false,
"unfilledtimeout": {
"buy": 10,
"sell": 30,
"sell": 10,
"exit_timeout_count": 0,
"unit": "minutes"
},
"bid_strategy": {