Created FundingFee class and added funding_fee to LocalTrade and freqtradebot

This commit is contained in:
Sam Germain 2021-08-03 12:55:22 -06:00
parent 5184cc7749
commit b7891485b3
9 changed files with 223 additions and 37 deletions

View File

@ -16,10 +16,11 @@ 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 RPCMessageType, SellType, State
from freqtrade.enums import RPCMessageType, SellType, State, TradingMode
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
InvalidOrderException, PricingError)
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.leverage.funding_fee import FundingFee
from freqtrade.misc import safe_value_fallback, safe_value_fallback2
from freqtrade.mixins import LoggingMixin
from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db
@ -102,6 +103,11 @@ class FreqtradeBot(LoggingMixin):
self._sell_lock = Lock()
LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe))
self.trading_mode = TradingMode.SPOT
if self.trading_mode == TradingMode.FUTURES:
self.funding_fee = FundingFee()
self.funding_fee.start()
def notify_status(self, msg: str) -> None:
"""
Public method for users of this class (worker, etc.) to send notifications
@ -559,6 +565,10 @@ class FreqtradeBot(LoggingMixin):
amount = safe_value_fallback(order, 'filled', 'amount')
buy_limit_filled_price = safe_value_fallback(order, 'average', 'price')
funding_fee = (self.funding_fee.initial_funding_fee(amount)
if self.trading_mode == TradingMode.FUTURES
else None)
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
trade = Trade(
@ -576,10 +586,15 @@ class FreqtradeBot(LoggingMixin):
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
buy_tag=buy_tag,
timeframe=timeframe_to_minutes(self.config['timeframe'])
timeframe=timeframe_to_minutes(self.config['timeframe']),
funding_fee=funding_fee,
trading_mode=self.trading_mode
)
trade.orders.append(order_obj)
if self.trading_mode == TradingMode.FUTURES:
self.funding_fee.add_new_trade(trade)
# Update fees if order is closed
if order_status == 'closed':
self.update_trade_state(trade, order_id, order)

View File

@ -1,2 +1 @@
# flake8: noqa: F401
from freqtrade.leverage.interest import interest

View File

@ -0,0 +1,80 @@
from datetime import datetime, timedelta
from typing import List
import schedule
from freqtrade.persistence import Trade
class FundingFee:
trades: List[Trade]
# Binance
begin_times = [
# TODO-lev: Make these UTC time
"23:59:45",
"07:59:45",
"15:59:45",
]
# FTX
# begin_times = every hour
def _is_time_between(self, begin_time, end_time):
# If check time is not given, default to current UTC time
check_time = datetime.utcnow().time()
if begin_time < end_time:
return check_time >= begin_time and check_time <= end_time
else: # crosses midnight
return check_time >= begin_time or check_time <= end_time
def _apply_funding_fees(self, num_of: int = 1):
if num_of == 0:
return
for trade in self.trades:
trade.adjust_funding_fee(self._calculate(trade.amount) * num_of)
def _calculate(self, amount):
# TODO-futures: implement
# TODO-futures: Check how other exchages do it and adjust accordingly
# https://www.binance.com/en/support/faq/360033525031
# mark_price =
# contract_size = maybe trade.amount
# funding_rate = # https://www.binance.com/en/futures/funding-history/0
# nominal_value = mark_price * contract_size
# adjustment = nominal_value * funding_rate
# return adjustment
# FTX - paid in USD(always)
# position size * TWAP of((future - index) / index) / 24
# https: // help.ftx.com/hc/en-us/articles/360027946571-Funding
return
def initial_funding_fee(self, amount) -> float:
# A funding fee interval is applied immediately if within 30s of an iterval
# May only exist on binance
for begin_string in self.begin_times:
begin_time = datetime.strptime(begin_string, "%H:%M:%S")
end_time = (begin_time + timedelta(seconds=30))
if self._is_time_between(begin_time.time(), end_time.time()):
return self._calculate(amount)
return 0.0
def start(self):
for interval in self.begin_times:
schedule.every().day.at(interval).do(self._apply_funding_fees())
# https://stackoverflow.com/a/30393162/6331353
# TODO-futures: Put schedule.run_pending() somewhere in the bot_loop
def reboot(self):
# TODO-futures Find out how many begin_times have passed since last funding_fee added
amount_missed = 0
self.apply_funding_fees(num_of=amount_missed)
self.start()
def add_new_trade(self, trade):
self.trades.append(trade)
def remove_trade(self, trade):
self.trades.remove(trade)

View File

@ -49,11 +49,21 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
strategy = get_column_def(cols, 'strategy', 'null')
buy_tag = get_column_def(cols, 'buy_tag', 'null')
trading_mode = get_column_def(cols, 'trading_mode', 'null')
# Leverage Properties
leverage = get_column_def(cols, 'leverage', '1.0')
interest_rate = get_column_def(cols, 'interest_rate', '0.0')
isolated_liq = get_column_def(cols, 'isolated_liq', 'null')
# sqlite does not support literals for booleans
is_short = get_column_def(cols, 'is_short', '0')
# Margin Properties
interest_rate = get_column_def(cols, 'interest_rate', '0.0')
# Futures properties
funding_fee = get_column_def(cols, 'funding_fee', '0.0')
last_funding_adjustment = get_column_def(cols, 'last_funding_adjustment', 'null')
# If ticker-interval existed use that, else null.
if has_column(cols, 'ticker_interval'):
timeframe = get_column_def(cols, 'timeframe', 'ticker_interval')
@ -91,7 +101,8 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
stoploss_order_id, stoploss_last_update,
max_rate, min_rate, sell_reason, sell_order_status, strategy, buy_tag,
timeframe, open_trade_value, close_profit_abs,
leverage, interest_rate, isolated_liq, is_short
trading_mode, leverage, isolated_liq, is_short,
interest_rate, funding_fee, last_funding_adjustment
)
select id, lower(exchange), pair,
is_open, {fee_open} fee_open, {fee_open_cost} fee_open_cost,
@ -108,8 +119,9 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
{sell_order_status} sell_order_status,
{strategy} strategy, {buy_tag} buy_tag, {timeframe} timeframe,
{open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs,
{leverage} leverage, {interest_rate} interest_rate,
{isolated_liq} isolated_liq, {is_short} is_short
{trading_mode} trading_mode, {leverage} leverage, {isolated_liq} isolated_liq,
{is_short} is_short, {interest_rate} interest_rate,
{funding_fee} funding_fee, {last_funding_adjustment} last_funding_adjustment
from {table_back_name}
"""))

View File

@ -2,11 +2,11 @@
This module contains the class to persist trades into SQLite
"""
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, String,
from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String,
create_engine, desc, func, inspect)
from sqlalchemy.exc import NoSuchModuleError
from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker
@ -14,9 +14,9 @@ from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.schema import UniqueConstraint
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES
from freqtrade.enums import SellType
from freqtrade.enums import SellType, TradingMode
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.leverage import interest
from freqtrade.leverage.interest import interest
from freqtrade.misc import safe_value_fallback
from freqtrade.persistence.migrations import check_migrate
@ -93,6 +93,12 @@ def clean_dry_run_db() -> None:
Trade.commit()
def hour_rounder(t):
# Rounds to nearest hour by adding a timedelta hour if minute >= 30
return (
t.replace(second=0, microsecond=0, minute=0, hour=t.hour) + timedelta(hours=t.minute//30))
class Order(_DECL_BASE):
"""
Order database model
@ -265,14 +271,20 @@ class LocalTrade():
buy_tag: Optional[str] = None
timeframe: Optional[int] = None
trading_mode: TradingMode = TradingMode.SPOT
# Leverage trading properties
is_short: bool = False
isolated_liq: Optional[float] = None
is_short: bool = False
leverage: float = 1.0
# Margin trading properties
interest_rate: float = 0.0
# Futures properties
funding_fee: Optional[float] = None
last_funding_adjustment: Optional[datetime] = None
@property
def has_no_leverage(self) -> bool:
"""Returns true if this is a non-leverage, non-short trade"""
@ -438,7 +450,10 @@ class LocalTrade():
'interest_rate': self.interest_rate,
'isolated_liq': self.isolated_liq,
'is_short': self.is_short,
'trading_mode': self.trading_mode,
'funding_fee': self.funding_fee,
'last_funding_adjustment': (self.last_funding_adjustment.strftime(DATETIME_PRINT_FORMAT)
if self.last_funding_adjustment else None),
'open_order_id': self.open_order_id,
}
@ -516,6 +531,10 @@ class LocalTrade():
f"Trailing stoploss saved us: "
f"{float(self.stop_loss) - float(self.initial_stop_loss):.8f}.")
def adjust_funding_fee(self, adjustment):
self.funding_fee = self.funding_fee + adjustment
self.last_funding_adjustment = datetime.utcnow()
def update(self, order: Dict) -> None:
"""
Updates this entity with amount and actual open/close rates.
@ -654,8 +673,20 @@ class LocalTrade():
rate = Decimal(interest_rate or self.interest_rate)
borrowed = Decimal(self.borrowed)
# TODO-lev: Pass trading mode to interest maybe
return interest(exchange_name=self.exchange, borrowed=borrowed, rate=rate, hours=hours)
def _calc_base_close(self, amount: Decimal, rate: Optional[float] = None,
fee: Optional[float] = None) -> Decimal:
close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore
fees = close_trade * Decimal(fee or self.fee_close)
if self.is_short:
return close_trade + fees
else:
return close_trade - fees
def calc_close_trade_value(self, rate: Optional[float] = None,
fee: Optional[float] = None,
interest_rate: Optional[float] = None) -> float:
@ -672,20 +703,32 @@ class LocalTrade():
if rate is None and not self.close_rate:
return 0.0
interest = self.calculate_interest(interest_rate)
amount = Decimal(self.amount)
trading_mode = self.trading_mode or TradingMode.SPOT
if trading_mode == TradingMode.SPOT:
return float(self._calc_base_close(amount, rate, fee))
elif (trading_mode == TradingMode.MARGIN):
total_interest = self.calculate_interest(interest_rate)
if self.is_short:
amount = Decimal(self.amount) + Decimal(interest)
amount = amount + total_interest
return float(self._calc_base_close(amount, rate, fee))
else:
# Currency already owned for longs, no need to purchase
amount = Decimal(self.amount)
close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore
fees = close_trade * Decimal(fee or self.fee_close)
return float(self._calc_base_close(amount, rate, fee) - total_interest)
elif (trading_mode == TradingMode.FUTURES):
funding_fee = self.funding_fee or 0.0
if self.is_short:
return float(close_trade + fees)
return float(self._calc_base_close(amount, rate, fee)) + funding_fee
else:
return float(close_trade - fees - interest)
return float(self._calc_base_close(amount, rate, fee)) - funding_fee
else:
raise OperationalException(
f"{self.trading_mode.value} trading is not yet available using freqtrade")
def calc_profit(self, rate: Optional[float] = None,
fee: Optional[float] = None,
@ -893,14 +936,19 @@ class Trade(_DECL_BASE, LocalTrade):
buy_tag = Column(String(100), nullable=True)
timeframe = Column(Integer, nullable=True)
# Leverage trading properties
leverage = Column(Float, nullable=True, default=1.0)
is_short = Column(Boolean, nullable=False, default=False)
isolated_liq = Column(Float, nullable=True)
trading_mode = Column(Enum(TradingMode))
# Margin Trading Properties
leverage = Column(Float, nullable=True, default=1.0)
isolated_liq = Column(Float, nullable=True)
is_short = Column(Boolean, nullable=False, default=False)
# Margin properties
interest_rate = Column(Float, nullable=False, default=0.0)
# Futures properties
funding_fee = Column(Float, nullable=True, default=None)
last_funding_adjustment = Column(DateTime, nullable=True)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.recalc_open_trade_value()

View File

@ -41,3 +41,6 @@ colorama==0.4.4
# Building config files interactively
questionary==1.10.0
prompt-toolkit==3.0.20
#Futures
schedule==1.1.0

View File

@ -3,7 +3,7 @@ from math import isclose
import pytest
from freqtrade.leverage import interest
from freqtrade.leverage.interest import interest
ten_mins = Decimal(1/6)

View File

@ -8,7 +8,7 @@ import pytest
from numpy import isnan
from freqtrade.edge import PairInfo
from freqtrade.enums import State
from freqtrade.enums import State, TradingMode
from freqtrade.exceptions import ExchangeError, InvalidOrderException, TemporaryError
from freqtrade.persistence import Trade
from freqtrade.persistence.pairlock_middleware import PairLocks
@ -108,10 +108,13 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
'stoploss_entry_dist_ratio': -0.10448878,
'open_order': None,
'exchange': 'binance',
'leverage': 1.0,
'interest_rate': 0.0,
'trading_mode': TradingMode.SPOT,
'isolated_liq': None,
'is_short': False,
'leverage': 1.0,
'interest_rate': 0.0,
'funding_fee': None,
'last_funding_adjustment': None,
}
mocker.patch('freqtrade.exchange.Exchange.get_rate',
@ -179,10 +182,13 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
'stoploss_entry_dist_ratio': -0.10448878,
'open_order': None,
'exchange': 'binance',
'leverage': 1.0,
'interest_rate': 0.0,
'trading_mode': TradingMode.SPOT,
'isolated_liq': None,
'is_short': False,
'leverage': 1.0,
'interest_rate': 0.0,
'funding_fee': None,
'last_funding_adjustment': None,
}

View File

@ -11,6 +11,7 @@ import pytest
from sqlalchemy import create_engine, inspect, text
from freqtrade import constants
from freqtrade.enums import TradingMode
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db
from tests.conftest import create_mock_trades, create_mock_trades_with_leverage, log_has, log_has_re
@ -90,7 +91,7 @@ def test_enter_exit_side(fee):
@pytest.mark.usefixtures("init_persistence")
def test__set_stop_loss_isolated_liq(fee):
def test_set_stop_loss_isolated_liq(fee):
trade = Trade(
id=2,
pair='ADA/USDT',
@ -236,6 +237,7 @@ def test_interest(market_buy_order_usdt, fee):
exchange='binance',
leverage=3.0,
interest_rate=0.0005,
trading_mode=TradingMode.MARGIN
)
# 10min, 3x leverage
@ -548,6 +550,7 @@ def test_update_limit_order(limit_buy_order_usdt, limit_sell_order_usdt, fee, ca
is_short=True,
leverage=3.0,
interest_rate=0.0005,
trading_mode=TradingMode.MARGIN
)
trade.open_order_id = 'something'
trade.update(limit_sell_order_usdt)
@ -639,6 +642,7 @@ def test_calc_open_close_trade_price(limit_buy_order_usdt, limit_sell_order_usdt
assert trade.calc_profit() == 5.685
assert trade.calc_profit_ratio() == round(0.0945137157107232, 8)
# 3x leverage, binance
trade.trading_mode = TradingMode.MARGIN
trade.leverage = 3
trade.exchange = "binance"
assert trade._calc_open_trade_value() == 60.15
@ -796,12 +800,19 @@ def test_calc_open_trade_value(limit_buy_order_usdt, fee):
# Get the open rate price with the standard fee rate
assert trade._calc_open_trade_value() == 60.15
# Margin
trade.trading_mode = TradingMode.MARGIN
trade.is_short = True
trade.recalc_open_trade_value()
assert trade._calc_open_trade_value() == 59.85
# 3x short margin leverage
trade.leverage = 3
trade.exchange = "binance"
assert trade._calc_open_trade_value() == 59.85
# 3x long margin leverage
trade.is_short = False
trade.recalc_open_trade_value()
assert trade._calc_open_trade_value() == 60.15
@ -838,6 +849,7 @@ def test_calc_close_trade_price(limit_buy_order_usdt, limit_sell_order_usdt, fee
assert trade.calc_close_trade_value(fee=0.005) == 65.67
# 3x leverage binance
trade.trading_mode = TradingMode.MARGIN
trade.leverage = 3.0
assert round(trade.calc_close_trade_value(rate=2.5), 8) == 74.81166667
assert round(trade.calc_close_trade_value(rate=2.5, fee=0.003), 8) == 74.77416667
@ -1037,6 +1049,8 @@ def test_calc_profit(limit_buy_order_usdt, limit_sell_order_usdt, fee):
trade.open_trade_value = 0.0
trade.open_trade_value = trade._calc_open_trade_value()
# Margin
trade.trading_mode = TradingMode.MARGIN
# 3x leverage, long ###################################################
trade.leverage = 3.0
# Higher than open rate - 2.1 quote
@ -1139,6 +1153,8 @@ def test_calc_profit_ratio(limit_buy_order_usdt, limit_sell_order_usdt, fee):
assert trade.calc_profit_ratio(fee=0.003) == 0.0
trade.open_trade_value = trade._calc_open_trade_value()
# Margin
trade.trading_mode = TradingMode.MARGIN
# 3x leverage, long ###################################################
trade.leverage = 3.0
# 2.1 quote - Higher than open rate
@ -1707,6 +1723,9 @@ def test_to_json(default_conf, fee):
'interest_rate': None,
'isolated_liq': None,
'is_short': None,
'trading_mode': None,
'funding_fee': None,
'last_funding_adjustment': None
}
# Simulate dry_run entries
@ -1778,6 +1797,9 @@ def test_to_json(default_conf, fee):
'interest_rate': None,
'isolated_liq': None,
'is_short': None,
'trading_mode': None,
'funding_fee': None,
'last_funding_adjustment': None
}
@ -2197,6 +2219,7 @@ def test_Trade_object_idem():
'get_open_trades_without_assigned_fees',
'get_open_order_trades',
'get_trades',
'last_funding_adjustment'
)
# Parent (LocalTrade) should have the same attributes