diff --git a/frontend/src/app/agent/components/strategy-items/modals/create-strategy-modal.tsx b/frontend/src/app/agent/components/strategy-items/modals/create-strategy-modal.tsx index 9fc6c4e66..da5d5f111 100644 --- a/frontend/src/app/agent/components/strategy-items/modals/create-strategy-modal.tsx +++ b/frontend/src/app/agent/components/strategy-items/modals/create-strategy-modal.tsx @@ -67,45 +67,70 @@ const step2Schema = z api_key: z.string(), secret_key: z.string(), passphrase: z.string(), // Required string, but can be empty for non-OKX exchanges + wallet_address: z.string(), // For Hyperliquid + private_key: z.string(), // For Hyperliquid }) .superRefine((data, ctx) => { // Only validate exchange credentials when live trading is selected if (data.trading_mode === "live") { - const fields = [ - { - name: "exchange_id", - label: "Exchange", - value: data.exchange_id, - }, - { - name: "api_key", - label: "API key", - value: data.api_key, - }, - { - name: "secret_key", - label: "Secret key", - value: data.secret_key, - }, - ]; - - for (const field of fields) { - if (!field.value?.trim()) { + // Hyperliquid uses different authentication + if (data.exchange_id === "hyperliquid") { + if (!data.wallet_address?.trim()) { ctx.addIssue({ code: "custom", - message: `${field.label} is required for live trading`, - path: [field.name], + message: "Wallet Address is required for Hyperliquid", + path: ["wallet_address"], }); } - } + if (!data.private_key?.trim()) { + ctx.addIssue({ + code: "custom", + message: "Private Key is required for Hyperliquid", + path: ["private_key"], + }); + } + } else { + // Standard exchanges require API key and secret + const fields = [ + { + name: "exchange_id", + label: "Exchange", + value: data.exchange_id, + }, + { + name: "api_key", + label: "API key", + value: data.api_key, + }, + { + name: "secret_key", + label: "Secret key", + value: data.secret_key, + }, + ]; + + for (const field of fields) { + if (!field.value?.trim()) { + ctx.addIssue({ + code: "custom", + message: `${field.label} is required for live trading`, + path: [field.name], + }); + } + } - // OKX requires passphrase - if (data.exchange_id === "okx" && !data.passphrase?.trim()) { - ctx.addIssue({ - code: "custom", - message: "Password is required for OKX", - path: ["passphrase"], - }); + // OKX and Coinbase require passphrase + if ( + (data.exchange_id === "okx" || + data.exchange_id === "coinbaseexchange") && + !data.passphrase?.trim() + ) { + ctx.addIssue({ + code: "custom", + message: `Passphrase is required for ${data.exchange_id === "okx" ? "OKX" : "Coinbase Exchange"}`, + path: ["passphrase"], + }); + } } } // Virtual trading mode: no validation needed for exchange fields @@ -240,6 +265,8 @@ const CreateStrategyModal: FC = ({ children }) => { api_key: "", secret_key: "", passphrase: "", + wallet_address: "", + private_key: "", }, validators: { onSubmit: step2Schema, @@ -513,6 +540,48 @@ const CreateStrategyModal: FC = ({ children }) => { Binance + +
+ + Blockchain.com +
+
+ +
+ + Coinbase Exchange +
+
+ +
+ + Gate.io +
+
+ +
+ + Hyperliquid +
+
+ +
+ + MEXC +
+
= ({ children }) => { )} - - {(field) => ( - - - API key - - - field.handleChange(e.target.value) - } - onBlur={field.handleBlur} - placeholder="Enter API Key" - /> - - - )} - - - - {(field) => ( - - - Secret Key - - - field.handleChange(e.target.value) - } - onBlur={field.handleBlur} - placeholder="Enter Secret Key" - /> - - + {/* Show different fields based on exchange type */} + + {(exchangeField) => ( + <> + {exchangeField.state.value === + "hyperliquid" ? ( + <> + {/* Hyperliquid: Wallet Address */} + + {(field) => ( + + + Wallet Address + + + field.handleChange( + e.target.value, + ) + } + onBlur={field.handleBlur} + placeholder="Enter Main Wallet Address (0x...)" + /> + + + )} + + + {/* Hyperliquid: Private Key */} + + {(field) => ( + + + Private Key + + + field.handleChange( + e.target.value, + ) + } + onBlur={field.handleBlur} + placeholder="Enter API Wallet Private Key (0x...)" + /> + + + )} + + + ) : ( + <> + {/* Standard exchanges: API Key */} + + {(field) => ( + + + API key + + + field.handleChange( + e.target.value, + ) + } + onBlur={field.handleBlur} + placeholder="Enter API Key" + /> + + + )} + + + {/* Standard exchanges: Secret Key */} + + {(field) => ( + + + Secret Key + + + field.handleChange( + e.target.value, + ) + } + onBlur={field.handleBlur} + placeholder="Enter Secret Key" + /> + + + )} + + + )} + )} - {/* Password field - only shown for OKX */} + {/* Passphrase field - only shown for OKX and Coinbase Exchange */} {(exchangeField) => - exchangeField.state.value === "okx" && ( + (exchangeField.state.value === "okx" || + exchangeField.state.value === + "coinbaseexchange") && ( {(field) => ( @@ -580,7 +720,7 @@ const CreateStrategyModal: FC = ({ children }) => { field.handleChange(e.target.value) } onBlur={field.handleBlur} - placeholder="Enter Passphrase (Required for OKX)" + placeholder={`Enter Passphrase (Required for ${exchangeField.state.value === "okx" ? "OKX" : "Coinbase Exchange"})`} /> = ({ children }) => { field.handleChange(value) } placeholder="Select trading symbols..." - searchPlaceholder="Search symbols..." + searchPlaceholder="Search or add symbols..." emptyText="No symbols found." maxDisplayed={5} + creatable /> diff --git a/frontend/src/assets/png/exchanges/blockchain.png b/frontend/src/assets/png/exchanges/blockchain.png new file mode 100644 index 000000000..01b2153ef Binary files /dev/null and b/frontend/src/assets/png/exchanges/blockchain.png differ diff --git a/frontend/src/assets/png/exchanges/coinbase.png b/frontend/src/assets/png/exchanges/coinbase.png new file mode 100644 index 000000000..e0eaae20a Binary files /dev/null and b/frontend/src/assets/png/exchanges/coinbase.png differ diff --git a/frontend/src/assets/png/exchanges/gate.png b/frontend/src/assets/png/exchanges/gate.png new file mode 100644 index 000000000..8c483ede6 Binary files /dev/null and b/frontend/src/assets/png/exchanges/gate.png differ diff --git a/frontend/src/assets/png/exchanges/hyperliquid.png b/frontend/src/assets/png/exchanges/hyperliquid.png new file mode 100644 index 000000000..4a48daa34 Binary files /dev/null and b/frontend/src/assets/png/exchanges/hyperliquid.png differ diff --git a/frontend/src/assets/png/exchanges/mexc.png b/frontend/src/assets/png/exchanges/mexc.png new file mode 100644 index 000000000..d1be6bd28 Binary files /dev/null and b/frontend/src/assets/png/exchanges/mexc.png differ diff --git a/frontend/src/assets/png/index.ts b/frontend/src/assets/png/index.ts index c309207dc..aab5cfe36 100644 --- a/frontend/src/assets/png/index.ts +++ b/frontend/src/assets/png/index.ts @@ -23,6 +23,11 @@ export { default as ValueCellAgentPng } from "./agents/ValueCellAgent.png"; export { default as WarrenBuffettPng } from "./agents/WarrenBuffett.png"; export { default as BinancePng } from "./exchanges/binance.png"; +export { default as BlockchainPng } from "./exchanges/blockchain.png"; +export { default as CoinbasePng } from "./exchanges/coinbase.png"; +export { default as GatePng } from "./exchanges/gate.png"; +export { default as HyperliquidPng } from "./exchanges/hyperliquid.png"; +export { default as MexcPng } from "./exchanges/mexc.png"; export { default as OkxPng } from "./exchanges/okx.png"; export { default as IconGroupPng } from "./icon-group.png"; export { default as MessageGroupPng } from "./message-group.png"; diff --git a/frontend/src/components/valuecell/multi-select.tsx b/frontend/src/components/valuecell/multi-select.tsx index 0dd87ed5b..c2d07ad3c 100644 --- a/frontend/src/components/valuecell/multi-select.tsx +++ b/frontend/src/components/valuecell/multi-select.tsx @@ -35,6 +35,7 @@ export interface MultiSelectProps { disabled?: boolean; maxSelected?: number; maxDisplayed?: number; + creatable?: boolean; } export const MultiSelect = React.forwardRef< @@ -54,12 +55,14 @@ export const MultiSelect = React.forwardRef< disabled = false, maxSelected, maxDisplayed = 3, + creatable = false, }, ref, ) => { const [open, setOpen] = React.useState(false); const [internalValue, setInternalValue] = React.useState(defaultValue); + const [inputValue, setInputValue] = React.useState(""); // Normalize options to MultiSelectOption[] const normalizedOptions = React.useMemo(() => { @@ -105,9 +108,16 @@ export const MultiSelect = React.forwardRef< onValueChange?.([]); }; - const selectedOptions = normalizedOptions.filter((opt) => - selectedValues.includes(opt.value), - ); + const selectedOptions = React.useMemo(() => { + const opts = [...normalizedOptions]; + // Add selected values that are not in options (for custom values) + selectedValues.forEach((val) => { + if (!opts.find((o) => o.value === val)) { + opts.push({ value: val, label: val }); + } + }); + return opts.filter((opt) => selectedValues.includes(opt.value)); + }, [normalizedOptions, selectedValues]); // Get displayed badges and count for remaining const displayedOptions = selectedOptions.slice(0, maxDisplayed); @@ -200,7 +210,12 @@ export const MultiSelect = React.forwardRef< sideOffset={4} > - + {emptyText} e.stopPropagation()}> @@ -247,6 +262,21 @@ export const MultiSelect = React.forwardRef< ); })} + {creatable && + inputValue.length > 0 && + !normalizedOptions.some((o) => o.value === inputValue) && + !selectedValues.includes(inputValue) && ( + { + handleSelect(inputValue); + setInputValue(""); + }} + className="cursor-pointer py-2 text-muted-foreground" + > + Create "{inputValue}" + + )} diff --git a/frontend/src/constants/icons.ts b/frontend/src/constants/icons.ts index 0ba01f317..736475c80 100644 --- a/frontend/src/constants/icons.ts +++ b/frontend/src/constants/icons.ts @@ -1,11 +1,16 @@ import { AzurePng, BinancePng, + BlockchainPng, BtcPng, + CoinbasePng, DeepSeekPng, DogePng, EthPng, + GatePng, GooglePng, + HyperliquidPng, + MexcPng, OkxPng, OpenAiCompatiblePng, OpenAiPng, @@ -27,6 +32,12 @@ export const MODEL_PROVIDER_ICONS = { export const EXCHANGE_ICONS = { binance: BinancePng, + blockchaincom: BlockchainPng, + coinbase: CoinbasePng, + coinbaseexchange: CoinbasePng, + gate: GatePng, + hyperliquid: HyperliquidPng, + mexc: MexcPng, okx: OkxPng, }; diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index 908e4f796..63daee496 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -130,6 +130,7 @@ async def run_once(self) -> DecisionCycleResult: self._request.exchange_config.trading_mode == TradingMode.LIVE and hasattr(self._execution_gateway, "fetch_balance") ): + logger.debug("Syncing portfolio balance from exchange in LIVE mode") balance = await self._execution_gateway.fetch_balance() free_map = {} free_section = ( @@ -158,19 +159,60 @@ async def run_once(self) -> DecisionCycleResult: quotes.append(s.split("-")[1]) # Deduplicate preserving order quotes = list(dict.fromkeys(quotes)) + free_cash = 0.0 + total_cash = 0.0 + + # Sum up free and total cash from relevant quote currencies if quotes: for q in quotes: free_cash += float(free_map.get(q, 0.0) or 0.0) + # Try to find total/equity in balance if available (often 'total' dict in CCXT) + # Hyperliquid/CCXT structure: balance[q]['total'] + q_data = balance.get(q) + if isinstance(q_data, dict): + total_cash += float(q_data.get("total", 0.0) or 0.0) + else: + # Fallback if structure is flat or missing + total_cash += float(free_map.get(q, 0.0) or 0.0) else: for q in ("USDT", "USD", "USDC"): free_cash += float(free_map.get(q, 0.0) or 0.0) - portfolio.account_balance = float(free_cash) + q_data = balance.get(q) + if isinstance(q_data, dict): + total_cash += float(q_data.get("total", 0.0) or 0.0) + else: + total_cash += float(free_map.get(q, 0.0) or 0.0) + + logger.debug( + f"Synced balance from exchange: free_cash={free_cash}, total_cash={total_cash}, quotes={quotes}" + ) + if self._request.exchange_config.market_type == MarketType.SPOT: + # Spot: Account Balance is Cash (Free). Buying Power is Cash. + portfolio.account_balance = float(free_cash) portfolio.buying_power = max(0.0, float(portfolio.account_balance)) + else: + # Derivatives: Account Balance should be Wallet Balance or Equity. + # We use total_cash (Equity) as the best approximation for account_balance + # to ensure InMemoryPortfolioService calculates Equity correctly (Equity + Unrealized). + # Note: If total_cash IS Equity, adding Unrealized PnL again in InMemoryService + # (Equity = Balance + Unreal) would double count PnL. + # However, separating Wallet Balance from Equity is exchange-specific. + # For now, we set account_balance = total_cash and rely on the fixed + # InMemoryPortfolioService to handle it (assuming Balance ~= Equity for initial sync). + portfolio.account_balance = float(total_cash) + # Buying Power is explicit Free Margin + portfolio.buying_power = float(free_cash) + # Also update free_cash field in view if it exists + portfolio.free_cash = float(free_cash) + except Exception: # If syncing fails, continue with existing portfolio view - pass + logger.warning( + "Failed to sync balance from exchange in LIVE mode, using cached portfolio view", + exc_info=True, + ) # VIRTUAL mode: cash-only for spot; derivatives keep margin-based buying power if self._request.exchange_config.trading_mode == TradingMode.VIRTUAL: if self._request.exchange_config.market_type == MarketType.SPOT: @@ -231,10 +273,36 @@ async def run_once(self) -> DecisionCycleResult: instructions, market_snapshot ) logger.info(f"✅ ExecutionGateway returned {len(tx_results)} results") + + # Filter out failed instructions and append reasons to rationale + failed_ids = set() + failure_msgs = [] for idx, tx in enumerate(tx_results): logger.info( f" 📊 TxResult {idx}: {tx.instrument.symbol} status={tx.status.value} filled_qty={tx.filled_qty}" ) + if tx.status in (TxStatus.REJECTED, TxStatus.ERROR): + failed_ids.add(tx.instruction_id) + reason = tx.reason or "Unknown error" + # Format failure message with clear details + msg = f"❌ Skipped {tx.instrument.symbol} {tx.side.value} qty={tx.requested_qty}: {reason}" + failure_msgs.append(msg) + logger.warning(f" ⚠️ Order rejected: {msg}") + + if failure_msgs: + # Append failure reasons to AI rationale for frontend display + prefix = "\n\n**Execution Warnings:**\n" + rationale = ( + (rationale or "") + + prefix + + "\n".join(f"- {msg}" for msg in failure_msgs) + ) + + if failed_ids: + # Remove failed instructions so they don't appear in history/UI + instructions = [ + inst for inst in instructions if inst.instruction_id not in failed_ids + ] trades = self._create_trades(tx_results, compose_id, timestamp_ms) self._portfolio_service.apply_trades(trades, market_snapshot) @@ -489,15 +557,9 @@ def _build_summary( try: view = self._portfolio_service.get_view() unrealized = float(view.total_unrealized_pnl or 0.0) - # In LIVE mode, treat equity as available cash (disallow financing) - try: - mode = getattr(self._request.exchange_config, "trading_mode", None) - except Exception: - mode = None - if str(mode).upper() == "LIVE": - equity = float(getattr(view, "cash", None) or 0.0) - else: - equity = float(view.total_value or 0.0) + # Use the portfolio view's total_value which now correctly reflects Equity + # (whether simulated or synced from exchange) + equity = float(view.total_value or 0.0) except Exception: # Fallback to internal tracking if portfolio service is unavailable unrealized = float(self._unrealized_pnl or 0.0) diff --git a/python/valuecell/agents/strategy_agent/data/market.py b/python/valuecell/agents/strategy_agent/data/market.py index 6134d9baa..ec70f8d44 100644 --- a/python/valuecell/agents/strategy_agent/data/market.py +++ b/python/valuecell/agents/strategy_agent/data/market.py @@ -29,17 +29,41 @@ def __init__( self._exchange_id = exchange_id or "binance" self._ccxt_options = ccxt_options or {} + def _normalize_symbol(self, symbol: str) -> str: + """Normalize symbol format for specific exchanges. + + For Hyperliquid: converts BTC-USDC to BTC/USDC:USDC (swap format) + For other exchanges: converts BTC-USDC to BTC/USDC:USDC + + Args: + symbol: Symbol in format 'BTC-USDC', 'ETH-USDT', etc. + + Returns: + Normalized CCXT symbol for the specific exchange + """ + # Replace dash with slash + base_symbol = symbol.replace("-", "/") + + # For most exchanges (especially those requiring settlement currency) + if ":" not in base_symbol: + parts = base_symbol.split("/") + if len(parts) == 2: + # Add settlement currency (e.g., BTC/USDC -> BTC/USDC:USDC) + base_symbol = f"{parts[0]}/{parts[1]}:{parts[1]}" + + return base_symbol + async def get_recent_candles( self, symbols: List[str], interval: str, lookback: int ) -> List[Candle]: - async def _fetch(symbol: str) -> List[List]: + async def _fetch(symbol: str, normalized_symbol: str) -> List[List]: # instantiate exchange class by name (e.g., ccxtpro.kraken) exchange_cls = get_exchange_cls(self._exchange_id) exchange = exchange_cls({"newUpdates": False, **self._ccxt_options}) try: - # ccxt.pro uses async fetch_ohlcv + # ccxt.pro uses async fetch_ohlcv with normalized symbol data = await exchange.fetch_ohlcv( - symbol, timeframe=interval, since=None, limit=lookback + normalized_symbol, timeframe=interval, since=None, limit=lookback ) return data finally: @@ -52,7 +76,9 @@ async def _fetch(symbol: str) -> List[List]: # Run fetch for each symbol sequentially for symbol in symbols: try: - raw = await _fetch(symbol) + # Normalize symbol format for the exchange (e.g., BTC-USDC -> BTC/USDC:USDC) + normalized_symbol = self._normalize_symbol(symbol) + raw = await _fetch(symbol, normalized_symbol) # raw is list of [ts, open, high, low, close, volume] for row in raw: ts, open_v, high_v, low_v, close_v, vol = row @@ -73,12 +99,17 @@ async def _fetch(symbol: str) -> List[List]: ) ) except Exception as exc: - logger.error( - "Failed to fetch candles for {} from {}, return empty candles. Error: {}", + logger.warning( + "Failed to fetch candles for {} (normalized: {}) from {}, data interval is {}, return empty candles. Error: {}", symbol, + normalized_symbol, self._exchange_id, + interval, exc, ) + logger.debug( + f"Fetch candles for {len(candles)} symbols: {symbols}, interval: {interval}, lookback: {lookback}" + ) return candles async def get_market_snapshot(self, symbols: List[str]) -> MarketSnapShotType: @@ -193,6 +224,7 @@ async def get_market_snapshot(self, symbols: List[str]) -> MarketSnapShotType: symbol, self._exchange_id, ) + logger.debug(f"Fetch market snapshot for {sym} data: {snapshot}") except Exception: logger.exception( "Failed to fetch market snapshot for {} at {}", diff --git a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py index f41a00120..783254dae 100644 --- a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py @@ -41,9 +41,11 @@ class CCXTExecutionGateway(ExecutionGateway): def __init__( self, exchange_id: str, - api_key: str, - secret_key: str, + api_key: str = "", + secret_key: str = "", passphrase: Optional[str] = None, + wallet_address: Optional[str] = None, + private_key: Optional[str] = None, testnet: bool = False, default_type: str = "swap", margin_mode: str = "cross", @@ -53,10 +55,12 @@ def __init__( """Initialize CCXT exchange gateway. Args: - exchange_id: Exchange identifier (e.g., 'binance', 'okx', 'bybit') - api_key: API key for authentication - secret_key: Secret key for authentication - passphrase: Optional passphrase (required for OKX) + exchange_id: Exchange identifier (e.g., 'binance', 'okx', 'bybit', 'hyperliquid') + api_key: API key for authentication (not required for Hyperliquid) + secret_key: Secret key for authentication (not required for Hyperliquid) + passphrase: Optional passphrase (required for OKX, Coinbase Exchange) + wallet_address: Wallet address (required for Hyperliquid) + private_key: Private key (required for Hyperliquid) testnet: Whether to use testnet/sandbox mode default_type: Default market type ('spot', 'future', 'swap', "margin") margin_mode: Default margin mode ('isolated' or 'cross') @@ -67,6 +71,8 @@ def __init__( self.api_key = api_key self.secret_key = secret_key self.passphrase = passphrase + self.wallet_address = wallet_address + self.private_key = private_key self.testnet = testnet self.default_type = default_type self.margin_mode = margin_mode @@ -104,10 +110,8 @@ async def _get_exchange(self) -> ccxt.Exchange: f"Available: {', '.join(ccxt.exchanges)}" ) - # Build configuration + # Build configuration based on exchange type config = { - "apiKey": self.api_key, - "secret": self.secret_key, "enableRateLimit": True, # Respect rate limits "options": { "defaultType": self._choose_default_type_for_exchange(), @@ -115,9 +119,25 @@ async def _get_exchange(self) -> ccxt.Exchange: }, } - # Add passphrase if provided (required for OKX) - if self.passphrase: - config["password"] = self.passphrase + # Hyperliquid uses wallet-based authentication + if self.exchange_id == "hyperliquid": + if self.wallet_address: + config["walletAddress"] = self.wallet_address + if self.private_key: + config["privateKey"] = self.private_key + # Disable builder fees by default (can be overridden in ccxt_options) + if "builderFee" not in config["options"]: + config["options"]["builderFee"] = False + if "approvedBuilderFee" not in config["options"]: + config["options"]["approvedBuilderFee"] = False + else: + # Standard API key/secret authentication + config["apiKey"] = self.api_key + config["secret"] = self.secret_key + + # Add passphrase if provided (required for OKX, Coinbase Exchange) + if self.passphrase: + config["password"] = self.passphrase # Create exchange instance self._exchange = exchange_class(config) @@ -257,14 +277,16 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict: exid = self.exchange_id # Idempotency / client order id (sanitize for OKX) - raw_client_id = params.get("clientOrderId", inst.instruction_id) - if raw_client_id: - client_id = ( - self._sanitize_client_order_id(raw_client_id) - if exid == "okx" - else raw_client_id - ) - params["clientOrderId"] = client_id + # Hyperliquid doesn't support clientOrderId + if exid != "hyperliquid": + raw_client_id = params.get("clientOrderId", inst.instruction_id) + if raw_client_id: + client_id = ( + self._sanitize_client_order_id(raw_client_id) + if exid == "okx" + else raw_client_id + ) + params["clientOrderId"] = client_id # Default tdMode for OKX on all orders if exid == "okx": @@ -284,6 +306,9 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict: params.setdefault("reduceOnly", False) elif exid == "bybit": params.setdefault("reduce_only", False) + elif exid == "hyperliquid": + # Hyperliquid only uses 'reduceOnly' (not 'reduce_only') + params.setdefault("reduceOnly", False) # Enforce single-sided mode: strip positionSide/posSide if present try: @@ -780,11 +805,71 @@ async def _submit_order( except Exception: pass + # Hyperliquid special handling for market orders + # Hyperliquid doesn't have true market orders; use IoC (Immediate or Cancel) to simulate + if self.exchange_id == "hyperliquid" and order_type == "market": + try: + logger.debug( + " 📊 Hyperliquid: Converting market order to IoC limit order" + ) + + # Fetch current market price + if price is None: + ticker = await exchange.fetch_ticker(symbol) + price = float(ticker.get("last") or ticker.get("close") or 0.0) + + if price > 0: + # Calculate slippage price based on direction + slippage_pct = ( + inst.max_slippage_bps or 50.0 + ) / 10000.0 # default 50 bps = 0.5% + if side == "buy": + # For buy orders, set price higher to ensure execution + price = price * (1 + slippage_pct) + else: + # For sell orders, set price lower to ensure execution + price = price * (1 - slippage_pct) + + # Apply CCXT price precision (critical for Hyperliquid) + # This handles both integer prices (BTC) and decimal prices (WIF, ENA, etc.) + try: + price = float(exchange.price_to_precision(symbol, price)) + logger.debug(f" 🔢 Price after precision: {price}") + except Exception as e: + logger.warning(f" ⚠️ Could not apply price precision: {e}") + # Fallback: try integer conversion for high-value assets + try: + market = (getattr(exchange, "markets", {}) or {}).get( + symbol + ) or {} + price_precision = market.get("precision", {}).get("price") + if price_precision is not None and price_precision == 0: + price = float(int(price)) + logger.debug( + f" 🔢 Fallback: rounded to integer {price}" + ) + except Exception: + pass + + # Use IoC (Immediate or Cancel) to simulate market execution + params["timeInForce"] = "Ioc" + logger.debug( + f" 💰 Using IoC limit order: {side} @ {price} (slippage: {slippage_pct:.2%})" + ) + else: + logger.warning( + f" ⚠️ Could not determine market price for {symbol}, will try without price" + ) + except Exception as e: + logger.warning(f" ⚠️ Could not setup Hyperliquid market order: {e}") + # Fallback: let exchange handle it + # Create order try: logger.info( f" 🔨 Creating {order_type} order: {side} {amount} {symbol} @ {price if price else 'market'}" ) + logger.debug(f" 📋 Order params: {params}") order = await exchange.create_order( symbol=symbol, type=order_type, @@ -797,8 +882,24 @@ async def _submit_order( f" ✓ Order created: id={order.get('id')}, status={order.get('status')}, filled={order.get('filled')}" ) except Exception as e: - logger.error(f" ❌ ERROR creating order for {symbol}: {e}") - raise RuntimeError(f"Failed to create order for {symbol}: {e}") from e + error_msg = str(e) + logger.error(f" ❌ ERROR creating order for {symbol}: {error_msg}") + logger.error( + f" 📋 Failed order details: side={side}, amount={amount}, price={price}, type={order_type}" + ) + logger.error(f" 📋 Failed order params: {params}") + + # Return error result instead of raising to allow other orders to proceed + return TxResult( + instruction_id=inst.instruction_id, + instrument=inst.instrument, + side=local_side, + requested_qty=amount, + filled_qty=0.0, + status=TxStatus.ERROR, + reason=f"create_order_failed: {error_msg}", + meta=inst.meta, + ) # For market orders, wait for fill and fetch final order status # Many exchanges don't immediately return filled quantities for market orders @@ -870,26 +971,42 @@ async def _exec_open_long( self, inst: TradeInstruction, exchange: ccxt.Exchange ) -> TxResult: # Ensure we do not mark reduceOnly on open - overrides = {"reduceOnly": False, "reduce_only": False} + # Use exchange-specific param name + if self.exchange_id == "bybit": + overrides = {"reduce_only": False} + else: + overrides = {"reduceOnly": False} return await self._submit_order(inst, exchange, overrides) async def _exec_open_short( self, inst: TradeInstruction, exchange: ccxt.Exchange ) -> TxResult: - overrides = {"reduceOnly": False, "reduce_only": False} + # Use exchange-specific param name + if self.exchange_id == "bybit": + overrides = {"reduce_only": False} + else: + overrides = {"reduceOnly": False} return await self._submit_order(inst, exchange, overrides) async def _exec_close_long( self, inst: TradeInstruction, exchange: ccxt.Exchange ) -> TxResult: # Force reduceOnly flags for closes - overrides = {"reduceOnly": True, "reduce_only": True} + # Use exchange-specific param name + if self.exchange_id == "bybit": + overrides = {"reduce_only": True} + else: + overrides = {"reduceOnly": True} return await self._submit_order(inst, exchange, overrides) async def _exec_close_short( self, inst: TradeInstruction, exchange: ccxt.Exchange ) -> TxResult: - overrides = {"reduceOnly": True, "reduce_only": True} + # Use exchange-specific param name + if self.exchange_id == "bybit": + overrides = {"reduce_only": True} + else: + overrides = {"reduceOnly": True} return await self._submit_order(inst, exchange, overrides) async def _exec_noop(self, inst: TradeInstruction) -> TxResult: diff --git a/python/valuecell/agents/strategy_agent/execution/exchanges.py b/python/valuecell/agents/strategy_agent/execution/exchanges.py index e69de29bb..ad7f4beec 100644 --- a/python/valuecell/agents/strategy_agent/execution/exchanges.py +++ b/python/valuecell/agents/strategy_agent/execution/exchanges.py @@ -0,0 +1,161 @@ +"""Exchange metadata and special configurations for CCXT. + +This module provides metadata about supported exchanges, including +authentication requirements and special handling notes. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import BaseModel + + +class ExchangeMetadata(BaseModel): + """Metadata for a supported exchange. + + Attributes: + id: CCXT exchange identifier + name: Display name + requires_passphrase: Whether the exchange requires a passphrase/password + requires_api_key: Whether the exchange requires an API key + requires_secret: Whether the exchange requires a secret key + special_auth: Special authentication requirements (e.g., privateKey, walletAddress) + testnet_supported: Whether testnet/sandbox mode is supported + notes: Additional notes or warnings + """ + + id: str + name: str + requires_passphrase: bool = False + requires_api_key: bool = True + requires_secret: bool = True + special_auth: Optional[List[str]] = None + testnet_supported: bool = False + notes: Optional[str] = None + + +# Supported exchanges metadata +SUPPORTED_EXCHANGES: Dict[str, ExchangeMetadata] = { + "binance": ExchangeMetadata( + id="binance", + name="Binance", + testnet_supported=True, + notes="Most popular exchange with comprehensive CCXT support", + ), + "okx": ExchangeMetadata( + id="okx", + name="OKX", + requires_passphrase=True, + testnet_supported=True, + notes="Requires passphrase for authentication", + ), + "blockchaincom": ExchangeMetadata( + id="blockchaincom", + name="Blockchain.com", + requires_api_key=False, + notes="Only requires secret key (no API key needed)", + ), + "coinbaseexchange": ExchangeMetadata( + id="coinbaseexchange", + name="Coinbase Exchange", + requires_passphrase=True, + testnet_supported=True, + notes="Main Coinbase exchange (not Coinbase International). Requires passphrase.", + ), + "gate": ExchangeMetadata( + id="gate", + name="Gate.io", + testnet_supported=True, + notes="Gate.io main exchange with standard API key/secret authentication", + ), + "hyperliquid": ExchangeMetadata( + id="hyperliquid", + name="Hyperliquid", + requires_api_key=False, + requires_secret=False, + special_auth=["privateKey", "walletAddress"], + notes="Uses wallet-based authentication (privateKey + walletAddress). Not standard API key/secret.", + ), + "mexc": ExchangeMetadata( + id="mexc", + name="MEXC Global", + testnet_supported=False, + notes="MEXC main exchange with standard API key/secret authentication", + ), +} + + +def get_exchange_metadata(exchange_id: str) -> Optional[ExchangeMetadata]: + """Get metadata for a specific exchange. + + Args: + exchange_id: CCXT exchange identifier + + Returns: + ExchangeMetadata if exchange is supported, None otherwise + """ + return SUPPORTED_EXCHANGES.get(exchange_id.lower()) + + +def requires_passphrase(exchange_id: str) -> bool: + """Check if an exchange requires a passphrase. + + Args: + exchange_id: CCXT exchange identifier + + Returns: + True if passphrase is required, False otherwise + """ + metadata = get_exchange_metadata(exchange_id) + return metadata.requires_passphrase if metadata else False + + +def get_supported_exchange_ids() -> List[str]: + """Get list of supported exchange IDs. + + Returns: + List of CCXT exchange identifiers + """ + return list(SUPPORTED_EXCHANGES.keys()) + + +def validate_exchange_credentials( + exchange_id: str, + api_key: Optional[str] = None, + secret_key: Optional[str] = None, + passphrase: Optional[str] = None, +) -> tuple[bool, Optional[str]]: + """Validate that provided credentials match exchange requirements. + + Args: + exchange_id: CCXT exchange identifier + api_key: API key + secret_key: Secret key + passphrase: Passphrase/password + + Returns: + Tuple of (is_valid, error_message) + """ + metadata = get_exchange_metadata(exchange_id) + if not metadata: + return False, f"Exchange '{exchange_id}' is not supported" + + # Check for special authentication requirements + if metadata.special_auth: + return ( + False, + f"Exchange '{exchange_id}' requires special authentication: {', '.join(metadata.special_auth)}", + ) + + # Check standard credentials + if metadata.requires_api_key and not api_key: + return False, f"Exchange '{exchange_id}' requires an API key" + + if metadata.requires_secret and not secret_key: + return False, f"Exchange '{exchange_id}' requires a secret key" + + if metadata.requires_passphrase and not passphrase: + return False, f"Exchange '{exchange_id}' requires a passphrase" + + return True, None diff --git a/python/valuecell/agents/strategy_agent/execution/factory.py b/python/valuecell/agents/strategy_agent/execution/factory.py index 06c12dbdf..62606f2f8 100644 --- a/python/valuecell/agents/strategy_agent/execution/factory.py +++ b/python/valuecell/agents/strategy_agent/execution/factory.py @@ -35,21 +35,33 @@ async def create_execution_gateway(config: ExchangeConfig) -> ExecutionGateway: if not config.exchange_id: raise ValueError( "exchange_id is required for live trading mode. " - "Please specify an exchange (e.g., 'binance', 'okx', 'bybit')" + "Please specify an exchange (e.g., 'binance', 'okx', 'bybit', 'hyperliquid')" ) - if not config.api_key or not config.secret_key: - raise ValueError( - f"API credentials are required for live trading on {config.exchange_id}. " - "Please provide api_key and secret_key in ExchangeConfig." - ) + # Validate credentials based on exchange type + if config.exchange_id.lower() == "hyperliquid": + # Hyperliquid requires wallet_address and private_key + if not config.wallet_address or not config.private_key: + raise ValueError( + "Hyperliquid requires wallet_address and private_key. " + "Please provide both in ExchangeConfig." + ) + else: + # Standard exchanges require api_key and secret_key + if not config.api_key or not config.secret_key: + raise ValueError( + f"API credentials are required for live trading on {config.exchange_id}. " + "Please provide api_key and secret_key in ExchangeConfig." + ) # Create CCXT gateway with full configuration gateway = CCXTExecutionGateway( exchange_id=config.exchange_id, - api_key=config.api_key, - secret_key=config.secret_key, + api_key=config.api_key or "", + secret_key=config.secret_key or "", passphrase=config.passphrase, + wallet_address=config.wallet_address, + private_key=config.private_key, testnet=config.testnet, default_type=config.market_type.value, margin_mode=config.margin_mode.value, diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 148e5d1db..2918fb30c 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -155,6 +155,14 @@ class ExchangeConfig(BaseModel): default=None, description="API passphrase (required for some exchanges like OKX)", ) + wallet_address: Optional[str] = Field( + default=None, + description="Wallet address (required for Hyperliquid)", + ) + private_key: Optional[str] = Field( + default=None, + description="Private key (required for Hyperliquid)", + ) testnet: bool = Field( default=False, description="Use testnet/sandbox mode for testing" ) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 07f632564..c65c0aba2 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -192,14 +192,21 @@ def apply_trades( notional = price * delta # Deduct fees from cash as well. Trade may include fee_cost (in quote ccy). fee = trade.fee_cost or 0.0 - if trade.side == TradeSide.BUY: - # buying reduces cash by notional plus fees - self._view.account_balance -= notional - self._view.account_balance -= fee + + if self._market_type == MarketType.SPOT: + if trade.side == TradeSide.BUY: + # buying reduces cash by notional plus fees + self._view.account_balance -= notional + self._view.account_balance -= fee + else: + # selling increases cash by notional minus fees + self._view.account_balance += notional + self._view.account_balance -= fee else: - # selling increases cash by notional minus fees - self._view.account_balance += notional + # Derivatives: Cash (Wallet Balance) only changes by Realized PnL and Fees + # Notional is not deducted from cash. self._view.account_balance -= fee + self._view.account_balance += realized_delta total_realized += realized_delta @@ -260,8 +267,14 @@ def apply_trades( self._view.net_exposure = net self._view.total_unrealized_pnl = unreal self._view.total_realized_pnl = total_realized - # Equity is cash plus net exposure (correct for both long and short) - equity = self._view.account_balance + net + + if self._market_type == MarketType.SPOT: + # Equity is cash plus net exposure (market value of assets) + equity = self._view.account_balance + net + else: + # Derivatives: Equity is Wallet Balance + Unrealized PnL + equity = self._view.account_balance + unreal + self._view.total_value = equity # Approximate buying power using market type policy diff --git a/python/valuecell/agents/strategy_agent/runtime.py b/python/valuecell/agents/strategy_agent/runtime.py index d48d8f50f..e492258c8 100644 --- a/python/valuecell/agents/strategy_agent/runtime.py +++ b/python/valuecell/agents/strategy_agent/runtime.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Optional +from loguru import logger + from valuecell.utils.uuid import generate_uuid from .core import DecisionCycleResult, DefaultDecisionCoordinator @@ -168,7 +170,9 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime if request.exchange_config.trading_mode == TradingMode.LIVE and hasattr( execution_gateway, "fetch_balance" ): + logger.info("Fetching exchange balance for LIVE trading mode") balance = await execution_gateway.fetch_balance() + logger.info(f"Raw balance response: {balance}") free_map = {} # ccxt balance may be shaped as: {'free': {...}, 'used': {...}, 'total': {...}} try: @@ -189,6 +193,7 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime free_map[str(k).upper()] = float(v.get("free") or 0.0) except Exception: continue + logger.info(f"Parsed free balance map: {free_map}") # collect quote currencies from configured symbols quotes: list[str] = [] for sym in request.trading_config.symbols or []: @@ -202,6 +207,7 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime if len(parts) == 2: quotes.append(parts[1]) quotes = list(dict.fromkeys(quotes)) # unique order-preserving + logger.info(f"Quote currencies from symbols: {quotes}") free_cash = 0.0 if quotes: for q in quotes: @@ -211,10 +217,25 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime for q in ("USDT", "USD", "USDC"): free_cash += float(free_map.get(q, 0.0) or 0.0) # Set initial capital to exchange free cash + logger.info( + f"Setting initial_capital to {free_cash} (from exchange balance)" + ) request.trading_config.initial_capital = float(free_cash) except Exception: - # Do not fail runtime creation if balance fetch or parsing fails - pass + # Log the error but continue - user might have set initial_capital manually + logger.exception( + "Failed to fetch exchange balance for LIVE mode. Will use configured initial_capital instead." + ) + + # Validate initial capital for LIVE mode + if request.exchange_config.trading_mode == TradingMode.LIVE: + initial_cap = request.trading_config.initial_capital or 0.0 + if initial_cap <= 0: + logger.error( + f"LIVE trading mode has initial_capital={initial_cap}. " + "This usually means balance fetch failed or account has no funds. " + "Strategy will not be able to trade without capital." + ) # Use the sync function with the pre-initialized gateway return create_strategy_runtime(request, execution_gateway=execution_gateway)