diff --git a/env_example.txt b/env_example.txt index 1e6cfb3d..0fe47669 100644 --- a/env_example.txt +++ b/env_example.txt @@ -53,6 +53,14 @@ APEX_OMNI_KEY_SEED=your_apex_omni_key_seed NADO_PRIVATE_KEY=your_nado_private_key_here NADO_MODE=MAINNET # or DEVNET +# Ethereal Configuration +ETHEREAL_BASE_URL=https://api.ethereal.trade +ETHEREAL_RPC_URL=https://rpc.ethereal.trade +ETHEREAL_CHAIN_ID=5064014 +ETHEREAL_PRIVATE_KEY=your_private_key # required for signing orders +ETHEREAL_SUBACCOUNT_ID=your_subaccount_id +ETHEREAL_ACCOUNT_NAME=primary + # Notification (optional) # guide: https://www.feishu.cn/hc/zh-CN/articles/185289387886-%E6%B6%88%E6%81%AF%E5%8A%A9%E6%89%8B-%E6%9C%BA%E5%99%A8%E4%BA%BA LARK_TOKEN= @@ -69,4 +77,4 @@ TIMEZONE=Asia/Shanghai # EdgeX API Endpoints EDGEX_BASE_URL=https://pro.edgex.exchange -EDGEX_WS_URL=wss://quote.edgex.exchange \ No newline at end of file +EDGEX_WS_URL=wss://quote.edgex.exchange diff --git a/exchanges/ethereal.py b/exchanges/ethereal.py new file mode 100644 index 00000000..e6ba26a8 --- /dev/null +++ b/exchanges/ethereal.py @@ -0,0 +1,879 @@ +""" +Ethereal exchange client skeleton built on the official ethereal-sdk. +""" + +import os +import asyncio +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID + +from .base import BaseExchangeClient, OrderResult, OrderInfo, query_retry +from helpers.logger import TradingLogger +from ethereal import AsyncRESTClient, AsyncWSClient + + +class EtherealClient(BaseExchangeClient): + """ + Ethereal exchange client wired to ethereal-sdk. + """ + + def __init__(self, config: Dict[str, Any]): + """Initialize Ethereal client with environment-driven defaults.""" + # REST + chain config (all optional; defaults follow the SDK docs) + self.base_url = os.getenv("ETHEREAL_BASE_URL", "https://api.ethereal.trade") + self.rpc_url = os.getenv("ETHEREAL_RPC_URL", "https://rpc.ethereal.trade") + self.private_key = os.getenv("ETHEREAL_PRIVATE_KEY") # required for signing + self.chain_id = int(os.getenv("ETHEREAL_CHAIN_ID", "42161")) + self.ws_url = os.getenv("ETHEREAL_WS_URL", "wss://ws.ethereal.trade") + + # Account / trading context + self.subaccount_id = os.getenv("ETHEREAL_SUBACCOUNT_ID") + self.account_name = os.getenv("ETHEREAL_ACCOUNT_NAME", "primary") + self.subaccount_hex = os.getenv("ETHEREAL_SUBACCOUNT") + if not self.subaccount_hex and self.account_name: + try: + self.subaccount_hex = self._encode_subaccount_name(self.account_name) + except Exception: + self.subaccount_hex = None + self._warned_missing_subaccount = False + + # Clients are created lazily in connect() + self._rest_client: Optional["AsyncRESTClient"] = None + self._ws_client: Optional["AsyncWSClient"] = None + self._order_update_handler = None + self._product_cache: Dict[str, Any] = {} + self._contract_to_product_id: Dict[str, UUID] = {} + self._product_id_to_contract: Dict[UUID, str] = {} + self._ws_task: Optional[asyncio.Task] = None + self._ws_stop: Optional[asyncio.Event] = None + + self.logger = TradingLogger( + exchange="ethereal", + ticker=getattr(config, "ticker", ""), + log_to_console=False + ) + + # Base init last: sets self.config and runs _validate_config + super().__init__(config) + + @staticmethod + def _normalize_contract_id(value: Optional[str]) -> str: + """Normalize contract id to uppercase ticker+USD form.""" + raw = value or "" + try: + return str(UUID(str(raw))) + except Exception: + pass + cid = str(raw).upper() + if cid and not cid.endswith("USD"): + cid = f"{cid}USD" + return cid + + @staticmethod + def _as_uuid(value: Any) -> Optional[UUID]: + """Convert value to UUID if possible.""" + if isinstance(value, UUID): + return value + try: + return UUID(str(value)) + except Exception: + return None + + def _validate_config(self) -> None: + """Validate Ethereal configuration.""" + # Ticker is required; contract_id always normalized to ticker+USD. + if not getattr(self.config, "ticker", None): + raise ValueError("Missing required config field: ticker") + ticker_val = str(self.config.ticker).upper() + self.config.ticker = ticker_val + contract_val = getattr(self.config, "contract_id", None) + normalized_contract = ( + self._normalize_contract_id(str(contract_val)) + if contract_val + else self._normalize_contract_id(ticker_val) + ) + self.config.contract_id = normalized_contract + + # Signing key is optional for read-only calls, but required for trading. + if not getattr(self, "private_key", None): + self.logger.log("ETHEREAL_PRIVATE_KEY not set; trading calls will fail.", "WARN") + + def _build_rest_config(self) -> Dict[str, Any]: + """ + Assemble the config dict expected by AsyncRESTClient.create(). + + Example from the SDK docs: + await AsyncRESTClient.create({ + "base_url": "https://api.ethereal.trade", + "chain_config": { + "rpc_url": "https://rpc.ethereal.trade", + "private_key": "your_private_key", + } + }) + """ + config: Dict[str, Any] = { + "base_url": self.base_url, + "chain_config": { + "rpc_url": self.rpc_url, + "chain_id": self.chain_id, + }, + } + if self.private_key: + config["chain_config"]["private_key"] = self.private_key + return config + + async def _ensure_rest_client(self) -> None: + """Create the AsyncRESTClient on-demand.""" + if self._rest_client is not None: + return + + if AsyncRESTClient is None: + raise ImportError("ethereal-sdk not installed or not available in this environment") + + try: + self._rest_client = await AsyncRESTClient.create(self._build_rest_config()) + except Exception as exc: + self.logger.log(f"[ethereal] failed to create REST client: {exc}", "ERROR") + raise + + async def _ensure_products(self) -> Dict[str, Any]: + """Cache products by ticker for quick lookups.""" + if self._product_cache: + return self._product_cache + await self._ensure_rest_client() + try: + products = await self._rest_client.products_by_ticker() + # store uppercase keys + cache = {k.upper(): v for k, v in products.items()} + self._product_cache = cache + self._contract_to_product_id = {} + self._product_id_to_contract = {} + for ticker_key, prod in cache.items(): + pid = getattr(prod, "id", None) + pid_uuid = self._as_uuid(pid) + if pid_uuid: + contract_key = self._normalize_contract_id(ticker_key) + self._contract_to_product_id[contract_key] = pid_uuid + self._product_id_to_contract[pid_uuid] = contract_key + except Exception as exc: + self.logger.log(f"[ethereal] products_by_ticker failed: {exc}", "WARNING") + self._product_cache = {} + self._contract_to_product_id = {} + self._product_id_to_contract = {} + return self._product_cache + + async def _start_ws(self) -> None: + """Start AsyncWSClient and subscribe to order stream.""" + if AsyncWSClient is None: + raise ImportError("ethereal-sdk websocket client not available") + + self._ws_client = AsyncWSClient({"base_url": self.ws_url}) + + # Set callbacks + self._ws_client.callbacks["OrderUpdate"] = [self._handle_ws_message_order_update] + + await self._ws_client.open(namespaces=["/", "/v1/stream"]) + # connected flag kept for future use + self._ws_connected = True + + # Best-effort subscribe + try: + if self.subaccount_id: + await self._ws_client.subscribe( + stream_type="OrderUpdate", + subaccount_id=self.subaccount_id, + ) + except Exception as exc: # pylint: disable=broad-except + self.logger.log(f"[ethereal] WS subscribe failed: {exc}", "WARNING") + return True + + async def _ws_reconnect_loop(self) -> None: + """Keep WS alive; reconnect proactively to handle 12h disconnects.""" + backoff = 1.0 + # Reconnect a bit before the 12h window (11h) to avoid server closes. + max_uptime = 60 * 60 * 11 + + while self._ws_stop and not self._ws_stop.is_set(): + try: + await self._start_ws() + self.logger.log("[ethereal] WS connected", "INFO") + backoff = 1.0 + + # Wait for either stop signal or proactive reconnect timeout + try: + await asyncio.wait_for(self._ws_stop.wait(), timeout=max_uptime) + break + except asyncio.TimeoutError: + # proactive reconnect + self.logger.log("[ethereal] WS proactive reconnect", "INFO") + except Exception: + break + except Exception as exc: + self.logger.log(f"[ethereal] WS connect error: {exc}", "ERROR") + finally: + try: + if self._ws_client: + await self._ws_client.close() + except Exception: + pass + + await asyncio.sleep(backoff) + backoff = min(60.0, backoff * 2) + self._ws_connected = False + + async def _get_product_by_contract_id(self, contract_id: Any) -> Optional[Any]: + """Resolve a product using contract_id (ticker+USD) or product_id.""" + products = await self._ensure_products() + if contract_id is None: + return None + + contract_key = self._normalize_contract_id(str(contract_id)) + if contract_key in products: + return products[contract_key] + + pid = self._as_uuid(contract_id) + if pid: + for item in products.values(): + if getattr(item, "id", None) == pid: + return item + return None + + async def _get_product_id_for_contract_id(self, contract_id: Any) -> Optional[UUID]: + """Map normalized contract_id to product_id (UUID).""" + await self._ensure_products() + if contract_id is None: + return None + + contract_key = self._normalize_contract_id(str(contract_id)) + pid = self._contract_to_product_id.get(contract_key) + if pid: + return pid + + product = await self._get_product_by_contract_id(contract_key) + if product: + pid_val = self._as_uuid(getattr(product, "id", None)) + resolved_contract = self._normalize_contract_id(getattr(product, "ticker", contract_key)) + if pid_val: + self._contract_to_product_id[resolved_contract] = pid_val + self._product_id_to_contract[pid_val] = resolved_contract + return pid_val + + fallback = self._as_uuid(contract_id) + return fallback + + async def get_ticker_by_product_id(self, product_id: Any) -> Optional[str]: + """Look up ticker string from a product id.""" + if product_id is None: + return None + await self._ensure_products() + pid = self._as_uuid(product_id) + if pid is None: + return None + ticker_cached = self._product_id_to_contract.get(pid) + if ticker_cached: + return ticker_cached + + products = await self._ensure_products() + for ticker_key, prod in products.items(): + if getattr(prod, "id", None) == pid: + contract_key = self._normalize_contract_id(ticker_key) + self._contract_to_product_id[contract_key] = pid + self._product_id_to_contract[pid] = contract_key + return contract_key + return None + + def _encode_subaccount_name(self, name: str) -> str: + """Encode subaccount name to bytes32 hex (0x...).""" + if not name: + raise ValueError("Subaccount name is empty") + b = name.encode("utf-8") + if len(b) > 32: + raise ValueError("Subaccount name too long for bytes32") + return "0x" + b.ljust(32, b"\x00").hex() + + async def _handle_ws_message_order_update(self, data: Dict[str, Any]): + """Bridge WS order events to the trading bot handler.""" + if not self._order_update_handler: + return + + try: + # Stream data type: + # https://docs.ethereal.trade/developer-guides/trading-api/websocket-gateway#subscribe + payload = data.get("data") if isinstance(data, dict) and "data" in data else data + if not isinstance(payload, list): + return + + for order in payload: + if not isinstance(order, dict): + continue + order_id = order.get("id") + status = self._normalize_status(order.get("status") or order.get("state")) + side_val = order.get("side") + side = "buy" if side_val in (0, "buy", "BUY") else "sell" + filled_size = order.get("filled") or 0 + size = order.get("quantity") or order.get("availableQuantity") or filled_size or 0 + price = order.get("price") or 0 + product_id_val = order.get("productId") + contract_id = None + if product_id_val is not None: + pid_uuid = self._as_uuid(product_id_val) + if pid_uuid: + contract_id = self._product_id_to_contract.get(pid_uuid) + if not contract_id: + try: + contract_id = await self.get_ticker_by_product_id(product_id_val) + except Exception: + contract_id = None + contract_id = contract_id or str(self.config.contract_id) + order_type = "CLOSE" if side == self.config.close_order_side else "OPEN" + + if not order_id: + continue + + self._order_update_handler( + { + "contract_id": contract_id, + "order_id": str(order_id), + "status": status or "", + "side": side, + "order_type": order_type, + "filled_size": str(filled_size), + "size": str(size), + "price": str(price), + } + ) + except Exception: + pass + + + async def connect(self) -> None: + """ + Initialize REST client and prepare WS hooks. + + The ethereal-sdk exposes AsyncRESTClient (for trading / queries) and + AsyncWSClient (for streaming order updates). + """ + await self._ensure_rest_client() + try: + await self._ensure_products() + except Exception as exc: + self.logger.log(f"Failed to preload products: {exc}", "WARNING") + + # Try to open WS for order updates; no REST polling fallback to avoid delays. + if self._order_update_handler: + if not self._ws_stop: + self._ws_stop = asyncio.Event() + if not self._ws_task or self._ws_task.done(): + self._ws_task = asyncio.create_task(self._ws_reconnect_loop()) + + async def disconnect(self) -> None: + """Tear down REST/WS clients.""" + try: + if self._ws_stop: + self._ws_stop.set() + if self._ws_task: + await self._ws_task + if self._ws_client: + await self._ws_client.close() + except Exception as exc: + self.logger.log(f"Error closing Ethereal WS client: {exc}", "ERROR") + self._ws_connected = False + self._ws_client = None + self._ws_task = None + self._ws_stop = None + + try: + if self._rest_client and hasattr(self._rest_client, "close"): + await self._rest_client.close() + except Exception as exc: + self.logger.log(f"Error closing Ethereal REST client: {exc}", "ERROR") + + self._ws_client = None + self._rest_client = None + + def get_exchange_name(self) -> str: + """Get the exchange name.""" + return "ethereal" + + def setup_order_update_handler(self, handler) -> None: + """ + Register a callback for order updates. + + AsyncWSClient supports a callbacks dict keyed by stream type + (see docs for subscribe/unsubscribe). Once WS wiring is added, + self._order_update_handler will be invoked from the WS message handler. + """ + self._order_update_handler = handler + + async def place_open_order(self, contract_id: str, quantity: Decimal, direction: str) -> OrderResult: + """ + Place an open order using AsyncRESTClient.create_order(). + """ + try: + await self._ensure_rest_client() + except ImportError as exc: + return OrderResult(success=False, error_message=str(exc)) + + if not self.private_key or not self.subaccount_id: + return OrderResult( + success=False, + error_message="Missing ETHEREAL_PRIVATE_KEY or ETHEREAL_SUBACCOUNT_ID" + ) + + product_id = await self._get_product_id_for_contract_id(contract_id or self.config.contract_id) + if not product_id: + return OrderResult(success=False, error_message="Product not found for ticker") + + best_bid, best_ask = await self._fetch_bbo(product_id) + if best_bid == 0 and best_ask == 0: + return OrderResult(success=False, error_message="Failed to fetch order book") + + # Determine order side and price + side = 0 if direction == "buy" else 1 + if direction == 'buy': + order_price = best_ask - self.config.tick_size + elif direction == 'sell': + order_price = best_bid + self.config.tick_size + else: + raise Exception(f"[OPEN] Invalid direction: {direction}") + + try: + order = await self._rest_client.create_order( + order_type="LIMIT", + quantity=float(quantity), + side=side, + price=float(order_price), + product_id=product_id, + subaccount=self.subaccount_hex, + time_in_force="GTD", + post_only=True, + ) + except Exception as exc: # pylint: disable=broad-except + return OrderResult(success=False, error_message=str(exc)) + + order_id = getattr(order, "id", None) or (order.get("id") if isinstance(order, dict) else None) + status = self._normalize_status(getattr(order, "status", "OPEN") if order_id else "FAILED") + + # Check order status after a short delay to see if it was rejected + order_info: Optional[OrderInfo] = None + if order_id: + try: + await asyncio.sleep(0.01) + order_info = await self.get_order_info(str(order_id)) + if order_info: + status = order_info.status or status + if status == "CANCELED": + return OrderResult( + success=False, + order_id=str(order_id), + side="buy" if side == 0 else "sell", + size=Decimal(str(quantity)), + price=Decimal(str(order_price)), + status=status, + error_message="Order rejected after placement", + filled_size=order_info.filled_size, + ) + except Exception: + pass + + return OrderResult( + success=bool(order_id), + order_id=str(order_id) if order_id else None, + side="buy" if side == 0 else "sell", + size=Decimal(str(quantity)), + price=Decimal(str(order_price)), + status=status, + filled_size=order_info.filled_size if order_info else None, + ) + + async def place_close_order(self, contract_id: str, quantity: Decimal, price: Decimal, side: str) -> OrderResult: + """ + Place a close/reduce order using AsyncRESTClient.create_order(). + """ + try: + await self._ensure_rest_client() + except ImportError as exc: + return OrderResult(success=False, error_message=str(exc)) + + if not self.private_key or not self.subaccount_id: + return OrderResult( + success=False, + error_message="Missing ETHEREAL_PRIVATE_KEY or ETHEREAL_SUBACCOUNT_ID" + ) + + product_id = await self._get_product_id_for_contract_id(contract_id or self.config.contract_id) + if not product_id: + return OrderResult(success=False, error_message="Product not found for ticker") + + # Ensure price respects tick size to avoid 400 from the API + tick = self.config.tick_size or Decimal(0) + price = self.round_to_tick(price) if tick > 0 else Decimal(str(price)) + + side_val = 0 if side.lower() == "buy" else 1 + try: + order = await self._rest_client.create_order( + order_type="LIMIT", + quantity=float(quantity), + side=side_val, + price=float(price), + product_id=product_id, + subaccount=self.subaccount_hex, + time_in_force="GTD", + reduce_only=True, + post_only=True, + ) + except Exception as exc: # pylint: disable=broad-except + return OrderResult(success=False, error_message=str(exc)) + + order_id = getattr(order, "id", None) or (order.get("id") if isinstance(order, dict) else None) + status = self._normalize_status(getattr(order, "status", "OPEN") if order_id else "FAILED") + + # Check order status after a short delay to see if it was rejected + order_info: Optional[OrderInfo] = None + if order_id: + try: + await asyncio.sleep(0.01) + order_info = await self.get_order_info(str(order_id)) + if order_info: + status = order_info.status or status + if status == "CANCELED": + return OrderResult( + success=False, + order_id=str(order_id), + side="buy" if side_val == 0 else "sell", + size=Decimal(str(quantity)), + price=Decimal(str(price)), + status=status, + error_message="Close order rejected after placement", + filled_size=order_info.filled_size, + ) + except Exception: + pass + + return OrderResult( + success=bool(order_id), + order_id=str(order_id) if order_id else None, + side="buy" if side_val == 0 else "sell", + size=Decimal(str(quantity)), + price=Decimal(str(price)), + status=status, + filled_size=order_info.filled_size if order_info else None, + ) + + async def cancel_order(self, order_id: str) -> OrderResult: + """ + Cancel an order. + + The SDK exposes AsyncRESTClient.cancel_orders([...]) / cancel_all_orders(). + """ + try: + await self._ensure_rest_client() + except ImportError as exc: + return OrderResult(success=False, error_message=str(exc)) + + if not self.private_key or not self.subaccount_id: + return OrderResult( + success=False, + error_message="Missing ETHEREAL_PRIVATE_KEY or ETHEREAL_SUBACCOUNT_ID" + ) + + try: + await self._rest_client.cancel_orders( + order_ids=[order_id], + sender=self._rest_client.chain.address if hasattr(self._rest_client, "chain") else None, + subaccount=self.subaccount_hex, + ) + return OrderResult(success=True, order_id=order_id) + except Exception as exc: # pylint: disable=broad-except + return OrderResult(success=False, error_message=str(exc)) + + async def get_order_info(self, order_id: str) -> Optional[OrderInfo]: + """ + Fetch order details via AsyncRESTClient.get_order(). + """ + try: + await self._ensure_rest_client() + except ImportError: + return None + + try: + order = await self._rest_client.get_order(id=order_id) + except Exception: + order = None + + if not order: + return None + + side = getattr(order, "side", None) + size = getattr(order, "quantity", None) + price = getattr(order, "price", None) + status = self._normalize_status(getattr(order, "status", "UNKNOWN")) + filled = getattr(order, "filled", None) + remaining = getattr(order, "available_quantity", None) + + return OrderInfo( + order_id=order_id, + side="buy" if side in (0, "buy", "BUY") else "sell", + size=Decimal(str(size or "0")), + price=Decimal(str(price or "0")), + status=status, + filled_size=Decimal(str(filled or "0")), + remaining_size=Decimal(str(remaining or "0")), + ) + + async def get_active_orders(self, contract_id: str) -> List[OrderInfo]: + """ + List open orders for the configured contract. + + The SDK provides AsyncRESTClient.list_orders() / list_trades(). + """ + try: + await self._ensure_rest_client() + except ImportError: + return [] + + if not self.subaccount_id: + self.logger.log("ETHEREAL_SUBACCOUNT_ID not set; cannot fetch active orders.", "WARNING") + return [] + + product_id = await self._get_product_id_for_contract_id(contract_id) + + if not product_id: + return [] + + try: + orders = await self._rest_client.list_orders( + subaccount_id=self.subaccount_id, + product_ids=[product_id] if product_id else None, + statuses=["NEW", "PENDING", "FILLED_PARTIAL"], + is_working=True, + ) + except Exception: + orders = [] + + order_infos: List[OrderInfo] = [] + for order in orders or []: + try: + status = self._normalize_status(getattr(order, "status", "")) + # Only keep active orders + if status not in ("OPEN", "PARTIALLY_FILLED"): + continue + + order_infos.append( + OrderInfo( + order_id=str(getattr(order, "id", "")), + side="buy" if getattr(order, "side", 0) in (0, "buy", "BUY") else "sell", + size=Decimal(str(getattr(order, "quantity", "0"))), + price=Decimal(str(getattr(order, "price", "0"))), + status=status, + filled_size=Decimal(str(getattr(order, "filled", "0"))), + remaining_size=Decimal(str(getattr(order, "available_quantity", getattr(order, "quantity", "0")))), + ) + ) + except Exception: + continue + + return order_infos + + async def get_order_price(self, direction: str) -> Decimal: + """ + Provide a placeholder price used by TradingBot when pacing orders. + + Uses current BBO to place near-touching maker orders. + """ + product_id = await self._get_product_id_for_contract_id(self.config.contract_id or self.config.ticker) + if not product_id: + raise ValueError("Product not found for ticker") + + best_bid, best_ask = await self._fetch_bbo(product_id) + if best_bid <= 0 or best_ask <= 0: + raise ValueError("Invalid bid/ask prices") + + if direction == 'buy': + return best_ask - self.config.tick_size + elif direction == 'sell': + return best_bid + self.config.tick_size + else: + raise ValueError("Invalid direction") + + async def fetch_bbo_prices(self, contract_id: str) -> Tuple[Decimal, Decimal]: + """Expose BBO for TradingBot stop/price checks.""" + product_id = await self._get_product_id_for_contract_id(contract_id or self.config.contract_id) + if not product_id: + return Decimal(0), Decimal(0) + return await self._fetch_bbo(product_id) + + async def _fetch_bbo(self, product_id: Any) -> Tuple[Decimal, Decimal]: + """Fetch best bid/ask via get_market_liquidity with manual retries.""" + if product_id is None: + raise ValueError("product_id is None for BBO fetch") + + max_attempts = 15 + wait = 0.2 + for attempt in range(max_attempts): + try: + liquidity = await self._rest_client.get_market_liquidity(product_id=product_id) + + bids = getattr(liquidity, "bids", None) or [] + asks = getattr(liquidity, "asks", None) or [] + best_bid = Decimal(str(bids[0][0])) if bids else Decimal(0) + best_ask = Decimal(str(asks[0][0])) if asks else Decimal(0) + return best_bid, best_ask + except Exception as exc: + status = None + retry_after = None + try: + status = getattr(getattr(exc, "response", None), "status_code", None) + retry_after = getattr(getattr(exc, "response", None), "headers", {}).get("Retry-After") + except Exception: + pass + + if status == 429 and retry_after: + try: + wait = max(wait, float(retry_after)) + except Exception: + wait = max(wait, 1.0) + + if attempt + 1 % 5 == 0: + self.logger.log( + f"[ethereal] get_market_liquidity failed for {product_id} " + f"(attempt {attempt + 1}/{max_attempts}): {type(exc).__name__}: {exc}", + "WARNING", + ) + if attempt < max_attempts - 1: + await asyncio.sleep(wait) + if status == 429: + wait = min(5.0, wait * 2) + else: + wait = min(1.0, wait * 2) + continue + return Decimal(0), Decimal(0) + + async def list_positions(self, subaccount_id: Optional[str] = None) -> List[Any]: + """ + Fetch positions for a subaccount using any available SDK method. + + - subaccount_id: overrides the default from ENV if provided. + Returns a list (can be empty). + """ + try: + await self._ensure_rest_client() + except ImportError as exc: + self.logger.log(f"ethereal-sdk not installed: {exc}", "ERROR") + return [] + + rest = self._rest_client + + sid = subaccount_id or self.subaccount_id + if not sid: + if not self._warned_missing_subaccount: + self.logger.log("ETHEREAL_SUBACCOUNT_ID not set; cannot fetch positions.", "WARN") + self._warned_missing_subaccount = True + return [] + + if hasattr(rest, "list_positions"): + try: + resp = await rest.list_positions(subaccount_id=sid) + return self._extract_positions(resp) + except Exception as exc: # pylint: disable=broad-except + self.logger.log(f"[ethereal] list_positions failed: {exc}", "WARNING") + + self.logger.log("No position endpoint available on Ethereal client", "ERROR") + return [] + + def _extract_positions(self, resp: Any) -> List[Any]: + """Normalize common Ethereal SDK response shapes to a list.""" + if resp is None: + return [] + if isinstance(resp, list): + return resp + if isinstance(resp, dict): + for key in ("positions", "data", "result", "results"): + if key in resp and isinstance(resp[key], list): + return resp[key] + if hasattr(resp, "positions"): + maybe = getattr(resp, "positions") + if isinstance(maybe, list): + return maybe + return [] + + def _normalize_status(self, status_raw: Any) -> str: + """Normalize SDK status to bot-friendly keywords.""" + if status_raw is None: + return "" + s = str(status_raw) + if "." in s: + s = s.split(".")[-1] + s_up = s.upper() + if s_up in ("NEW", "PENDING", "OPEN"): + return "OPEN" + if s_up in ("FILLED_PARTIAL", "FILLED_PARTIALLY", "PARTIALLY_FILLED"): + return "PARTIALLY_FILLED" + if s_up == "FILLED": + return "FILLED" + if s_up in ("CANCELED", "CANCELLED", "EXPIRED"): + return "CANCELED" + return s_up + + async def get_account_positions(self) -> Decimal: + """ + Retrieve current position size for the configured contract. + + The SDK provides AsyncRESTClient.list_positions() and get_position(). + """ + await self._ensure_rest_client() + positions = await self.list_positions() + target_pid = await self._get_product_id_for_contract_id(self.config.contract_id) + for pos in positions: + product_id = ( + pos.get("product_id") + if isinstance(pos, dict) + else getattr(pos, "product_id", None) + ) + if not product_id: + product_id = ( + pos.get("productId") + if isinstance(pos, dict) + else getattr(pos, "productId", None) + ) + + pid_uuid = self._as_uuid(product_id) + if target_pid and pid_uuid and pid_uuid == target_pid: + size_val = ( + pos.get("size") + if isinstance(pos, dict) + else getattr(pos, "size", None) + ) + try: + return abs(Decimal(str(size_val or "0"))) + except Exception: + return Decimal(0) + + return Decimal(0) + + async def get_contract_attributes(self) -> Tuple[str, Decimal]: + """ + Retrieve contract identifier and tick size for a ticker. + + Uses products_by_ticker; falls back to ticker if lookup fails. + """ + ticker = self.config.ticker + if not ticker: + raise ValueError("Ticker is empty") + + product = await self._get_product_by_contract_id(self.config.contract_id) or await self._get_product_by_contract_id(ticker) + if product: + contract_key = self._normalize_contract_id(getattr(product, "ticker", ticker)) + pid = self._as_uuid(getattr(product, "id", None)) + self.config.contract_id = contract_key + tick_size_val = getattr(product, "tick_size", None) + self.config.tick_size = Decimal(str(tick_size_val)) if tick_size_val else Decimal(0) + if contract_key and pid: + self._contract_to_product_id[contract_key] = pid + self._product_id_to_contract[pid] = contract_key + else: + self.config.contract_id = self._normalize_contract_id(ticker) + self.config.tick_size = Decimal(0) + + return self.config.contract_id, self.config.tick_size diff --git a/exchanges/factory.py b/exchanges/factory.py index 19feb812..9904944d 100644 --- a/exchanges/factory.py +++ b/exchanges/factory.py @@ -19,6 +19,7 @@ class ExchangeFactory: 'extended': 'exchanges.extended.ExtendedClient', 'apex': 'exchanges.apex.ApexClient', 'nado': 'exchanges.nado.NadoClient', + 'ethereal': 'exchanges.ethereal.EtherealClient', } @classmethod diff --git a/exchanges/grvt.py b/exchanges/grvt.py index 1f1b8e27..7b357571 100644 --- a/exchanges/grvt.py +++ b/exchanges/grvt.py @@ -284,7 +284,7 @@ async def place_post_only_order(self, contract_id: str, quantity: Decimal, price order_status = order_info.status if order_status == 'PENDING': - raise Exception('Paradex Server Error: Order not processed after 10 seconds') + raise Exception('GRVT Server Error: Order not processed after 10 seconds') else: return order_info diff --git a/helpers/ethereal/README.md b/helpers/ethereal/README.md new file mode 100644 index 00000000..f5e703f1 --- /dev/null +++ b/helpers/ethereal/README.md @@ -0,0 +1,47 @@ +# Ethereal Subaccount 绑定工具 + +## 使用方法 + +本目录包含两个辅助工具,用于完成 Ethereal “linked signer” 绑定所需的双重签名流程: + +1. `subaccount_eip712.html` + + - 作用:用浏览器 + MetaMask 生成主钱包的 EIP-712 `signature`(授权把 linked signer 绑定到子账户),并生成需要的请求体/命令。 + - 用法: + - 用浏览器打开该文件,连接主钱包。 + - 填入 `signer`(待绑定的 linked signer 地址),保持 `subaccount label` 默认 `primary`。 + - 点击 “Sign Typed Data” 生成 `signature`,页面会自动生成 `signerSignature` 的 Python 命令。 + - 将 linked signer 私钥放在 `.env` 的 `ETHEREAL_PRIVATE_KEY` 后,运行生成的命令,得到 `signerSignature`,粘贴回页面。 + - 点击 “Link Subaccount” 发送 `/v1/linked-signer/link` 请求,状态区会显示成功/失败。 + - 安全性:主钱包签名在浏览器/钱包内完成,避免以明文暴露主钱包私钥;官方 UI 暂未提供该绑定流程,因此用本地页面完成交互。 + +2. `sign_linked_signer.py` + - 作用:用 linked signer 私钥生成 `signerSignature`(证明你控制待绑定的 signer 地址)。 + - 前置:在 `.env` 配置 `ETHEREAL_PRIVATE_KEY` 为 linked signer 私钥。 + - 用法示例(参数来自页面提示): + + ```bash + python helpers/ethereal/sign_linked_signer.py \ + --sender <主钱包地址> \ + --subaccount \ + --nonce <页面给出的 nonce> \ + --signed-at <页面给出的 signedAt> + ``` + + 仅输出签名 hex,将其填入 HTML 的 `signerSignature` 输入框即可。 + +## 为什么需要用工具绑定子账户? + +- Ethereal 官方界面暂未提供子账户绑定 linked signer 的前端;需要用户自己完成 EIP-712 授权。 +- 主钱包私钥不应离开钱包/浏览器环境,HTML + MetaMask 可避免在本地脚本中明文处理主钱包私钥。 +- linked signer 私钥只用于生成 `signerSignature`,可放在 `.env` 后由 Python 脚本离线签名。 + +## 可能遇到的问题 + +1. subaccount label 目前只支持 'primary' 的 bytes32 编码,使用其他编码会报错(暂时不知道原因) +2. subaccount address 如果之前添加过,然后 revoke 了,不能再次添加,需要换一个新的钱包来作为 subaccount 绑定 +3. subaccount 不需要 gas ,临时生成一个即可(例如 `cast wallet new`) + +--- + +请在安全环境中使用以上工具,并确认网络指向官方 API(默认 `https://api.ethereal.trade`)。\*\*\* diff --git a/helpers/ethereal/sign_linked_signer.py b/helpers/ethereal/sign_linked_signer.py new file mode 100644 index 00000000..be48db93 --- /dev/null +++ b/helpers/ethereal/sign_linked_signer.py @@ -0,0 +1,118 @@ +""" +Utility to produce signerSignature for Ethereal linked signer flow. + +Minimal use: + python sign_linked_signer.py \ + --sender 0xYourEOA \ + --subaccount 0xYourBytes32Label + +Other fields auto-generated: +- signer = address derived from ETHEREAL_PRIVATE_KEY (read from .env) +- nonce = time.time_ns() +- signedAt= current seconds +- chainId / verifyingContract / domain name+version use Ethereal defaults (override flags if needed) + +Install requirement if missing: pip install eth_account python-dotenv +""" + +import argparse +import json +import os +import sys +import time +from typing import Any, Dict + +from dotenv import load_dotenv + +from eth_account import Account +from eth_account.messages import encode_typed_data +from eth_utils import to_checksum_address + + +TYPES = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "LinkSigner": [ + {"name": "sender", "type": "address"}, + {"name": "signer", "type": "address"}, + {"name": "subaccount", "type": "bytes32"}, + {"name": "nonce", "type": "uint64"}, + {"name": "signedAt", "type": "uint64"}, + ], +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create signerSignature for Ethereal linked signer") + parser.add_argument("--sender", required=True, help="EOA that owns the subaccount") + parser.add_argument("--subaccount", required=True, help="bytes32 subaccount label (0x...)") + parser.add_argument("--nonce", help="uint64 nonce as string; default: time.time_ns()") + parser.add_argument("--signed-at", dest="signed_at", type=int, help="Signed at timestamp (seconds); default: now") + parser.add_argument("--chain-id", dest="chain_id", type=int, default=5064014, help="Chain ID (default: 5064014)") + parser.add_argument( + "--verifying-contract", + dest="verifying_contract", + default="0xb3cdc82035c495c484c9ff11ed5f3ff6d342e3cc", + help="Domain verifyingContract (default: Ethereal mainnet)", + ) + parser.add_argument("--name", default="Ethereal", help="Domain name (default: Ethereal)") + parser.add_argument("--version", default="1", help="Domain version (default: 1)") + return parser.parse_args() + + +def build_typed_data(args: argparse.Namespace) -> Dict[str, Any]: + domain = { + "name": args.name, + "version": args.version, + "chainId": args.chain_id, + "verifyingContract": args.verifying_contract, + } + message = { + "sender": args.sender, + "signer": args.signer, + "subaccount": args.subaccount, + "nonce": str(args.nonce), + "signedAt": int(args.signed_at), + } + return { + "types": TYPES, + "primaryType": "LinkSigner", + "domain": domain, + "message": message, + } + + +def main(): + load_dotenv() + args = parse_args() + + priv_key = os.getenv("ETHEREAL_PRIVATE_KEY") + if not priv_key: + sys.stderr.write("ETHEREAL_PRIVATE_KEY not set in environment or .env\n") + sys.exit(1) + + account = Account.from_key(priv_key) + + # Autofill signer, nonce, signedAt if missing + args.signer = to_checksum_address(account.address) + args.sender = to_checksum_address(args.sender) + args.subaccount = args.subaccount + if not args.nonce: + args.nonce = str(time.time_ns()) + if not args.signed_at: + args.signed_at = int(time.time()) + + typed_data = build_typed_data(args) + signable = encode_typed_data(full_message=typed_data) + signed = account.sign_message(signable) + + # Output only the signature hex (signerSignature) + sys.stdout.write("\n0x" + signed.signature.hex() + "\n") + + +if __name__ == "__main__": + main() diff --git a/helpers/ethereal/subaccount_eip712.html b/helpers/ethereal/subaccount_eip712.html new file mode 100644 index 00000000..29452b0e --- /dev/null +++ b/helpers/ethereal/subaccount_eip712.html @@ -0,0 +1,363 @@ + + + + + Ethereal Linked Signer EIP-712 + + + + +

Ethereal Linked Signer EIP-712

+ Connect MetaMask, fill signer + subaccount info, then sign typed data for /linked-signer/link. + +
+ +
Not connected
+
+
+ +
+ Message + + + + + + + + + +
+
+ + +
+
+ + +
+
+
+ +
+ Action + + + +
+ +
+ Send to Ethereal API + + + + + + + +

+
+    
+    
+    
+ + +
+
+ + + + diff --git a/requirements.txt b/requirements.txt index 3156696c..88d44f01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,4 +28,7 @@ lighter-sdk==0.1.4 x10-python-trading-starknet==0.0.10 # Nado Protocol SDK -git+https://github.com/your-quantguy/nado-python-sdk.git@7728e97dd030cf744f08afdc0637f7e63b7527e3 \ No newline at end of file +git+https://github.com/your-quantguy/nado-python-sdk.git@7728e97dd030cf744f08afdc0637f7e63b7527e3 + +# Ethereal exchange SDK +ethereal-sdk==0.1.0b23 \ No newline at end of file