From edd469e814f9a177b9175e8e45de5be6dbf515eb Mon Sep 17 00:00:00 2001 From: Herklos Date: Thu, 29 Jan 2026 23:15:59 +0100 Subject: [PATCH] [FuturesPortfolioValueHolder] Fix open orders value calculation for symbol valuation --- CHANGELOG.md | 4 + README.md | 2 +- octobot_trading/__init__.py | 2 +- .../ohlcv/channel/ohlcv_updater.py | 8 +- .../personal_data/orders/orders_manager.py | 2 +- .../holders/futures_portfolio_value_holder.py | 17 +++- .../portfolios/portfolio_value_holder.py | 19 +++++ .../portfolios/test_portfolio_value_holder.py | 82 +++++++++++++++++++ 8 files changed, 127 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4e96b97..45ce7e414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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.5.11] - 2026-01-29 +### Fixed +[PositionValueHolder] Fix futures open orders value calculation when using symbol instead of currency + ## [2.5.10] - 2026-01-29 ### Fixed [PositionsUpdater] Missing is_option is should_run check diff --git a/README.md b/README.md index 57ff08c61..06743c065 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OctoBot-Trading [2.5.10](https://github.com/Drakkar-Software/OctoBot-Trading/blob/master/CHANGELOG.md) +# OctoBot-Trading [2.5.11](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 4c8ee3d12..a62a6319d 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.5.10" # major.minor.revision +VERSION = "2.5.11" # major.minor.revision diff --git a/octobot_trading/exchange_data/ohlcv/channel/ohlcv_updater.py b/octobot_trading/exchange_data/ohlcv/channel/ohlcv_updater.py index 6ee2c5a8c..dedad1bb5 100644 --- a/octobot_trading/exchange_data/ohlcv/channel/ohlcv_updater.py +++ b/octobot_trading/exchange_data/ohlcv/channel/ohlcv_updater.py @@ -320,7 +320,9 @@ async def _candle_update_loop(self, time_frame, pair): except errors.UnSupportedSymbolError as err: self.logger.warning( f"{self.channel.exchange_manager.exchange_name} is not supporting {pair} on {time_frame.value}: {err}") - await self._remove_unsupported_pairs(pair) + if await self._remove_unsupported_pairs(pair): + # Exit the loop after removing the pair to avoid endless retries + return except errors.NotSupported as err: self.logger.warning( f"{self.channel.exchange_manager.exchange_name} is not supporting updates: {err}") @@ -386,11 +388,13 @@ def _set_initialized(self, pair, time_frame, initialized): self.initialized_candles_by_tf_by_symbol[pair] = {} self.initialized_candles_by_tf_by_symbol[pair][time_frame] = initialized - async def _remove_unsupported_pairs(self, pair: str): + async def _remove_unsupported_pairs(self, pair: str) -> bool: # For now only remove the pair from the traded pairs if it's an option exchange if self.channel.exchange_manager.is_option: self.logger.warning(f"Removing {pair} from traded pairs...") await self.channel.exchange_manager.exchange_config.remove_traded_symbols([pair]) + return True + return False async def resume(self) -> None: await super().resume() diff --git a/octobot_trading/personal_data/orders/orders_manager.py b/octobot_trading/personal_data/orders/orders_manager.py index 13cb1d350..8d64f0d97 100644 --- a/octobot_trading/personal_data/orders/orders_manager.py +++ b/octobot_trading/personal_data/orders/orders_manager.py @@ -150,7 +150,7 @@ def create_group( async def upsert_order_from_raw(self, exchange_order_id: str, raw_order: dict, is_from_exchange: bool) -> tuple[bool, order_class.Order]: if not self.has_order(None, exchange_order_id=exchange_order_id): - self.logger.info(f"Including new order fetched from exchange: {raw_order}") + self.logger.debug(f"Including new order fetched from exchange: {raw_order}") new_order = order_factory.create_order_instance_from_raw(self.trader, raw_order) # replace new_order by previously created pending_order if any relevant pending_order new_order = await self.get_and_update_pending_order(new_order) or new_order diff --git a/octobot_trading/personal_data/portfolios/holders/futures_portfolio_value_holder.py b/octobot_trading/personal_data/portfolios/holders/futures_portfolio_value_holder.py index beb2778fc..7891601ad 100644 --- a/octobot_trading/personal_data/portfolios/holders/futures_portfolio_value_holder.py +++ b/octobot_trading/personal_data/portfolios/holders/futures_portfolio_value_holder.py @@ -38,8 +38,9 @@ def get_holdings_ratio( self.portfolio_manager.reference_market, traded_symbols_only=traded_symbols_only, coins_whitelist=coins_whitelist, include_assets_in_open_orders=include_assets_in_open_orders ) + currency_is_full_symbol = symbol_util.is_symbol(currency) symbol = currency - if not symbol_util.is_symbol(symbol): + if not currency_is_full_symbol: try: symbol = symbol_util.merge_currencies(currency, self.portfolio_manager.reference_market, settlement_asset=self.portfolio_manager.reference_market) position = positions_manager.get_symbol_position(symbol, enums.PositionSide.BOTH) @@ -64,8 +65,16 @@ def get_holdings_ratio( ) if include_assets_in_open_orders: - assets_in_open_orders = self._get_total_holdings_in_open_orders(currency) - current_in_order_value = self.value_converter.evaluate_value(currency, assets_in_open_orders, init_price_fetchers=False) - position_value += current_in_order_value + if currency_is_full_symbol: + # For full symbols get orders by exact symbol + pending_order_value = self._get_open_orders_value_for_symbol(symbol) + position_value += pending_order_value + else: + # For simple currencies (e.g., "ETH"), use currency-based matching + pending_order_holdings = self._get_total_holdings_in_open_orders(currency) + pending_order_value = self.value_converter.evaluate_value( + currency, pending_order_holdings, init_price_fetchers=False + ) + position_value += pending_order_value return position_value / total_portfolio_value if total_portfolio_value > constants.ZERO else constants.ZERO \ No newline at end of file diff --git a/octobot_trading/personal_data/portfolios/portfolio_value_holder.py b/octobot_trading/personal_data/portfolios/portfolio_value_holder.py index e1817cf18..8ee5d68a8 100644 --- a/octobot_trading/personal_data/portfolios/portfolio_value_holder.py +++ b/octobot_trading/personal_data/portfolios/portfolio_value_holder.py @@ -147,6 +147,25 @@ def _get_orders_delta(self, currency: str) -> decimal.Decimal: assets_in_open_orders += order.total_cost return assets_in_open_orders + def _get_open_orders_value_for_symbol(self, symbol: str) -> decimal.Decimal: + """ + Get the net pending order value for a specific symbol. + For options/futures, orders are matched by their full symbol. + Buy orders increase the value (pending acquisition), sell orders decrease it (pending disposal). + :param symbol: the full symbol (e.g., 'BTC/USDC:USDC') + :return: the net pending order value in the settlement currency + """ + total_order_value = constants.ZERO + orders_manager = self.portfolio_manager.exchange_manager.exchange_personal_data.orders_manager + for order in orders_manager.get_open_orders(symbol=symbol): + pending_quantity = order.origin_quantity - order.filled_quantity + order_value = pending_quantity * order.origin_price + if order.side is enums.TradeOrderSide.BUY: + total_order_value += order_value + elif order.side is enums.TradeOrderSide.SELL: + total_order_value -= order_value + return total_order_value + def handle_profitability_recalculation(self, force_recompute_origin_portfolio): """ Initialize values required by portfolio profitability to perform its profitability calculation diff --git a/tests/personal_data/portfolios/test_portfolio_value_holder.py b/tests/personal_data/portfolios/test_portfolio_value_holder.py index dd053f23f..0db51c657 100644 --- a/tests/personal_data/portfolios/test_portfolio_value_holder.py +++ b/tests/personal_data/portfolios/test_portfolio_value_holder.py @@ -637,3 +637,85 @@ async def test_get_orders_delta(backtesting_trader, currency, order_symbol, orde assert isinstance(result, decimal.Decimal) assert result == expected_delta + + +@pytest.mark.parametrize("backtesting_exchange_manager", ["spot", "margin", "futures", "options"], indirect=True) +@pytest.mark.parametrize("order_side,order_quantity,filled_quantity,order_price,expected_value", [ + # Buy order: (quantity - filled) * price = (10 - 0) * 50 = 500 + (enums.TradeOrderSide.BUY, decimal.Decimal("10"), decimal.Decimal("0"), decimal.Decimal("50"), decimal.Decimal("500")), + # Buy order partially filled: (10 - 3) * 50 = 350 + (enums.TradeOrderSide.BUY, decimal.Decimal("10"), decimal.Decimal("3"), decimal.Decimal("50"), decimal.Decimal("350")), + # Sell order: decreases value by (quantity - filled) * price = -500 + (enums.TradeOrderSide.SELL, decimal.Decimal("10"), decimal.Decimal("0"), decimal.Decimal("50"), decimal.Decimal("-500")), + # Sell order partially filled: -(10 - 5) * 50 = -250 + (enums.TradeOrderSide.SELL, decimal.Decimal("10"), decimal.Decimal("5"), decimal.Decimal("50"), decimal.Decimal("-250")), +]) +async def test_get_open_orders_value_for_symbol(backtesting_trader, order_side, order_quantity, filled_quantity, order_price, expected_value): + config, exchange_manager, trader = backtesting_trader + portfolio_manager = exchange_manager.exchange_personal_data.portfolio_manager + portfolio_value_holder = portfolio_manager.portfolio_value_holder + + test_symbol = "slug/USDC:USDC-260331-0-YES" + + # Create a real order object based on the side + if order_side == enums.TradeOrderSide.BUY: + order = personal_data.BuyLimitOrder(trader) + else: + order = personal_data.SellLimitOrder(trader) + + order.update(order_type=enums.TraderOrderType.BUY_LIMIT if order_side == enums.TradeOrderSide.BUY else enums.TraderOrderType.SELL_LIMIT, + symbol=test_symbol, + current_price=order_price, + quantity=order_quantity, + price=order_price) + order.filled_quantity = filled_quantity + + with mock.patch.object( + portfolio_manager.exchange_manager.exchange_personal_data.orders_manager, + "get_open_orders", + return_value=[order] + ): + result = portfolio_value_holder._get_open_orders_value_for_symbol(test_symbol) + + assert isinstance(result, decimal.Decimal) + assert result == expected_value + + +@pytest.mark.parametrize("backtesting_exchange_manager", ["spot", "margin", "futures", "options"], indirect=True) +async def test_get_open_orders_value_for_symbol_multiple_orders(backtesting_trader): + """Test that multiple orders for the same symbol are summed correctly.""" + config, exchange_manager, trader = backtesting_trader + portfolio_manager = exchange_manager.exchange_personal_data.portfolio_manager + portfolio_value_holder = portfolio_manager.portfolio_value_holder + + test_symbol = "slug/USDC:USDC-260331-0-YES" + + # Buy order: 10 * 50 = 500 + buy_order = personal_data.BuyLimitOrder(trader) + buy_order.update(order_type=enums.TraderOrderType.BUY_LIMIT, + symbol=test_symbol, + current_price=decimal.Decimal("50"), + quantity=decimal.Decimal("10"), + price=decimal.Decimal("50")) + buy_order.filled_quantity = decimal.Decimal("0") + + # Sell order: -5 * 40 = -200 + sell_order = personal_data.SellLimitOrder(trader) + sell_order.update(order_type=enums.TraderOrderType.SELL_LIMIT, + symbol=test_symbol, + current_price=decimal.Decimal("40"), + quantity=decimal.Decimal("5"), + price=decimal.Decimal("40")) + sell_order.filled_quantity = decimal.Decimal("0") + + # Net value: 500 - 200 = 300 + with mock.patch.object( + portfolio_manager.exchange_manager.exchange_personal_data.orders_manager, + "get_open_orders", + return_value=[buy_order, sell_order] + ): + result = portfolio_value_holder._get_open_orders_value_for_symbol(test_symbol) + + assert isinstance(result, decimal.Decimal) + assert result == decimal.Decimal("300") +