diff --git a/Services/Services_bases/bird_service/metadata.json b/Services/Services_bases/bird_service/metadata.json index 2b1839144..f36d10808 100644 --- a/Services/Services_bases/bird_service/metadata.json +++ b/Services/Services_bases/bird_service/metadata.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BirdService"], "tentacles-requirements": [] diff --git a/Services/Services_bases/tavily_service/metadata.json b/Services/Services_bases/tavily_service/metadata.json index b429fd19d..f4750b84e 100644 --- a/Services/Services_bases/tavily_service/metadata.json +++ b/Services/Services_bases/tavily_service/metadata.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TavilyService"], "tentacles-requirements": [] diff --git a/Trading/Exchange/polymarket/ccxt/polymarket_async.py b/Trading/Exchange/polymarket/ccxt/polymarket_async.py index d5ea57dd9..d10a31500 100644 --- a/Trading/Exchange/polymarket/ccxt/polymarket_async.py +++ b/Trading/Exchange/polymarket/ccxt/polymarket_async.py @@ -770,7 +770,7 @@ def parse_market(self, market: dict) -> Market: 'option': True, # Prediction markets are treated 'active': enableOrderBook and active and not closed and not archived, 'contract': True, - 'linear': True, + 'linear': True, # Only linear makes sense 'inverse': None, 'contractSize': contractSize, 'expiry': expiry, @@ -842,16 +842,14 @@ async def fetch_order_book(self, symbol: str, limit: Int = None, params={}) -> O await self.load_markets() market = self.market(symbol) request: dict = {} - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) request['token_id'] = tokenId response = await self.clob_public_get_orderbook_token_id(self.extend(request, params)) return self.parse_order_book(response, symbol) @@ -876,11 +874,10 @@ async def fetch_order_books(self, symbols: Strings = None, limit: Int = None, pa for i in range(0, len(symbols)): symbol = symbols[i] market = self.market(symbol) - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + tokenId = self.safe_string(market, 'id') + if tokenId is not None: tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: @@ -1048,15 +1045,14 @@ async def fetch_ticker(self, symbol: str, params={}) -> Ticker: await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) # Fetch prices using POST /prices endpoint with both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = await self.clob_public_post_prices(self.extend({ @@ -1104,11 +1100,10 @@ async def fetch_tickers(self, symbols: Strings = None, params={}) -> Tickers: for i in range(0, len(symbolsToFetch)): symbol = symbolsToFetch[i] market = self.market(symbol) - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + tokenId = self.safe_string(market, 'id') + if tokenId is not None: tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: @@ -1573,16 +1568,14 @@ async def fetch_ohlcv(self, symbol: str, timeframe: str = '1h', since: Int = Non await self.load_markets() market = self.market(symbol) request: dict = {} - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) request['market'] = tokenId # API uses 'market' parameter for token_id # Note: REST API /prices-history endpoint requires either: # 1. startTs and endTs(mutually exclusive with interval) @@ -1959,15 +1952,14 @@ async def build_order(self, symbol: str, type: OrderType, side: OrderSide, amoun """ market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) # Convert CCXT side to Polymarket side(BUY/SELL) polymarketSide = 'BUY' if (side == 'buy') else 'SELL' # Convert amount and price to strings @@ -2303,7 +2295,7 @@ async def cancel_all_orders(self, symbol: Str = None, params={}) -> List[Order]: await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) - response = None + response if symbol is not None: # Use cancel-market-orders endpoint when symbol is provided # See https://docs.polymarket.com/developers/CLOB/orders/cancel-market-orders @@ -2311,13 +2303,15 @@ async def cancel_all_orders(self, symbol: Str = None, params={}) -> List[Order]: marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id(market ID) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) - # Get asset_id from clobTokenIds - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) + # Get asset_id from market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol request: dict = {} if conditionId is not None: request['market'] = conditionId - if len(clobTokenIds) > 0: - request['asset_id'] = clobTokenIds[0] + assetId = self.safe_string(market, 'id') + if assetId is not None: + request['asset_id'] = assetId # Response format: {canceled: string[], not_canceled: {order_id -> reason}} response = await self.clob_private_delete_cancel_market_orders(self.extend(request, params)) else: @@ -2390,12 +2384,14 @@ async def fetch_orders(self, symbol: Str = None, since: Int = None, limit: Int = if conditionId is not None: request['market'] = conditionId # Also include asset_id for backward compatibility and more specific filtering - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + assetId = self.safe_string(market, 'id') + if assetId is not None: # The Polymarket L2 getOpenOrders() endpoint filters by asset_id - request['asset_id'] = clobTokenIds[0] + request['asset_id'] = assetId # Keep backward compatibility for legacy token_id usage - request['token_id'] = clobTokenIds[0] + request['token_id'] = assetId id = self.safe_string(params, 'id') if id is not None: request['id'] = id @@ -2431,7 +2427,6 @@ async def fetch_open_orders(self, symbol: Str = None, since: Int = None, limit: :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ - # The Polymarket getOpenOrders() endpoint already returns open orders return await self.fetch_orders(symbol, since, limit, params) def parse_order(self, order: dict, market: Market = None) -> Order: @@ -2702,73 +2697,69 @@ async def fetch_user_closed_positions(self, userId: str, symbols: Strings = None positions = response if isinstance(response, list) else [] return self.parse_positions(positions, symbols) - def safe_market_with_fallback(self, market_id: str, market: Market = None, metadata: dict = None) -> Market: + def safe_market_with_fallback(self, marketId: str, market: Market = None, metadata: dict = None) -> Market: """ - Safely get a market by ID, with fallback to synthetic market for missing/closed markets. - - This method wraps safe_market and automatically handles missing markets by: - - Returning the market if found in markets_by_id - - Creating a synthetic market from metadata if available (position/order data) - - Returning the minimal market structure from safe_market as last resort - - :param str market_id: Market ID (asset ID, clobTokenId, or symbol) to look up + Safely get a market by ID, with fallback to synthetic market for missing/closed markets + :param str marketId: Market ID(asset ID, clobTokenId, or symbol) to look up :param dict [market]: Optional pre-existing market structure - :param dict [metadata]: Optional metadata (position, order, etc.) containing market info - (slug, conditionId, outcome, endDate, etc.) for synthetic market creation - :returns dict: Market structure (from markets_by_id, synthetic, or minimal) + :param dict [metadata]: Optional metadata(position, order, etc.) containing market info + :returns dict: Market structure(from marketsById, synthetic, or minimal) """ try: - market = self.safe_market(market_id, market) + market = self.safe_market(marketId, market) # Check if market was actually found in markets_by_id - if market_id in (self.markets_by_id or {}): + if self.markets_by_id is not None and marketId in self.markets_by_id: return market # Market found, return it - except ArgumentsRequired: - # safe_market can raise ArgumentsRequired for ambiguous markets - # In this case, we'll try to create synthetic or return minimal structure - pass - - # Market not found in markets_by_id - # If we have metadata (position/order), try to create synthetic market + except Exception as e: + # safeMarket can raise ArgumentsRequired for ambiguous markets + # In self case, we'll try to create synthetic or return minimal structure + if isinstance(e, ArgumentsRequired): + market = None + else: + # Re-raise other exceptions + raise e + # Market not found in markets_by_id - if we have metadata, try to create synthetic market if metadata is not None: - condition_id = self.safe_string(metadata, 'conditionId') + conditionId = self.safe_string(metadata, 'conditionId') slug = self.safe_string(metadata, 'slug') - if condition_id or slug: - return self._create_synthetic_closed_market(metadata, market_id) - - # No metadata or can't create synthetic - return minimal structure from safe_market - # This will create a basic market structure that won't break downstream code - return self.safe_market(market_id, market) + if conditionId is not None or slug is not None: + return self.create_synthetic_closed_market(metadata, marketId) + # No metadata or can't create synthetic - return minimal structure from safeMarket + return self.safe_market(marketId, market) - def _create_synthetic_closed_market(self, position: dict, asset: str) -> dict: + def create_synthetic_closed_market(self, position: dict, asset: str) -> Market: """ - Create a synthetic market structure for a closed/expired market. - This allows position parsing to continue even when the market is no longer active. + Create a synthetic market structure for a closed/expired market + :param dict position: position response from the exchange + :param str asset: asset ID + :returns dict: synthetic market structure """ conditionId = self.safe_string(position, 'conditionId') slug = self.safe_string(position, 'slug') title = self.safe_string(position, 'title') outcome = self.safe_string(position, 'outcome') endDate = self.safe_string(position, 'endDate') - - # Use slug or conditionId as base + # Use slug or conditionId baseId = slug or conditionId or asset quoteId = self.safe_string(self.options, 'defaultCollateral', 'USDC') - # Parse endDate for symbol construction expiry = None - if endDate: + if endDate is not None: try: - if 'T' in endDate or 'Z' in endDate: + if endDate.find('T') >= 0 or endDate.find('Z') >= 0: expiry = self.parse8601(endDate) else: expiry = self.parse8601(endDate + 'T00:00:00Z') - except Exception: - pass - - ymd = self.yymmdd(expiry) if expiry else '999999' # Far future if no date - optionType = self.parse_option_type(outcome) if outcome else 'UNKNOWN' + except Exception as e: + expiry = None + # Far future if no date + ymd = '999999' + if expiry is not None: + ymd = self.yymmdd(expiry) + optionType = 'UNKNOWN' + if outcome is not None: + optionType = self.parse_option_type(outcome) symbol = baseId + '/' + quoteId + ':' + quoteId + '-' + ymd + '-0-' + optionType - return self.safe_market_structure({ 'id': asset, 'symbol': symbol, @@ -2783,7 +2774,7 @@ def _create_synthetic_closed_market(self, position: dict, asset: str) -> dict: 'swap': False, 'future': False, 'option': True, - 'active': False, # Mark as inactive since it's closed + 'active': False, # Mark since it's closed 'contract': True, 'linear': True, 'info': { @@ -2852,7 +2843,6 @@ def parse_position(self, position: dict, market: Market = None) -> Position: endDate = self.safe_string(position, 'endDate') negativeRisk = self.safe_bool(position, 'negativeRisk') timestamp = self.safe_integer(position, 'timestamp') - # Get market with automatic fallback for closed markets market = self.safe_market_with_fallback(asset, market, position) symbol = market['symbol'] @@ -2882,7 +2872,6 @@ def parse_position(self, position: dict, market: Market = None) -> Position: leverage = self.safe_number(position, 'leverage', 1) # Build extended info object with parsed values extendedInfo = self.extend(position, { - 'conditionId': conditionId, 'proxyWallet': proxyWallet, 'totalBought': totalBought, 'realizedPnl': realizedPnl, @@ -2993,14 +2982,14 @@ async def fetch_trading_fee(self, symbol: str, params={}) -> TradingFeeInterface await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = await self.clob_public_get_fee_rate(self.extend({'token_id': tokenId}, params)) diff --git a/Trading/Exchange/polymarket/ccxt/polymarket_pro.py b/Trading/Exchange/polymarket/ccxt/polymarket_pro.py index 02b44b7bd..05deb4a5a 100644 --- a/Trading/Exchange/polymarket/ccxt/polymarket_pro.py +++ b/Trading/Exchange/polymarket/ccxt/polymarket_pro.py @@ -8,7 +8,8 @@ from ccxt.base.types import Any, Int, Order, OrderBook, Str, Ticker, Trade from ccxt.async_support.base.ws.client import Client from typing import List -from ccxt.base.errors import ArgumentsRequired, ExchangeError +from ccxt.base.errors import ExchangeError +from ccxt.base.errors import ArgumentsRequired class polymarket(polymarket): @@ -26,6 +27,7 @@ def describe(self) -> Any: 'watchOrders': True, 'watchOrderBook': True, 'watchOHLCV': False, + 'watchMarkets': True, }, 'urls': { 'api': { @@ -37,8 +39,11 @@ def describe(self) -> Any: }, }, 'options': { - 'wsMarketChannelType': 'market', - 'wsUserChannelType': 'user', + 'wsMarketChannelType': 'MARKET', + 'wsUserChannelType': 'USER', + 'headers': {'Origin': 'https://polymarket.com'}, + 'ping': True, + 'keepalive': True, }, 'streaming': { }, @@ -137,6 +142,7 @@ async def watch_ticker(self, symbol: str, params={}) -> Ticker: request: dict = { 'type': self.options['wsMarketChannelType'], 'assets_ids': [assetId], + 'custom_feature_enabled': False, } subscription: dict = { 'symbol': symbol, @@ -144,6 +150,26 @@ async def watch_ticker(self, symbol: str, params={}) -> Ticker: } return await self.watch(url, messageHash, request, messageHash, subscription) + async def watch_markets(self, params={}) -> Any: + """ + watches for new and resolved markets + :param dict [params]: extra parameters specific to the exchange API endpoint + :param boolean [params.custom_feature_enabled]: enable custom features like market updates(default False) + :returns dict: A dictionary of `market structures ` indexed by market symbols + """ + await self.load_markets() + customFeatureEnabled = self.safe_bool(params, 'custom_feature_enabled', False) + url = self.urls['api']['ws']['market'] + messageHash = 'markets' + request: dict = { + 'type': self.options['wsMarketChannelType'], + 'custom_feature_enabled': customFeatureEnabled, + } + subscription: dict = { + 'custom_feature_enabled': customFeatureEnabled, + } + return await self.watch(url, messageHash, request, messageHash, subscription) + async def watch_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ watches information on an order made by the user @@ -289,7 +315,7 @@ def handle_order_book(self, client: Client, message): orderbook['datetime'] = datetime client.resolve(orderbook, messageHash) - def handle_price_change_orderbook(self, client: Client, message): + def handle_price_change_order_book(self, client: Client, message): # # Market websocket price_change event for orderbook updates: # { @@ -696,7 +722,7 @@ def handle_market_event(self, client: Client, message: Any, eventType: str): self.handle_trades(client, message) elif eventType == 'price_change': # price_change updates both orderbook and ticker - self.handle_price_change_orderbook(client, message) + self.handle_price_change_order_book(client, message) self.handle_ticker(client, message) elif eventType == 'last_trade_price': self.handle_ticker(client, message) @@ -704,6 +730,16 @@ def handle_market_event(self, client: Client, message: Any, eventType: str): # Tick size change - can be used to update ticker if self.verbose: self.log('Tick size change event:', message) + elif eventType == 'new_market': + marketsSubscription = self.safe_value(client.subscriptions, 'markets') + customFeatureEnabled = self.safe_bool(marketsSubscription, 'custom_feature_enabled', False) if marketsSubscription else False + if customFeatureEnabled: + self.handle_new_market(client, message) + elif eventType == 'market_resolved': + marketsSubscription = self.safe_value(client.subscriptions, 'markets') + customFeatureEnabled = self.safe_bool(marketsSubscription, 'custom_feature_enabled', False) if marketsSubscription else False + if customFeatureEnabled: + self.handle_market_resolved(client, message) else: # Unknown event type, log but don't error if self.verbose: @@ -719,6 +755,36 @@ def handle_user_event(self, client: Client, message: Any, eventType: str): if self.verbose: self.log('Unknown user websocket event type:', eventType, message) + def handle_new_market(self, client: Client, message): + # Handle new market event + # Docs: https://docs.polymarket.com/developers/CLOB/websocket/market-channel#new_market-message + marketData = self.safe_dict(message, 'market', {}) + marketId = self.safe_string(marketData, 'id') + if marketId: + # Update marketUpdates cache + if not self.marketUpdates: + self.marketUpdates = {} + self.marketUpdates[marketId] = { + 'event_type': 'new_market', + 'market': marketData, + } + client.resolve(self.marketUpdates, 'markets') + + def handle_market_resolved(self, client: Client, message): + # Handle market resolved event + # Docs: https://docs.polymarket.com/developers/CLOB/websocket/market-channel#market_resolved-message + marketData = self.safe_dict(message, 'market', {}) + marketId = self.safe_string(marketData, 'id') + if marketId: + # Update marketUpdates cache + if not self.marketUpdates: + self.marketUpdates = {} + self.marketUpdates[marketId] = { + 'event_type': 'market_resolved', + 'market': marketData, + } + client.resolve(self.marketUpdates, 'markets') + async def authenticate(self, params={}): url = self.urls['api']['ws']['user'] client = self.client(url) @@ -746,116 +812,111 @@ async def watch(self, url: str, messageHash: str, message=None, subscribeHash=No client = self.client(url) if subscribeHash is None: subscribeHash = messageHash - - # Handle market channel subscriptions with dynamic subscribe/unsubscribe (following onetrading pattern) + # Handle market channel subscriptions with dynamic subscribe/unsubscribe(following onetrading pattern) if subscription is not None and url.find('/ws/market') >= 0: # Use wsMarketChannelType for subscription hash channelSubscriptionHash = self.options['wsMarketChannelType'] - # Extract asset_id from message or subscription - assetId = None + assetId: str | None = None if message and isinstance(message, dict): - assets_ids = self.safe_value(message, 'assets_ids', []) - if isinstance(assets_ids, list) and len(assets_ids) > 0: - assetId = assets_ids[0] + assetsIds = self.safe_value(message, 'assets_ids', []) + if isinstance(assetsIds, list) and len(assetsIds) > 0: + assetId = self.safe_string(assetsIds, 0) if assetId is None: assetId = self.safe_string_2(subscription, 'asset_id', 'token_id') - - if assetId: + if assetId is not None: # Get existing subscription or create new one channelSubscription = self.safe_value(client.subscriptions, channelSubscriptionHash, {}) - if not isinstance(channelSubscription, dict): + if not isinstance(channelSubscription, dict) or isinstance(channelSubscription, list): channelSubscription = {} - # Check if we're already connected - is_connected = client.connected.done() - + isConnected = client.connection and client.connection.readyState == 1 # WebSocket.OPEN = 1 # Check if asset_id needs to be added - needs_subscribe = assetId not in channelSubscription - - if is_connected and needs_subscribe: + needsSubscribe = not (assetId in channelSubscription) + if isConnected and needsSubscribe: # Connection exists and asset_id not subscribed - use dynamic subscribe await self.subscribe_to_asset_ids([assetId]) - # Add asset_id to subscription tracking channelSubscription[assetId] = True client.subscriptions[channelSubscriptionHash] = channelSubscription - - # Build message with all subscribed asset_ids (following onetrading pattern) - all_asset_ids = list(channelSubscription.keys()) + # Build message with all subscribed asset_ids(following onetrading pattern) + allAssetIds = list(channelSubscription.keys()) if message and isinstance(message, dict): - message['assets_ids'] = all_asset_ids + message['assets_ids'] = allAssetIds message['type'] = self.options['wsMarketChannelType'] - # Store individual subscription info for message routing if not (subscribeHash in client.subscriptions): client.subscriptions[subscribeHash] = subscription - - return await super(polymarket, self).watch(url, messageHash, message, subscribeHash, subscription) + params = {} + params['headers'] = self.options['headers'] + params['ping'] = self.options['ping'] + params['keepalive'] = self.options['keepalive'] + return await super(polymarket, self).watch(url, messageHash, message, subscribeHash, subscription, params) async def subscribe_to_asset_ids(self, asset_ids: List[str], params={}): """ Dynamically subscribe to additional asset IDs on an existing market channel connection - :param List[str] asset_ids: list of asset IDs to subscribe to + :param str[] asset_ids: list of asset IDs to subscribe to :param dict [params]: extra parameters - :returns: None + :returns Promise: """ url = self.urls['api']['ws']['market'] client = self.client(url) - if not client.connected.done(): - raise ExchangeError(self.id + ' subscribe_to_asset_ids() requires an active WebSocket connection') - + if not (client.connection and client.connection.readyState == 1): + raise ExchangeError(self.id + ' subscribeToAssetIds() requires an active WebSocket connection') channelSubscriptionHash = self.options['wsMarketChannelType'] channelSubscription = self.safe_value(client.subscriptions, channelSubscriptionHash, {}) - if not isinstance(channelSubscription, dict): + if not isinstance(channelSubscription, dict) or isinstance(channelSubscription, list): channelSubscription = {} - # Filter out already subscribed asset_ids - new_asset_ids = [aid for aid in asset_ids if aid not in channelSubscription] - if len(new_asset_ids) == 0: + newAssetIds = [] + for i in range(0, len(asset_ids)): + aid = asset_ids[i] + if not (aid in channelSubscription): + newAssetIds.append(aid) + if len(newAssetIds) == 0: return # All already subscribed - request: dict = { - 'assets_ids': new_asset_ids, + 'assets_ids': newAssetIds, 'operation': 'subscribe', } await client.send(request) - # Track newly subscribed asset_ids - for aid in new_asset_ids: + for i in range(0, len(newAssetIds)): + aid = newAssetIds[i] channelSubscription[aid] = True client.subscriptions[channelSubscriptionHash] = channelSubscription async def unsubscribe_from_asset_ids(self, asset_ids: List[str], params={}): """ Dynamically unsubscribe from asset IDs on an existing market channel connection - :param List[str] asset_ids: list of asset IDs to unsubscribe from + :param str[] asset_ids: list of asset IDs to unsubscribe from :param dict [params]: extra parameters - :returns: None + :returns Promise: """ url = self.urls['api']['ws']['market'] client = self.client(url) - if not client.connected.done(): - raise ExchangeError(self.id + ' unsubscribe_from_asset_ids() requires an active WebSocket connection') - + if not (client.connection and client.connection.readyState == 1): + raise ExchangeError(self.id + ' unsubscribeFromAssetIds() requires an active WebSocket connection') channelSubscriptionHash = self.options['wsMarketChannelType'] channelSubscription = self.safe_value(client.subscriptions, channelSubscriptionHash, {}) - if not isinstance(channelSubscription, dict): + if not isinstance(channelSubscription, dict) or isinstance(channelSubscription, list): channelSubscription = {} - # Filter to only unsubscribe from actually subscribed asset_ids - subscribed_asset_ids = [aid for aid in asset_ids if aid in channelSubscription] - if len(subscribed_asset_ids) == 0: + subscribedAssetIds = [] + for i in range(0, len(asset_ids)): + aid = asset_ids[i] + if aid in channelSubscription: + subscribedAssetIds.append(aid) + if len(subscribedAssetIds) == 0: return # None are subscribed - - request: dict = { - 'assets_ids': subscribed_asset_ids, - 'operation': 'unsubscribe', - } + request = {} + request['assets_ids'] = subscribedAssetIds + request['operation'] = 'unsubscribe' await client.send(request) - # Remove from tracking - for aid in subscribed_asset_ids: + for i in range(0, len(subscribedAssetIds)): + aid = subscribedAssetIds[i] del channelSubscription[aid] client.subscriptions[channelSubscriptionHash] = channelSubscription diff --git a/Trading/Exchange/polymarket/ccxt/polymarket_sync.py b/Trading/Exchange/polymarket/ccxt/polymarket_sync.py index ebbc280e5..6818dc543 100644 --- a/Trading/Exchange/polymarket/ccxt/polymarket_sync.py +++ b/Trading/Exchange/polymarket/ccxt/polymarket_sync.py @@ -770,7 +770,7 @@ def parse_market(self, market: dict) -> Market: 'option': True, # Prediction markets are treated 'active': enableOrderBook and active and not closed and not archived, 'contract': True, - 'linear': None, + 'linear': True, # Only linear makes sense 'inverse': None, 'contractSize': contractSize, 'expiry': expiry, @@ -842,16 +842,14 @@ def fetch_order_book(self, symbol: str, limit: Int = None, params={}) -> OrderBo self.load_markets() market = self.market(symbol) request: dict = {} - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) request['token_id'] = tokenId response = self.clob_public_get_orderbook_token_id(self.extend(request, params)) return self.parse_order_book(response, symbol) @@ -876,11 +874,10 @@ def fetch_order_books(self, symbols: Strings = None, limit: Int = None, params={ for i in range(0, len(symbols)): symbol = symbols[i] market = self.market(symbol) - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + tokenId = self.safe_string(market, 'id') + if tokenId is not None: tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: @@ -1048,15 +1045,14 @@ def fetch_ticker(self, symbol: str, params={}) -> Ticker: self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) # Fetch prices using POST /prices endpoint with both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = self.clob_public_post_prices(self.extend({ @@ -1104,11 +1100,10 @@ def fetch_tickers(self, symbols: Strings = None, params={}) -> Tickers: for i in range(0, len(symbolsToFetch)): symbol = symbolsToFetch[i] market = self.market(symbol) - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + tokenId = self.safe_string(market, 'id') + if tokenId is not None: tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: @@ -1420,7 +1415,7 @@ def parse_trade(self, trade: dict, market: Market = None) -> Trade: if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif conditionId is not None: - resolved = self.safe_market(conditionId, None) + resolved = self.safe_market_with_fallback(conditionId, None, trade) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol @@ -1481,14 +1476,14 @@ def parse_trade(self, trade: dict, market: Market = None) -> Trade: if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif tradeMarket is not None: - resolved = self.safe_market(tradeMarket, None) + resolved = self.safe_market_with_fallback(tradeMarket, None, trade) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = tradeMarket elif assetId is not None: - resolved = self.safe_market(assetId, market) + resolved = self.safe_market_with_fallback(assetId, market, trade) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol @@ -1573,16 +1568,14 @@ def fetch_ohlcv(self, symbol: str, timeframe: str = '1h', since: Int = None, lim self.load_markets() market = self.market(symbol) request: dict = {} - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - marketInfo = self.safe_dict(market, 'info', {}) - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) request['market'] = tokenId # API uses 'market' parameter for token_id # Note: REST API /prices-history endpoint requires either: # 1. startTs and endTs(mutually exclusive with interval) @@ -1959,15 +1952,14 @@ def build_order(self, symbol: str, type: OrderType, side: OrderSide, amount: flo """ market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - # Use first token ID if multiple outcomes exist - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) # Convert CCXT side to Polymarket side(BUY/SELL) polymarketSide = 'BUY' if (side == 'buy') else 'SELL' # Convert amount and price to strings @@ -2311,13 +2303,15 @@ def cancel_all_orders(self, symbol: Str = None, params={}) -> List[Order]: marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id(market ID) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) - # Get asset_id from clobTokenIds - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) + # Get asset_id from market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol request: dict = {} if conditionId is not None: request['market'] = conditionId - if len(clobTokenIds) > 0: - request['asset_id'] = clobTokenIds[0] + assetId = self.safe_string(market, 'id') + if assetId is not None: + request['asset_id'] = assetId # Response format: {canceled: string[], not_canceled: {order_id -> reason}} response = self.clob_private_delete_cancel_market_orders(self.extend(request, params)) else: @@ -2390,12 +2384,14 @@ def fetch_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, if conditionId is not None: request['market'] = conditionId # Also include asset_id for backward compatibility and more specific filtering - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol + assetId = self.safe_string(market, 'id') + if assetId is not None: # The Polymarket L2 getOpenOrders() endpoint filters by asset_id - request['asset_id'] = clobTokenIds[0] + request['asset_id'] = assetId # Keep backward compatibility for legacy token_id usage - request['token_id'] = clobTokenIds[0] + request['token_id'] = assetId id = self.safe_string(params, 'id') if id is not None: request['id'] = id @@ -2431,7 +2427,6 @@ def fetch_open_orders(self, symbol: Str = None, since: Int = None, limit: Int = :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ - # The Polymarket getOpenOrders() endpoint already returns open orders return self.fetch_orders(symbol, since, limit, params) def parse_order(self, order: dict, market: Market = None) -> Order: @@ -2473,7 +2468,7 @@ def parse_order(self, order: dict, market: Market = None) -> Order: marketId = self.safe_string(order, 'market') assetId = self.safe_string(order, 'asset_id') if market is None and marketId is not None: - market = self.safe_market(marketId, None) + market = self.safe_market_with_fallback(marketId, None, order) symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] @@ -2702,6 +2697,96 @@ def fetch_user_closed_positions(self, userId: str, symbols: Strings = None, para positions = response if isinstance(response, list) else [] return self.parse_positions(positions, symbols) + def safe_market_with_fallback(self, marketId: str, market: Market = None, metadata: dict = None) -> Market: + """ + Safely get a market by ID, with fallback to synthetic market for missing/closed markets + :param str marketId: Market ID(asset ID, clobTokenId, or symbol) to look up + :param dict [market]: Optional pre-existing market structure + :param dict [metadata]: Optional metadata(position, order, etc.) containing market info + :returns dict: Market structure(from marketsById, synthetic, or minimal) + """ + try: + market = self.safe_market(marketId, market) + # Check if market was actually found in markets_by_id + if self.markets_by_id is not None and marketId in self.markets_by_id: + return market # Market found, return it + except Exception as e: + # safeMarket can raise ArgumentsRequired for ambiguous markets + # In self case, we'll try to create synthetic or return minimal structure + if isinstance(e, ArgumentsRequired): + market = None + else: + # Re-raise other exceptions + raise e + # Market not found in markets_by_id - if we have metadata, try to create synthetic market + if metadata is not None: + conditionId = self.safe_string(metadata, 'conditionId') + slug = self.safe_string(metadata, 'slug') + if conditionId is not None or slug is not None: + return self.create_synthetic_closed_market(metadata, marketId) + # No metadata or can't create synthetic - return minimal structure from safeMarket + return self.safe_market(marketId, market) + + def create_synthetic_closed_market(self, position: dict, asset: str) -> Market: + """ + Create a synthetic market structure for a closed/expired market + :param dict position: position response from the exchange + :param str asset: asset ID + :returns dict: synthetic market structure + """ + conditionId = self.safe_string(position, 'conditionId') + slug = self.safe_string(position, 'slug') + title = self.safe_string(position, 'title') + outcome = self.safe_string(position, 'outcome') + endDate = self.safe_string(position, 'endDate') + # Use slug or conditionId + baseId = slug or conditionId or asset + quoteId = self.safe_string(self.options, 'defaultCollateral', 'USDC') + # Parse endDate for symbol construction + expiry = None + if endDate is not None: + try: + if endDate.find('T') >= 0 or endDate.find('Z') >= 0: + expiry = self.parse8601(endDate) + else: + expiry = self.parse8601(endDate + 'T00:00:00Z') + except Exception as e: + expiry = None + # Far future if no date + ymd = '999999' + if expiry is not None: + ymd = self.yymmdd(expiry) + optionType = 'UNKNOWN' + if outcome is not None: + optionType = self.parse_option_type(outcome) + symbol = baseId + '/' + quoteId + ':' + quoteId + '-' + ymd + '-0-' + optionType + return self.safe_market_structure({ + 'id': asset, + 'symbol': symbol, + 'base': baseId, + 'quote': quoteId, + 'settle': quoteId, + 'baseId': baseId, + 'quoteId': quoteId, + 'type': 'option', + 'spot': False, + 'margin': False, + 'swap': False, + 'future': False, + 'option': True, + 'active': False, # Mark since it's closed + 'contract': True, + 'linear': True, + 'info': { + 'conditionId': conditionId, + 'slug': slug, + 'title': title, + 'outcome': outcome, + 'endDate': endDate, + 'closed': True, + }, + }) + def parse_position(self, position: dict, market: Market = None) -> Position: """ parses a position from the exchange response format @@ -2758,7 +2843,8 @@ def parse_position(self, position: dict, market: Market = None) -> Position: endDate = self.safe_string(position, 'endDate') negativeRisk = self.safe_bool(position, 'negativeRisk') timestamp = self.safe_integer(position, 'timestamp') - market = self.safe_market(asset, market) + # Get market with automatic fallback for closed markets + market = self.safe_market_with_fallback(asset, market, position) symbol = market['symbol'] sizeString = self.safe_string(position, 'size', '0') contracts = self.parse_number(sizeString) @@ -2786,7 +2872,6 @@ def parse_position(self, position: dict, market: Market = None) -> Position: leverage = self.safe_number(position, 'leverage', 1) # Build extended info object with parsed values extendedInfo = self.extend(position, { - 'conditionId': conditionId, 'proxyWallet': proxyWallet, 'totalBought': totalBought, 'realizedPnl': realizedPnl, @@ -2897,14 +2982,14 @@ def fetch_trading_fee(self, symbol: str, params={}) -> TradingFeeInterface: self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) - # Get token ID from params or market info + # Get token ID from params or market + # Use market['id'] which is the specific token ID for self outcome(YES/NO) + # Do NOT use clobTokenIds[0] always picks the first outcome regardless of symbol tokenId = self.safe_string(params, 'token_id') if tokenId is None: - clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) - if len(clobTokenIds) > 0: - tokenId = clobTokenIds[0] - else: - raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) + tokenId = self.safe_string(market, 'id') + if tokenId is None: + raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = self.clob_public_get_fee_rate(self.extend({'token_id': tokenId}, params)) diff --git a/Trading/Exchange/polymarket/polymarket_exchange.py b/Trading/Exchange/polymarket/polymarket_exchange.py index 2571938ca..00ce69c84 100644 --- a/Trading/Exchange/polymarket/polymarket_exchange.py +++ b/Trading/Exchange/polymarket/polymarket_exchange.py @@ -130,7 +130,7 @@ def is_inverse_symbol(self, symbol) -> bool: """ return False -def _parse_end_date(end_date: str) -> datetime.datetime: +def _parse_end_date(end_date: str) -> typing.Optional[datetime.datetime]: try: parsed_date = datetime.datetime.fromisoformat(end_date.replace('Z', '+00:00')) if parsed_date.tzinfo is not None: diff --git a/Trading/Mode/index_trading_mode/index_trading.py b/Trading/Mode/index_trading_mode/index_trading.py index 7ff61e60e..2c17ca7b3 100644 --- a/Trading/Mode/index_trading_mode/index_trading.py +++ b/Trading/Mode/index_trading_mode/index_trading.py @@ -355,11 +355,53 @@ async def _prepare_indexed_coins(self): for coin in self.trading_mode.indexed_coins: await self.trading_mode.rebalancer.prepare_coin_rebalancing(coin) + def _get_full_traded_pairs(self): + return self.exchange_manager.exchange_config.traded_symbol_pairs + self.exchange_manager.exchange_config.additional_traded_pairs + + async def _wait_for_topic_init(self, topic, topic_name, timeout) -> bool: + """ + Wait for topic to be initialized. This ensures that existing topic data + are loaded from the exchange before calculating holdings ratios. + """ + try: + full_symbols = self._get_full_traded_pairs() + if full_symbols: + await trading_util.wait_for_topic_init( + self.exchange_manager, timeout, + topic, + symbols=full_symbols + ) + return True + except (asyncio.TimeoutError,): + self.logger.warning( + f"{topic_name} initialization took more than {timeout} seconds. " + f"Existing {topic_name} might not be reflected in holdings calculations." + ) + return False + + async def _wait_for_positions_init(self, timeout) -> bool: + if not (self.exchange_manager.is_future or self.exchange_manager.is_option): + return True + return await self._wait_for_topic_init( + commons_enums.InitializationEventExchangeTopics.POSITIONS.value, + "positions", + timeout + ) + + async def _wait_for_orders_init(self, timeout) -> bool: + return await self._wait_for_topic_init( + commons_enums.InitializationEventExchangeTopics.ORDERS.value, + "orders", + timeout + ) + @trading_modes.enabled_trader_only() async def ensure_index(self): await self._wait_for_symbol_prices_and_profitability_init(self._get_config_init_timeout()) await self._prepare_indexed_coins() await self._register_traded_symbol_pairs_update() + await self._wait_for_positions_init(self._get_config_init_timeout()) + await self._wait_for_orders_init(self._get_config_init_timeout()) self.logger.info( f"Ensuring Index on [{self.exchange_manager.exchange_name}] " f"{len(self.trading_mode.indexed_coins)} coins: {self.trading_mode.indexed_coins} with reference market: " @@ -436,7 +478,7 @@ def _register_coins_update(self, rebalance_details: dict) -> bool: for coin in set(self.trading_mode.indexed_coins): # Use adjusted target ratio to account for reference market percentage target_ratio = self.trading_mode.get_adjusted_target_ratio(coin) - coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True) + coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) beyond_ratio = True if coin_ratio == trading_constants.ZERO and target_ratio > trading_constants.ZERO: # missing coin in portfolio @@ -464,7 +506,7 @@ def _register_removed_coin(self, rebalance_details: dict, available_traded_bases should_rebalance = False for coin in self.trading_mode.get_removed_coins_from_config(available_traded_bases): if coin in available_traded_bases: - coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True) + coin_ratio = self.get_holdings_ratio(coin, traded_symbols_only=True, include_assets_in_open_orders=True) if coin_ratio >= self.MIN_RATIO_TO_SELL: # coin to sell in portfolio rebalance_details[RebalanceDetails.REMOVE.value][coin] = coin_ratio @@ -517,7 +559,7 @@ def _empty_rebalance_details(self) -> dict: RebalanceDetails.FORCED_REBALANCE.value: False, } - def _get_rebalance_details(self) -> (bool, dict): + def _get_rebalance_details(self) -> typing.Tuple[bool, dict]: rebalance_details = self._empty_rebalance_details() should_rebalance = False # look for coins update in indexed_coins diff --git a/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py b/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py index 1a4af4e03..e276ef837 100644 --- a/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py +++ b/Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py @@ -3379,8 +3379,9 @@ def __is_index_config_applied(*args): _is_index_config_applied_mock.reset_mock() -async def test_is_index_config_applied(tools): - mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) +@pytest.mark.parametrize("trading_tools", ["spot", "futures"], indirect=True) +async def test_is_index_config_applied(trading_tools): + mode, producer, consumer, trader = await _init_mode(trading_tools, _get_config(trading_tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT", "ETH/USDT", "SOL/USDT", "ADA/USDT"] @@ -3453,13 +3454,13 @@ def _get_symbol_position(symbol, side=None): # Match symbol by checking if it contains the coin name (handles both "BTC/USDT" and "BTC/USDT:USDT") symbol_str = str(symbol) if not isinstance(symbol, str) else symbol if "BTC" in symbol_str and "USDT" in symbol_str: - position_mock.get_value.return_value = btc_position_value + position_mock.margin = btc_position_value position_mock.size = decimal.Decimal("0.6") # Position size for BTC elif "ETH" in symbol_str and "USDT" in symbol_str: - position_mock.get_value.return_value = eth_position_value + position_mock.margin = eth_position_value position_mock.size = decimal.Decimal("0.4") # Position size for ETH else: - position_mock.get_value.return_value = decimal.Decimal("0") + position_mock.margin = decimal.Decimal("0") position_mock.size = decimal.Decimal("0") position_mock.is_idle.return_value = False position_mock.is_open.return_value = True # Position is open @@ -3470,29 +3471,32 @@ def _get_symbol_position(symbol, side=None): position_mock.is_open.return_value = False return position_mock - with mock.patch.object( - portfolio_value_holder, - "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { - "BTC": decimal.Decimal("0.6"), # 60% target - "ETH": decimal.Decimal("0.4"), # 40% target - }.get(coin, decimal.Decimal("0"))) - ) as get_holdings_ratio_mock, mock.patch.object( - positions_manager, "get_symbol_position", mock.Mock(side_effect=_get_symbol_position) - ) as get_symbol_position_mock: - if is_futures: - with mock.patch.object( - portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=total_portfolio_value) - ) as get_traded_assets_holdings_value_mock: - assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True - assert get_holdings_ratio_mock.call_count == 0 - assert get_symbol_position_mock.call_count == 2 - get_holdings_ratio_mock.reset_mock() - else: + if is_futures: + with mock.patch.object( + positions_manager, "get_symbol_position", mock.Mock(side_effect=_get_symbol_position) + ) as get_symbol_position_mock, \ + mock.patch.object( + portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=total_portfolio_value) + ) as get_traded_assets_holdings_value_mock: + assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True + assert get_symbol_position_mock.call_count == 2 + assert "BTC" in str(get_symbol_position_mock.mock_calls[0].args[0]) + assert "ETH" in str(get_symbol_position_mock.mock_calls[1].args[0]) + get_symbol_position_mock.reset_mock() + assert get_traded_assets_holdings_value_mock.call_count == 2 # called for each coin + get_traded_assets_holdings_value_mock.reset_mock() + else: + with mock.patch.object( + portfolio_value_holder, + "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { + "BTC": decimal.Decimal("0.6"), # 60% target + "ETH": decimal.Decimal("0.4"), # 40% target + }.get(coin, decimal.Decimal("0"))) + ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 assert get_holdings_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_holdings_ratio_mock.mock_calls[1].args[0] == "ETH" - assert get_symbol_position_mock.call_count == 0 get_holdings_ratio_mock.reset_mock() # Test 6: Valid distribution with holdings within tolerance range diff --git a/Trading/Mode/profile_copy_trading_mode/profile_distribution.py b/Trading/Mode/profile_copy_trading_mode/profile_distribution.py index e4a0a8f61..cb63a9b4b 100644 --- a/Trading/Mode/profile_copy_trading_mode/profile_distribution.py +++ b/Trading/Mode/profile_copy_trading_mode/profile_distribution.py @@ -88,6 +88,9 @@ def get_smoothed_distribution_from_profile_data( profile_data.positions, new_position_only, started_at, min_unrealized_pnl_percent, max_unrealized_pnl_percent, min_mark_price, max_mark_price ) + if not profile_positions: + return [] + total_initial_margin = decimal.Decimal(sum( decimal.Decimal(str(position.get( trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value,