Merge pull request #7194 from freqtrade/rpc/partial_forceexit
Partial forceExit
This commit is contained in:
commit
53251e7140
@ -624,7 +624,8 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
ordertype: Optional[str] = None,
|
ordertype: Optional[str] = None,
|
||||||
enter_tag: Optional[str] = None,
|
enter_tag: Optional[str] = None,
|
||||||
trade: Optional[Trade] = None,
|
trade: Optional[Trade] = None,
|
||||||
order_adjust: bool = False
|
order_adjust: bool = False,
|
||||||
|
leverage_: Optional[float] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Executes a limit buy for the given pair
|
Executes a limit buy for the given pair
|
||||||
@ -640,7 +641,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
pos_adjust = trade is not None
|
pos_adjust = trade is not None
|
||||||
|
|
||||||
enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake(
|
enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake(
|
||||||
pair, price, stake_amount, trade_side, enter_tag, trade, order_adjust)
|
pair, price, stake_amount, trade_side, enter_tag, trade, order_adjust, leverage_)
|
||||||
|
|
||||||
if not stake_amount:
|
if not stake_amount:
|
||||||
return False
|
return False
|
||||||
@ -787,6 +788,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
entry_tag: Optional[str],
|
entry_tag: Optional[str],
|
||||||
trade: Optional[Trade],
|
trade: Optional[Trade],
|
||||||
order_adjust: bool,
|
order_adjust: bool,
|
||||||
|
leverage_: Optional[float],
|
||||||
) -> Tuple[float, float, float]:
|
) -> Tuple[float, float, float]:
|
||||||
|
|
||||||
if price:
|
if price:
|
||||||
@ -809,16 +811,19 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
if not enter_limit_requested:
|
if not enter_limit_requested:
|
||||||
raise PricingError('Could not determine entry price.')
|
raise PricingError('Could not determine entry price.')
|
||||||
|
|
||||||
if trade is None:
|
if self.trading_mode != TradingMode.SPOT and trade is None:
|
||||||
max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
|
max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
|
||||||
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
|
if leverage_:
|
||||||
pair=pair,
|
leverage = leverage_
|
||||||
current_time=datetime.now(timezone.utc),
|
else:
|
||||||
current_rate=enter_limit_requested,
|
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
|
||||||
proposed_leverage=1.0,
|
pair=pair,
|
||||||
max_leverage=max_leverage,
|
current_time=datetime.now(timezone.utc),
|
||||||
side=trade_side, entry_tag=entry_tag,
|
current_rate=enter_limit_requested,
|
||||||
) if self.trading_mode != TradingMode.SPOT else 1.0
|
proposed_leverage=1.0,
|
||||||
|
max_leverage=max_leverage,
|
||||||
|
side=trade_side, entry_tag=entry_tag,
|
||||||
|
)
|
||||||
# Cap leverage between 1.0 and max_leverage.
|
# Cap leverage between 1.0 and max_leverage.
|
||||||
leverage = min(max(leverage, 1.0), max_leverage)
|
leverage = min(max(leverage, 1.0), max_leverage)
|
||||||
else:
|
else:
|
||||||
|
@ -325,11 +325,13 @@ class ForceEnterPayload(BaseModel):
|
|||||||
ordertype: Optional[OrderTypeValues]
|
ordertype: Optional[OrderTypeValues]
|
||||||
stakeamount: Optional[float]
|
stakeamount: Optional[float]
|
||||||
entry_tag: Optional[str]
|
entry_tag: Optional[str]
|
||||||
|
leverage: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
class ForceExitPayload(BaseModel):
|
class ForceExitPayload(BaseModel):
|
||||||
tradeid: str
|
tradeid: str
|
||||||
ordertype: Optional[OrderTypeValues]
|
ordertype: Optional[OrderTypeValues]
|
||||||
|
amount: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
class BlacklistPayload(BaseModel):
|
class BlacklistPayload(BaseModel):
|
||||||
|
@ -37,7 +37,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# 2.14: Add entry/exit orders to trade response
|
# 2.14: Add entry/exit orders to trade response
|
||||||
# 2.15: Add backtest history endpoints
|
# 2.15: Add backtest history endpoints
|
||||||
# 2.16: Additional daily metrics
|
# 2.16: Additional daily metrics
|
||||||
API_VERSION = 2.16
|
# 2.17: Forceentry - leverage, partial force_exit
|
||||||
|
API_VERSION = 2.17
|
||||||
|
|
||||||
# Public API, requires no auth.
|
# Public API, requires no auth.
|
||||||
router_public = APIRouter()
|
router_public = APIRouter()
|
||||||
@ -142,12 +143,11 @@ def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(g
|
|||||||
@router.post('/forcebuy', response_model=ForceEnterResponse, tags=['trading'])
|
@router.post('/forcebuy', response_model=ForceEnterResponse, tags=['trading'])
|
||||||
def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
|
def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
|
||||||
ordertype = payload.ordertype.value if payload.ordertype else None
|
ordertype = payload.ordertype.value if payload.ordertype else None
|
||||||
stake_amount = payload.stakeamount if payload.stakeamount else None
|
|
||||||
entry_tag = payload.entry_tag if payload.entry_tag else 'force_entry'
|
|
||||||
|
|
||||||
trade = rpc._rpc_force_entry(payload.pair, payload.price, order_side=payload.side,
|
trade = rpc._rpc_force_entry(payload.pair, payload.price, order_side=payload.side,
|
||||||
order_type=ordertype, stake_amount=stake_amount,
|
order_type=ordertype, stake_amount=payload.stakeamount,
|
||||||
enter_tag=entry_tag)
|
enter_tag=payload.entry_tag or 'force_entry',
|
||||||
|
leverage=payload.leverage)
|
||||||
|
|
||||||
if trade:
|
if trade:
|
||||||
return ForceEnterResponse.parse_obj(trade.to_json())
|
return ForceEnterResponse.parse_obj(trade.to_json())
|
||||||
@ -161,7 +161,7 @@ def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
|
|||||||
@router.post('/forcesell', response_model=ResultMsg, tags=['trading'])
|
@router.post('/forcesell', response_model=ResultMsg, tags=['trading'])
|
||||||
def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)):
|
def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)):
|
||||||
ordertype = payload.ordertype.value if payload.ordertype else None
|
ordertype = payload.ordertype.value if payload.ordertype else None
|
||||||
return rpc._rpc_force_exit(payload.tradeid, ordertype)
|
return rpc._rpc_force_exit(payload.tradeid, ordertype, amount=payload.amount)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist'])
|
@router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist'])
|
||||||
|
@ -660,36 +660,48 @@ class RPC:
|
|||||||
|
|
||||||
return {'status': 'No more buy will occur from now. Run /reload_config to reset.'}
|
return {'status': 'No more buy will occur from now. Run /reload_config to reset.'}
|
||||||
|
|
||||||
def _rpc_force_exit(self, trade_id: str, ordertype: Optional[str] = None) -> Dict[str, str]:
|
def __exec_force_exit(self, trade: Trade, ordertype: Optional[str],
|
||||||
|
amount: Optional[float] = None) -> None:
|
||||||
|
# Check if there is there is an open order
|
||||||
|
fully_canceled = False
|
||||||
|
if trade.open_order_id:
|
||||||
|
order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair)
|
||||||
|
|
||||||
|
if order['side'] == trade.entry_side:
|
||||||
|
fully_canceled = self._freqtrade.handle_cancel_enter(
|
||||||
|
trade, order, CANCEL_REASON['FORCE_EXIT'])
|
||||||
|
|
||||||
|
if order['side'] == trade.exit_side:
|
||||||
|
# Cancel order - so it is placed anew with a fresh price.
|
||||||
|
self._freqtrade.handle_cancel_exit(trade, order, CANCEL_REASON['FORCE_EXIT'])
|
||||||
|
|
||||||
|
if not fully_canceled:
|
||||||
|
# Get current rate and execute sell
|
||||||
|
current_rate = self._freqtrade.exchange.get_rate(
|
||||||
|
trade.pair, side='exit', is_short=trade.is_short, refresh=True)
|
||||||
|
exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT)
|
||||||
|
order_type = ordertype or self._freqtrade.strategy.order_types.get(
|
||||||
|
"force_exit", self._freqtrade.strategy.order_types["exit"])
|
||||||
|
sub_amount: Optional[float] = None
|
||||||
|
if amount and amount < trade.amount:
|
||||||
|
# Partial exit ...
|
||||||
|
min_exit_stake = self._freqtrade.exchange.get_min_pair_stake_amount(
|
||||||
|
trade.pair, current_rate, trade.stop_loss_pct)
|
||||||
|
remaining = (trade.amount - amount) * current_rate
|
||||||
|
if remaining < min_exit_stake:
|
||||||
|
raise RPCException(f'Remaining amount of {remaining} would be too small.')
|
||||||
|
sub_amount = amount
|
||||||
|
|
||||||
|
self._freqtrade.execute_trade_exit(
|
||||||
|
trade, current_rate, exit_check, ordertype=order_type,
|
||||||
|
sub_trade_amt=sub_amount)
|
||||||
|
|
||||||
|
def _rpc_force_exit(self, trade_id: str, ordertype: Optional[str] = None, *,
|
||||||
|
amount: Optional[float] = None) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Handler for forceexit <id>.
|
Handler for forceexit <id>.
|
||||||
Sells the given trade at current price
|
Sells the given trade at current price
|
||||||
"""
|
"""
|
||||||
def _exec_force_exit(trade: Trade) -> None:
|
|
||||||
# Check if there is there is an open order
|
|
||||||
fully_canceled = False
|
|
||||||
if trade.open_order_id:
|
|
||||||
order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair)
|
|
||||||
|
|
||||||
if order['side'] == trade.entry_side:
|
|
||||||
fully_canceled = self._freqtrade.handle_cancel_enter(
|
|
||||||
trade, order, CANCEL_REASON['FORCE_EXIT'])
|
|
||||||
|
|
||||||
if order['side'] == trade.exit_side:
|
|
||||||
# Cancel order - so it is placed anew with a fresh price.
|
|
||||||
self._freqtrade.handle_cancel_exit(trade, order, CANCEL_REASON['FORCE_EXIT'])
|
|
||||||
|
|
||||||
if not fully_canceled:
|
|
||||||
# Get current rate and execute sell
|
|
||||||
current_rate = self._freqtrade.exchange.get_rate(
|
|
||||||
trade.pair, side='exit', is_short=trade.is_short, refresh=True)
|
|
||||||
exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT)
|
|
||||||
order_type = ordertype or self._freqtrade.strategy.order_types.get(
|
|
||||||
"force_exit", self._freqtrade.strategy.order_types["exit"])
|
|
||||||
|
|
||||||
self._freqtrade.execute_trade_exit(
|
|
||||||
trade, current_rate, exit_check, ordertype=order_type)
|
|
||||||
# ---- EOF def _exec_forcesell ----
|
|
||||||
|
|
||||||
if self._freqtrade.state != State.RUNNING:
|
if self._freqtrade.state != State.RUNNING:
|
||||||
raise RPCException('trader is not running')
|
raise RPCException('trader is not running')
|
||||||
@ -698,7 +710,7 @@ class RPC:
|
|||||||
if trade_id == 'all':
|
if trade_id == 'all':
|
||||||
# Execute sell for all open orders
|
# Execute sell for all open orders
|
||||||
for trade in Trade.get_open_trades():
|
for trade in Trade.get_open_trades():
|
||||||
_exec_force_exit(trade)
|
self.__exec_force_exit(trade, ordertype)
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
self._freqtrade.wallets.update()
|
self._freqtrade.wallets.update()
|
||||||
return {'result': 'Created sell orders for all open trades.'}
|
return {'result': 'Created sell orders for all open trades.'}
|
||||||
@ -711,7 +723,7 @@ class RPC:
|
|||||||
logger.warning('force_exit: Invalid argument received')
|
logger.warning('force_exit: Invalid argument received')
|
||||||
raise RPCException('invalid argument')
|
raise RPCException('invalid argument')
|
||||||
|
|
||||||
_exec_force_exit(trade)
|
self.__exec_force_exit(trade, ordertype, amount)
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
self._freqtrade.wallets.update()
|
self._freqtrade.wallets.update()
|
||||||
return {'result': f'Created sell order for trade {trade_id}.'}
|
return {'result': f'Created sell order for trade {trade_id}.'}
|
||||||
@ -720,7 +732,8 @@ class RPC:
|
|||||||
order_type: Optional[str] = None,
|
order_type: Optional[str] = None,
|
||||||
order_side: SignalDirection = SignalDirection.LONG,
|
order_side: SignalDirection = SignalDirection.LONG,
|
||||||
stake_amount: Optional[float] = None,
|
stake_amount: Optional[float] = None,
|
||||||
enter_tag: Optional[str] = 'force_entry') -> Optional[Trade]:
|
enter_tag: Optional[str] = 'force_entry',
|
||||||
|
leverage: Optional[float] = None) -> Optional[Trade]:
|
||||||
"""
|
"""
|
||||||
Handler for forcebuy <asset> <price>
|
Handler for forcebuy <asset> <price>
|
||||||
Buys a pair trade at the given or current price
|
Buys a pair trade at the given or current price
|
||||||
@ -762,6 +775,7 @@ class RPC:
|
|||||||
ordertype=order_type, trade=trade,
|
ordertype=order_type, trade=trade,
|
||||||
is_short=is_short,
|
is_short=is_short,
|
||||||
enter_tag=enter_tag,
|
enter_tag=enter_tag,
|
||||||
|
leverage_=leverage,
|
||||||
):
|
):
|
||||||
Trade.commit()
|
Trade.commit()
|
||||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||||
|
@ -275,14 +275,20 @@ class FtRestClient():
|
|||||||
}
|
}
|
||||||
return self._post("forceenter", data=data)
|
return self._post("forceenter", data=data)
|
||||||
|
|
||||||
def forceexit(self, tradeid):
|
def forceexit(self, tradeid, ordertype=None, amount=None):
|
||||||
"""Force-exit a trade.
|
"""Force-exit a trade.
|
||||||
|
|
||||||
:param tradeid: Id of the trade (can be received via status command)
|
:param tradeid: Id of the trade (can be received via status command)
|
||||||
|
:param ordertype: Order type to use (must be market or limit)
|
||||||
|
:param amount: Amount to sell. Full sell if not given
|
||||||
:return: json object
|
:return: json object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self._post("forceexit", data={"tradeid": tradeid})
|
return self._post("forceexit", data={
|
||||||
|
"tradeid": tradeid,
|
||||||
|
"ordertype": ordertype,
|
||||||
|
"amount": amount,
|
||||||
|
})
|
||||||
|
|
||||||
def strategies(self):
|
def strategies(self):
|
||||||
"""Lists available strategies
|
"""Lists available strategies
|
||||||
|
@ -214,7 +214,8 @@ def mock_trade_4(fee, is_short: bool):
|
|||||||
open_order_id=f'prod_buy_{direc(is_short)}_12345',
|
open_order_id=f'prod_buy_{direc(is_short)}_12345',
|
||||||
strategy='StrategyTestV3',
|
strategy='StrategyTestV3',
|
||||||
timeframe=5,
|
timeframe=5,
|
||||||
is_short=is_short
|
is_short=is_short,
|
||||||
|
stop_loss_pct=0.10
|
||||||
)
|
)
|
||||||
o = Order.parse_from_ccxt_object(mock_order_4(is_short), 'ETC/BTC', entry_side(is_short))
|
o = Order.parse_from_ccxt_object(mock_order_4(is_short), 'ETC/BTC', entry_side(is_short))
|
||||||
trade.orders.append(o)
|
trade.orders.append(o)
|
||||||
@ -270,7 +271,8 @@ def mock_trade_5(fee, is_short: bool):
|
|||||||
enter_tag='TEST1',
|
enter_tag='TEST1',
|
||||||
stoploss_order_id=f'prod_stoploss_{direc(is_short)}_3455',
|
stoploss_order_id=f'prod_stoploss_{direc(is_short)}_3455',
|
||||||
timeframe=5,
|
timeframe=5,
|
||||||
is_short=is_short
|
is_short=is_short,
|
||||||
|
stop_loss_pct=0.10,
|
||||||
)
|
)
|
||||||
o = Order.parse_from_ccxt_object(mock_order_5(is_short), 'XRP/BTC', entry_side(is_short))
|
o = Order.parse_from_ccxt_object(mock_order_5(is_short), 'XRP/BTC', entry_side(is_short))
|
||||||
trade.orders.append(o)
|
trade.orders.append(o)
|
||||||
|
@ -1205,7 +1205,7 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets):
|
|||||||
fetch_ticker=ticker,
|
fetch_ticker=ticker,
|
||||||
get_fee=fee,
|
get_fee=fee,
|
||||||
markets=PropertyMock(return_value=markets),
|
markets=PropertyMock(return_value=markets),
|
||||||
_is_dry_limit_order_filled=MagicMock(return_value=False),
|
_is_dry_limit_order_filled=MagicMock(return_value=True),
|
||||||
)
|
)
|
||||||
patch_get_signal(ftbot)
|
patch_get_signal(ftbot)
|
||||||
|
|
||||||
@ -1215,12 +1215,27 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets):
|
|||||||
assert rc.json() == {"error": "Error querying /api/v1/forceexit: invalid argument"}
|
assert rc.json() == {"error": "Error querying /api/v1/forceexit: invalid argument"}
|
||||||
Trade.query.session.rollback()
|
Trade.query.session.rollback()
|
||||||
|
|
||||||
ftbot.enter_positions()
|
create_mock_trades(fee)
|
||||||
|
trade = Trade.get_trades([Trade.id == 5]).first()
|
||||||
|
assert pytest.approx(trade.amount) == 123
|
||||||
|
rc = client_post(client, f"{BASE_URI}/forceexit",
|
||||||
|
data='{"tradeid": "5", "ordertype": "market", "amount": 23}')
|
||||||
|
assert_response(rc)
|
||||||
|
assert rc.json() == {'result': 'Created sell order for trade 5.'}
|
||||||
|
Trade.query.session.rollback()
|
||||||
|
|
||||||
|
trade = Trade.get_trades([Trade.id == 5]).first()
|
||||||
|
assert pytest.approx(trade.amount) == 100
|
||||||
|
assert trade.is_open is True
|
||||||
|
|
||||||
rc = client_post(client, f"{BASE_URI}/forceexit",
|
rc = client_post(client, f"{BASE_URI}/forceexit",
|
||||||
data='{"tradeid": "1"}')
|
data='{"tradeid": "5"}')
|
||||||
assert_response(rc)
|
assert_response(rc)
|
||||||
assert rc.json() == {'result': 'Created sell order for trade 1.'}
|
assert rc.json() == {'result': 'Created sell order for trade 5.'}
|
||||||
|
Trade.query.session.rollback()
|
||||||
|
|
||||||
|
trade = Trade.get_trades([Trade.id == 5]).first()
|
||||||
|
assert trade.is_open is False
|
||||||
|
|
||||||
|
|
||||||
def test_api_pair_candles(botclient, ohlcv_history):
|
def test_api_pair_candles(botclient, ohlcv_history):
|
||||||
|
@ -973,6 +973,14 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order,
|
|||||||
trade.is_short = is_short
|
trade.is_short = is_short
|
||||||
assert pytest.approx(trade.stake_amount) == 500
|
assert pytest.approx(trade.stake_amount) == 500
|
||||||
|
|
||||||
|
order['id'] = '55673'
|
||||||
|
|
||||||
|
freqtrade.strategy.leverage.reset_mock()
|
||||||
|
assert freqtrade.execute_entry(pair, 200, leverage_=3)
|
||||||
|
assert freqtrade.strategy.leverage.call_count == 0
|
||||||
|
trade = Trade.query.all()[10]
|
||||||
|
assert trade.leverage == 1 if trading_mode == 'spot' else 3
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("is_short", [False, True])
|
@pytest.mark.parametrize("is_short", [False, True])
|
||||||
def test_execute_entry_confirm_error(mocker, default_conf_usdt, fee, limit_order, is_short) -> None:
|
def test_execute_entry_confirm_error(mocker, default_conf_usdt, fee, limit_order, is_short) -> None:
|
||||||
|
Loading…
Reference in New Issue
Block a user