Merge pull request #3239 from freqtrade/feat/fee_handling
Improve fee handling
This commit is contained in:
@@ -22,7 +22,7 @@ from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
|
||||
from freqtrade.exceptions import (DependencyException, InvalidOrderException,
|
||||
OperationalException, TemporaryError)
|
||||
from freqtrade.exchange.common import BAD_EXCHANGES, retrier, retrier_async
|
||||
from freqtrade.misc import deep_merge_dicts
|
||||
from freqtrade.misc import deep_merge_dicts, safe_value_fallback
|
||||
|
||||
CcxtModuleType = Any
|
||||
|
||||
@@ -471,26 +471,31 @@ class Exchange:
|
||||
'pair': pair,
|
||||
'price': rate,
|
||||
'amount': _amount,
|
||||
"cost": _amount * rate,
|
||||
'cost': _amount * rate,
|
||||
'type': ordertype,
|
||||
'side': side,
|
||||
'remaining': _amount,
|
||||
'datetime': arrow.utcnow().isoformat(),
|
||||
'status': "closed" if ordertype == "market" else "open",
|
||||
'fee': None,
|
||||
"info": {}
|
||||
'info': {}
|
||||
}
|
||||
self._store_dry_order(dry_order)
|
||||
self._store_dry_order(dry_order, pair)
|
||||
# Copy order and close it - so the returned order is open unless it's a market order
|
||||
return dry_order
|
||||
|
||||
def _store_dry_order(self, dry_order: Dict) -> None:
|
||||
def _store_dry_order(self, dry_order: Dict, pair: str) -> None:
|
||||
closed_order = dry_order.copy()
|
||||
if closed_order["type"] in ["market", "limit"]:
|
||||
if closed_order['type'] in ["market", "limit"]:
|
||||
closed_order.update({
|
||||
"status": "closed",
|
||||
"filled": closed_order["amount"],
|
||||
"remaining": 0
|
||||
'status': 'closed',
|
||||
'filled': closed_order['amount'],
|
||||
'remaining': 0,
|
||||
'fee': {
|
||||
'currency': self.get_pair_quote_currency(pair),
|
||||
'cost': dry_order['cost'] * self.get_fee(pair),
|
||||
'rate': self.get_fee(pair)
|
||||
}
|
||||
})
|
||||
if closed_order["type"] in ["stop_loss_limit"]:
|
||||
closed_order["info"].update({"stopPrice": closed_order["price"]})
|
||||
@@ -1066,6 +1071,61 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@staticmethod
|
||||
def order_has_fee(order: Dict) -> bool:
|
||||
"""
|
||||
Verifies if the passed in order dict has the needed keys to extract fees,
|
||||
and that these keys (currency, cost) are not empty.
|
||||
:param order: Order or trade (one trade) dict
|
||||
:return: True if the fee substructure contains currency and cost, false otherwise
|
||||
"""
|
||||
if not isinstance(order, dict):
|
||||
return False
|
||||
return ('fee' in order and order['fee'] is not None
|
||||
and (order['fee'].keys() >= {'currency', 'cost'})
|
||||
and order['fee']['currency'] is not None
|
||||
and order['fee']['cost'] is not None
|
||||
)
|
||||
|
||||
def calculate_fee_rate(self, order: Dict) -> Optional[float]:
|
||||
"""
|
||||
Calculate fee rate if it's not given by the exchange.
|
||||
:param order: Order or trade (one trade) dict
|
||||
"""
|
||||
if order['fee'].get('rate') is not None:
|
||||
return order['fee'].get('rate')
|
||||
fee_curr = order['fee']['currency']
|
||||
# Calculate fee based on order details
|
||||
if fee_curr in self.get_pair_base_currency(order['symbol']):
|
||||
# Base currency - divide by amount
|
||||
return round(
|
||||
order['fee']['cost'] / safe_value_fallback(order, order, 'filled', 'amount'), 8)
|
||||
elif fee_curr in self.get_pair_quote_currency(order['symbol']):
|
||||
# Quote currency - divide by cost
|
||||
return round(order['fee']['cost'] / order['cost'], 8)
|
||||
else:
|
||||
# If Fee currency is a different currency
|
||||
try:
|
||||
comb = self.get_valid_pair_combination(fee_curr, self._config['stake_currency'])
|
||||
tick = self.fetch_ticker(comb)
|
||||
|
||||
fee_to_quote_rate = safe_value_fallback(tick, tick, 'last', 'ask')
|
||||
return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8)
|
||||
except DependencyException:
|
||||
return None
|
||||
|
||||
def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]:
|
||||
"""
|
||||
Extract tuple of cost, currency, rate.
|
||||
Requires order_has_fee to run first!
|
||||
:param order: Order or trade (one trade) dict
|
||||
:return: Tuple with cost, currency, rate of the given fee dict
|
||||
"""
|
||||
return (order['fee']['cost'],
|
||||
order['fee']['currency'],
|
||||
self.calculate_fee_rate(order))
|
||||
# calculate rate ? (order['fee']['cost'] / (order['amount'] * order['price']))
|
||||
|
||||
|
||||
def is_exchange_bad(exchange_name: str) -> bool:
|
||||
return exchange_name in BAD_EXCHANGES
|
||||
|
@@ -613,7 +613,7 @@ class FreqtradeBot:
|
||||
trades_closed += 1
|
||||
continue
|
||||
# Check if we can sell our current pair
|
||||
if trade.open_order_id is None and self.handle_trade(trade):
|
||||
if trade.open_order_id is None and trade.is_open and self.handle_trade(trade):
|
||||
trades_closed += 1
|
||||
|
||||
except DependencyException as exception:
|
||||
@@ -755,7 +755,7 @@ class FreqtradeBot:
|
||||
# We check if stoploss order is fulfilled
|
||||
if stoploss_order and stoploss_order['status'] == 'closed':
|
||||
trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value
|
||||
trade.update(stoploss_order)
|
||||
self.update_trade_state(trade, stoploss_order, sl_order=True)
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
self.strategy.lock_pair(trade.pair,
|
||||
timeframe_to_next_date(self.config['ticker_interval']))
|
||||
@@ -1042,7 +1042,7 @@ class FreqtradeBot:
|
||||
trade.sell_reason = sell_reason.value
|
||||
# In case of market sell orders the order can be closed immediately
|
||||
if order.get('status', 'unknown') == 'closed':
|
||||
trade.update(order)
|
||||
self.update_trade_state(trade, order)
|
||||
Trade.session.flush()
|
||||
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
@@ -1133,7 +1133,7 @@ class FreqtradeBot:
|
||||
#
|
||||
|
||||
def update_trade_state(self, trade: Trade, action_order: dict = None,
|
||||
order_amount: float = None) -> bool:
|
||||
order_amount: float = None, sl_order: bool = False) -> bool:
|
||||
"""
|
||||
Checks trades with open orders and updates the amount if necessary
|
||||
Handles closing both buy and sell orders.
|
||||
@@ -1141,84 +1141,125 @@ class FreqtradeBot:
|
||||
"""
|
||||
# Get order details for actual price per unit
|
||||
if trade.open_order_id:
|
||||
# Update trade with order values
|
||||
logger.info('Found open order for %s', trade)
|
||||
try:
|
||||
order = action_order or self.exchange.get_order(trade.open_order_id, trade.pair)
|
||||
except InvalidOrderException as exception:
|
||||
logger.warning('Unable to fetch order %s: %s', trade.open_order_id, exception)
|
||||
return False
|
||||
# Try update amount (binance-fix)
|
||||
try:
|
||||
new_amount = self.get_real_amount(trade, order, order_amount)
|
||||
if not isclose(order['amount'], new_amount, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
order['amount'] = new_amount
|
||||
order.pop('filled', None)
|
||||
# Fee was applied, so set to 0
|
||||
trade.fee_open = 0
|
||||
trade.recalc_open_trade_price()
|
||||
except DependencyException as exception:
|
||||
logger.warning("Could not update trade amount: %s", exception)
|
||||
order_id = trade.open_order_id
|
||||
elif trade.stoploss_order_id and sl_order:
|
||||
order_id = trade.stoploss_order_id
|
||||
else:
|
||||
return False
|
||||
# Update trade with order values
|
||||
logger.info('Found open order for %s', trade)
|
||||
try:
|
||||
order = action_order or self.exchange.get_order(order_id, trade.pair)
|
||||
except InvalidOrderException as exception:
|
||||
logger.warning('Unable to fetch order %s: %s', order_id, exception)
|
||||
return False
|
||||
# Try update amount (binance-fix)
|
||||
try:
|
||||
new_amount = self.get_real_amount(trade, order, order_amount)
|
||||
if not isclose(order['amount'], new_amount, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
order['amount'] = new_amount
|
||||
order.pop('filled', None)
|
||||
trade.recalc_open_trade_price()
|
||||
except DependencyException as exception:
|
||||
logger.warning("Could not update trade amount: %s", exception)
|
||||
|
||||
if self.exchange.check_order_canceled_empty(order):
|
||||
# Trade has been cancelled on exchange
|
||||
# Handling of this will happen in check_handle_timeout.
|
||||
return True
|
||||
trade.update(order)
|
||||
|
||||
# Updating wallets when order is closed
|
||||
if not trade.is_open:
|
||||
self.wallets.update()
|
||||
if self.exchange.check_order_canceled_empty(order):
|
||||
# Trade has been cancelled on exchange
|
||||
# Handling of this will happen in check_handle_timeout.
|
||||
return True
|
||||
trade.update(order)
|
||||
|
||||
# Updating wallets when order is closed
|
||||
if not trade.is_open:
|
||||
self.wallets.update()
|
||||
return False
|
||||
|
||||
def apply_fee_conditional(self, trade: Trade, trade_base_currency: str,
|
||||
amount: float, fee_abs: float) -> float:
|
||||
"""
|
||||
Applies the fee to amount (either from Order or from Trades).
|
||||
Can eat into dust if more than the required asset is available.
|
||||
"""
|
||||
self.wallets.update()
|
||||
if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount:
|
||||
# Eat into dust if we own more than base currency
|
||||
logger.info(f"Fee amount for {trade} was in base currency - "
|
||||
f"Eating Fee {fee_abs} into dust.")
|
||||
elif fee_abs != 0:
|
||||
real_amount = self.exchange.amount_to_precision(trade.pair, amount - fee_abs)
|
||||
logger.info(f"Applying fee on amount for {trade} "
|
||||
f"(from {amount} to {real_amount}).")
|
||||
return real_amount
|
||||
return amount
|
||||
|
||||
def get_real_amount(self, trade: Trade, order: Dict, order_amount: float = None) -> float:
|
||||
"""
|
||||
Get real amount for the trade
|
||||
Detect and update trade fee.
|
||||
Calls trade.update_fee() uppon correct detection.
|
||||
Returns modified amount if the fee was taken from the destination currency.
|
||||
Necessary for exchanges which charge fees in base currency (e.g. binance)
|
||||
:return: identical (or new) amount for the trade
|
||||
"""
|
||||
# Init variables
|
||||
if order_amount is None:
|
||||
order_amount = order['amount']
|
||||
# Only run for closed orders
|
||||
if trade.fee_open == 0 or order['status'] == 'open':
|
||||
if trade.fee_updated(order.get('side', '')) or order['status'] == 'open':
|
||||
return order_amount
|
||||
|
||||
trade_base_currency = self.exchange.get_pair_base_currency(trade.pair)
|
||||
# use fee from order-dict if possible
|
||||
if ('fee' in order and order['fee'] is not None and
|
||||
(order['fee'].keys() >= {'currency', 'cost'})):
|
||||
if (order['fee']['currency'] is not None and
|
||||
order['fee']['cost'] is not None and
|
||||
trade_base_currency == order['fee']['currency']):
|
||||
new_amount = order_amount - order['fee']['cost']
|
||||
logger.info("Applying fee on amount for %s (from %s to %s) from Order",
|
||||
trade, order['amount'], new_amount)
|
||||
return new_amount
|
||||
if self.exchange.order_has_fee(order):
|
||||
fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order)
|
||||
logger.info(f"Fee for Trade {trade} [{order.get('side')}]: "
|
||||
f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}")
|
||||
|
||||
# Fallback to Trades
|
||||
trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', ''))
|
||||
if trade_base_currency == fee_currency:
|
||||
# Apply fee to amount
|
||||
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)
|
||||
|
||||
def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float:
|
||||
"""
|
||||
fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee.
|
||||
"""
|
||||
trades = self.exchange.get_trades_for_order(trade.open_order_id, trade.pair,
|
||||
trade.open_date)
|
||||
|
||||
if len(trades) == 0:
|
||||
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)
|
||||
return order_amount
|
||||
fee_currency = None
|
||||
amount = 0
|
||||
fee_abs = 0
|
||||
fee_abs = 0.0
|
||||
fee_cost = 0.0
|
||||
trade_base_currency = self.exchange.get_pair_base_currency(trade.pair)
|
||||
fee_rate_array: List[float] = []
|
||||
for exectrade in trades:
|
||||
amount += exectrade['amount']
|
||||
if ("fee" in exectrade and exectrade['fee'] is not None and
|
||||
(exectrade['fee'].keys() >= {'currency', 'cost'})):
|
||||
if self.exchange.order_has_fee(exectrade):
|
||||
fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade)
|
||||
fee_cost += fee_cost_
|
||||
if fee_rate_ is not None:
|
||||
fee_rate_array.append(fee_rate_)
|
||||
# only applies if fee is in quote currency!
|
||||
if (exectrade['fee']['currency'] is not None and
|
||||
exectrade['fee']['cost'] is not None and
|
||||
trade_base_currency == exectrade['fee']['currency']):
|
||||
fee_abs += exectrade['fee']['cost']
|
||||
if trade_base_currency == fee_currency:
|
||||
fee_abs += fee_cost_
|
||||
# Ensure at least one trade was found:
|
||||
if fee_currency:
|
||||
# fee_rate should use mean
|
||||
fee_rate = sum(fee_rate_array) / float(len(fee_rate_array)) if fee_rate_array else None
|
||||
trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', ''))
|
||||
|
||||
if not isclose(amount, order_amount, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
logger.warning(f"Amount {amount} does not match amount {trade.amount}")
|
||||
raise DependencyException("Half bought? Amounts don't match")
|
||||
real_amount = amount - fee_abs
|
||||
|
||||
if fee_abs != 0:
|
||||
logger.info(f"Applying fee on amount for {trade} "
|
||||
f"(from {order_amount} to {real_amount}) from Trades")
|
||||
return real_amount
|
||||
return self.apply_fee_conditional(trade, trade_base_currency,
|
||||
amount=amount, fee_abs=fee_abs)
|
||||
else:
|
||||
return amount
|
||||
|
@@ -86,11 +86,15 @@ def check_migrate(engine) -> None:
|
||||
logger.debug(f'trying {table_back_name}')
|
||||
|
||||
# Check for latest column
|
||||
if not has_column(cols, 'close_profit_abs'):
|
||||
if not has_column(cols, 'fee_close_cost'):
|
||||
logger.info(f'Running database migration - backup available as {table_back_name}')
|
||||
|
||||
fee_open = get_column_def(cols, 'fee_open', 'fee')
|
||||
fee_open_cost = get_column_def(cols, 'fee_open_cost', 'null')
|
||||
fee_open_currency = get_column_def(cols, 'fee_open_currency', 'null')
|
||||
fee_close = get_column_def(cols, 'fee_close', 'fee')
|
||||
fee_close_cost = get_column_def(cols, 'fee_close_cost', 'null')
|
||||
fee_close_currency = get_column_def(cols, 'fee_close_currency', 'null')
|
||||
open_rate_requested = get_column_def(cols, 'open_rate_requested', 'null')
|
||||
close_rate_requested = get_column_def(cols, 'close_rate_requested', 'null')
|
||||
stop_loss = get_column_def(cols, 'stop_loss', '0.0')
|
||||
@@ -120,7 +124,9 @@ def check_migrate(engine) -> None:
|
||||
|
||||
# Copy data back - following the correct schema
|
||||
engine.execute(f"""insert into trades
|
||||
(id, exchange, pair, is_open, fee_open, fee_close, open_rate,
|
||||
(id, exchange, pair, is_open,
|
||||
fee_open, fee_open_cost, fee_open_currency,
|
||||
fee_close, fee_close_cost, fee_open_currency, open_rate,
|
||||
open_rate_requested, close_rate, close_rate_requested, close_profit,
|
||||
stake_amount, amount, open_date, close_date, open_order_id,
|
||||
stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct,
|
||||
@@ -136,7 +142,9 @@ def check_migrate(engine) -> None:
|
||||
else pair
|
||||
end
|
||||
pair,
|
||||
is_open, {fee_open} fee_open, {fee_close} fee_close,
|
||||
is_open, {fee_open} fee_open, {fee_open_cost} fee_open_cost,
|
||||
{fee_open_currency} fee_open_currency, {fee_close} fee_close,
|
||||
{fee_close_cost} fee_close_cost, {fee_close_currency} fee_close_currency,
|
||||
open_rate, {open_rate_requested} open_rate_requested, close_rate,
|
||||
{close_rate_requested} close_rate_requested, close_profit,
|
||||
stake_amount, amount, open_date, close_date, open_order_id,
|
||||
@@ -185,7 +193,11 @@ class Trade(_DECL_BASE):
|
||||
pair = Column(String, nullable=False, index=True)
|
||||
is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
fee_open = Column(Float, nullable=False, default=0.0)
|
||||
fee_open_cost = Column(Float, nullable=True)
|
||||
fee_open_currency = Column(String, nullable=True)
|
||||
fee_close = Column(Float, nullable=False, default=0.0)
|
||||
fee_close_cost = Column(Float, nullable=True)
|
||||
fee_close_currency = Column(String, nullable=True)
|
||||
open_rate = Column(Float)
|
||||
open_rate_requested = Column(Float)
|
||||
# open_trade_price - calculated via _calc_open_trade_price
|
||||
@@ -235,7 +247,11 @@ class Trade(_DECL_BASE):
|
||||
'pair': self.pair,
|
||||
'is_open': self.is_open,
|
||||
'fee_open': self.fee_open,
|
||||
'fee_open_cost': self.fee_open_cost,
|
||||
'fee_open_currency': self.fee_open_currency,
|
||||
'fee_close': self.fee_close,
|
||||
'fee_close_cost': self.fee_close_cost,
|
||||
'fee_close_currency': self.fee_close_currency,
|
||||
'open_date_hum': arrow.get(self.open_date).humanize(),
|
||||
'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
'close_date_hum': (arrow.get(self.close_date).humanize()
|
||||
@@ -360,6 +376,35 @@ class Trade(_DECL_BASE):
|
||||
self
|
||||
)
|
||||
|
||||
def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float],
|
||||
side: str) -> None:
|
||||
"""
|
||||
Update Fee parameters. Only acts once per side
|
||||
"""
|
||||
if side == 'buy' and self.fee_open_currency is None:
|
||||
self.fee_open_cost = fee_cost
|
||||
self.fee_open_currency = fee_currency
|
||||
if fee_rate is not None:
|
||||
self.fee_open = fee_rate
|
||||
# Assume close-fee will fall into the same fee category and take an educated guess
|
||||
self.fee_close = fee_rate
|
||||
elif side == 'sell' and self.fee_close_currency is None:
|
||||
self.fee_close_cost = fee_cost
|
||||
self.fee_close_currency = fee_currency
|
||||
if fee_rate is not None:
|
||||
self.fee_close = fee_rate
|
||||
|
||||
def fee_updated(self, side: str) -> bool:
|
||||
"""
|
||||
Verify if this side (buy / sell) has already been updated
|
||||
"""
|
||||
if side == 'buy':
|
||||
return self.fee_open_currency is not None
|
||||
elif side == 'sell':
|
||||
return self.fee_close_currency is not None
|
||||
else:
|
||||
return False
|
||||
|
||||
def _calc_open_trade_price(self) -> float:
|
||||
"""
|
||||
Calculate the open_rate including open_fee.
|
||||
|
Reference in New Issue
Block a user