Merge branch 'develop' into feat/short

This commit is contained in:
Matthias
2021-11-18 20:20:01 +01:00
49 changed files with 1112 additions and 363 deletions

View File

@@ -160,6 +160,7 @@ CONF_SCHEMA = {
'properties': {
'buy': {'type': 'number', 'minimum': 1},
'sell': {'type': 'number', 'minimum': 1},
'exit_timeout_count': {'type': 'number', 'minimum': 0, 'default': 0},
'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'}
}
},
@@ -210,7 +211,10 @@ CONF_SCHEMA = {
'sell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'forcesell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'forcebuy': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'emergencysell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'emergencysell': {
'type': 'string',
'enum': ORDERTYPE_POSSIBILITIES,
'default': 'market'},
'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'stoploss_on_exchange': {'type': 'boolean'},
'stoploss_on_exchange_interval': {'type': 'number'},

View File

@@ -113,7 +113,7 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str)
pct_missing = (len_after - len_before) / len_before if len_before > 0 else 0
if len_before != len_after:
message = (f"Missing data fillup for {pair}: before: {len_before} - after: {len_after}"
f" - {round(pct_missing * 100, 2)}%")
f" - {pct_missing:.2%}")
if pct_missing > 0.01:
logger.info(message)
else:

View File

@@ -1,5 +1,3 @@
class FreqtradeException(Exception):
"""
Freqtrade base exception. Handled at the outermost level.

View File

@@ -201,8 +201,9 @@ class Binance(Exchange):
raise OperationalException(e) from e
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, is_new_pair: bool
) -> List:
since_ms: int, is_new_pair: bool = False,
raise_: bool = False
) -> Tuple[str, str, List]:
"""
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
Does not work for other exchanges, which don't return the earliest data when called with "0"
@@ -215,7 +216,8 @@ class Binance(Exchange):
logger.info(f"Candle-data for {pair} available starting with "
f"{arrow.get(since_ms // 1000).isoformat()}.")
return await super()._async_get_historic_ohlcv(
pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair)
pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair,
raise_=raise_)
def funding_fee_cutoff(self, open_date: datetime):
"""

View File

@@ -81,9 +81,16 @@ def retrier_async(f):
count -= 1
kwargs.update({'count': count})
if isinstance(ex, DDosProtection):
backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT)
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
await asyncio.sleep(backoff_delay)
if "kucoin" in str(ex) and "429000" in str(ex):
# Temporary fix for 429000 error on kucoin
# see https://github.com/freqtrade/freqtrade/issues/5700 for details.
logger.warning(
f"Kucoin 429 error, avoid triggering DDosProtection backoff delay. "
f"{count} tries left before giving up")
else:
backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT)
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
await asyncio.sleep(backoff_delay)
return await wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)

View File

@@ -168,9 +168,10 @@ class Exchange:
self.validate_pairs(config['exchange']['pair_whitelist'])
self.validate_ordertypes(config.get('order_types', {}))
self.validate_order_time_in_force(config.get('order_time_in_force', {}))
self.validate_required_startup_candles(config.get('startup_candle_count', 0),
config.get('timeframe', ''))
self.required_candle_call_count = self.validate_required_startup_candles(
config.get('startup_candle_count', 0), config.get('timeframe', ''))
self.validate_trading_mode_and_collateral(self.trading_mode, self.collateral)
# Converts the interval provided in minutes in config to seconds
self.markets_refresh_interval: int = exchange_config.get(
"markets_refresh_interval", 60) * 60
@@ -523,16 +524,29 @@ class Exchange:
raise OperationalException(
f'Time in force policies are not supported for {self.name} yet.')
def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> None:
def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> int:
"""
Checks if required startup_candles is more than ohlcv_candle_limit().
Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default.
"""
candle_limit = self.ohlcv_candle_limit(timeframe)
if startup_candles + 5 > candle_limit:
# Require one more candle - to account for the still open candle.
candle_count = startup_candles + 1
# Allow 5 calls to the exchange per pair
required_candle_call_count = int(
(candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1))
if required_candle_call_count > 5:
# Only allow 5 calls per pair to somewhat limit the impact
raise OperationalException(
f"This strategy requires {startup_candles} candles to start. "
f"{self.name} only provides {candle_limit - 5} for {timeframe}.")
f"This strategy requires {startup_candles} candles to start, which is more than 5x "
f"the amount of candles {self.name} provides for {timeframe}.")
if required_candle_call_count > 1:
logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. "
f"This can result in slower operations for the bot. Please check "
f"if you really need {startup_candles} candles for your strategy")
return required_candle_call_count
def validate_trading_mode_and_collateral(
self,
@@ -1306,9 +1320,11 @@ class Exchange:
:param since_ms: Timestamp in milliseconds to get history from
:return: List with candle (OHLCV) data
"""
return asyncio.get_event_loop().run_until_complete(
pair, timeframe, data = asyncio.get_event_loop().run_until_complete(
self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe,
since_ms=since_ms, is_new_pair=is_new_pair))
logger.info(f"Downloaded data for {pair} with length {len(data)}.")
return data
def get_historic_ohlcv_as_df(self, pair: str, timeframe: str,
since_ms: int) -> DataFrame:
@@ -1324,8 +1340,9 @@ class Exchange:
drop_incomplete=self._ohlcv_partial_candle)
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, is_new_pair: bool
) -> List:
since_ms: int, is_new_pair: bool = False,
raise_: bool = False
) -> Tuple[str, str, List]:
"""
Download historic ohlcv
:param is_new_pair: used by binance subclass to allow "fast" new pair downloading
@@ -1349,15 +1366,17 @@ class Exchange:
for res in results:
if isinstance(res, Exception):
logger.warning("Async code raised an exception: %s", res.__class__.__name__)
if raise_:
raise
continue
# Deconstruct tuple if it's not an exception
p, _, new_data = res
if p == pair:
data.extend(new_data)
else:
# Deconstruct tuple if it's not an exception
p, _, new_data = res
if p == pair:
data.extend(new_data)
# Sort data again after extending the result - above calls return in "async order"
data = sorted(data, key=lambda x: x[0])
logger.info(f"Downloaded data for {pair} with length {len(data)}.")
return data
return pair, timeframe, data
def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *,
since_ms: Optional[int] = None, cache: bool = True
@@ -1377,10 +1396,22 @@ class Exchange:
cached_pairs = []
# Gather coroutines to run
for pair, timeframe in set(pair_list):
if (((pair, timeframe) not in self._klines)
if ((pair, timeframe) not in self._klines
or self._now_is_time_to_refresh(pair, timeframe)):
input_coroutines.append(self._async_get_candle_history(pair, timeframe,
since_ms=since_ms))
if not since_ms and self.required_candle_call_count > 1:
# Multiple calls for one pair - to get more history
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe)
move_to = one_call * self.required_candle_call_count
now = timeframe_to_next_date(timeframe)
since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000)
if since_ms:
input_coroutines.append(self._async_get_historic_ohlcv(
pair, timeframe, since_ms=since_ms, raise_=True))
else:
# One call ... "regular" refresh
input_coroutines.append(self._async_get_candle_history(
pair, timeframe, since_ms=since_ms))
else:
logger.debug(
"Using cached candle (OHLCV) data for pair %s, timeframe %s ...",

View File

@@ -1,4 +1,4 @@
""" Kucoin exchange subclass """
"""Kucoin exchange subclass."""
import logging
from typing import Dict
@@ -9,9 +9,9 @@ logger = logging.getLogger(__name__)
class Kucoin(Exchange):
"""
Kucoin exchange class. Contains adjustments needed for Freqtrade to work
with this exchange.
"""Kucoin exchange class.
Contains adjustments needed for Freqtrade to work with this exchange.
Please note that this exchange is not included in the list of exchanges
officially supported by the Freqtrade development team. So some features

View File

@@ -9,9 +9,9 @@ logger = logging.getLogger(__name__)
class Okex(Exchange):
"""
Okex exchange class. Contains adjustments needed for Freqtrade to work
with this exchange.
"""Okex exchange class.
Contains adjustments needed for Freqtrade to work with this exchange.
"""
_ft_has: Dict = {

View File

@@ -219,19 +219,20 @@ class FreqtradeBot(LoggingMixin):
def check_for_open_trades(self):
"""
Notify the user when the bot is stopped
Notify the user when the bot is stopped (not reloaded)
and there are still open trades active.
"""
open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all()
if len(open_trades) != 0:
if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG:
msg = {
'type': RPCMessageType.WARNING,
'status': f"{len(open_trades)} open trades active.\n\n"
f"Handle these trades manually on {self.exchange.name}, "
f"or '/start' the bot again and use '/stopbuy' "
f"to handle open trades gracefully. \n"
f"{'Trades are simulated.' if self.config['dry_run'] else ''}",
'status':
f"{len(open_trades)} open trades active.\n\n"
f"Handle these trades manually on {self.exchange.name}, "
f"or '/start' the bot again and use '/stopbuy' "
f"to handle open trades gracefully. \n"
f"{'Note: Trades are simulated (dry run).' if self.config['dry_run'] else ''}",
}
self.rpc.send_msg(msg)
@@ -622,7 +623,7 @@ class FreqtradeBot(LoggingMixin):
side='short' if is_short else 'long'
)
stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
if not stake_amount:
return False
@@ -1075,6 +1076,7 @@ class FreqtradeBot(LoggingMixin):
side = trade.enter_side if is_entering else trade.exit_side
timed_out = self._check_timed_out(side, order)
time_method = 'check_sell_timeout' if order['side'] == 'sell' else 'check_buy_timeout'
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
if not_closed and (fully_cancelled or timed_out or (
strategy_safe_wrapper(getattr(self.strategy, time_method), default_retval=False)(
@@ -1087,6 +1089,13 @@ class FreqtradeBot(LoggingMixin):
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
else:
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
canceled_count = trade.get_exit_order_count()
if max_timeouts > 0 and canceled_count >= max_timeouts:
logger.warning(f'Emergencyselling trade {trade}, as the sell order '
f'timed out {max_timeouts} times.')
self.execute_trade_exit(
trade, order.get('price'),
sell_reason=SellCheckTuple(sell_type=SellType.EMERGENCY_SELL))
def cancel_all_open_orders(self) -> None:
"""
@@ -1468,7 +1477,7 @@ class FreqtradeBot(LoggingMixin):
if self.exchange.check_order_canceled_empty(order):
# Trade has been cancelled on exchange
# Handling of this will happen in check_handle_timeout.
# Handling of this will happen in check_handle_timedout.
return True
# Try update amount (binance-fix)
@@ -1559,14 +1568,17 @@ class FreqtradeBot(LoggingMixin):
return self.apply_fee_conditional(trade, trade_base_currency,
amount=order_amount, fee_abs=fee_cost)
return order_amount
return self.fee_detection_from_trades(trade, order, order_amount)
return self.fee_detection_from_trades(trade, order, order_amount, order.get('trades', []))
def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float:
def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float,
trades: List) -> float:
"""
fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee.
fee-detection fallback to Trades.
Either uses provided trades list or the result of fetch_my_trades to get correct fee.
"""
trades = self.exchange.get_trades_for_order(self.exchange.get_order_id_conditional(order),
trade.pair, trade.open_date)
if not trades:
trades = self.exchange.get_trades_for_order(
self.exchange.get_order_id_conditional(order), trade.pair, trade.open_date)
if len(trades) == 0:
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)

View File

@@ -379,16 +379,6 @@ class Backtesting:
if sell.sell_flag:
trade.close_date = sell_candle_time
trade.sell_reason = sell.sell_reason
# Checks and adds an exit tag, after checking that the length of the
# sell_row has the length for an exit tag column
if(
len(sell_row) > EXIT_TAG_IDX
and sell_row[EXIT_TAG_IDX] is not None
and len(sell_row[EXIT_TAG_IDX]) > 0
):
trade.sell_reason = sell_row[EXIT_TAG_IDX]
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
@@ -403,6 +393,17 @@ class Backtesting:
current_time=sell_candle_time):
return None
trade.sell_reason = sell.sell_reason
# Checks and adds an exit tag, after checking that the length of the
# sell_row has the length for an exit tag column
if(
len(sell_row) > EXIT_TAG_IDX
and sell_row[EXIT_TAG_IDX] is not None
and len(sell_row[EXIT_TAG_IDX]) > 0
):
trade.sell_reason = sell_row[EXIT_TAG_IDX]
trade.close(closerate, show_msg=False)
return trade
@@ -451,7 +452,7 @@ class Backtesting:
pair=pair, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
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)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
if not stake_amount:
return None

View File

@@ -284,10 +284,10 @@ class HyperoptTools():
return (f"{results_metrics['total_trades']:6d} trades. "
f"{results_metrics['wins']}/{results_metrics['draws']}"
f"/{results_metrics['losses']} Wins/Draws/Losses. "
f"Avg profit {results_metrics['profit_mean'] * 100: 6.2f}%. "
f"Median profit {results_metrics['profit_median'] * 100: 6.2f}%. "
f"Total profit {results_metrics['profit_total_abs']: 11.8f} {stake_currency} "
f"({results_metrics['profit_total'] * 100: 7.2f}%). "
f"Avg profit {results_metrics['profit_mean']:7.2%}. "
f"Median profit {results_metrics['profit_median']:7.2%}. "
f"Total profit {results_metrics['profit_total_abs']:11.8f} {stake_currency} "
f"({results_metrics['profit_total']:8.2%}). "
f"Avg duration {results_metrics['holding_avg']} min."
)

View File

@@ -725,22 +725,22 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])),
('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
strat_results['stake_currency'])),
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2)}%"),
('Total profit %', f"{strat_results['profit_total']:.2%}"),
('Trades per day', strat_results['trades_per_day']),
('Avg. daily profit %',
f"{round(strat_results['profit_total'] / strat_results['backtest_days'] * 100, 2)}%"),
f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"),
('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
strat_results['stake_currency'])),
('Total trade volume', round_coin_value(strat_results['total_volume'],
strat_results['stake_currency'])),
('', ''), # Empty line to improve readability
('Best Pair', f"{strat_results['best_pair']['key']} "
f"{round(strat_results['best_pair']['profit_sum_pct'], 2)}%"),
f"{strat_results['best_pair']['profit_sum']:.2%}"),
('Worst Pair', f"{strat_results['worst_pair']['key']} "
f"{round(strat_results['worst_pair']['profit_sum_pct'], 2)}%"),
('Best trade', f"{best_trade['pair']} {round(best_trade['profit_ratio'] * 100, 2)}%"),
f"{strat_results['worst_pair']['profit_sum']:.2%}"),
('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"),
('Worst trade', f"{worst_trade['pair']} "
f"{round(worst_trade['profit_ratio'] * 100, 2)}%"),
f"{worst_trade['profit_ratio']:.2%}"),
('Best day', round_coin_value(strat_results['backtest_best_day_abs'],
strat_results['stake_currency'])),
@@ -758,7 +758,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Max balance', round_coin_value(strat_results['csum_max'],
strat_results['stake_currency'])),
('Drawdown', f"{round(strat_results['max_drawdown'] * 100, 2)}%"),
('Drawdown', f"{strat_results['max_drawdown']:.2%}"),
('Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
strat_results['stake_currency'])),
('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
@@ -767,7 +767,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])),
('Drawdown Start', strat_results['drawdown_start']),
('Drawdown End', strat_results['drawdown_end']),
('Market change', f"{round(strat_results['market_change'] * 100, 2)}%"),
('Market change', f"{strat_results['market_change']:.2%}"),
]
return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl")
@@ -864,5 +864,5 @@ def show_sorted_pairlist(config: Dict, backtest_stats: Dict):
print(f"Pairs for Strategy {strategy}: \n[")
for result in results['results_per_pair']:
if result["key"] != 'TOTAL':
print(f'"{result["key"]}", // {round(result["profit_mean_pct"], 2)}%')
print(f'"{result["key"]}", // {result["profit_mean"]:.2%}')
print("]")

View File

@@ -200,6 +200,8 @@ class Order(_DECL_BASE):
@staticmethod
def get_open_orders() -> List['Order']:
"""
Retrieve open orders from the database
:return: List of open orders
"""
return Order.query.filter(Order.ft_is_open.is_(True)).all()
@@ -620,6 +622,13 @@ class LocalTrade():
def update_order(self, order: Dict) -> None:
Order.update_orders(self.orders, order)
def get_exit_order_count(self) -> int:
"""
Get amount of failed exiting orders
assumes full exits.
"""
return len([o for o in self.orders if o.ft_order_side == 'sell'])
def _calc_open_trade_value(self) -> float:
"""
Calculate the open_rate including open_fee.
@@ -1002,7 +1011,7 @@ class Trade(_DECL_BASE, LocalTrade):
return Trade.query
@staticmethod
def get_open_order_trades():
def get_open_order_trades() -> List['Trade']:
"""
Returns all open trades
NOTE: Not supported in Backtesting.
@@ -1190,6 +1199,7 @@ class Trade(_DECL_BASE, LocalTrade):
if not any(item["mix_tag"] == mix_tag for item in return_list):
return_list.append({'mix_tag': mix_tag,
'profit': profit,
'profit_pct': round(profit * 100, 2),
'profit_abs': profit_abs,
'count': count})
else:
@@ -1198,11 +1208,11 @@ class Trade(_DECL_BASE, LocalTrade):
return_list[i] = {
'mix_tag': mix_tag,
'profit': profit + return_list[i]["profit"],
'profit_pct': round(profit + return_list[i]["profit"] * 100, 2),
'profit_abs': profit_abs + return_list[i]["profit_abs"],
'count': 1 + return_list[i]["count"]}
i += 1
[x.update({'profit': round(x['profit'] * 100, 2)}) for x in return_list]
return return_list
@staticmethod

View File

@@ -169,8 +169,8 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
df_comb.loc[timeframe_to_prev_date(timeframe, lowdate), 'cum_profit'],
],
mode='markers',
name=f"Max drawdown {max_drawdown * 100:.2f}%",
text=f"Max drawdown {max_drawdown * 100:.2f}%",
name=f"Max drawdown {max_drawdown:.2%}",
text=f"Max drawdown {max_drawdown:.2%}",
marker=dict(
symbol='square-open',
size=9,
@@ -192,7 +192,7 @@ 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"{round(row['profit_ratio'] * 100, 1)}%, "
trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, "
f"{row['sell_reason']}, "
f"{row['trade_duration']} min",
axis=1)

View File

@@ -50,7 +50,7 @@ class PriceFilter(IPairList):
"""
active_price_filters = []
if self._low_price_ratio != 0:
active_price_filters.append(f"below {self._low_price_ratio * 100}%")
active_price_filters.append(f"below {self._low_price_ratio:.1%}")
if self._min_price != 0:
active_price_filters.append(f"below {self._min_price:.8f}")
if self._max_price != 0:
@@ -82,7 +82,7 @@ class PriceFilter(IPairList):
changeperc = compare / ticker['last']
if changeperc > self._low_price_ratio:
self.log_once(f"Removed {pair} from whitelist, "
f"because 1 unit is {changeperc * 100:.3f}%", logger.info)
f"because 1 unit is {changeperc:.3%}", logger.info)
return False
# Perform low_amount check

View File

@@ -34,7 +34,7 @@ class SpreadFilter(IPairList):
Short whitelist method description - used for startup-messages
"""
return (f"{self.name} - Filtering pairs with ask/bid diff above "
f"{self._max_spread_ratio * 100}%.")
f"{self._max_spread_ratio:.2%}.")
def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool:
"""
@@ -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:.3f}% > {self._max_spread_ratio * 100}%",
f"{spread * 100:.3%} > {self._max_spread_ratio:.3%}",
logger.info)
return False
else:

View File

@@ -95,6 +95,7 @@ class Profit(BaseModel):
avg_duration: str
best_pair: str
best_rate: float
best_pair_profit_ratio: float
winning_trades: int
losing_trades: int
@@ -123,7 +124,26 @@ class Daily(BaseModel):
stake_currency: str
class UnfilledTimeout(BaseModel):
buy: int
sell: int
unit: str
exit_timeout_count: Optional[int]
class OrderTypes(BaseModel):
buy: str
sell: str
emergencysell: Optional[str]
forcesell: Optional[str]
forcebuy: Optional[str]
stoploss: str
stoploss_on_exchange: bool
stoploss_on_exchange_interval: Optional[int]
class ShowConfig(BaseModel):
version: str
dry_run: bool
stake_currency: str
stake_amount: Union[float, str]
@@ -136,6 +156,8 @@ class ShowConfig(BaseModel):
trailing_stop_positive: Optional[float]
trailing_stop_positive_offset: Optional[float]
trailing_only_offset_is_reached: Optional[bool]
unfilledtimeout: UnfilledTimeout
order_types: OrderTypes
use_custom_stoploss: Optional[bool]
timeframe: Optional[str]
timeframe_ms: int

View File

@@ -9,9 +9,11 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import arrow
import psutil
from dateutil.relativedelta import relativedelta
from numpy import NAN, inf, int64, mean
from pandas import DataFrame
from freqtrade import __version__
from freqtrade.configuration.timerange import TimeRange
from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT
from freqtrade.data.history import load_data
@@ -104,6 +106,7 @@ class RPC:
information via rpc.
"""
val = {
'version': __version__,
'dry_run': config['dry_run'],
'stake_currency': config['stake_currency'],
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
@@ -117,7 +120,9 @@ class RPC:
'trailing_stop_positive': config.get('trailing_stop_positive'),
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'),
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'),
'unfilledtimeout': config.get('unfilledtimeout'),
'use_custom_stoploss': config.get('use_custom_stoploss'),
'order_types': config.get('order_types'),
'bot_name': config.get('bot_name', 'freqtrade'),
'timeframe': config.get('timeframe'),
'timeframe_ms': timeframe_to_msecs(config['timeframe']
@@ -222,9 +227,8 @@ class RPC:
trade.pair, refresh=False, side=closing_side)
except (PricingError, ExchangeError):
current_rate = NAN
trade_percent = (100 * trade.calc_profit_ratio(current_rate))
trade_profit = trade.calc_profit(current_rate)
profit_str = f'{trade_percent:.2f}%'
profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}'
if self._fiat_converter:
fiat_profit = self._fiat_converter.convert_amount(
trade_profit,
@@ -253,7 +257,7 @@ class RPC:
def _rpc_daily_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
today = datetime.utcnow().date()
today = datetime.now(timezone.utc).date()
profit_days: Dict[date, Dict] = {}
if not (isinstance(timescale, int) and timescale > 0):
@@ -292,6 +296,91 @@ class RPC:
'data': data
}
def _rpc_weekly_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
today = datetime.now(timezone.utc).date()
first_iso_day_of_week = today - timedelta(days=today.weekday()) # Monday
profit_weeks: Dict[date, Dict] = {}
if not (isinstance(timescale, int) and timescale > 0):
raise RPCException('timescale must be an integer greater than 0')
for week in range(0, timescale):
profitweek = first_iso_day_of_week - timedelta(weeks=week)
trades = Trade.get_trades(trade_filter=[
Trade.is_open.is_(False),
Trade.close_date >= profitweek,
Trade.close_date < (profitweek + timedelta(weeks=1))
]).order_by(Trade.close_date).all()
curweekprofit = sum(
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
profit_weeks[profitweek] = {
'amount': curweekprofit,
'trades': len(trades)
}
data = [
{
'date': key,
'abs_profit': value["amount"],
'fiat_value': self._fiat_converter.convert_amount(
value['amount'],
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0,
'trade_count': value["trades"],
}
for key, value in profit_weeks.items()
]
return {
'stake_currency': stake_currency,
'fiat_display_currency': fiat_display_currency,
'data': data
}
def _rpc_monthly_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
first_day_of_month = datetime.now(timezone.utc).date().replace(day=1)
profit_months: Dict[date, Dict] = {}
if not (isinstance(timescale, int) and timescale > 0):
raise RPCException('timescale must be an integer greater than 0')
for month in range(0, timescale):
profitmonth = first_day_of_month - relativedelta(months=month)
trades = Trade.get_trades(trade_filter=[
Trade.is_open.is_(False),
Trade.close_date >= profitmonth,
Trade.close_date < (profitmonth + relativedelta(months=1))
]).order_by(Trade.close_date).all()
curmonthprofit = sum(
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
profit_months[profitmonth] = {
'amount': curmonthprofit,
'trades': len(trades)
}
data = [
{
'date': f"{key.year}-{key.month:02d}",
'abs_profit': value["amount"],
'fiat_value': self._fiat_converter.convert_amount(
value['amount'],
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0,
'trade_count': value["trades"],
}
for key, value in profit_months.items()
]
return {
'stake_currency': stake_currency,
'fiat_display_currency': fiat_display_currency,
'data': data
}
def _rpc_trade_history(self, limit: int, offset: int = 0, order_by_id: bool = False) -> Dict:
""" Returns the X last trades """
order_by = Trade.id if order_by_id else Trade.close_date.desc()
@@ -448,7 +537,8 @@ class RPC:
'latest_trade_timestamp': int(last_date.timestamp() * 1000) if last_date else 0,
'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0],
'best_pair': best_pair[0] if best_pair else '',
'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0,
'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0, # Deprecated
'best_pair_profit_ratio': best_pair[1] if best_pair else 0,
'winning_trades': winning_trades,
'losing_trades': losing_trades,
}
@@ -824,15 +914,15 @@ class RPC:
if has_content:
dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].view(int64) // 1000 // 1000
# Move open to separate column when signal for easy plotting
# Move signal close to separate column when signal for easy plotting
if 'buy' in dataframe.columns:
buy_mask = (dataframe['buy'] == 1)
buy_signals = int(buy_mask.sum())
dataframe.loc[buy_mask, '_buy_signal_open'] = dataframe.loc[buy_mask, 'open']
dataframe.loc[buy_mask, '_buy_signal_close'] = dataframe.loc[buy_mask, 'close']
if 'sell' in dataframe.columns:
sell_mask = (dataframe['sell'] == 1)
sell_signals = int(sell_mask.sum())
dataframe.loc[sell_mask, '_sell_signal_open'] = dataframe.loc[sell_mask, 'open']
dataframe.loc[sell_mask, '_sell_signal_close'] = dataframe.loc[sell_mask, 'close']
dataframe = dataframe.replace([inf, -inf], NAN)
dataframe = dataframe.replace({NAN: None})

View File

@@ -159,6 +159,8 @@ class Telegram(RPCHandler):
CommandHandler('mix_tags', self._mix_tag_performance),
CommandHandler('stats', self._stats),
CommandHandler('daily', self._daily),
CommandHandler('weekly', self._weekly),
CommandHandler('monthly', self._monthly),
CommandHandler('count', self._count),
CommandHandler('locks', self._locks),
CommandHandler(['unlock', 'delete_locks'], self._delete_locks),
@@ -175,6 +177,8 @@ class Telegram(RPCHandler):
callbacks = [
CallbackQueryHandler(self._status_table, pattern='update_status_table'),
CallbackQueryHandler(self._daily, pattern='update_daily'),
CallbackQueryHandler(self._weekly, pattern='update_weekly'),
CallbackQueryHandler(self._monthly, pattern='update_monthly'),
CallbackQueryHandler(self._profit, pattern='update_profit'),
CallbackQueryHandler(self._balance, pattern='update_balance'),
CallbackQueryHandler(self._performance, pattern='update_performance'),
@@ -215,26 +219,28 @@ class Telegram(RPCHandler):
msg['stake_amount'], msg['stake_currency'], msg['fiat_currency'])
else:
msg['stake_amount_fiat'] = 0
is_fill = msg['type'] == RPCMessageType.BUY_FILL
emoji = '\N{CHECK MARK}' if is_fill else '\N{LARGE BLUE CIRCLE}'
content = []
content.append(
f"\N{LARGE BLUE CIRCLE} *{msg['exchange']}:* Buying {msg['pair']}"
message = (
f"{emoji} *{msg['exchange']}:* {'Bought' if is_fill else 'Buying'} {msg['pair']}"
f" (#{msg['trade_id']})\n"
)
if msg.get('buy_tag', None):
content.append(f"*Buy Tag:* `{msg['buy_tag']}`\n")
content.append(f"*Amount:* `{msg['amount']:.8f}`\n")
content.append(f"*Open Rate:* `{msg['limit']:.8f}`\n")
content.append(f"*Current Rate:* `{msg['current_rate']:.8f}`\n")
content.append(
f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}"
)
if msg.get('fiat_currency', None):
content.append(
f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
)
message += f"*Buy Tag:* `{msg['buy_tag']}`\n" if msg.get('buy_tag', None) else ""
message += f"*Amount:* `{msg['amount']:.8f}`\n"
if msg['type'] == RPCMessageType.BUY_FILL:
message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n"
elif msg['type'] == RPCMessageType.BUY:
message += f"*Open Rate:* `{msg['limit']:.8f}`\n"\
f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}"
if msg.get('fiat_currency', None):
message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
message = ''.join(content)
message += ")`"
return message
@@ -254,54 +260,57 @@ class Telegram(RPCHandler):
and self._rpc._fiat_converter):
msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount(
msg['profit_amount'], msg['stake_currency'], msg['fiat_currency'])
msg['profit_extra'] = (' ({gain}: {profit_amount:.8f} {stake_currency}'
' / {profit_fiat:.3f} {fiat_currency})').format(**msg)
msg['profit_extra'] = (
f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}"
f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']})")
else:
msg['profit_extra'] = ''
is_fill = msg['type'] == RPCMessageType.SELL_FILL
message = (
f"{msg['emoji']} *{msg['exchange']}:* "
f"{'Sold' if is_fill else 'Selling'} {msg['pair']} (#{msg['trade_id']})\n"
f"*{'Profit' if is_fill else 'Unrealized Profit'}:* "
f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n"
f"*Buy Tag:* `{msg['buy_tag']}`\n"
f"*Sell Reason:* `{msg['sell_reason']}`\n"
f"*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`\n"
f"*Amount:* `{msg['amount']:.8f}`\n")
message = ("{emoji} *{exchange}:* Selling {pair} (#{trade_id})\n"
"*Profit:* `{profit_percent:.2f}%{profit_extra}`\n"
"*Buy Tag:* `{buy_tag}`\n"
"*Sell Reason:* `{sell_reason}`\n"
"*Duration:* `{duration} ({duration_min:.1f} min)`\n"
"*Amount:* `{amount:.8f}`\n"
"*Open Rate:* `{open_rate:.8f}`\n"
"*Current Rate:* `{current_rate:.8f}`\n"
"*Close Rate:* `{limit:.8f}`").format(**msg)
if msg['type'] == RPCMessageType.SELL:
message += (f"*Open Rate:* `{msg['open_rate']:.8f}`\n"
f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
f"*Close Rate:* `{msg['limit']:.8f}`")
elif msg['type'] == RPCMessageType.SELL_FILL:
message += f"*Close Rate:* `{msg['close_rate']:.8f}`"
return message
def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str:
if msg_type == RPCMessageType.BUY:
if msg_type in [RPCMessageType.BUY, RPCMessageType.BUY_FILL]:
message = self._format_buy_msg(msg)
elif msg_type in [RPCMessageType.SELL, RPCMessageType.SELL_FILL]:
message = self._format_sell_msg(msg)
elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell'
message = ("\N{WARNING SIGN} *{exchange}:* "
"Cancelling open {message_side} Order for {pair} (#{trade_id}). "
"Reason: {reason}.".format(**msg))
elif msg_type == RPCMessageType.BUY_FILL:
message = ("\N{LARGE CIRCLE} *{exchange}:* "
"Buy order for {pair} (#{trade_id}) filled "
"for {open_rate}.".format(**msg))
elif msg_type == RPCMessageType.SELL_FILL:
message = ("\N{LARGE CIRCLE} *{exchange}:* "
"Sell order for {pair} (#{trade_id}) filled "
"for {close_rate}.".format(**msg))
elif msg_type == RPCMessageType.SELL:
message = self._format_sell_msg(msg)
elif msg_type == RPCMessageType.PROTECTION_TRIGGER:
message = (
"*Protection* triggered due to {reason}. "
"`{pair}` will be locked until `{lock_end_time}`."
).format(**msg)
elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
message = (
"*Protection* triggered due to {reason}. "
"*All pairs* will be locked until `{lock_end_time}`."
).format(**msg)
elif msg_type == RPCMessageType.STATUS:
message = '*Status:* `{status}`'.format(**msg)
@@ -353,7 +362,7 @@ class Telegram(RPCHandler):
elif float(msg['profit_percent']) >= 0.0:
return "\N{EIGHT SPOKED ASTERISK}"
elif msg['sell_reason'] == "stop_loss":
return"\N{WARNING SIGN}"
return "\N{WARNING SIGN}"
else:
return "\N{CROSS MARK}"
@@ -393,19 +402,19 @@ class Telegram(RPCHandler):
"*Close Rate:* `{close_rate}`" if r['close_rate'] else "",
"*Current Rate:* `{current_rate:.8f}`",
("*Current Profit:* " if r['is_open'] else "*Close Profit: *")
+ "`{profit_pct:.2f}%`",
+ "`{profit_ratio:.2%}`",
]
if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
and r['initial_stop_loss_pct'] is not None):
and r['initial_stop_loss_ratio'] is not None):
# Adding initial stoploss only if it is different from stoploss
lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
"`({initial_stop_loss_pct:.2f}%)`")
"`({initial_stop_loss_ratio:.2%})`")
# Adding stoploss and stoploss percentage only if it is not None
lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " +
("`({stop_loss_pct:.2f}%)`" if r['stop_loss_pct'] else ""))
("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` "
"`({stoploss_current_dist_pct:.2f}%)`")
"`({stoploss_current_dist_ratio:.2%})`")
if r['open_order']:
if r['sell_order_status']:
lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`")
@@ -501,6 +510,86 @@ class Telegram(RPCHandler):
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _weekly(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /weekly <n>
Returns a weekly profit (in BTC) over the last n weeks.
:param bot: telegram bot
:param update: message update
:return: None
"""
stake_cur = self._config['stake_currency']
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else 8
except (TypeError, ValueError, IndexError):
timescale = 8
try:
stats = self._rpc._rpc_weekly_profit(
timescale,
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(
[[week['date'],
f"{round_coin_value(week['abs_profit'], stats['stake_currency'])}",
f"{week['fiat_value']:.3f} {stats['fiat_display_currency']}",
f"{week['trade_count']} trades"] for week in stats['data']],
headers=[
'Monday',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Weekly Profit over the last {timescale} weeks ' \
f'(starting from Monday)</b>:\n<pre>{stats_tab}</pre> '
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_weekly", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _monthly(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /monthly <n>
Returns a monthly profit (in BTC) over the last n months.
:param bot: telegram bot
:param update: message update
:return: None
"""
stake_cur = self._config['stake_currency']
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else 6
except (TypeError, ValueError, IndexError):
timescale = 6
try:
stats = self._rpc._rpc_monthly_profit(
timescale,
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(
[[month['date'],
f"{round_coin_value(month['abs_profit'], stats['stake_currency'])}",
f"{month['fiat_value']:.3f} {stats['fiat_display_currency']}",
f"{month['trade_count']} trades"] for month in stats['data']],
headers=[
'Month',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Monthly Profit over the last {timescale} months' \
f'</b>:\n<pre>{stats_tab}</pre> '
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_monthly", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _profit(self, update: Update, context: CallbackContext) -> None:
"""
@@ -528,11 +617,11 @@ class Telegram(RPCHandler):
fiat_disp_cur,
start_date)
profit_closed_coin = stats['profit_closed_coin']
profit_closed_percent_mean = stats['profit_closed_percent_mean']
profit_closed_ratio_mean = stats['profit_closed_ratio_mean']
profit_closed_percent = stats['profit_closed_percent']
profit_closed_fiat = stats['profit_closed_fiat']
profit_all_coin = stats['profit_all_coin']
profit_all_percent_mean = stats['profit_all_percent_mean']
profit_all_ratio_mean = stats['profit_all_ratio_mean']
profit_all_percent = stats['profit_all_percent']
profit_all_fiat = stats['profit_all_fiat']
trade_count = stats['trade_count']
@@ -540,7 +629,7 @@ class Telegram(RPCHandler):
latest_trade_date = stats['latest_trade_date']
avg_duration = stats['avg_duration']
best_pair = stats['best_pair']
best_rate = stats['best_rate']
best_pair_profit_ratio = stats['best_pair_profit_ratio']
if stats['trade_count'] == 0:
markdown_msg = 'No trades yet.'
else:
@@ -548,7 +637,7 @@ class Telegram(RPCHandler):
if stats['closed_trade_count'] > 0:
markdown_msg = ("*ROI:* Closed trades\n"
f"∙ `{round_coin_value(profit_closed_coin, stake_cur)} "
f"({profit_closed_percent_mean:.2f}%) "
f"({profit_closed_ratio_mean:.2%}) "
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"∙ `{round_coin_value(profit_closed_fiat, fiat_disp_cur)}`\n")
else:
@@ -557,7 +646,7 @@ class Telegram(RPCHandler):
markdown_msg += (
f"*ROI:* All trades\n"
f"∙ `{round_coin_value(profit_all_coin, stake_cur)} "
f"({profit_all_percent_mean:.2f}%) "
f"({profit_all_ratio_mean:.2%}) "
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n"
f"*Total Trade Count:* `{trade_count}`\n"
@@ -568,7 +657,7 @@ class Telegram(RPCHandler):
)
if stats['closed_trade_count'] > 0:
markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`")
self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
query=update.callback_query)
@@ -597,10 +686,16 @@ class Telegram(RPCHandler):
count['losses']
] for reason, count in stats['sell_reasons'].items()
]
sell_reasons_msg = tabulate(
sell_reasons_tabulate,
headers=['Sell Reason', 'Sells', 'Wins', 'Losses']
)
sell_reasons_msg = 'No trades yet.'
for reason in chunks(sell_reasons_tabulate, 25):
sell_reasons_msg = tabulate(
reason,
headers=['Sell Reason', 'Sells', 'Wins', 'Losses']
)
if len(sell_reasons_tabulate) > 25:
self._send_msg(sell_reasons_msg, ParseMode.MARKDOWN)
sell_reasons_msg = ''
durations = stats['durations']
duration_msg = tabulate(
[
@@ -671,10 +766,10 @@ class Telegram(RPCHandler):
output += ("\n*Estimated Value*:\n"
f"\t`{result['stake']}: "
f"{round_coin_value(result['total'], result['stake'], False)}`"
f" `({result['starting_capital_pct']}%)`\n"
f" `({result['starting_capital_ratio']:.2%})`\n"
f"\t`{result['symbol']}: "
f"{round_coin_value(result['value'], result['symbol'], False)}`"
f" `({result['starting_capital_fiat_pct']}%)`\n")
f" `({result['starting_capital_fiat_ratio']:.2%})`\n")
self._send_msg(output, reload_able=True, callback_path="update_balance",
query=update.callback_query)
except RPCException as e:
@@ -809,7 +904,7 @@ class Telegram(RPCHandler):
trades_tab = tabulate(
[[arrow.get(trade['close_date']).humanize(),
trade['pair'] + " (#" + str(trade['trade_id']) + ")",
f"{(100 * trade['close_profit']):.2f}% ({trade['close_profit_abs']})"]
f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"]
for trade in trades['trades']],
headers=[
'Close Date',
@@ -861,7 +956,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i+1}.\t <code>{trade['pair']}\t"
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_pct']:.2f}%) "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
@@ -896,7 +991,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i+1}.\t <code>{trade['buy_tag']}\t"
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_pct']:.2f}%) "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
@@ -931,7 +1026,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i+1}.\t <code>{trade['sell_reason']}\t"
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_pct']:.2f}%) "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
@@ -966,7 +1061,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i+1}.\t <code>{trade['mix_tag']}\t"
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit']:.2f}%) "
f"({trade['profit']:.2%}) "
f"({trade['count']})</code>\n")
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
@@ -1149,44 +1244,56 @@ class Telegram(RPCHandler):
forcebuy_text = ("*/forcebuy <pair> [<rate>]:* `Instantly buys the given pair. "
"Optionally takes a rate at which to buy "
"(only applies to limit orders).` \n")
message = ("*/start:* `Starts the trader`\n"
"*/stop:* `Stops the trader`\n"
"*/status <trade_id>|[table]:* `Lists all open trades`\n"
" *<trade_id> :* `Lists one or more specific trades.`\n"
" `Separate multiple <trade_id> with a blank space.`\n"
" *table :* `will display trades in a table`\n"
" `pending buy orders are marked with an asterisk (*)`\n"
" `pending sell orders are marked with a double asterisk (**)`\n"
"*/buys <pair|none>:* `Shows the buy_tag performance`\n"
"*/sells <pair|none>:* `Shows the sell reason performance`\n"
"*/mix_tags <pair|none>:* `Shows combined buy tag + sell reason performance`\n"
"*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
"over the last n days`\n"
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, "
"regardless of profit`\n"
f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/performance:* `Show performance of each finished trade grouped by pair`\n"
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/stats:* `Shows Wins / losses by Sell reason as well as "
"Avg. holding durationsfor buys and sells.`\n"
"*/count:* `Show number of active trades compared to allowed number of trades`\n"
"*/locks:* `Show currently locked pairs`\n"
"*/unlock <pair|id>:* `Unlock this Pair (or this lock id if it's numeric)`\n"
"*/balance:* `Show account balance per currency`\n"
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
"*/reload_config:* `Reload configuration file` \n"
"*/show_config:* `Show running configuration` \n"
"*/logs [limit]:* `Show latest logs - defaults to 10` \n"
"*/whitelist:* `Show current whitelist` \n"
"*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs "
"to the blacklist.` \n"
"*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
"*/help:* `This help message`\n"
"*/version:* `Show version`")
message = (
"_BotControl_\n"
"------------\n"
"*/start:* `Starts the trader`\n"
"*/stop:* Stops the trader\n"
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, "
"regardless of profit`\n"
f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/whitelist:* `Show current whitelist` \n"
"*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs "
"to the blacklist.` \n"
"*/reload_config:* `Reload configuration file` \n"
"*/unlock <pair|id>:* `Unlock this Pair (or this lock id if it's numeric)`\n"
self._send_msg(message)
"_Current state_\n"
"------------\n"
"*/show_config:* `Show running configuration` \n"
"*/locks:* `Show currently locked pairs`\n"
"*/balance:* `Show account balance per currency`\n"
"*/logs [limit]:* `Show latest logs - defaults to 10` \n"
"*/count:* `Show number of active trades compared to allowed number of trades`\n"
"*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
"_Statistics_\n"
"------------\n"
"*/status <trade_id>|[table]:* `Lists all open trades`\n"
" *<trade_id> :* `Lists one or more specific trades.`\n"
" `Separate multiple <trade_id> with a blank space.`\n"
" *table :* `will display trades in a table`\n"
" `pending buy orders are marked with an asterisk (*)`\n"
" `pending sell orders are marked with a double asterisk (**)`\n"
"*/buys <pair|none>:* `Shows the buy_tag performance`\n"
"*/sells <pair|none>:* `Shows the sell reason performance`\n"
"*/mix_tags <pair|none>:* `Shows combined buy tag + sell reason performance`\n"
"*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
"over the last n days`\n"
"*/performance:* `Show performance of each finished trade grouped by pair`\n"
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/weekly <n>:* `Shows statistics per week, over the last n weeks`\n"
"*/monthly <n>:* `Shows statistics per month, over the last n months`\n"
"*/stats:* `Shows Wins / losses by Sell reason as well as "
"Avg. holding durationsfor buys and sells.`\n"
"*/help:* `This help message`\n"
"*/version:* `Show version`"
)
self._send_msg(message, parse_mode=ParseMode.MARKDOWN)
@authorized_only
def _version(self, update: Update, context: CallbackContext) -> None:

View File

@@ -292,7 +292,7 @@ class BooleanParameter(CategoricalParameter):
load=load, **kwargs)
class HyperStrategyMixin(object):
class HyperStrategyMixin:
"""
A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell
strategy logic.

View File

@@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
CUSTOM_SELL_MAX_LENGTH = 64
class SellCheckTuple(object):
class SellCheckTuple:
"""
NamedTuple for Sell type + reason
"""
@@ -868,7 +868,7 @@ class IStrategy(ABC, HyperStrategyMixin):
if self.trailing_stop_positive is not None and bound_profit > sl_offset:
stop_loss_value = self.trailing_stop_positive
logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} "
f"offset: {sl_offset:.4g} profit: {current_profit:.4f}%")
f"offset: {sl_offset:.4g} profit: {current_profit:.2%}")
trade.adjust_stop_loss(bound or current_rate, stop_loss_value)

View File

@@ -1,4 +1,5 @@
import logging
from copy import deepcopy
from freqtrade.exceptions import StrategyError
@@ -14,6 +15,9 @@ def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_err
"""
def wrapper(*args, **kwargs):
try:
if 'trade' in kwargs:
# Protect accidental modifications from within the strategy
kwargs['trade'] = deepcopy(kwargs['trade'])
return f(*args, **kwargs)
except ValueError as error:
logger.warning(

View File

@@ -73,7 +73,7 @@ class Wallets:
tot_profit = Trade.get_total_closed_profit()
else:
tot_profit = LocalTrade.total_profit
tot_in_trades = sum([trade.stake_amount for trade in open_trades])
tot_in_trades = sum(trade.stake_amount for trade in open_trades)
current_stake = self.start_cap + tot_profit - tot_in_trades
_wallets[self._config['stake_currency']] = Wallet(
@@ -238,7 +238,7 @@ class Wallets:
return self._check_available_stake_amount(stake_amount, available_amount)
def _validate_stake_amount(self, pair, stake_amount, min_stake_amount):
def validate_stake_amount(self, pair, stake_amount, min_stake_amount):
if not stake_amount:
logger.debug(f"Stake amount is {stake_amount}, ignoring possible trade for {pair}.")
return 0
@@ -250,17 +250,27 @@ class Wallets:
logger.warning("Minimum stake amount > available balance.")
return 0
if min_stake_amount is not None and stake_amount < min_stake_amount:
stake_amount = min_stake_amount
if self._log:
logger.info(
f"Stake amount for pair {pair} is too small "
f"({stake_amount} < {min_stake_amount}), adjusting to {min_stake_amount}."
)
if stake_amount * 1.3 < min_stake_amount:
# Top-cap stake-amount adjustments to +30%.
if self._log:
logger.info(
f"Adjusted stake amount for pair {pair} is more than 30% bigger than "
f"the desired stake ({stake_amount} * 1.3 > {max_stake_amount}), "
f"ignoring trade."
)
return 0
stake_amount = min_stake_amount
if stake_amount > max_stake_amount:
stake_amount = max_stake_amount
if self._log:
logger.info(
f"Stake amount for pair {pair} is too big "
f"({stake_amount} > {max_stake_amount}), adjusting to {max_stake_amount}."
)
stake_amount = max_stake_amount
return stake_amount