diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cad359e..edab23f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.4.128] - 2023-12-03 +## [2.4.129] - 2023-12-03 ### Updated +[Futures] fix simulation numbers and update api +### Fixed +[Exchanges] fix cancelled order status error + +## [2.4.128] - 2023-`12-03 +###` Updated [OHLCVUpdater] prevent missing candles spam ## [2.4.127] - 2023-11-28 diff --git a/README.md b/README.md index 2faa78839..d41370123 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OctoBot-Trading [2.4.128](https://github.com/Drakkar-Software/OctoBot-Trading/blob/master/CHANGELOG.md) +# OctoBot-Trading [2.4.129](https://github.com/Drakkar-Software/OctoBot-Trading/blob/master/CHANGELOG.md) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/903b6b22bceb4661b608a86fea655f69)](https://app.codacy.com/gh/Drakkar-Software/OctoBot-Trading?utm_source=github.com&utm_medium=referral&utm_content=Drakkar-Software/OctoBot-Trading&utm_campaign=Badge_Grade_Dashboard) [![PyPI](https://img.shields.io/pypi/v/OctoBot-Trading.svg)](https://pypi.python.org/pypi/OctoBot-Trading/) [![Coverage Status](https://coveralls.io/repos/github/Drakkar-Software/OctoBot-Trading/badge.svg?branch=master)](https://coveralls.io/github/Drakkar-Software/OctoBot-Trading?branch=master) diff --git a/octobot_trading/__init__.py b/octobot_trading/__init__.py index 945bae8df..04133411f 100644 --- a/octobot_trading/__init__.py +++ b/octobot_trading/__init__.py @@ -15,4 +15,4 @@ # License along with this library. PROJECT_NAME = "OctoBot-Trading" -VERSION = "2.4.128" # major.minor.revision +VERSION = "2.4.129" # major.minor.revision diff --git a/octobot_trading/api/__init__.py b/octobot_trading/api/__init__.py index be266350a..c1a127b54 100644 --- a/octobot_trading/api/__init__.py +++ b/octobot_trading/api/__init__.py @@ -201,12 +201,17 @@ from octobot_trading.api.positions import ( get_positions, close_position, + set_is_exclusively_using_exchange_position_details, + update_position_mark_price, ) from octobot_trading.api.contracts import ( is_inverse_future_contract, is_perpetual_future_contract, get_pair_contracts, is_handled_contract, + has_pair_future_contract, + load_pair_contract, + create_default_future_contract, ) from octobot_trading.api.storage import ( clear_trades_storage_history, @@ -380,6 +385,11 @@ "is_perpetual_future_contract", "get_pair_contracts", "is_handled_contract", + "has_pair_future_contract", + "load_pair_contract", + "create_default_future_contract", + "set_is_exclusively_using_exchange_position_details", + "update_position_mark_price", "clear_trades_storage_history", "clear_candles_storage_history", "clear_database_storage_history", diff --git a/octobot_trading/api/contracts.py b/octobot_trading/api/contracts.py index a1ed69da1..414e77689 100644 --- a/octobot_trading/api/contracts.py +++ b/octobot_trading/api/contracts.py @@ -13,8 +13,10 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. -import octobot_trading.exchange_data as exchange_data +import decimal +import octobot_trading.enums as enums +import octobot_trading.exchange_data as exchange_data def is_inverse_future_contract(contract_type): return exchange_data.FutureContract(None, None, contract_type).is_inverse_contract() @@ -30,3 +32,17 @@ def get_pair_contracts(exchange_manager) -> dict: def is_handled_contract(contract) -> bool: return contract.is_handled_contract() + + +def has_pair_future_contract(exchange_manager, pair: str) -> bool: + return exchange_manager.exchange.has_pair_future_contract(pair) + + +def load_pair_contract(exchange_manager, contract_dict: dict): + exchange_data.update_future_contract_from_dict(exchange_manager, contract_dict) + + +def create_default_future_contract( + pair: str, leverage: decimal.Decimal, contract_type: enums.FutureContractType +) -> exchange_data.FutureContract: + return exchange_data.create_default_future_contract(pair, leverage, contract_type) diff --git a/octobot_trading/api/positions.py b/octobot_trading/api/positions.py index 15aa77d71..a4f406d51 100644 --- a/octobot_trading/api/positions.py +++ b/octobot_trading/api/positions.py @@ -34,3 +34,20 @@ async def close_position(exchange_manager, symbol: str, side: enums.PositionSide emit_trading_signals=emit_trading_signals ) else 0 return 0 + + +def set_is_exclusively_using_exchange_position_details( + exchange_manager, is_exclusively_using_exchange_position_details: bool +): + exchange_manager.exchange_personal_data.positions_manager.is_exclusively_using_exchange_position_details = ( + is_exclusively_using_exchange_position_details + ) + + +async def update_position_mark_price( + exchange_manager, symbol: str, side: enums.PositionSide, mark_price: decimal.Decimal +): + for position in exchange_manager.exchange_personal_data.positions_manager.get_symbol_positions(symbol): + if position.side is side: + await position.update(mark_price=mark_price) + return position diff --git a/octobot_trading/enums.py b/octobot_trading/enums.py index 6bfefedb5..bce06afd7 100644 --- a/octobot_trading/enums.py +++ b/octobot_trading/enums.py @@ -342,6 +342,7 @@ class TradeExtraConstants(enum.Enum): class ExchangeConstantsPositionColumns(enum.Enum): ID = "id" + LOCAL_ID = "local_id" TIMESTAMP = "timestamp" SYMBOL = "symbol" ENTRY_PRICE = "entry_price" @@ -355,6 +356,7 @@ class ExchangeConstantsPositionColumns(enum.Enum): SIZE = "size" NOTIONAL = "notional" INITIAL_MARGIN = "initial_margin" + AUTO_DEPOSIT_MARGIN = "auto_deposit_margin" COLLATERAL = "collateral" LEVERAGE = "leverage" MARGIN_TYPE = "margin_type" @@ -366,6 +368,23 @@ class ExchangeConstantsPositionColumns(enum.Enum): SIDE = "side" +class ExchangeConstantsMarginContractColumns(enum.Enum): + PAIR = "pair" + MARGIN_TYPE = "margin_type" + CONTRACT_SIZE = "contract_size" + MAXIMUM_LEVERAGE = "maximum_leverage" + CURRENT_LEVERAGE = "current_leverage" + RISK_LIMIT = "risk_limit" + + +class ExchangeConstantsFutureContractColumns(enum.Enum): + CONTRACT_TYPE = "contract_type" + MINIMUM_TICK_SIZE = "minimum_tick_size" + POSITION_MODE = "position_mode" + MAINTENANCE_MARGIN_RATE = "maintenance_margin_rate" + TAKE_PROFIT_STOP_LOSS_MODE = "take_profit_stop_loss_mode" + + class ExchangeConstantsLiquidationColumns(enum.Enum): ID = "id" TIMESTAMP = "timestamp" diff --git a/octobot_trading/exchange_data/__init__.py b/octobot_trading/exchange_data/__init__.py index 2b8e37291..11cd3773e 100644 --- a/octobot_trading/exchange_data/__init__.py +++ b/octobot_trading/exchange_data/__init__.py @@ -91,6 +91,8 @@ MarginContract, FutureContract, update_contracts_from_positions, + update_future_contract_from_dict, + create_default_future_contract, ) from octobot_trading.exchange_data import exchange_symbol_data from octobot_trading.exchange_data.exchange_symbol_data import ( @@ -193,6 +195,8 @@ "MarginContract", "FutureContract", "update_contracts_from_positions", + "update_future_contract_from_dict", + "create_default_future_contract", "ExchangeSymbolsData", "ExchangeSymbolData", "UNAUTHENTICATED_UPDATER_PRODUCERS", diff --git a/octobot_trading/exchange_data/contracts/__init__.py b/octobot_trading/exchange_data/contracts/__init__.py index 878f8e66f..300659d5e 100644 --- a/octobot_trading/exchange_data/contracts/__init__.py +++ b/octobot_trading/exchange_data/contracts/__init__.py @@ -27,10 +27,14 @@ from octobot_trading.exchange_data.contracts import contract_factory from octobot_trading.exchange_data.contracts.contract_factory import ( update_contracts_from_positions, + update_future_contract_from_dict, + create_default_future_contract, ) __all__ = [ "MarginContract", "FutureContract", "update_contracts_from_positions", + "update_future_contract_from_dict", + "create_default_future_contract", ] diff --git a/octobot_trading/exchange_data/contracts/contract_factory.py b/octobot_trading/exchange_data/contracts/contract_factory.py index 00d7e044f..c67d59f11 100644 --- a/octobot_trading/exchange_data/contracts/contract_factory.py +++ b/octobot_trading/exchange_data/contracts/contract_factory.py @@ -13,10 +13,13 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import decimal + import octobot_commons.logging as logging import octobot_trading.enums as enums import octobot_trading.constants as constants +import octobot_trading.exchange_data.contracts.future_contract as future_contract def update_contracts_from_positions(exchange_manager, positions) -> bool: @@ -51,5 +54,44 @@ def update_contracts_from_positions(exchange_manager, positions) -> bool: return updated +def update_future_contract_from_dict(exchange_manager, contract: dict) -> bool: + return exchange_manager.exchange.create_pair_contract( + pair=contract[enums.ExchangeConstantsMarginContractColumns.PAIR.value], + current_leverage=decimal.Decimal(str( + contract[enums.ExchangeConstantsMarginContractColumns.CURRENT_LEVERAGE.value] + )), + contract_size=decimal.Decimal(str( + contract[enums.ExchangeConstantsMarginContractColumns.CONTRACT_SIZE.value] + )), + margin_type=enums.MarginType(contract[enums.ExchangeConstantsMarginContractColumns.MARGIN_TYPE.value]), + contract_type=enums.FutureContractType(contract[enums.ExchangeConstantsFutureContractColumns.CONTRACT_TYPE.value]), + position_mode=enums.PositionMode(contract[enums.ExchangeConstantsFutureContractColumns.POSITION_MODE.value]) + if contract[enums.ExchangeConstantsFutureContractColumns.POSITION_MODE.value] + else contract[enums.ExchangeConstantsFutureContractColumns.POSITION_MODE.value], + maintenance_margin_rate=decimal.Decimal(str( + contract[enums.ExchangeConstantsFutureContractColumns.MAINTENANCE_MARGIN_RATE.value] + )), + maximum_leverage=None if contract[enums.ExchangeConstantsMarginContractColumns.MAXIMUM_LEVERAGE.value] is None + else decimal.Decimal(str( + contract[enums.ExchangeConstantsMarginContractColumns.MAXIMUM_LEVERAGE.value] + )) + ) + + +def create_default_future_contract( + pair: str, leverage: decimal.Decimal, contract_type: enums.FutureContractType +) -> future_contract.FutureContract: + return future_contract.FutureContract( + pair=pair, + contract_size=constants.DEFAULT_SYMBOL_CONTRACT_SIZE, + margin_type=constants.DEFAULT_SYMBOL_MARGIN_TYPE, + contract_type=contract_type, + maximum_leverage=constants.DEFAULT_SYMBOL_MAX_LEVERAGE, + current_leverage=leverage, + position_mode=constants.DEFAULT_SYMBOL_POSITION_MODE, + maintenance_margin_rate=constants.DEFAULT_SYMBOL_MAINTENANCE_MARGIN_RATE + ) + + def _get_logger(): return logging.get_logger("contract_factory") diff --git a/octobot_trading/exchange_data/contracts/future_contract.py b/octobot_trading/exchange_data/contracts/future_contract.py index ab563b260..ea4bfc2c0 100644 --- a/octobot_trading/exchange_data/contracts/future_contract.py +++ b/octobot_trading/exchange_data/contracts/future_contract.py @@ -111,3 +111,19 @@ def update_from_position(self, raw_position) -> bool: logging.get_logger(str(self)).debug(f"Changed position mode to {pos_mode}") changed = True return changed + + def to_dict(self): + return { + **super().to_dict(), + **{ + enums.ExchangeConstantsFutureContractColumns.CONTRACT_TYPE.value: + self.contract_type.value if self.contract_type else self.contract_type, + enums.ExchangeConstantsFutureContractColumns.MINIMUM_TICK_SIZE.value: self.minimum_tick_size, + enums.ExchangeConstantsFutureContractColumns.POSITION_MODE.value: + self.position_mode.value if self.position_mode else self.position_mode, + enums.ExchangeConstantsFutureContractColumns.MAINTENANCE_MARGIN_RATE.value: + self.maintenance_margin_rate, + enums.ExchangeConstantsFutureContractColumns.TAKE_PROFIT_STOP_LOSS_MODE.value: + self.take_profit_stop_loss_mode, + } + } diff --git a/octobot_trading/exchange_data/contracts/margin_contract.py b/octobot_trading/exchange_data/contracts/margin_contract.py index c02e1ce1f..3dd700237 100644 --- a/octobot_trading/exchange_data/contracts/margin_contract.py +++ b/octobot_trading/exchange_data/contracts/margin_contract.py @@ -82,3 +82,13 @@ def update_from_position(self, raw_position) -> bool: logging.get_logger(str(self)).debug(f"Changed margin type to {margin_type}") changed = True return changed + + def to_dict(self): + return { + enums.ExchangeConstantsMarginContractColumns.PAIR.value: self.pair, + enums.ExchangeConstantsMarginContractColumns.MARGIN_TYPE.value: self.margin_type.value, + enums.ExchangeConstantsMarginContractColumns.CONTRACT_SIZE.value: self.contract_size, + enums.ExchangeConstantsMarginContractColumns.MAXIMUM_LEVERAGE.value: self.maximum_leverage, + enums.ExchangeConstantsMarginContractColumns.CURRENT_LEVERAGE.value: self.current_leverage, + enums.ExchangeConstantsMarginContractColumns.RISK_LIMIT.value: self.risk_limit, + } diff --git a/octobot_trading/exchanges/connectors/ccxt/ccxt_adapter.py b/octobot_trading/exchanges/connectors/ccxt/ccxt_adapter.py index d397ba2de..00fdac311 100644 --- a/octobot_trading/exchanges/connectors/ccxt/ccxt_adapter.py +++ b/octobot_trading/exchanges/connectors/ccxt/ccxt_adapter.py @@ -273,21 +273,36 @@ def parse_position(self, fixed, force_empty=False, **kwargs): # CCXT standard position parsing logic # if mode is enums.PositionMode.ONE_WAY: original_side = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SIDE.value) - position_side = enums.PositionSide.BOTH - # todo when handling cross positions - # side = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SIDE.value, enums.PositionSide.UNKNOWN.value) - # position_side = enums.PositionSide.LONG \ - # if side == enums.PositionSide.LONG.value else enums.PositionSide. symbol = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SYMBOL.value) contract_size = decimal.Decimal(str(fixed.get(ccxt_enums.ExchangePositionCCXTColumns.CONTRACT_SIZE.value, 0))) contracts = constants.ZERO if force_empty \ else decimal.Decimal(str(fixed.get(ccxt_enums.ExchangePositionCCXTColumns.CONTRACTS.value, 0))) is_empty = contracts == constants.ZERO + position_mode = ( + enums.PositionMode.HEDGE if fixed.get(ccxt_enums.ExchangePositionCCXTColumns.HEDGED.value, False) + else enums.PositionMode.ONE_WAY + ) + if position_mode is enums.PositionMode.HEDGE: + # todo when handling hedge positions + side = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SIDE.value, enums.PositionSide.UNKNOWN.value) + position_side = enums.PositionSide.LONG \ + if side == enums.PositionSide.LONG.value else enums.PositionSide.SHORT + log_func = self.logger.debug + if is_empty: + log_func = self.logger.error + log_func(f"Unhandled {symbol} position mode ({position_mode.value}). This position can't be traded.") + else: + # One way position use BOTH side as there is always only one position per symbol. + # This position can turn long and short + position_side = enums.PositionSide.BOTH liquidation_price = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.LIQUIDATION_PRICE.value, 0) - if margin_type := fixed.get(ccxt_enums.ExchangePositionCCXTColumns.MARGIN_TYPE.value, None): + if margin_type := fixed.get( + ccxt_enums.ExchangePositionCCXTColumns.MARGIN_TYPE.value, + fixed.get(ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value, None) # can also be contained in margin mode + ): margin_type = enums.MarginType(margin_type) if force_empty or liquidation_price is None: - liquidation_price = constants.NaN + liquidation_price = constants.ZERO else: liquidation_price = decimal.Decimal(str(liquidation_price)) try: @@ -306,9 +321,7 @@ def parse_position(self, fixed, force_empty=False, **kwargs): enums.ExchangeConstantsPositionColumns.LEVERAGE.value: self.safe_decimal(fixed, ccxt_enums.ExchangePositionCCXTColumns.LEVERAGE.value, constants.DEFAULT_SYMBOL_LEVERAGE), - enums.ExchangeConstantsPositionColumns.POSITION_MODE.value: None if is_empty else - enums.PositionMode.HEDGE if fixed.get(ccxt_enums.ExchangePositionCCXTColumns.HEDGED.value, True) - else enums.PositionMode.ONE_WAY, + enums.ExchangeConstantsPositionColumns.POSITION_MODE.value: position_mode, # next values are always 0 when the position empty (0 contracts) enums.ExchangeConstantsPositionColumns.COLLATERAL.value: constants.ZERO if is_empty else decimal.Decimal( @@ -319,6 +332,7 @@ def parse_position(self, fixed, force_empty=False, **kwargs): enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: constants.ZERO if is_empty else decimal.Decimal( f"{fixed.get(ccxt_enums.ExchangePositionCCXTColumns.INITIAL_MARGIN.value, 0) or 0}"), + enums.ExchangeConstantsPositionColumns.AUTO_DEPOSIT_MARGIN.value: False, # default value enums.ExchangeConstantsPositionColumns.UNREALIZED_PNL.value: constants.ZERO if is_empty else decimal.Decimal( f"{fixed.get(ccxt_enums.ExchangePositionCCXTColumns.UNREALISED_PNL.value, 0) or 0}"), diff --git a/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py b/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py index 12b8c2f6c..368966f2d 100644 --- a/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py +++ b/octobot_trading/exchanges/connectors/ccxt/ccxt_connector.py @@ -157,7 +157,11 @@ async def load_symbol_markets( except KeyError: load_markets = True if load_markets: - self.logger.info(f"Loading {self.exchange_manager.exchange_name} exchange markets") + self.logger.info( + f"Loading {self.exchange_manager.exchange_name} " + f"{exchanges.get_exchange_type(self.exchange_manager).value}" + f"{' sandbox' if self.exchange_manager.is_sandboxed else ''} exchange markets" + ) try: await self._load_markets(self.client, reload) ccxt_client_util.set_markets_cache(self.client) diff --git a/octobot_trading/exchanges/implementations/exchange_simulator.py b/octobot_trading/exchanges/implementations/exchange_simulator.py index eaf78af6b..5bbc26f36 100644 --- a/octobot_trading/exchanges/implementations/exchange_simulator.py +++ b/octobot_trading/exchanges/implementations/exchange_simulator.py @@ -19,6 +19,7 @@ import octobot_trading.exchanges.util as exchange_util import octobot_trading.exchanges.connectors.simulator.exchange_simulator_connector as exchange_simulator_connector import octobot_trading.exchanges.types.rest_exchange as rest_exchange +import octobot_trading.exchange_data.contracts.contract_factory as contract_factory class ExchangeSimulator(rest_exchange.RestExchange): @@ -119,15 +120,20 @@ async def load_pair_future_contract(self, pair: str): Create a new FutureContract for the pair :param pair: the pair """ + contract = contract_factory.create_default_future_contract( + pair, + constants.DEFAULT_SYMBOL_LEVERAGE, + self.exchange_manager.exchange_config.backtesting_exchange_config.future_contract_type + ) return self.create_pair_contract( - pair=pair, - current_leverage=constants.DEFAULT_SYMBOL_LEVERAGE, - contract_size=constants.DEFAULT_SYMBOL_CONTRACT_SIZE, - margin_type=constants.DEFAULT_SYMBOL_MARGIN_TYPE, - contract_type=self.exchange_manager.exchange_config.backtesting_exchange_config.future_contract_type, - position_mode=constants.DEFAULT_SYMBOL_POSITION_MODE, - maintenance_margin_rate=constants.DEFAULT_SYMBOL_MAINTENANCE_MARGIN_RATE, - maximum_leverage=constants.DEFAULT_SYMBOL_MAX_LEVERAGE + contract.pair, + contract.current_leverage, + contract.contract_size, + contract.margin_type, + contract.contract_type, + contract.position_mode, + contract.maintenance_margin_rate, + maximum_leverage = contract.maximum_leverage, ) async def get_symbol_leverage(self, symbol: str, **kwargs: dict): diff --git a/octobot_trading/exchanges/traders/trader.py b/octobot_trading/exchanges/traders/trader.py index 313eade24..1f95d1de3 100644 --- a/octobot_trading/exchanges/traders/trader.py +++ b/octobot_trading/exchanges/traders/trader.py @@ -19,6 +19,7 @@ import typing import octobot_commons.logging as logging +import octobot_commons.html_util as html_util import octobot_commons.constants import octobot_trading.personal_data.orders.order_factory as order_factory @@ -452,7 +453,8 @@ async def _handle_order_cancel_error(self, order, err, wait_for_cancelling, canc elif order.is_open(): if isinstance(err, errors.OrderNotFoundOnCancelError): raise errors.OrderNotFoundOnCancelError( - f"Tried to cancel an order that can't be found, it might be cancelled or filled already ({err}). " + f"Tried to cancel an order that can't be found, it might be cancelled or filled already " + f"({html_util.get_html_summary_if_relevant(err)}). " f"Order: {order}" ) from err raise errors.OpenOrderError( diff --git a/octobot_trading/modes/abstract_trading_mode.py b/octobot_trading/modes/abstract_trading_mode.py index d61c76cf8..77830e13b 100644 --- a/octobot_trading/modes/abstract_trading_mode.py +++ b/octobot_trading/modes/abstract_trading_mode.py @@ -207,6 +207,13 @@ def is_backtestable() -> bool: """ return True + def is_updating_exchange_settings(self, context) -> bool: + """ + :return: True if the TradingMode should update exchange settings + (such as leverage) upon initializing user inputs + """ + return True + async def initialize(self, trading_config=None, auto_start=True) -> None: """ Triggers producers and consumers creation diff --git a/octobot_trading/modes/channel/abstract_mode_consumer.py b/octobot_trading/modes/channel/abstract_mode_consumer.py index 1d055c748..09cb104a9 100644 --- a/octobot_trading/modes/channel/abstract_mode_consumer.py +++ b/octobot_trading/modes/channel/abstract_mode_consumer.py @@ -172,7 +172,7 @@ async def can_create_order(self, symbol, state): ) can_create_order = max_order_size > symbol_min_amount self.logger.debug( - f"can_create_order: {can_create_order} = " + f"can_create_order: {can_create_order} [{symbol}] = " f"max_order_size > symbol_min_amount = {max_order_size} > {symbol_min_amount}" ) return can_create_order @@ -182,7 +182,7 @@ async def can_create_order(self, symbol, state): if state == enums.EvaluatorStates.VERY_SHORT.value or state == enums.EvaluatorStates.SHORT.value: can_create_order = portfolio.get_currency_portfolio(currency).available > symbol_min_amount self.logger.debug( - f"can_create_order: {can_create_order} = " + f"can_create_order: {can_create_order} [{symbol}] = " f"portfolio.get_currency_portfolio(currency).available > symbol_min_amount = " f"{portfolio.get_currency_portfolio(currency).available} > {symbol_min_amount}" ) @@ -192,7 +192,7 @@ async def can_create_order(self, symbol, state): elif state == enums.EvaluatorStates.LONG.value or state == enums.EvaluatorStates.VERY_LONG.value: can_create_order = portfolio.get_currency_portfolio(market).available > order_min_amount self.logger.debug( - f"can_create_order: {can_create_order} = " + f"can_create_order: {can_create_order} [{symbol}] = " f"portfolio.get_currency_portfolio(market).available > order_min_amount = " f"{portfolio.get_currency_portfolio(market).available} > {order_min_amount}" ) diff --git a/octobot_trading/modes/channel/abstract_mode_producer.py b/octobot_trading/modes/channel/abstract_mode_producer.py index 91a0fe66d..91cd33f1c 100644 --- a/octobot_trading/modes/channel/abstract_mode_producer.py +++ b/octobot_trading/modes/channel/abstract_mode_producer.py @@ -502,11 +502,12 @@ async def _register_and_apply_required_user_inputs(self, context): common_enums.ActivationTopics.EVALUATION_CYCLE.value, activation_topic_values ) - try: - await self._apply_exchange_side_config(context) - except Exception as err: - # TODO important error to display - self.logger.exception(err, True, f"Error when applying exchange side config: {err}") + if self.trading_mode.is_updating_exchange_settings(context): + try: + await self._apply_exchange_side_config(context) + except Exception as err: + # TODO important error to display + self.logger.exception(err, True, f"Error when applying exchange side config: {err}") async def _apply_exchange_side_config(self, context): # can be slow, call in a task if necessary diff --git a/octobot_trading/modes/script_keywords/basic_keywords/amount.py b/octobot_trading/modes/script_keywords/basic_keywords/amount.py index 151d781b7..0dd835a5b 100644 --- a/octobot_trading/modes/script_keywords/basic_keywords/amount.py +++ b/octobot_trading/modes/script_keywords/basic_keywords/amount.py @@ -16,6 +16,7 @@ import octobot_commons.symbols as commons_symbols import octobot_trading.modes.script_keywords.dsl as dsl import octobot_trading.modes.script_keywords.basic_keywords.account_balance as account_balance +import octobot_trading.modes.script_keywords.basic_keywords.position as position_kw import octobot_trading.personal_data as trading_personal_data import octobot_trading.errors as trading_errors import octobot_trading.enums as trading_enums @@ -77,7 +78,18 @@ async def get_amount_from_input_amount( ) amount_value = total_symbol_assets_holdings_value * amount_value / trading_constants.ONE_HUNDRED elif amount_type is dsl.QuantityType.POSITION_PERCENT: - raise NotImplementedError(amount_type) + if context.exchange_manager.is_future: + if position_kw.is_in_one_way_position_mode(context): + # use abs() since short positions have negative size + amount_value = abs( + position_kw.get_position( + context, symbol=context.symbol, side=trading_enums.PositionSide.BOTH.value + ).size + ) * amount_value / trading_constants.ONE_HUNDRED + else: + raise NotImplementedError(f"{amount_type} input type is not implemented for non-one-way positions") + else: + raise NotImplementedError(f"{amount_type} input type is not implemented for non-future exchanges") else: raise trading_errors.InvalidArgumentError(f"Unsupported input: {input_amount} make sure to use a supported syntax for amount") adapted_amount = await account_balance.adapt_amount_to_holdings( diff --git a/octobot_trading/personal_data/__init__.py b/octobot_trading/personal_data/__init__.py index e2b5b9f9e..b3237d282 100644 --- a/octobot_trading/personal_data/__init__.py +++ b/octobot_trading/personal_data/__init__.py @@ -162,9 +162,14 @@ PositionsUpdater, PositionsManager, create_position_instance_from_raw, + create_position_instance_from_dict, + sanitize_raw_position, create_position_from_type, create_symbol_position, parse_position_status, + parse_position_side, + parse_position_margin_type, + parse_position_mode, LiquidatePositionState, IdlePositionState, ActivePositionState, @@ -176,6 +181,7 @@ TradesProducer, TradesChannel, create_trade_instance_from_raw, + create_closed_order_instance_from_raw_trade, create_trade_from_order, create_trade_from_dict, TradesUpdater, @@ -376,13 +382,19 @@ "PositionsUpdater", "PositionsManager", "create_position_instance_from_raw", + "create_position_instance_from_dict", + "sanitize_raw_position", "create_position_from_type", "create_symbol_position", "parse_position_status", + "parse_position_side", + "parse_position_margin_type", + "parse_position_mode", "TradesManager", "TradesProducer", "TradesChannel", "create_trade_instance_from_raw", + "create_closed_order_instance_from_raw_trade", "create_trade_from_order", "create_trade_from_dict", "TradesUpdater", diff --git a/octobot_trading/personal_data/exchange_personal_data.py b/octobot_trading/personal_data/exchange_personal_data.py index 060e84543..383b2e857 100644 --- a/octobot_trading/personal_data/exchange_personal_data.py +++ b/octobot_trading/personal_data/exchange_personal_data.py @@ -55,8 +55,9 @@ async def initialize_impl(self): self.exchange = self.exchange_manager.exchange if self.trader.is_enabled: try: - self.portfolio_manager = portfolio_manager.PortfolioManager(self.config, self.trader, - self.exchange_manager) + self.portfolio_manager = portfolio_manager.PortfolioManager( + self.config, self.trader, self.exchange_manager + ) self.trades_manager = trades_manager.TradesManager(self.trader) self.orders_manager = orders_manager.OrdersManager(self.trader) self.positions_manager = positions_manager.PositionsManager(self.trader) @@ -97,7 +98,6 @@ async def handle_portfolio_and_position_update_from_order( await self.handle_portfolio_update_notification(self.portfolio_manager.portfolio.portfolio) if self.exchange_manager.is_future: - # should this be done only "if should_notify" ? await self.handle_position_instance_update( order.exchange_manager.exchange_personal_data.positions_manager.get_order_position(order), should_notify=True diff --git a/octobot_trading/personal_data/positions/__init__.py b/octobot_trading/personal_data/positions/__init__.py index d81169d12..e31f35669 100644 --- a/octobot_trading/personal_data/positions/__init__.py +++ b/octobot_trading/personal_data/positions/__init__.py @@ -54,11 +54,16 @@ from octobot_trading.personal_data.positions import position_util from octobot_trading.personal_data.positions.position_util import ( parse_position_status, + parse_position_side, + parse_position_margin_type, + parse_position_mode, ) from octobot_trading.personal_data.positions import position_factory from octobot_trading.personal_data.positions.position_factory import ( create_position_instance_from_raw, + create_position_instance_from_dict, + sanitize_raw_position, create_position_from_type, create_symbol_position, ) @@ -74,9 +79,14 @@ "PositionsUpdater", "PositionsManager", "create_position_instance_from_raw", + "create_position_instance_from_dict", + "sanitize_raw_position", "create_position_from_type", "create_symbol_position", "parse_position_status", + "parse_position_side", + "parse_position_margin_type", + "parse_position_mode", "LiquidatePositionState", "IdlePositionState", "ActivePositionState", diff --git a/octobot_trading/personal_data/positions/position.py b/octobot_trading/personal_data/positions/position.py index f556ab719..25a642927 100644 --- a/octobot_trading/personal_data/positions/position.py +++ b/octobot_trading/personal_data/positions/position.py @@ -68,6 +68,7 @@ def __init__(self, trader, symbol_contract): self.value = constants.ZERO self.initial_margin = constants.ZERO self.margin = constants.ZERO + self.auto_deposit_margin = False # PNL self.unrealized_pnl = constants.ZERO @@ -131,7 +132,7 @@ def _should_change(self, original_value, new_value): def _update(self, position_id, exchange_position_id, symbol, currency, market, timestamp, entry_price, mark_price, liquidation_price, - quantity, size, value, initial_margin, + quantity, size, value, initial_margin, auto_deposit_margin, unrealized_pnl, realised_pnl, fee_to_close, status=None): changed: bool = False @@ -147,6 +148,8 @@ def _update(self, position_id, exchange_position_id, symbol, currency, market, t if self._should_change(self.timestamp, timestamp): self.timestamp = timestamp + # if we have a timestamp, it's a real trader => need to format timestamp if necessary + self.creation_time = self.exchange_manager.exchange.get_uniformized_timestamp(timestamp) if not self.timestamp: if not timestamp: self.creation_time = self.exchange_manager.exchange.get_exchange_current_time() @@ -172,6 +175,10 @@ def _update(self, position_id, exchange_position_id, symbol, currency, market, t self._update_margin() changed = True + if self._should_change(self.auto_deposit_margin, auto_deposit_margin): + self.auto_deposit_margin = auto_deposit_margin + changed = True + if self._should_change(self.unrealized_pnl, unrealized_pnl): self.unrealized_pnl = unrealized_pnl changed = True @@ -202,8 +209,15 @@ def _update(self, position_id, exchange_position_id, symbol, currency, market, t self._update_quantity_or_size_if_necessary() # update side after quantity as it relies on self.quantity - self._update_side(not entry_price) + self._update_side( + not entry_price, + creation_timestamp=self.exchange_manager.exchange.get_uniformized_timestamp(timestamp) + ) self._update_prices_if_necessary(mark_price) + if changed: + # ensure fee to close and margin are up to date now that all other attributes are set + self.update_fee_to_close() + self._update_margin() return changed async def ensure_position_initialized(self, **kwargs): @@ -407,6 +421,12 @@ def _is_update_closing(self, size_update): return self.size + size_update <= constants.ZERO return self.size + size_update >= constants.ZERO + def is_exclusively_using_exchange_position_details(self) -> bool: + return ( + self.exchange_manager.exchange_personal_data.positions_manager + .is_exclusively_using_exchange_position_details + ) + def _update_size(self, size_update, realised_pnl_update=constants.ZERO, trigger_source=enums.PNLTransactionSource.UNKNOWN): """ @@ -424,7 +444,7 @@ def _update_size(self, size_update, realised_pnl_update=constants.ZERO, self._check_and_update_size(size_update) self._update_quantity() self._update_side(True) - if self.exchange_manager.is_simulated: + if self.exchange_manager.is_simulated and not self.is_exclusively_using_exchange_position_details(): margin_update = self._update_initial_margin() self.update_fee_to_close() self.update_liquidation_price() @@ -684,15 +704,16 @@ def on_pnl_update(self): """ self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.update_portfolio_from_pnl(self) - def _on_side_update(self, reset_entry_price): + def _on_side_update(self, reset_entry_price, creation_timestamp=0): """ Resets the side related data when a position side changes """ if reset_entry_price: self._reset_entry_price() self.exit_price = constants.ZERO - self.creation_time = self.exchange_manager.exchange.get_exchange_current_time() - logging.get_logger(self.get_logger_name()).info(f"Changed position side: now {self.side.name}") + # use update_timestamp when available, use exchange time otherwise + self.creation_time = creation_timestamp or self.exchange_manager.exchange.get_exchange_current_time() + logging.get_logger(self.get_logger_name()).debug(f"Changed position side: now {self.side.name}") # update position state if necessary positions_states.create_position_state(self) @@ -736,6 +757,9 @@ def update_from_raw(self, raw_position): value=raw_position.get(enums.ExchangeConstantsPositionColumns.NOTIONAL.value, constants.ZERO), initial_margin=raw_position.get(enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value, constants.ZERO), + auto_deposit_margin=raw_position.get( + enums.ExchangeConstantsPositionColumns.AUTO_DEPOSIT_MARGIN.value, False + ), position_id=self.position_id or symbol, exchange_position_id=str(raw_position.get(enums.ExchangeConstantsPositionColumns.ID.value, None) or symbol), timestamp=raw_position.get(enums.ExchangeConstantsPositionColumns.TIMESTAMP.value, 0), @@ -748,7 +772,8 @@ def update_from_raw(self, raw_position): def to_dict(self): return { - enums.ExchangeConstantsPositionColumns.ID.value: self.position_id, + enums.ExchangeConstantsPositionColumns.ID.value: self.exchange_position_id, + enums.ExchangeConstantsPositionColumns.LOCAL_ID.value: self.position_id, enums.ExchangeConstantsPositionColumns.SYMBOL.value: self.symbol, enums.ExchangeConstantsPositionColumns.STATUS.value: self.status.value, enums.ExchangeConstantsPositionColumns.TIMESTAMP.value: self.timestamp, @@ -757,19 +782,27 @@ def to_dict(self): enums.ExchangeConstantsPositionColumns.SIZE.value: self.size, enums.ExchangeConstantsPositionColumns.NOTIONAL.value: self.value, enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: self.initial_margin, + enums.ExchangeConstantsPositionColumns.AUTO_DEPOSIT_MARGIN.value: self.auto_deposit_margin, enums.ExchangeConstantsPositionColumns.COLLATERAL.value: self.margin, + enums.ExchangeConstantsPositionColumns.LEVERAGE.value: self.symbol_contract.current_leverage, + enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value: self.symbol_contract.margin_type.value + if self.symbol_contract and self.symbol_contract.margin_type else None, + enums.ExchangeConstantsPositionColumns.POSITION_MODE.value: self.symbol_contract.position_mode.value + if self.symbol_contract and self.symbol_contract.position_mode else None, enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value: self.entry_price, enums.ExchangeConstantsPositionColumns.MARK_PRICE.value: self.mark_price, enums.ExchangeConstantsPositionColumns.LIQUIDATION_PRICE.value: self.liquidation_price, enums.ExchangeConstantsPositionColumns.UNREALIZED_PNL.value: self.unrealized_pnl, enums.ExchangeConstantsPositionColumns.REALISED_PNL.value: self.realised_pnl, + enums.ExchangeConstantsPositionColumns.MAINTENANCE_MARGIN_RATE.value: self.symbol_contract.maintenance_margin_rate, } def _check_for_liquidation(self): """ _check_for_liquidation defines rules for a position to be liquidated """ - if self.liquidation_price.is_nan(): + if self.liquidation_price.is_nan() or self.is_exclusively_using_exchange_position_details(): + # should skip liquidation check return if (self.is_short() and self.mark_price >= self.liquidation_price > constants.ZERO) or ( @@ -793,7 +826,7 @@ def _reset_entry_price(self): self.entry_price = constants.ZERO self._update_prices_if_necessary(self.mark_price) - def _update_side(self, reset_entry_price): + def _update_side(self, reset_entry_price, creation_timestamp=0): """ Checks if self.side still represents the position side Only relevant when account is using one way position mode @@ -811,7 +844,7 @@ def _update_side(self, reset_entry_price): else: self.side = enums.PositionSide.UNKNOWN if changed_side: - self._on_side_update(reset_entry_price) + self._on_side_update(reset_entry_price, creation_timestamp) def __str__(self): return self.to_string() @@ -857,6 +890,7 @@ async def reset(self): self.unrealized_pnl = constants.ZERO self.realised_pnl = constants.ZERO self.creation_time = 0 + self.timestamp = 0 self.on_pnl_update() # notify portfolio to reset unrealized PNL positions_states.create_position_state(self) diff --git a/octobot_trading/personal_data/positions/position_factory.py b/octobot_trading/personal_data/positions/position_factory.py index 3170a3f70..c203ab604 100644 --- a/octobot_trading/personal_data/positions/position_factory.py +++ b/octobot_trading/personal_data/positions/position_factory.py @@ -13,11 +13,29 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. +import decimal + import octobot_trading.personal_data as personal_data import octobot_trading.enums as enums import octobot_trading.errors as errors +POSITION_DICT_DECIMAL_KEYS = [ + enums.ExchangeConstantsPositionColumns.QUANTITY.value, + enums.ExchangeConstantsPositionColumns.SIZE.value, + enums.ExchangeConstantsPositionColumns.NOTIONAL.value, + enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value, + enums.ExchangeConstantsPositionColumns.COLLATERAL.value, + enums.ExchangeConstantsPositionColumns.LEVERAGE.value, + enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value, + enums.ExchangeConstantsPositionColumns.MARK_PRICE.value, + enums.ExchangeConstantsPositionColumns.LIQUIDATION_PRICE.value, + enums.ExchangeConstantsPositionColumns.UNREALIZED_PNL.value, + enums.ExchangeConstantsPositionColumns.REALISED_PNL.value, + enums.ExchangeConstantsPositionColumns.MAINTENANCE_MARGIN_RATE.value, +] + + def create_position_instance_from_raw(trader, raw_position): """ Creates a position instance from a raw position dictionary @@ -26,12 +44,44 @@ def create_position_instance_from_raw(trader, raw_position): :return: the created position """ position_symbol_contract = trader.exchange_manager.exchange.get_pair_future_contract( - raw_position.get(enums.ExchangeConstantsPositionColumns.SYMBOL.value)) + raw_position.get(enums.ExchangeConstantsPositionColumns.SYMBOL.value) + ) position = create_position_from_type(trader, position_symbol_contract) position.update_from_raw(raw_position) return position +def create_position_instance_from_dict(trader, position_dict: dict): + """ + Creates a position instance from a raw position dictionary + :param trader: the trader instance + :param position_dict: the raw position dictionary as from position.to_dict + :return: the created position + """ + raw_position = sanitize_raw_decimals(position_dict) + return create_position_instance_from_raw(trader, raw_position) + + +def sanitize_raw_position(position_dict: dict): + position_dict[enums.ExchangeConstantsPositionColumns.STATUS.value] = ( + personal_data.parse_position_status(position_dict)) + position_dict[enums.ExchangeConstantsPositionColumns.SIDE.value] = ( + personal_data.parse_position_side(position_dict)) + position_dict[enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] = ( + personal_data.parse_position_margin_type(position_dict)) + position_dict[enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] = ( + personal_data.parse_position_mode(position_dict)) + return sanitize_raw_decimals(position_dict) + + +def sanitize_raw_decimals(position_dict: dict): + for key in POSITION_DICT_DECIMAL_KEYS: + value = position_dict.get(key) + if value is not None and value != "": + position_dict[key] = decimal.Decimal(str(value)) + return position_dict + + def create_position_from_type(trader, symbol_contract): """ Creates a position instance from a position type diff --git a/octobot_trading/personal_data/positions/position_util.py b/octobot_trading/personal_data/positions/position_util.py index b61628045..8d8b4806e 100644 --- a/octobot_trading/personal_data/positions/position_util.py +++ b/octobot_trading/personal_data/positions/position_util.py @@ -21,3 +21,24 @@ def parse_position_status(raw_position): return enums.PositionStatus(raw_position[enums.ExchangeConstantsPositionColumns.STATUS.value]) except KeyError: return None + + +def parse_position_side(raw_position): + try: + return enums.PositionSide(raw_position[enums.ExchangeConstantsPositionColumns.SIDE.value]) + except KeyError: + return None + + +def parse_position_margin_type(raw_position): + try: + return enums.MarginType(raw_position[enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value]) + except KeyError: + return None + + +def parse_position_mode(raw_position): + try: + return enums.PositionMode(raw_position[enums.ExchangeConstantsPositionColumns.POSITION_MODE.value]) + except KeyError: + return None diff --git a/octobot_trading/personal_data/positions/positions_manager.py b/octobot_trading/personal_data/positions/positions_manager.py index 7c0a76bd6..51af7f844 100644 --- a/octobot_trading/personal_data/positions/positions_manager.py +++ b/octobot_trading/personal_data/positions/positions_manager.py @@ -14,12 +14,15 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import collections +import contextlib +import typing import octobot_commons.logging as logging import octobot_commons.enums as commons_enums import octobot_commons.tree as commons_tree import octobot_trading.personal_data.positions.position_factory as position_factory +import octobot_trading.personal_data.positions.position as position_import import octobot_trading.util as util import octobot_trading.enums as enums import octobot_trading.constants as constants @@ -37,6 +40,12 @@ def __init__(self, trader): self.positions = collections.OrderedDict() self.logged_unsupported_positions = set() + # When True, liquidation prices, PNL and other metrics are only read from exchange. + # They are never computed by OctoBot + self.is_exclusively_using_exchange_position_details = False + + self._enable_position_update_from_order = True + async def initialize_impl(self): self._reset_positions() @@ -72,6 +81,12 @@ def get_symbol_positions(self, symbol=None): return list(self.positions.values()) return self._get_symbol_positions(symbol) + def get_symbol_position_margin_type(self, symbol: str) -> enums.MarginType: + positions = self.get_symbol_positions(symbol=symbol) + if len(positions) != 1: + raise ValueError(f"{len(positions)} positions found for symbol {symbol}") + return positions[0].symbol_contract.margin_type + async def upsert_position(self, symbol: str, side, raw_position: dict) -> bool: """ Create or update a position from a raw dictionary @@ -81,7 +96,7 @@ async def upsert_position(self, symbol: str, side, raw_position: dict) -> bool: :return: True when the creation or the update succeeded """ self._ensure_support(raw_position) - position_id = self._generate_position_id(symbol=symbol, side=side) + position_id = self._position_id_factory(symbol=symbol, side=side) if position_id not in self.positions: new_position = position_factory.create_position_instance_from_raw(self.trader, raw_position=raw_position) new_position.position_id = position_id @@ -122,7 +137,7 @@ async def recreate_position(self, position) -> bool: """ new_position = position_factory.create_position_instance_from_raw(self.trader, raw_position=position.to_dict()) position.clear() - position.position_id = self._generate_position_id(symbol=position.symbol, side=position.side) + position.position_id = self._position_id_factory(symbol=position.symbol, side=position.side) return await self._finalize_position_creation(new_position) async def handle_position_update_from_order(self, order, require_exchange_update: bool) -> bool: @@ -133,7 +148,7 @@ async def handle_position_update_from_order(self, order, require_exchange_update position changes using order data (as in trading simulator) :return: True if the position was updated """ - if self.trader.is_enabled: + if self.trader.is_enabled and self._enable_position_update_from_order: # portfolio might be updated when refreshing the position async with self.trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_history_update(): if self.trader.simulate or not require_exchange_update: @@ -150,6 +165,9 @@ async def handle_position_update_from_order(self, order, require_exchange_update ) return False + def add_position(self, position: position_import.Position): + self.positions[position.position_id] = position + def _refresh_simulated_position_from_order(self, order): if order.is_filled(): # Don't update if order filled quantity is null @@ -179,14 +197,14 @@ async def refresh_real_trader_position(self, position, force_job_execution=False position, wait_for_refresh=True, force_job_execution=force_job_execution ) - def upsert_position_instance(self, position) -> bool: + def upsert_position_instance(self, position: position_import.Position) -> bool: """ Save an existing position instance to positions list :param position: the position instance :return: True when the operation succeeded """ if position.position_id not in self.positions: - self.positions[position.position_id] = position + self.add_position(position) return True return False @@ -198,12 +216,31 @@ def clear(self): position.clear() self._reset_positions() + def create_position_id(self, position: position_import.Position) -> str: + return self._position_id_factory( + position.symbol, side=None if position.symbol_contract.is_one_way_position_mode() else position.side + ) + + @contextlib.contextmanager + def disabled_positions_update_from_order(self): + """ + Can be used to locally disable position refresh when an order is updated + """ + self._enable_position_update_from_order = False + try: + yield + finally: + self._enable_position_update_from_order = True + # private - def _generate_position_id(self, symbol, side, expiration_time=None): + + def _position_id_factory( + self, symbol: str, side: typing.Union[None, enums.PositionSide], expiration_time=None + ) -> str: """ Generate a position ID for one way and hedge position modes :param symbol: the position symbol - :param side: the position side + :param side: the position side. Should be None (or enums.PositionSide.BOTH) for one-way positions :param expiration_time: the symbol expiration timestamp :return: the computed position id """ @@ -218,7 +255,7 @@ async def _finalize_position_creation(self, new_position, is_from_exchange_data= :param is_from_exchange_data: True when the exchange creation comes from exchange data :return: True when the process succeeded """ - self.positions[new_position.position_id] = new_position + self.add_position(new_position) await new_position.initialize(is_from_exchange_data=is_from_exchange_data) return True @@ -231,7 +268,7 @@ def _create_symbol_position(self, symbol, position_id): """ new_position = position_factory.create_symbol_position(self.trader, symbol) new_position.position_id = position_id - self.positions[position_id] = new_position + self.add_position(new_position) return new_position def _get_or_create_position(self, symbol, side): @@ -241,11 +278,11 @@ def _get_or_create_position(self, symbol, side): :param side: the expected position side :return: the matching position """ - expected_position_id = self._generate_position_id(symbol=symbol, side=side) + expected_position_id = self._position_id_factory(symbol=symbol, side=side) try: return self.positions[expected_position_id] except KeyError: - self.positions[expected_position_id] = self._create_symbol_position(symbol, expected_position_id) + self._create_symbol_position(symbol, expected_position_id) return self.positions[expected_position_id] def _get_symbol_positions(self, symbol): @@ -256,7 +293,7 @@ def _get_symbol_positions(self, symbol): """ positions = [] for side in [enums.PositionSide.BOTH, enums.PositionSide.SHORT, enums.PositionSide.LONG]: - position_id = self._generate_position_id(symbol=symbol, side=side) + position_id = self._position_id_factory(symbol=symbol, side=side) try: positions.append(self.positions[position_id]) except KeyError: diff --git a/octobot_trading/personal_data/positions/types/inverse_position.py b/octobot_trading/personal_data/positions/types/inverse_position.py index 4affc621c..05612e521 100644 --- a/octobot_trading/personal_data/positions/types/inverse_position.py +++ b/octobot_trading/personal_data/positions/types/inverse_position.py @@ -102,14 +102,17 @@ def get_bankruptcy_price(self, price, side, with_mark_price=False): Short position = (Entry Price x Leverage) / (Leverage - 1) """ try: + price = self.mark_price if with_mark_price else price if side is enums.PositionSide.LONG: - return (self.mark_price if with_mark_price else - price * self.symbol_contract.current_leverage) \ + return ( + price * self.symbol_contract.current_leverage / (self.symbol_contract.current_leverage + constants.ONE) + ) elif side is enums.PositionSide.SHORT: - return (self.mark_price if with_mark_price else - price * self.symbol_contract.current_leverage) \ + return ( + price * self.symbol_contract.current_leverage / (self.symbol_contract.current_leverage - constants.ONE) + ) return constants.ZERO except (decimal.DivisionByZero, decimal.InvalidOperation): return constants.ZERO @@ -131,10 +134,10 @@ def get_fee_to_open(self, quantity, price, symbol): def get_fee_to_close(self, quantity, price, side, symbol, with_mark_price=False): """ - :return: Fee to open = (Quantity * Mark Price) x Taker fee + :return: Fee to close = (Quantity / Bankruptcy price) x Taker fee """ try: - return quantity / \ + return abs(quantity) / \ self.get_bankruptcy_price(price, side, with_mark_price=with_mark_price) * self.get_taker_fee(symbol) except (decimal.DivisionByZero, decimal.InvalidOperation): return constants.ZERO diff --git a/octobot_trading/personal_data/positions/types/linear_position.py b/octobot_trading/personal_data/positions/types/linear_position.py index a770f98fd..65d582590 100644 --- a/octobot_trading/personal_data/positions/types/linear_position.py +++ b/octobot_trading/personal_data/positions/types/linear_position.py @@ -107,9 +107,9 @@ def get_fee_to_open(self, quantity, price, symbol): def get_fee_to_close(self, quantity, price, side, symbol, with_mark_price=False): """ - :return: Fee to open = (Quantity * Mark Price) x Taker fee + :return: Fee to close = (Quantity * Bankruptcy price) x Taker fee """ - return quantity * self.get_bankruptcy_price(price, side, with_mark_price=with_mark_price) * \ + return abs(quantity) * self.get_bankruptcy_price(price, side, with_mark_price=with_mark_price) * \ self.get_taker_fee(symbol) def get_order_cost(self): @@ -124,6 +124,7 @@ def update_fee_to_close(self): """ self.fee_to_close = self.get_fee_to_close(self.size, self.entry_price, self.side, self.symbol, with_mark_price=True) + self._update_margin() def update_average_entry_price(self, update_size, update_price): """ diff --git a/octobot_trading/personal_data/trades/__init__.py b/octobot_trading/personal_data/trades/__init__.py index 903742701..dd27549d7 100644 --- a/octobot_trading/personal_data/trades/__init__.py +++ b/octobot_trading/personal_data/trades/__init__.py @@ -24,6 +24,7 @@ ) from octobot_trading.personal_data.trades.trade_factory import ( create_trade_instance_from_raw, + create_closed_order_instance_from_raw_trade, create_trade_from_order, create_trade_from_dict, ) @@ -48,6 +49,7 @@ "TradesProducer", "TradesChannel", "create_trade_instance_from_raw", + "create_closed_order_instance_from_raw_trade", "create_trade_from_order", "create_trade_from_dict", "TradesUpdater", diff --git a/octobot_trading/personal_data/trades/trade_factory.py b/octobot_trading/personal_data/trades/trade_factory.py index 422836acf..51b3bd442 100644 --- a/octobot_trading/personal_data/trades/trade_factory.py +++ b/octobot_trading/personal_data/trades/trade_factory.py @@ -22,14 +22,7 @@ def create_trade_instance_from_raw(trader, raw_trade): try: - order = order_factory.create_order_from_raw(trader, raw_trade) - order.update_from_raw(raw_trade) - if order.status is enums.OrderStatus.CANCELED: - # ensure order is considered canceled - order.consider_as_canceled() - else: - # ensure order is considered filled - order.consider_as_filled() + order = create_closed_order_instance_from_raw_trade(trader, raw_trade) exchange_trade_id = raw_trade.get(enums.ExchangeConstantsOrderColumns.EXCHANGE_TRADE_ID.value) return create_trade_from_order(order, exchange_trade_id=exchange_trade_id) except KeyError: @@ -37,6 +30,18 @@ def create_trade_instance_from_raw(trader, raw_trade): return None +def create_closed_order_instance_from_raw_trade(trader, raw_trade): + order = order_factory.create_order_from_raw(trader, raw_trade) + order.update_from_raw(raw_trade) + if order.status is enums.OrderStatus.CANCELED: + # ensure order is considered canceled + order.consider_as_canceled() + else: + # ensure order is considered filled + order.consider_as_filled() + return order + + def create_trade_from_order(order, close_status=None, creation_time=0, diff --git a/octobot_trading/util/test_tools/exchange_data.py b/octobot_trading/util/test_tools/exchange_data.py index bedbbf1d9..49e2eb20b 100644 --- a/octobot_trading/util/test_tools/exchange_data.py +++ b/octobot_trading/util/test_tools/exchange_data.py @@ -86,6 +86,12 @@ class PortfolioDetails(octobot_commons.dataclasses.FlexibleDataclass, octobot_co asset_values: dict = dataclasses.field(default_factory=dict) +@dataclasses.dataclass +class PositionDetails(octobot_commons.dataclasses.FlexibleDataclass, octobot_commons.dataclasses.UpdatableDataclass): + position: dict = dataclasses.field(default_factory=dict) + contract: dict = dataclasses.field(default_factory=dict) + + @dataclasses.dataclass class ExchangeData(octobot_commons.dataclasses.MinimizableDataclass, octobot_commons.dataclasses.UpdatableDataclass): auth_details: ExchangeAuthDetails = dataclasses.field(default_factory=ExchangeAuthDetails) @@ -94,11 +100,15 @@ class ExchangeData(octobot_commons.dataclasses.MinimizableDataclass, octobot_com portfolio_details: PortfolioDetails = dataclasses.field(default_factory=PortfolioDetails) orders_details: OrdersDetails = dataclasses.field(default_factory=OrdersDetails) trades: list[dict] = dataclasses.field(default_factory=list) + positions: list[PositionDetails] = dataclasses.field(default_factory=list) # pylint: disable=E1134 def __post_init__(self): if self.markets and isinstance(self.markets[0], dict): self.markets = [MarketDetails.from_dict(market) for market in self.markets] if self.markets else [] + if self.positions and isinstance(self.positions[0], dict): + self.positions = [PositionDetails.from_dict(position) for position in self.positions] \ + if self.positions else [] def get_price(self, symbol): for market in self.markets: diff --git a/octobot_trading/util/test_tools/exchanges_test_tools.py b/octobot_trading/util/test_tools/exchanges_test_tools.py index ce929a096..f50218dea 100644 --- a/octobot_trading/util/test_tools/exchanges_test_tools.py +++ b/octobot_trading/util/test_tools/exchanges_test_tools.py @@ -26,6 +26,7 @@ import octobot_trading.enums as enums import octobot_trading.api as trading_api import octobot_trading.exchanges as exchanges +import octobot_trading.exchange_data as trading_exchange_data import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.personal_data as personal_data @@ -205,9 +206,9 @@ async def _get_trades(symbol): async def create_orders( exchange_manager, - exchange_data: exchange_data_import.ExchangeData, orders: list, - order_creation_timeout: float + order_creation_timeout: float, + price_by_symbol: dict[str, float], ) -> list: async def _create_order(order_dict) -> personal_data.Order: symbol = order_dict[enums.ExchangeConstantsOrderColumns.SYMBOL.value] @@ -218,7 +219,7 @@ async def _create_order(order_dict) -> personal_data.Order: decimal.Decimal(str(order_dict[enums.ExchangeConstantsOrderColumns.AMOUNT.value])), price=decimal.Decimal(str(order_dict[enums.ExchangeConstantsOrderColumns.PRICE.value])), side=side, - current_price=decimal.Decimal(str(exchange_data.get_price(symbol))), + current_price=price_by_symbol[symbol], reduce_only=order_dict[enums.ExchangeConstantsOrderColumns.REDUCE_ONLY.value], ) # is private, to use in tests context only @@ -257,3 +258,21 @@ async def wait_for_other_status(order: personal_data.Order, timeout) -> personal break await asyncio.sleep(constants.CREATED_ORDER_FORCED_UPDATE_PERIOD) raise TimeoutError(f"Order was not found with another status than {origin_status} within {timeout} seconds") + + +async def get_positions( + exchange_manager, + exchange_data: exchange_data_import.ExchangeData, + symbols: list = None +) -> list[dict]: + symbols = symbols or [market.symbol for market in exchange_data.markets] + raw_positions = await exchange_manager.exchange.get_positions(symbols=symbols) + # initialize relevant contracts first as they might be waited for + trading_exchange_data.update_contracts_from_positions(exchange_manager, raw_positions) + dict_positions = [ + personal_data.create_position_instance_from_raw( + exchange_manager.trader, raw_position + ).to_dict() + for raw_position in raw_positions + ] + return dict_positions diff --git a/tests/modes/script_keywords/basic_keywords/test_amount.py b/tests/modes/script_keywords/basic_keywords/test_amount.py index 59689e435..e03300453 100644 --- a/tests/modes/script_keywords/basic_keywords/test_amount.py +++ b/tests/modes/script_keywords/basic_keywords/test_amount.py @@ -24,6 +24,7 @@ import octobot_trading.modes.script_keywords as script_keywords import octobot_trading.modes.script_keywords.dsl as dsl import octobot_trading.modes.script_keywords.basic_keywords.account_balance as account_balance +import octobot_trading.modes.script_keywords.basic_keywords.position as position_kw from tests import event_loop from tests.modes.script_keywords import null_context @@ -46,10 +47,6 @@ async def test_get_amount_from_input_amount(null_context): with pytest.raises(errors.InvalidArgumentError): await script_keywords.get_amount_from_input_amount(null_context, "1sdsqdq") - with pytest.raises(NotImplementedError): - await script_keywords.get_amount_from_input_amount(null_context, - f"1{script_keywords.QuantityType.POSITION_PERCENT.value}") - with mock.patch.object(account_balance, "adapt_amount_to_holdings", mock.AsyncMock(return_value=decimal.Decimal(1))) as adapt_amount_to_holdings_mock: for quantity_delta in (script_keywords.QuantityType.DELTA, script_keywords.QuantityType.DELTA_BASE): @@ -243,3 +240,44 @@ async def test_get_amount_from_input_amount(null_context): parse_quantity_mock.assert_called_once_with("50") get_holdings_value_mock.assert_called_once_with({'BTC', 'ETH', 'SOL', 'USDT'}, "BTC") adapt_amount_to_holdings_mock.reset_mock() + + +async def test_get_amount_from_input_amount_for_position(null_context): + null_context.exchange_manager = mock.Mock( + exchange_personal_data=mock.Mock( + portfolio_manager=mock.Mock( + portfolio_value_holder=mock.Mock() + ) + ) + ) + null_context.exchange_manager.is_future = False + with pytest.raises(NotImplementedError): + # not futures + await script_keywords.get_amount_from_input_amount( + null_context, f"1{script_keywords.QuantityType.POSITION_PERCENT.value}" + ) + + null_context.exchange_manager.is_future = True + + # not one-way + with mock.patch.object( + position_kw, "is_in_one_way_position_mode", mock.Mock(return_value=False) + ) as is_in_one_way_position_mode_mock: + with pytest.raises(NotImplementedError): + await script_keywords.get_amount_from_input_amount( + null_context, f"1{script_keywords.QuantityType.POSITION_PERCENT.value}" + ) + is_in_one_way_position_mode_mock.assert_called_once_with(null_context) + + # futures one-way: works + with (mock.patch.object(position_kw, "is_in_one_way_position_mode", mock.Mock(return_value=True)) + as is_in_one_way_position_mode_mock, + mock.patch.object(position_kw, "get_position", mock.Mock(return_value=mock.Mock(size=decimal.Decimal("100")))) + as get_position_mock, mock.patch.object(account_balance, "adapt_amount_to_holdings", + mock.AsyncMock(return_value=decimal.Decimal(1))) as adapt_amount_to_holdings_mock): + assert await script_keywords.get_amount_from_input_amount( + null_context, f"1{script_keywords.QuantityType.POSITION_PERCENT.value}" + ) == decimal.Decimal(1) + is_in_one_way_position_mode_mock.assert_called_once_with(null_context) + get_position_mock.assert_called_once() + adapt_amount_to_holdings_mock.assert_called_once() diff --git a/tests/personal_data/positions/test_position.py b/tests/personal_data/positions/test_position.py index 54c82a545..41a292317 100644 --- a/tests/personal_data/positions/test_position.py +++ b/tests/personal_data/positions/test_position.py @@ -78,16 +78,27 @@ async def test_update_margin_linear(btc_usdt_future_trader_simulator_with_defaul assert position_inst.mark_price == constants.ZERO assert position_inst.initial_margin == constants.ZERO assert position_inst.size == constants.ZERO + assert position_inst.creation_time > 0 + initial_creation_time = 123 + # force changing creation time + position_inst.creation_time = 123 await position_inst.update(mark_price=constants.ONE, update_margin=decimal.Decimal(5)) assert position_inst.mark_price == constants.ONE assert position_inst.initial_margin == decimal.Decimal(5) + assert position_inst.margin == decimal.Decimal("5.0300") # initial margin + closing fees assert position_inst.size == decimal.Decimal(50) + # creation time got updated: site changed + assert initial_creation_time != position_inst.creation_time + updated_creation_time = position_inst.creation_time await position_inst.update(mark_price=constants.ONE, update_margin=-decimal.Decimal(3)) assert position_inst.mark_price == constants.ONE assert position_inst.initial_margin == decimal.Decimal(2) + assert position_inst.margin == decimal.Decimal("2.0120") assert position_inst.size == decimal.Decimal(20) + # creation time did not get updated + assert updated_creation_time == position_inst.creation_time async def test_update_margin_inverse(btc_usdt_future_trader_simulator_with_default_inverse): @@ -103,11 +114,13 @@ async def test_update_margin_inverse(btc_usdt_future_trader_simulator_with_defau await position_inst.update(mark_price=constants.ONE, update_margin=decimal.Decimal(8) / constants.ONE_HUNDRED) assert position_inst.mark_price == constants.ONE assert position_inst.initial_margin == decimal.Decimal('0.08') + assert position_inst.margin == decimal.Decimal("0.080288") # initial margin + closing fees assert position_inst.size == decimal.Decimal('0.4') await position_inst.update(mark_price=constants.ONE, update_margin=-constants.ONE / constants.ONE_HUNDRED) assert position_inst.mark_price == constants.ONE assert position_inst.initial_margin == decimal.Decimal('0.07') + assert position_inst.margin == decimal.Decimal("0.070252") assert position_inst.size == decimal.Decimal('0.35') diff --git a/tests/personal_data/positions/test_positions_manager.py b/tests/personal_data/positions/test_positions_manager.py index a5525dbdc..dfaf794d6 100644 --- a/tests/personal_data/positions/test_positions_manager.py +++ b/tests/personal_data/positions/test_positions_manager.py @@ -93,16 +93,16 @@ async def test__generate_position_id(future_trader_simulator_with_default_linear sep = positions_mgr.PositionsManager.POSITION_ID_SEPARATOR current_time = time.time() symbol_contract.set_position_mode(is_one_way=True) - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, None) == DEFAULT_FUTURE_SYMBOL - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, None, None) == DEFAULT_FUTURE_SYMBOL - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, None, expiration_time=current_time) == \ + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, None) == DEFAULT_FUTURE_SYMBOL + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, None, None) == DEFAULT_FUTURE_SYMBOL + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, None, expiration_time=current_time) == \ DEFAULT_FUTURE_SYMBOL + sep + str(current_time) symbol_contract.set_position_mode(is_one_way=False) - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.LONG) == \ + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.LONG) == \ DEFAULT_FUTURE_SYMBOL + sep + enums.PositionSide.LONG.value - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.LONG, current_time) == \ + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.LONG, current_time) == \ DEFAULT_FUTURE_SYMBOL + sep + str(current_time) + sep + enums.PositionSide.LONG.value - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.SHORT) == \ + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.SHORT) == \ DEFAULT_FUTURE_SYMBOL + sep + enums.PositionSide.SHORT.value - assert positions_manager._generate_position_id(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.SHORT, current_time) == \ + assert positions_manager._position_id_factory(DEFAULT_FUTURE_SYMBOL, enums.PositionSide.SHORT, current_time) == \ DEFAULT_FUTURE_SYMBOL + sep + str(current_time) + sep + enums.PositionSide.SHORT.value diff --git a/tests/personal_data/positions/types/test_inverse_position.py b/tests/personal_data/positions/types/test_inverse_position.py index f2062c8f8..9f6cf355a 100644 --- a/tests/personal_data/positions/types/test_inverse_position.py +++ b/tests/personal_data/positions/types/test_inverse_position.py @@ -217,13 +217,11 @@ async def test_get_bankruptcy_price_with_long(future_trader_simulator_with_defau assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal(50) default_contract.set_current_leverage(constants.ONE_HUNDRED) assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side) == decimal.Decimal("99.00990099009900990099009901") - assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal("0.9900990099009900990099009901") + assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal('99.00990099009900990099009901') await position_inst.update(update_size=constants.ONE_HUNDRED, mark_price=decimal.Decimal(2) * constants.ONE_HUNDRED) assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side) == decimal.Decimal("99.00990099009900990099009901") - assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal("1.980198019801980198019801980") - assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == \ - decimal.Decimal("1.980198019801980198019801980") + assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal("198.0198019801980198019801980") assert position_inst.get_bankruptcy_price(decimal.Decimal("200"), position_inst.side) == decimal.Decimal("198.0198019801980198019801980") assert position_inst.get_bankruptcy_price(decimal.Decimal("200"), enums.PositionSide.SHORT) \ == decimal.Decimal("202.0202020202020202020202020") @@ -239,7 +237,7 @@ async def test_get_bankruptcy_price_with_short(future_trader_simulator_with_defa assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == constants.ZERO default_contract.set_current_leverage(constants.ONE_HUNDRED) assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side) == decimal.Decimal("101.0101010101010101010101010") - assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal("1.010101010101010101010101010") + assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side, with_mark_price=True) == decimal.Decimal("101.0101010101010101010101010") await position_inst.update(update_size=constants.ONE_HUNDRED, mark_price=decimal.Decimal(2) * constants.ONE_HUNDRED) assert position_inst.get_bankruptcy_price(position_inst.entry_price, position_inst.side) == constants.ZERO @@ -248,7 +246,7 @@ async def test_get_bankruptcy_price_with_short(future_trader_simulator_with_defa position_inst.entry_price = constants.ONE_HUNDRED await position_inst.update(update_size=-constants.ONE_HUNDRED, mark_price=constants.ONE_HUNDRED) assert position_inst.get_bankruptcy_price(decimal.Decimal("20"), position_inst.side, with_mark_price=True) == \ - decimal.Decimal("16.66666666666666666666666667") + decimal.Decimal("116.6666666666666666666666667") assert position_inst.get_bankruptcy_price(decimal.Decimal("100"), position_inst.side) == \ decimal.Decimal("116.6666666666666666666666667") assert position_inst.get_bankruptcy_price(decimal.Decimal("100"), enums.PositionSide.LONG) \