From b7891485b35d52223793cab10d9fd3859f0f2726 Mon Sep 17 00:00:00 2001 From: Sam Germain Date: Tue, 3 Aug 2021 12:55:22 -0600 Subject: [PATCH] Created FundingFee class and added funding_fee to LocalTrade and freqtradebot --- freqtrade/freqtradebot.py | 19 +++++- freqtrade/leverage/__init__.py | 1 - freqtrade/leverage/funding_fee.py | 80 ++++++++++++++++++++++++ freqtrade/persistence/migrations.py | 20 ++++-- freqtrade/persistence/models.py | 94 ++++++++++++++++++++++------- requirements.txt | 3 + tests/leverage/test_leverage.py | 2 +- tests/rpc/test_rpc.py | 16 +++-- tests/test_persistence.py | 25 +++++++- 9 files changed, 223 insertions(+), 37 deletions(-) create mode 100644 freqtrade/leverage/funding_fee.py diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 53ca2764b..4659a634c 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -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) diff --git a/freqtrade/leverage/__init__.py b/freqtrade/leverage/__init__.py index ae78f4722..9186b160e 100644 --- a/freqtrade/leverage/__init__.py +++ b/freqtrade/leverage/__init__.py @@ -1,2 +1 @@ # flake8: noqa: F401 -from freqtrade.leverage.interest import interest diff --git a/freqtrade/leverage/funding_fee.py b/freqtrade/leverage/funding_fee.py new file mode 100644 index 000000000..738fa1344 --- /dev/null +++ b/freqtrade/leverage/funding_fee.py @@ -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) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index c81a4156c..f4deef45b 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -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} """)) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index b73611c1b..72d2fafc9 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -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 @@ -57,7 +57,7 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None: f"is no valid database URL! (See {_SQL_DOCS_URL})") # https://docs.sqlalchemy.org/en/13/orm/contextual.html#thread-local-scope - # Scoped sessions proxy requests to the appropriate thread-local session. + # Scoped sessions proxy reque sts to the appropriate thread-local session. # We should use the scoped_session object - not a seperately initialized version Trade._session = scoped_session(sessionmaker(bind=engine, autoflush=True)) Trade.query = Trade._session.query_property() @@ -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) - if self.is_short: - amount = Decimal(self.amount) + Decimal(interest) - else: - # Currency already owned for longs, no need to purchase - amount = Decimal(self.amount) + amount = Decimal(self.amount) + trading_mode = self.trading_mode or TradingMode.SPOT - close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore - fees = close_trade * Decimal(fee or self.fee_close) + if trading_mode == TradingMode.SPOT: + return float(self._calc_base_close(amount, rate, fee)) - if self.is_short: - return float(close_trade + fees) + elif (trading_mode == TradingMode.MARGIN): + + total_interest = self.calculate_interest(interest_rate) + + if self.is_short: + amount = amount + total_interest + return float(self._calc_base_close(amount, rate, fee)) + else: + # Currency already owned for longs, no need to purchase + 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(self._calc_base_close(amount, rate, fee)) + funding_fee + else: + return float(self._calc_base_close(amount, rate, fee)) - funding_fee else: - return float(close_trade - fees - interest) + 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() diff --git a/requirements.txt b/requirements.txt index f77edddfe..73a4a9cb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/leverage/test_leverage.py b/tests/leverage/test_leverage.py index 7b7ca0f9b..9a6e99806 100644 --- a/tests/leverage/test_leverage.py +++ b/tests/leverage/test_leverage.py @@ -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) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 56e64db69..d649581a6 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -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, } diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 911d7d6c2..a33f2c1b0 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -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