mirror of
https://github.com/freqtrade/freqtrade.git
synced 2024-11-14 04:03:55 +00:00
Merge pull request #8560 from freqtrade/feat/recoverTrades
Recover trades after selling on exchange
This commit is contained in:
commit
7bba034efd
|
@ -134,7 +134,9 @@ python3 scripts/rest_client.py --config rest_config.json <command> [optional par
|
||||||
| `reload_config` | Reloads the configuration file.
|
| `reload_config` | Reloads the configuration file.
|
||||||
| `trades` | List last trades. Limited to 500 trades per call.
|
| `trades` | List last trades. Limited to 500 trades per call.
|
||||||
| `trade/<tradeid>` | Get specific trade.
|
| `trade/<tradeid>` | Get specific trade.
|
||||||
| `delete_trade <trade_id>` | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange.
|
| `trade/<tradeid>` | DELETE - Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange.
|
||||||
|
| `trade/<tradeid>/open-order` | DELETE - Cancel open order for this trade.
|
||||||
|
| `trade/<tradeid>/reload` | GET - Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange.
|
||||||
| `show_config` | Shows part of the current configuration with relevant settings to operation.
|
| `show_config` | Shows part of the current configuration with relevant settings to operation.
|
||||||
| `logs` | Shows last log messages.
|
| `logs` | Shows last log messages.
|
||||||
| `status` | Lists all open trades.
|
| `status` | Lists all open trades.
|
||||||
|
|
|
@ -187,6 +187,7 @@ official commands. You can ask at any moment for help with `/help`.
|
||||||
| `/forcelong <pair> [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True)
|
| `/forcelong <pair> [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True)
|
||||||
| `/forceshort <pair> [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True)
|
| `/forceshort <pair> [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True)
|
||||||
| `/delete <trade_id>` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange.
|
| `/delete <trade_id>` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange.
|
||||||
|
| `/reload_trade <trade_id>` | Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange.
|
||||||
| `/cancel_open_order <trade_id> | /coo <trade_id>` | Cancel an open order for a trade.
|
| `/cancel_open_order <trade_id> | /coo <trade_id>` | Cancel an open order for a trade.
|
||||||
| **Metrics** |
|
| **Metrics** |
|
||||||
| `/profit [<n>]` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default)
|
| `/profit [<n>]` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default)
|
||||||
|
|
|
@ -15,6 +15,7 @@ class ExitType(Enum):
|
||||||
EMERGENCY_EXIT = "emergency_exit"
|
EMERGENCY_EXIT = "emergency_exit"
|
||||||
CUSTOM_EXIT = "custom_exit"
|
CUSTOM_EXIT = "custom_exit"
|
||||||
PARTIAL_EXIT = "partial_exit"
|
PARTIAL_EXIT = "partial_exit"
|
||||||
|
SOLD_ON_EXCHANGE = "sold_on_exchange"
|
||||||
NONE = ""
|
NONE = ""
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
@ -85,6 +85,7 @@ EXCHANGE_HAS_OPTIONAL = [
|
||||||
# 'fetchPositions', # Futures trading
|
# 'fetchPositions', # Futures trading
|
||||||
# 'fetchLeverageTiers', # Futures initialization
|
# 'fetchLeverageTiers', # Futures initialization
|
||||||
# 'fetchMarketLeverageTiers', # Futures initialization
|
# 'fetchMarketLeverageTiers', # Futures initialization
|
||||||
|
# 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance...
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1432,6 +1432,47 @@ class Exchange:
|
||||||
except ccxt.BaseError as e:
|
except ccxt.BaseError as e:
|
||||||
raise OperationalException(e) from e
|
raise OperationalException(e) from e
|
||||||
|
|
||||||
|
@retrier(retries=0)
|
||||||
|
def fetch_orders(self, pair: str, since: datetime) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Fetch all orders for a pair "since"
|
||||||
|
:param pair: Pair for the query
|
||||||
|
:param since: Starting time for the query
|
||||||
|
"""
|
||||||
|
if self._config['dry_run']:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_orders_emulate() -> List[Dict]:
|
||||||
|
orders = []
|
||||||
|
if self.exchange_has('fetchClosedOrders'):
|
||||||
|
orders = self._api.fetch_closed_orders(pair, since=since_ms)
|
||||||
|
if self.exchange_has('fetchOpenOrders'):
|
||||||
|
orders_open = self._api.fetch_open_orders(pair, since=since_ms)
|
||||||
|
orders.extend(orders_open)
|
||||||
|
return orders
|
||||||
|
|
||||||
|
try:
|
||||||
|
since_ms = int((since.timestamp() - 10) * 1000)
|
||||||
|
if self.exchange_has('fetchOrders'):
|
||||||
|
try:
|
||||||
|
orders: List[Dict] = self._api.fetch_orders(pair, since=since_ms)
|
||||||
|
except ccxt.NotSupported:
|
||||||
|
# Some exchanges don't support fetchOrders
|
||||||
|
# attempt to fetch open and closed orders separately
|
||||||
|
orders = fetch_orders_emulate()
|
||||||
|
else:
|
||||||
|
orders = fetch_orders_emulate()
|
||||||
|
self._log_exchange_response('fetch_orders', orders)
|
||||||
|
orders = [self._order_contracts_to_amount(o) for o in orders]
|
||||||
|
return orders
|
||||||
|
except ccxt.DDoSProtection as e:
|
||||||
|
raise DDosProtection(e) from e
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not fetch positions due to {e.__class__.__name__}. Message: {e}') from e
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e) from e
|
||||||
|
|
||||||
@retrier
|
@retrier
|
||||||
def fetch_trading_fees(self) -> Dict[str, Any]:
|
def fetch_trading_fees(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -451,6 +451,42 @@ class FreqtradeBot(LoggingMixin):
|
||||||
except ExchangeError:
|
except ExchangeError:
|
||||||
logger.warning(f"Error updating {order.order_id}.")
|
logger.warning(f"Error updating {order.order_id}.")
|
||||||
|
|
||||||
|
def handle_onexchange_order(self, trade: Trade):
|
||||||
|
"""
|
||||||
|
Try refinding a order that is not in the database.
|
||||||
|
Only used balance disappeared, which would make exiting impossible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
orders = self.exchange.fetch_orders(trade.pair, trade.open_date_utc)
|
||||||
|
for order in orders:
|
||||||
|
trade_order = [o for o in trade.orders if o.order_id == order['id']]
|
||||||
|
if trade_order:
|
||||||
|
continue
|
||||||
|
logger.info(f"Found previously unknown order {order['id']} for {trade.pair}.")
|
||||||
|
|
||||||
|
order_obj = Order.parse_from_ccxt_object(order, trade.pair, order['side'])
|
||||||
|
order_obj.order_filled_date = datetime.fromtimestamp(
|
||||||
|
safe_value_fallback(order, 'lastTradeTimestamp', 'timestamp') // 1000,
|
||||||
|
tz=timezone.utc)
|
||||||
|
trade.orders.append(order_obj)
|
||||||
|
# TODO: how do we handle open_order_id ...
|
||||||
|
Trade.commit()
|
||||||
|
prev_exit_reason = trade.exit_reason
|
||||||
|
trade.exit_reason = ExitType.SOLD_ON_EXCHANGE.value
|
||||||
|
self.update_trade_state(trade, order['id'], order)
|
||||||
|
|
||||||
|
logger.info(f"handled order {order['id']}")
|
||||||
|
if not trade.is_open:
|
||||||
|
# Trade was just closed
|
||||||
|
trade.close_date = order_obj.order_filled_date
|
||||||
|
Trade.commit()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
trade.exit_reason = prev_exit_reason
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
except ExchangeError:
|
||||||
|
logger.warning("Error finding onexchange order")
|
||||||
#
|
#
|
||||||
# BUY / enter positions / open trades logic and methods
|
# BUY / enter positions / open trades logic and methods
|
||||||
#
|
#
|
||||||
|
@ -1034,6 +1070,13 @@ class FreqtradeBot(LoggingMixin):
|
||||||
"""
|
"""
|
||||||
trades_closed = 0
|
trades_closed = 0
|
||||||
for trade in trades:
|
for trade in trades:
|
||||||
|
|
||||||
|
if not self.wallets.check_exit_amount(trade):
|
||||||
|
logger.warning(
|
||||||
|
f'Not enough {trade.safe_base_currency} in wallet to exit {trade}. '
|
||||||
|
'Trying to recover.')
|
||||||
|
self.handle_onexchange_order(trade)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
if (self.strategy.order_types.get('stoploss_on_exchange') and
|
if (self.strategy.order_types.get('stoploss_on_exchange') and
|
||||||
|
@ -1536,13 +1579,13 @@ class FreqtradeBot(LoggingMixin):
|
||||||
# Update wallets to ensure amounts tied up in a stoploss is now free!
|
# Update wallets to ensure amounts tied up in a stoploss is now free!
|
||||||
self.wallets.update()
|
self.wallets.update()
|
||||||
if self.trading_mode == TradingMode.FUTURES:
|
if self.trading_mode == TradingMode.FUTURES:
|
||||||
|
# A safe exit amount isn't needed for futures, you can just exit/close the position
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
trade_base_currency = self.exchange.get_pair_base_currency(pair)
|
trade_base_currency = self.exchange.get_pair_base_currency(pair)
|
||||||
wallet_amount = self.wallets.get_free(trade_base_currency)
|
wallet_amount = self.wallets.get_free(trade_base_currency)
|
||||||
logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}")
|
logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}")
|
||||||
if wallet_amount >= amount:
|
if wallet_amount >= amount:
|
||||||
# A safe exit amount isn't needed for futures, you can just exit/close the position
|
|
||||||
return amount
|
return amount
|
||||||
elif wallet_amount > amount * 0.98:
|
elif wallet_amount > amount * 0.98:
|
||||||
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
|
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
|
||||||
|
|
|
@ -44,7 +44,8 @@ logger = logging.getLogger(__name__)
|
||||||
# 2.24: Add cancel_open_order endpoint
|
# 2.24: Add cancel_open_order endpoint
|
||||||
# 2.25: Add several profit values to /status endpoint
|
# 2.25: Add several profit values to /status endpoint
|
||||||
# 2.26: increase /balance output
|
# 2.26: increase /balance output
|
||||||
API_VERSION = 2.26
|
# 2.27: Add /trades/<id>/reload endpoint
|
||||||
|
API_VERSION = 2.27
|
||||||
|
|
||||||
# Public API, requires no auth.
|
# Public API, requires no auth.
|
||||||
router_public = APIRouter()
|
router_public = APIRouter()
|
||||||
|
@ -127,11 +128,17 @@ def trades_delete(tradeid: int, rpc: RPC = Depends(get_rpc)):
|
||||||
|
|
||||||
|
|
||||||
@router.delete('/trades/{tradeid}/open-order', response_model=OpenTradeSchema, tags=['trading'])
|
@router.delete('/trades/{tradeid}/open-order', response_model=OpenTradeSchema, tags=['trading'])
|
||||||
def cancel_open_order(tradeid: int, rpc: RPC = Depends(get_rpc)):
|
def trade_cancel_open_order(tradeid: int, rpc: RPC = Depends(get_rpc)):
|
||||||
rpc._rpc_cancel_open_order(tradeid)
|
rpc._rpc_cancel_open_order(tradeid)
|
||||||
return rpc._rpc_trade_status([tradeid])[0]
|
return rpc._rpc_trade_status([tradeid])[0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/trades/{tradeid}/reload', response_model=OpenTradeSchema, tags=['trading'])
|
||||||
|
def trade_reload(tradeid: int, rpc: RPC = Depends(get_rpc)):
|
||||||
|
rpc._rpc_reload_trade_from_exchange(tradeid)
|
||||||
|
return rpc._rpc_trade_status([tradeid])[0]
|
||||||
|
|
||||||
|
|
||||||
# TODO: Missing response model
|
# TODO: Missing response model
|
||||||
@router.get('/edge', tags=['info'])
|
@router.get('/edge', tags=['info'])
|
||||||
def edge(rpc: RPC = Depends(get_rpc)):
|
def edge(rpc: RPC = Depends(get_rpc)):
|
||||||
|
|
|
@ -740,6 +740,18 @@ class RPC:
|
||||||
|
|
||||||
return {'status': 'No more entries will occur from now. Run /reload_config to reset.'}
|
return {'status': 'No more entries will occur from now. Run /reload_config to reset.'}
|
||||||
|
|
||||||
|
def _rpc_reload_trade_from_exchange(self, trade_id: int) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Handler for reload_trade_from_exchange.
|
||||||
|
Reloads a trade from it's orders, should manual interaction have happened.
|
||||||
|
"""
|
||||||
|
trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first()
|
||||||
|
if not trade:
|
||||||
|
raise RPCException(f"Could not find trade with id {trade_id}.")
|
||||||
|
|
||||||
|
self._freqtrade.handle_onexchange_order(trade)
|
||||||
|
return {'status': 'Reloaded from orders from exchange'}
|
||||||
|
|
||||||
def __exec_force_exit(self, trade: Trade, ordertype: Optional[str],
|
def __exec_force_exit(self, trade: Trade, ordertype: Optional[str],
|
||||||
amount: Optional[float] = None) -> None:
|
amount: Optional[float] = None) -> None:
|
||||||
# Check if there is there is an open order
|
# Check if there is there is an open order
|
||||||
|
|
|
@ -196,6 +196,7 @@ class Telegram(RPCHandler):
|
||||||
self._force_enter, order_side=SignalDirection.LONG)),
|
self._force_enter, order_side=SignalDirection.LONG)),
|
||||||
CommandHandler('forceshort', partial(
|
CommandHandler('forceshort', partial(
|
||||||
self._force_enter, order_side=SignalDirection.SHORT)),
|
self._force_enter, order_side=SignalDirection.SHORT)),
|
||||||
|
CommandHandler('reload_trade', self._reload_trade_from_exchange),
|
||||||
CommandHandler('trades', self._trades),
|
CommandHandler('trades', self._trades),
|
||||||
CommandHandler('delete', self._delete_trade),
|
CommandHandler('delete', self._delete_trade),
|
||||||
CommandHandler(['coo', 'cancel_open_order'], self._cancel_open_order),
|
CommandHandler(['coo', 'cancel_open_order'], self._cancel_open_order),
|
||||||
|
@ -1074,6 +1075,17 @@ class Telegram(RPCHandler):
|
||||||
msg = self._rpc._rpc_stopentry()
|
msg = self._rpc._rpc_stopentry()
|
||||||
await self._send_msg(f"Status: `{msg['status']}`")
|
await self._send_msg(f"Status: `{msg['status']}`")
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
async def _reload_trade_from_exchange(self, update: Update, context: CallbackContext) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /reload_trade <tradeid>.
|
||||||
|
"""
|
||||||
|
if not context.args or len(context.args) == 0:
|
||||||
|
raise RPCException("Trade-id not set.")
|
||||||
|
trade_id = int(context.args[0])
|
||||||
|
msg = self._rpc._rpc_reload_trade_from_exchange(trade_id)
|
||||||
|
await self._send_msg(f"Status: `{msg['status']}`")
|
||||||
|
|
||||||
@authorized_only
|
@authorized_only
|
||||||
async def _force_exit(self, update: Update, context: CallbackContext) -> None:
|
async def _force_exit(self, update: Update, context: CallbackContext) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -1561,6 +1573,7 @@ class Telegram(RPCHandler):
|
||||||
"*/fx <trade_id>|all:* `Alias to /forceexit`\n"
|
"*/fx <trade_id>|all:* `Alias to /forceexit`\n"
|
||||||
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
|
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
|
||||||
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
|
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
|
||||||
|
"*/reload_trade <trade_id>:* `Relade trade from exchange Orders`\n"
|
||||||
"*/cancel_open_order <trade_id>:* `Cancels open orders for trade. "
|
"*/cancel_open_order <trade_id>:* `Cancels open orders for trade. "
|
||||||
"Only valid when the trade has open orders.`\n"
|
"Only valid when the trade has open orders.`\n"
|
||||||
"*/coo <trade_id>|all:* `Alias to /cancel_open_order`\n"
|
"*/coo <trade_id>|all:* `Alias to /cancel_open_order`\n"
|
||||||
|
|
|
@ -181,6 +181,35 @@ class Wallets:
|
||||||
def get_all_positions(self) -> Dict[str, PositionWallet]:
|
def get_all_positions(self) -> Dict[str, PositionWallet]:
|
||||||
return self._positions
|
return self._positions
|
||||||
|
|
||||||
|
def _check_exit_amount(self, trade: Trade) -> bool:
|
||||||
|
if trade.trading_mode != TradingMode.FUTURES:
|
||||||
|
# Slightly higher offset than in safe_exit_amount.
|
||||||
|
wallet_amount: float = self.get_total(trade.safe_base_currency) * (2 - 0.981)
|
||||||
|
else:
|
||||||
|
# wallet_amount: float = self.wallets.get_free(trade.safe_base_currency)
|
||||||
|
position = self._positions.get(trade.pair)
|
||||||
|
if position is None:
|
||||||
|
# We don't own anything :O
|
||||||
|
return False
|
||||||
|
wallet_amount = position.position
|
||||||
|
|
||||||
|
if wallet_amount >= trade.amount:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_exit_amount(self, trade: Trade) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if the exit amount is available in the wallet.
|
||||||
|
:param trade: Trade to check
|
||||||
|
:return: True if the exit amount is available, False otherwise
|
||||||
|
"""
|
||||||
|
if not self._check_exit_amount(trade):
|
||||||
|
# Update wallets just to make sure
|
||||||
|
self.update()
|
||||||
|
return self._check_exit_amount(trade)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def get_starting_balance(self) -> float:
|
def get_starting_balance(self) -> float:
|
||||||
"""
|
"""
|
||||||
Retrieves starting balance - based on either available capital,
|
Retrieves starting balance - based on either available capital,
|
||||||
|
|
|
@ -1779,6 +1779,71 @@ def test_fetch_positions(default_conf, mocker, exchange_name):
|
||||||
"fetch_positions", "fetch_positions")
|
"fetch_positions", "fetch_positions")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exchange_name", EXCHANGES)
|
||||||
|
def test_fetch_orders(default_conf, mocker, exchange_name, limit_order):
|
||||||
|
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_orders = MagicMock(return_value=[
|
||||||
|
limit_order['buy'],
|
||||||
|
limit_order['sell'],
|
||||||
|
])
|
||||||
|
api_mock.fetch_open_orders = MagicMock(return_value=[limit_order['buy']])
|
||||||
|
api_mock.fetch_closed_orders = MagicMock(return_value=[limit_order['buy']])
|
||||||
|
|
||||||
|
mocker.patch(f'{EXMS}.exchange_has', return_value=True)
|
||||||
|
start_time = datetime.now(timezone.utc) - timedelta(days=5)
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
|
||||||
|
# Not available in dry-run
|
||||||
|
assert exchange.fetch_orders('mocked', start_time) == []
|
||||||
|
assert api_mock.fetch_orders.call_count == 0
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
|
||||||
|
res = exchange.fetch_orders('mocked', start_time)
|
||||||
|
assert api_mock.fetch_orders.call_count == 1
|
||||||
|
assert api_mock.fetch_open_orders.call_count == 0
|
||||||
|
assert api_mock.fetch_closed_orders.call_count == 0
|
||||||
|
assert len(res) == 2
|
||||||
|
|
||||||
|
res = exchange.fetch_orders('mocked', start_time)
|
||||||
|
|
||||||
|
api_mock.fetch_orders.reset_mock()
|
||||||
|
|
||||||
|
def has_resp(_, endpoint):
|
||||||
|
if endpoint == 'fetchOrders':
|
||||||
|
return False
|
||||||
|
if endpoint == 'fetchClosedOrders':
|
||||||
|
return True
|
||||||
|
if endpoint == 'fetchOpenOrders':
|
||||||
|
return True
|
||||||
|
|
||||||
|
mocker.patch(f'{EXMS}.exchange_has', has_resp)
|
||||||
|
|
||||||
|
# happy path without fetchOrders
|
||||||
|
res = exchange.fetch_orders('mocked', start_time)
|
||||||
|
assert api_mock.fetch_orders.call_count == 0
|
||||||
|
assert api_mock.fetch_open_orders.call_count == 1
|
||||||
|
assert api_mock.fetch_closed_orders.call_count == 1
|
||||||
|
|
||||||
|
mocker.patch(f'{EXMS}.exchange_has', return_value=True)
|
||||||
|
|
||||||
|
ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name,
|
||||||
|
"fetch_orders", "fetch_orders", retries=1,
|
||||||
|
pair='mocked', since=start_time)
|
||||||
|
|
||||||
|
# Unhappy path - first fetch-orders call fails.
|
||||||
|
api_mock.fetch_orders = MagicMock(side_effect=ccxt.NotSupported())
|
||||||
|
api_mock.fetch_open_orders.reset_mock()
|
||||||
|
api_mock.fetch_closed_orders.reset_mock()
|
||||||
|
|
||||||
|
res = exchange.fetch_orders('mocked', start_time)
|
||||||
|
|
||||||
|
assert api_mock.fetch_orders.call_count == 1
|
||||||
|
assert api_mock.fetch_open_orders.call_count == 1
|
||||||
|
assert api_mock.fetch_closed_orders.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_trading_fees(default_conf, mocker):
|
def test_fetch_trading_fees(default_conf, mocker):
|
||||||
api_mock = MagicMock()
|
api_mock = MagicMock()
|
||||||
tick = {
|
tick = {
|
||||||
|
|
|
@ -740,6 +740,33 @@ def test_api_delete_open_order(botclient, mocker, fee, markets, ticker, is_short
|
||||||
assert cancel_mock.call_count == 1
|
assert cancel_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('is_short', [True, False])
|
||||||
|
def test_api_trade_reload_trade(botclient, mocker, fee, markets, ticker, is_short):
|
||||||
|
ftbot, client = botclient
|
||||||
|
patch_get_signal(ftbot, enter_long=not is_short, enter_short=is_short)
|
||||||
|
stoploss_mock = MagicMock()
|
||||||
|
cancel_mock = MagicMock()
|
||||||
|
ftbot.handle_onexchange_order = MagicMock()
|
||||||
|
mocker.patch.multiple(
|
||||||
|
EXMS,
|
||||||
|
markets=PropertyMock(return_value=markets),
|
||||||
|
fetch_ticker=ticker,
|
||||||
|
cancel_order=cancel_mock,
|
||||||
|
cancel_stoploss_order=stoploss_mock,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc = client_get(client, f"{BASE_URI}/trades/10/reload")
|
||||||
|
assert_response(rc, 502)
|
||||||
|
assert 'Could not find trade with id 10.' in rc.json()['error']
|
||||||
|
assert ftbot.handle_onexchange_order.call_count == 0
|
||||||
|
|
||||||
|
create_mock_trades(fee, is_short=is_short)
|
||||||
|
Trade.commit()
|
||||||
|
|
||||||
|
rc = client_get(client, f"{BASE_URI}/trades/5/reload")
|
||||||
|
assert ftbot.handle_onexchange_order.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_api_logs(botclient):
|
def test_api_logs(botclient):
|
||||||
ftbot, client = botclient
|
ftbot, client = botclient
|
||||||
rc = client_get(client, f"{BASE_URI}/logs")
|
rc = client_get(client, f"{BASE_URI}/logs")
|
||||||
|
|
|
@ -143,8 +143,8 @@ def test_telegram_init(default_conf, mocker, caplog) -> None:
|
||||||
message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], "
|
message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], "
|
||||||
"['balance'], ['start'], ['stop'], "
|
"['balance'], ['start'], ['stop'], "
|
||||||
"['forceexit', 'forcesell', 'fx'], ['forcebuy', 'forcelong'], ['forceshort'], "
|
"['forceexit', 'forcesell', 'fx'], ['forcebuy', 'forcelong'], ['forceshort'], "
|
||||||
"['trades'], ['delete'], ['cancel_open_order', 'coo'], ['performance'], "
|
"['reload_trade'], ['trades'], ['delete'], ['cancel_open_order', 'coo'], "
|
||||||
"['buys', 'entries'], ['exits', 'sells'], ['mix_tags'], "
|
"['performance'], ['buys', 'entries'], ['exits', 'sells'], ['mix_tags'], "
|
||||||
"['stats'], ['daily'], ['weekly'], ['monthly'], "
|
"['stats'], ['daily'], ['weekly'], ['monthly'], "
|
||||||
"['count'], ['locks'], ['delete_locks', 'unlock'], "
|
"['count'], ['locks'], ['delete_locks', 'unlock'], "
|
||||||
"['reload_conf', 'reload_config'], ['show_conf', 'show_config'], "
|
"['reload_conf', 'reload_config'], ['show_conf', 'show_config'], "
|
||||||
|
@ -1763,6 +1763,25 @@ async def test_telegram_delete_trade(mocker, update, default_conf, fee, is_short
|
||||||
assert "Please make sure to take care of this asset" in msg_mock.call_args_list[0][0][0]
|
assert "Please make sure to take care of this asset" in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('is_short', [True, False])
|
||||||
|
async def test_telegram_reload_trade_from_exchange(mocker, update, default_conf, fee, is_short):
|
||||||
|
|
||||||
|
telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
|
||||||
|
context = MagicMock()
|
||||||
|
context.args = []
|
||||||
|
|
||||||
|
await telegram._reload_trade_from_exchange(update=update, context=context)
|
||||||
|
assert "Trade-id not set." in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
create_mock_trades(fee, is_short=is_short)
|
||||||
|
|
||||||
|
context.args = [5]
|
||||||
|
|
||||||
|
await telegram._reload_trade_from_exchange(update=update, context=context)
|
||||||
|
assert "Status: `Reloaded from orders from exchange`" in msg_mock.call_args_list[0][0][0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('is_short', [True, False])
|
@pytest.mark.parametrize('is_short', [True, False])
|
||||||
async def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short, ticker):
|
async def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short, ticker):
|
||||||
|
|
||||||
|
|
|
@ -5552,6 +5552,51 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap
|
||||||
assert log_has(f"Error updating {order['id']}.", caplog)
|
assert log_has(f"Error updating {order['id']}.", caplog)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
@pytest.mark.parametrize("is_short", [False, True])
|
||||||
|
def test_handle_onexchange_order(mocker, default_conf_usdt, limit_order, is_short, caplog):
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
|
||||||
|
mock_uts = mocker.spy(freqtrade, 'update_trade_state')
|
||||||
|
|
||||||
|
entry_order = limit_order[entry_side(is_short)]
|
||||||
|
exit_order = limit_order[exit_side(is_short)]
|
||||||
|
mock_fo = mocker.patch(f'{EXMS}.fetch_orders', return_value=[
|
||||||
|
entry_order,
|
||||||
|
exit_order,
|
||||||
|
])
|
||||||
|
|
||||||
|
order_id = entry_order['id']
|
||||||
|
|
||||||
|
trade = Trade(
|
||||||
|
open_order_id=order_id,
|
||||||
|
pair='ETH/USDT',
|
||||||
|
fee_open=0.001,
|
||||||
|
fee_close=0.001,
|
||||||
|
open_rate=entry_order['price'],
|
||||||
|
open_date=arrow.utcnow().datetime,
|
||||||
|
stake_amount=entry_order['cost'],
|
||||||
|
amount=entry_order['amount'],
|
||||||
|
exchange="binance",
|
||||||
|
is_short=is_short,
|
||||||
|
leverage=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
trade.orders.append(Order.parse_from_ccxt_object(
|
||||||
|
entry_order, 'ADA/USDT', entry_side(is_short))
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
freqtrade.handle_onexchange_order(trade)
|
||||||
|
assert log_has_re(r"Found previously unknown order .*", caplog)
|
||||||
|
assert mock_uts.call_count == 1
|
||||||
|
assert mock_fo.call_count == 1
|
||||||
|
|
||||||
|
trade = Trade.session.scalars(select(Trade)).first()
|
||||||
|
|
||||||
|
assert len(trade.orders) == 2
|
||||||
|
assert trade.is_open is False
|
||||||
|
assert trade.exit_reason == ExitType.SOLD_ON_EXCHANGE.value
|
||||||
|
|
||||||
|
|
||||||
def test_get_valid_price(mocker, default_conf_usdt) -> None:
|
def test_get_valid_price(mocker, default_conf_usdt) -> None:
|
||||||
patch_RPCManager(mocker)
|
patch_RPCManager(mocker)
|
||||||
patch_exchange(mocker)
|
patch_exchange(mocker)
|
||||||
|
|
|
@ -75,8 +75,9 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
|
||||||
_notify_exit=MagicMock(),
|
_notify_exit=MagicMock(),
|
||||||
)
|
)
|
||||||
mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock)
|
mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock)
|
||||||
wallets_mock = mocker.patch("freqtrade.wallets.Wallets.update", MagicMock())
|
wallets_mock = mocker.patch("freqtrade.wallets.Wallets.update")
|
||||||
mocker.patch("freqtrade.wallets.Wallets.get_free", MagicMock(return_value=1000))
|
mocker.patch("freqtrade.wallets.Wallets.get_free", return_value=1000)
|
||||||
|
mocker.patch("freqtrade.wallets.Wallets.check_exit_amount", return_value=True)
|
||||||
|
|
||||||
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
freqtrade.strategy.order_types['stoploss_on_exchange'] = True
|
freqtrade.strategy.order_types['stoploss_on_exchange'] = True
|
||||||
|
|
|
@ -3,9 +3,11 @@ from copy import deepcopy
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
|
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
|
||||||
from freqtrade.exceptions import DependencyException
|
from freqtrade.exceptions import DependencyException
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
from tests.conftest import EXMS, create_mock_trades, get_patched_freqtradebot, patch_wallet
|
from tests.conftest import EXMS, create_mock_trades, get_patched_freqtradebot, patch_wallet
|
||||||
|
|
||||||
|
|
||||||
|
@ -364,3 +366,48 @@ def test_sync_wallet_futures_dry(mocker, default_conf, fee):
|
||||||
free = freqtrade.wallets.get_free('BTC')
|
free = freqtrade.wallets.get_free('BTC')
|
||||||
used = freqtrade.wallets.get_used('BTC')
|
used = freqtrade.wallets.get_used('BTC')
|
||||||
assert free + used == total
|
assert free + used == total
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_exit_amount(mocker, default_conf, fee):
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
update_mock = mocker.patch("freqtrade.wallets.Wallets.update")
|
||||||
|
total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=123)
|
||||||
|
|
||||||
|
create_mock_trades(fee, is_short=None)
|
||||||
|
trade = Trade.session.scalars(select(Trade)).first()
|
||||||
|
assert trade.amount == 123
|
||||||
|
|
||||||
|
assert freqtrade.wallets.check_exit_amount(trade) is True
|
||||||
|
assert update_mock.call_count == 0
|
||||||
|
assert total_mock.call_count == 1
|
||||||
|
|
||||||
|
update_mock.reset_mock()
|
||||||
|
# Reduce returned amount to below the trade amount - which should
|
||||||
|
# trigger a wallet update and return False, triggering "order refinding"
|
||||||
|
total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=100)
|
||||||
|
assert freqtrade.wallets.check_exit_amount(trade) is False
|
||||||
|
assert update_mock.call_count == 1
|
||||||
|
assert total_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_exit_amount_futures(mocker, default_conf, fee):
|
||||||
|
default_conf['trading_mode'] = 'futures'
|
||||||
|
default_conf['margin_mode'] = 'isolated'
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=123)
|
||||||
|
|
||||||
|
create_mock_trades(fee, is_short=None)
|
||||||
|
trade = Trade.session.scalars(select(Trade)).first()
|
||||||
|
trade.trading_mode = 'futures'
|
||||||
|
assert trade.amount == 123
|
||||||
|
|
||||||
|
assert freqtrade.wallets.check_exit_amount(trade) is True
|
||||||
|
assert total_mock.call_count == 0
|
||||||
|
|
||||||
|
update_mock = mocker.patch("freqtrade.wallets.Wallets.update")
|
||||||
|
trade.amount = 150
|
||||||
|
# Reduce returned amount to below the trade amount - which should
|
||||||
|
# trigger a wallet update and return False, triggering "order refinding"
|
||||||
|
assert freqtrade.wallets.check_exit_amount(trade) is False
|
||||||
|
assert total_mock.call_count == 0
|
||||||
|
assert update_mock.call_count == 1
|
||||||
|
|
Loading…
Reference in New Issue
Block a user