Merge pull request #6866 from freqtrade/dry_order_db

Dry orders from db
This commit is contained in:
Matthias 2022-05-20 12:10:09 +02:00 committed by GitHub
commit 0138114fc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 83 additions and 91 deletions

View File

@ -583,7 +583,7 @@ Once you will be happy with your bot performance running in the Dry-run mode, yo
* Market orders fill based on orderbook volume the moment the order is placed. * Market orders fill based on orderbook volume the moment the order is placed.
* Limit orders fill once the price reaches the defined level - or time out based on `unfilledtimeout` settings. * Limit orders fill once the price reaches the defined level - or time out based on `unfilledtimeout` settings.
* In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled. * In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled.
* Open orders (not trades, which are stored in the database) are reset on bot restart. * Open orders (not trades, which are stored in the database) are kept open after bot restarts, with the assumption that they were not filled while being offline.
## Switch to production mode ## Switch to production mode

View File

@ -19,9 +19,9 @@ def start_convert_db(args: Dict[str, Any]) -> None:
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
init_db(config['db_url'], False) init_db(config['db_url'])
session_target = Trade._session session_target = Trade._session
init_db(config['db_url_from'], False) init_db(config['db_url_from'])
logger.info("Starting db migration.") logger.info("Starting db migration.")
trade_count = 0 trade_count = 0

View File

@ -212,7 +212,7 @@ def start_show_trades(args: Dict[str, Any]) -> None:
raise OperationalException("--db-url is required for this command.") raise OperationalException("--db-url is required for this command.")
logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"') logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"')
init_db(config['db_url'], clean_open_orders=False) init_db(config['db_url'])
tfilter = [] tfilter = []
if config.get('trade_ids'): if config.get('trade_ids'):

View File

@ -353,7 +353,7 @@ def load_trades_from_db(db_url: str, strategy: Optional[str] = None) -> pd.DataF
Can also serve as protection to load the correct result. Can also serve as protection to load the correct result.
:return: Dataframe containing Trades :return: Dataframe containing Trades
""" """
init_db(db_url, clean_open_orders=False) init_db(db_url)
filters = [] filters = []
if strategy: if strategy:

View File

@ -953,6 +953,12 @@ class Exchange:
order = self.check_dry_limit_order_filled(order) order = self.check_dry_limit_order_filled(order)
return order return order
except KeyError as e: except KeyError as e:
from freqtrade.persistence import Order
order = Order.order_by_id(order_id)
if order:
ccxt_order = order.to_ccxt_object()
self._dry_run_open_orders[order_id] = ccxt_order
return ccxt_order
# Gracefully handle errors with dry-run orders. # Gracefully handle errors with dry-run orders.
raise InvalidOrderException( raise InvalidOrderException(
f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e

View File

@ -67,7 +67,7 @@ class FreqtradeBot(LoggingMixin):
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run']) init_db(self.config.get('db_url', None))
self.wallets = Wallets(self.config, self.exchange) self.wallets = Wallets(self.config, self.exchange)

View File

@ -1,5 +1,5 @@
# flake8: noqa: F401 # flake8: noqa: F401
from freqtrade.persistence.models import clean_dry_run_db, cleanup_db, init_db from freqtrade.persistence.models import cleanup_db, init_db
from freqtrade.persistence.pairlock_middleware import PairLocks from freqtrade.persistence.pairlock_middleware import PairLocks
from freqtrade.persistence.trade_model import LocalTrade, Order, Trade from freqtrade.persistence.trade_model import LocalTrade, Order, Trade

View File

@ -21,14 +21,12 @@ logger = logging.getLogger(__name__)
_SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls' _SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls'
def init_db(db_url: str, clean_open_orders: bool = False) -> None: def init_db(db_url: str) -> None:
""" """
Initializes this module with the given config, Initializes this module with the given config,
registers all known command handlers registers all known command handlers
and starts polling for message updates and starts polling for message updates
:param db_url: Database to use :param db_url: Database to use
:param clean_open_orders: Remove open orders from the database.
Useful for dry-run or if all orders have been reset on the exchange.
:return: None :return: None
""" """
kwargs = {} kwargs = {}
@ -64,10 +62,6 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
_DECL_BASE.metadata.create_all(engine) _DECL_BASE.metadata.create_all(engine)
check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables)
# Clean dry_run DB if the db is not in-memory
if clean_open_orders and db_url != 'sqlite://':
clean_dry_run_db()
def cleanup_db() -> None: def cleanup_db() -> None:
""" """
@ -75,15 +69,3 @@ def cleanup_db() -> None:
:return: None :return: None
""" """
Trade.commit() Trade.commit()
def clean_dry_run_db() -> None:
"""
Remove open_order_id from a Dry_run DB
:return: None
"""
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
# Check we are updating only a dry_run order not a prod one
if 'dry_run' in trade.open_order_id:
trade.open_order_id = None
Trade.commit()

View File

@ -118,6 +118,25 @@ class Order(_DECL_BASE):
self.order_filled_date = datetime.now(timezone.utc) self.order_filled_date = datetime.now(timezone.utc)
self.order_update_date = datetime.now(timezone.utc) self.order_update_date = datetime.now(timezone.utc)
def to_ccxt_object(self) -> Dict[str, Any]:
return {
'id': self.order_id,
'symbol': self.ft_pair,
'price': self.price,
'average': self.average,
'amount': self.amount,
'cost': self.cost,
'type': self.order_type,
'side': self.ft_order_side,
'filled': self.filled,
'remaining': self.remaining,
'datetime': self.order_date_utc.strftime('%Y-%m-%dT%H:%M:%S.%f'),
'timestamp': int(self.order_date_utc.timestamp() * 1000),
'status': self.status,
'fee': None,
'info': {},
}
def to_json(self, entry_side: str) -> Dict[str, Any]: def to_json(self, entry_side: str) -> Dict[str, Any]:
return { return {
'pair': self.ft_pair, 'pair': self.ft_pair,
@ -190,6 +209,14 @@ class Order(_DECL_BASE):
""" """
return Order.query.filter(Order.ft_is_open.is_(True)).all() return Order.query.filter(Order.ft_is_open.is_(True)).all()
@staticmethod
def order_by_id(order_id: str) -> Optional['Order']:
"""
Retrieve order based on order_id
:return: Order or None
"""
return Order.query.filter(Order.order_id == order_id).first()
class LocalTrade(): class LocalTrade():
""" """

View File

@ -1495,7 +1495,7 @@ def test_start_convert_db(mocker, fee, tmpdir, caplog):
] ]
assert not db_src_file.is_file() assert not db_src_file.is_file()
init_db(db_from, False) init_db(db_from)
create_mock_trades(fee) create_mock_trades(fee)

View File

@ -384,7 +384,7 @@ def patch_coingekko(mocker) -> None:
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def init_persistence(default_conf): def init_persistence(default_conf):
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -1616,6 +1616,7 @@ def limit_buy_order_open():
'datetime': arrow.utcnow().isoformat(), 'datetime': arrow.utcnow().isoformat(),
'price': 0.00001099, 'price': 0.00001099,
'amount': 90.99181073, 'amount': 90.99181073,
'average': None,
'filled': 0.0, 'filled': 0.0,
'cost': 0.0009999, 'cost': 0.0009999,
'remaining': 90.99181073, 'remaining': 90.99181073,

View File

@ -2808,6 +2808,7 @@ def test_get_historic_trades_notsupported(default_conf, mocker, caplog, exchange
until=trades_history[-1][0]) until=trades_history[-1][0])
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_cancel_order_dry_run(default_conf, mocker, exchange_name): def test_cancel_order_dry_run(default_conf, mocker, exchange_name):
default_conf['dry_run'] = True default_conf['dry_run'] = True
@ -2973,6 +2974,7 @@ def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name):
exchange.cancel_stoploss_order_with_result(order_id='_', pair='TKN/BTC', amount=123) exchange.cancel_stoploss_order_with_result(order_id='_', pair='TKN/BTC', amount=123)
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_order(default_conf, mocker, exchange_name, caplog): def test_fetch_order(default_conf, mocker, exchange_name, caplog):
default_conf['dry_run'] = True default_conf['dry_run'] = True
@ -3025,6 +3027,7 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog):
order_id='_', pair='TKN/BTC') order_id='_', pair='TKN/BTC')
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_stoploss_order(default_conf, mocker, exchange_name): def test_fetch_stoploss_order(default_conf, mocker, exchange_name):
# Don't test FTX here - that needs a separate test # Don't test FTX here - that needs a separate test

View File

@ -174,6 +174,7 @@ def test_stoploss_adjust_ftx(mocker, default_conf, sl1, sl2, sl3, side):
assert not exchange.stoploss_adjust(sl3, order, side=side) assert not exchange.stoploss_adjust(sl3, order, side=side)
@pytest.mark.usefixtures("init_persistence")
def test_fetch_stoploss_order_ftx(default_conf, mocker, limit_sell_order, limit_buy_order): def test_fetch_stoploss_order_ftx(default_conf, mocker, limit_sell_order, limit_buy_order):
default_conf['dry_run'] = True default_conf['dry_run'] = True
order = MagicMock() order = MagicMock()

View File

@ -34,6 +34,7 @@ def test_validate_order_types_gateio(default_conf, mocker):
ExchangeResolver.load_exchange('gateio', default_conf, True) ExchangeResolver.load_exchange('gateio', default_conf, True)
@pytest.mark.usefixtures("init_persistence")
def test_fetch_stoploss_order_gateio(default_conf, mocker): def test_fetch_stoploss_order_gateio(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf, id='gateio') exchange = get_patched_exchange(mocker, default_conf, id='gateio')

View File

@ -3044,6 +3044,7 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order
trade.entry_side = "buy" trade.entry_side = "buy"
trade.open_rate = 200 trade.open_rate = 200
trade.entry_side = "buy" trade.entry_side = "buy"
trade.open_order_id = "open_order_noop"
l_order['filled'] = 0.0 l_order['filled'] = 0.0
l_order['status'] = 'open' l_order['status'] = 'open'
reason = CANCEL_REASON['TIMEOUT'] reason = CANCEL_REASON['TIMEOUT']
@ -4786,9 +4787,6 @@ def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_s
freqtrade.config['dry_run'] = False freqtrade.config['dry_run'] = False
freqtrade.startup_update_open_orders() freqtrade.startup_update_open_orders()
assert log_has_re(r"Error updating Order .*", caplog)
caplog.clear()
assert len(Order.get_open_orders()) == 3 assert len(Order.get_open_orders()) == 3
matching_buy_order = mock_order_4(is_short=is_short) matching_buy_order = mock_order_4(is_short=is_short)
matching_buy_order.update({ matching_buy_order.update({
@ -4799,6 +4797,11 @@ def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_s
# Only stoploss and sell orders are kept open # Only stoploss and sell orders are kept open
assert len(Order.get_open_orders()) == 2 assert len(Order.get_open_orders()) == 2
caplog.clear()
mocker.patch('freqtrade.exchange.Exchange.fetch_order', side_effect=InvalidOrderException)
freqtrade.startup_update_open_orders()
assert log_has_re(r"Error updating Order .*", caplog)
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("is_short", [False, True]) @pytest.mark.parametrize("is_short", [False, True])

View File

@ -13,7 +13,7 @@ from sqlalchemy import create_engine, text
from freqtrade import constants from freqtrade import constants
from freqtrade.enums import TradingMode from freqtrade.enums import TradingMode
from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db from freqtrade.persistence import LocalTrade, Order, Trade, init_db
from freqtrade.persistence.migrations import get_last_sequence_ids, set_sequence_ids from freqtrade.persistence.migrations import get_last_sequence_ids, set_sequence_ids
from freqtrade.persistence.models import PairLock from freqtrade.persistence.models import PairLock
from tests.conftest import create_mock_trades, create_mock_trades_with_leverage, log_has, log_has_re from tests.conftest import create_mock_trades, create_mock_trades_with_leverage, log_has, log_has_re
@ -24,7 +24,7 @@ spot, margin, futures = TradingMode.SPOT, TradingMode.MARGIN, TradingMode.FUTURE
def test_init_create_session(default_conf): def test_init_create_session(default_conf):
# Check if init create a session # Check if init create a session
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert hasattr(Trade, '_session') assert hasattr(Trade, '_session')
assert 'scoped_session' in type(Trade._session).__name__ assert 'scoped_session' in type(Trade._session).__name__
@ -36,7 +36,7 @@ def test_init_custom_db_url(default_conf, tmpdir):
default_conf.update({'db_url': f'sqlite:///{filename}'}) default_conf.update({'db_url': f'sqlite:///{filename}'})
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert Path(filename).is_file() assert Path(filename).is_file()
r = Trade._session.execute(text("PRAGMA journal_mode")) r = Trade._session.execute(text("PRAGMA journal_mode"))
assert r.first() == ('wal',) assert r.first() == ('wal',)
@ -45,10 +45,10 @@ def test_init_custom_db_url(default_conf, tmpdir):
def test_init_invalid_db_url(): def test_init_invalid_db_url():
# Update path to a value other than default, but still in-memory # Update path to a value other than default, but still in-memory
with pytest.raises(OperationalException, match=r'.*no valid database URL*'): with pytest.raises(OperationalException, match=r'.*no valid database URL*'):
init_db('unknown:///some.url', True) init_db('unknown:///some.url')
with pytest.raises(OperationalException, match=r'Bad db-url.*For in-memory database, pl.*'): with pytest.raises(OperationalException, match=r'Bad db-url.*For in-memory database, pl.*'):
init_db('sqlite:///', True) init_db('sqlite:///')
def test_init_prod_db(default_conf, mocker): def test_init_prod_db(default_conf, mocker):
@ -57,7 +57,7 @@ def test_init_prod_db(default_conf, mocker):
create_engine_mock = mocker.patch('freqtrade.persistence.models.create_engine', MagicMock()) create_engine_mock = mocker.patch('freqtrade.persistence.models.create_engine', MagicMock())
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert create_engine_mock.call_count == 1 assert create_engine_mock.call_count == 1
assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tradesv3.sqlite' assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tradesv3.sqlite'
@ -70,7 +70,7 @@ def test_init_dryrun_db(default_conf, tmpdir):
'db_url': f'sqlite:///{filename}' 'db_url': f'sqlite:///{filename}'
}) })
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert Path(filename).is_file() assert Path(filename).is_file()
@ -1129,56 +1129,6 @@ def test_calc_profit(
assert pytest.approx(trade.calc_profit_ratio(rate=close_rate)) == round(profit_ratio, 8) assert pytest.approx(trade.calc_profit_ratio(rate=close_rate)) == round(profit_ratio, 8)
@pytest.mark.usefixtures("init_persistence")
def test_clean_dry_run_db(default_conf, fee):
# Simulate dry_run entries
trade = Trade(
pair='ADA/USDT',
stake_amount=0.001,
amount=123.0,
fee_open=fee.return_value,
fee_close=fee.return_value,
open_rate=0.123,
exchange='binance',
open_order_id='dry_run_buy_12345'
)
Trade.query.session.add(trade)
trade = Trade(
pair='ETC/BTC',
stake_amount=0.001,
amount=123.0,
fee_open=fee.return_value,
fee_close=fee.return_value,
open_rate=0.123,
exchange='binance',
open_order_id='dry_run_sell_12345'
)
Trade.query.session.add(trade)
# Simulate prod entry
trade = Trade(
pair='ETC/BTC',
stake_amount=0.001,
amount=123.0,
fee_open=fee.return_value,
fee_close=fee.return_value,
open_rate=0.123,
exchange='binance',
open_order_id='prod_buy_12345'
)
Trade.query.session.add(trade)
# We have 3 entries: 2 dry_run, 1 prod
assert len(Trade.query.filter(Trade.open_order_id.isnot(None)).all()) == 3
clean_dry_run_db()
# We have now only the prod
assert len(Trade.query.filter(Trade.open_order_id.isnot(None)).all()) == 1
def test_migrate_new(mocker, default_conf, fee, caplog): def test_migrate_new(mocker, default_conf, fee, caplog):
""" """
Test Database migration (starting with new pairformat) Test Database migration (starting with new pairformat)
@ -1310,7 +1260,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog):
connection.execute(text("create table trades_bak1 as select * from trades")) connection.execute(text("create table trades_bak1 as select * from trades"))
# Run init to test migration # Run init to test migration
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert len(Trade.query.filter(Trade.id == 1).all()) == 1 assert len(Trade.query.filter(Trade.id == 1).all()) == 1
trade = Trade.query.filter(Trade.id == 1).first() trade = Trade.query.filter(Trade.id == 1).first()
@ -1393,7 +1343,7 @@ def test_migrate_too_old(mocker, default_conf, fee, caplog):
# Run init to test migration # Run init to test migration
with pytest.raises(OperationalException, match=r'Your database seems to be very old'): with pytest.raises(OperationalException, match=r'Your database seems to be very old'):
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
def test_migrate_get_last_sequence_ids(): def test_migrate_get_last_sequence_ids():
@ -1467,7 +1417,7 @@ def test_migrate_pairlocks(mocker, default_conf, fee, caplog):
connection.execute(text(create_index2)) connection.execute(text(create_index2))
connection.execute(text(create_index3)) connection.execute(text(create_index3))
init_db(default_conf['db_url'], default_conf['dry_run']) init_db(default_conf['db_url'])
assert len(PairLock.query.all()) == 2 assert len(PairLock.query.all()) == 2
assert len(PairLock.query.filter(PairLock.pair == '*').all()) == 1 assert len(PairLock.query.filter(PairLock.pair == '*').all()) == 1
@ -2721,3 +2671,21 @@ def test_select_filled_orders(fee):
orders = trades[4].select_filled_orders('sell') orders = trades[4].select_filled_orders('sell')
assert orders is not None assert orders is not None
assert len(orders) == 0 assert len(orders) == 0
@pytest.mark.usefixtures("init_persistence")
def test_order_to_ccxt(limit_buy_order_open):
order = Order.parse_from_ccxt_object(limit_buy_order_open, 'mocked', 'buy')
order.query.session.add(order)
Order.query.session.commit()
order_resp = Order.order_by_id(limit_buy_order_open['id'])
assert order_resp
raw_order = order_resp.to_ccxt_object()
del raw_order['fee']
del raw_order['datetime']
del raw_order['info']
del limit_buy_order_open['datetime']
assert raw_order == limit_buy_order_open