From 7c5feb6c2499263050cbf544b34ac78d21be50b6 Mon Sep 17 00:00:00 2001 From: paisley Date: Wed, 12 Nov 2025 17:02:53 +0800 Subject: [PATCH 1/8] refactor action --- .../strategy_agent/decision/composer.py | 52 ++++++-- .../strategy_agent/decision/system_prompt.py | 8 +- .../strategy_agent/execution/ccxt_trading.py | 111 ++++++++++++++++-- .../valuecell/agents/strategy_agent/models.py | 46 +++++--- 4 files changed, 181 insertions(+), 36 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index cd2c32014..5e78dd2cd 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -17,7 +17,6 @@ LlmDecisionAction, LlmPlanProposal, MarketType, - PriceMode, TradeInstruction, TradeSide, UserRequest, @@ -674,15 +673,28 @@ def _create_instruction( if item.rationale: meta["rationale"] = item.rationale + # For derivatives/perpetual markets, mark reduceOnly when instruction reduces absolute exposure to avoid accidental reverse opens + try: + if self._request.exchange_config.market_type != MarketType.SPOT: + if abs(final_target) < abs(current_qty): + meta["reduceOnly"] = True + # Bybit uses a different param key + if ( + self._request.exchange_config.exchange_id or "" + ).lower() == "bybit": + meta["reduce_only"] = True + except Exception: + # Ignore any exception; do not block instruction creation + pass + instruction = TradeInstruction( instruction_id=f"{context.compose_id}:{symbol}:{idx}", compose_id=context.compose_id, instrument=item.instrument, + action=item.action, side=side, quantity=quantity, leverage=final_leverage, - price_mode=PriceMode.MARKET, - limit_price=None, max_slippage_bps=self._default_slippage_bps, meta=meta, ) @@ -702,18 +714,38 @@ def _resolve_target_quantity( current_qty: float, max_position_qty: Optional[float], ) -> float: - # If the composer requested NOOP, keep current quantity + # NOOP: keep current position if item.action == LlmDecisionAction.NOOP: return current_qty - # Interpret target_qty as a magnitude; apply action to determine sign - mag = float(item.target_qty) - if item.action == LlmDecisionAction.SELL: - target = -abs(mag) + # Interpret target_qty as operation magnitude (not final position), normalized to positive + mag = abs(float(item.target_qty)) + target = current_qty + + # Compute target position per open/close long/short action + if item.action == LlmDecisionAction.OPEN_LONG: + base = current_qty if current_qty > 0 else 0.0 + target = base + mag + elif item.action == LlmDecisionAction.OPEN_SHORT: + base = current_qty if current_qty < 0 else 0.0 + target = base - mag + elif item.action == LlmDecisionAction.CLOSE_LONG: + if current_qty > 0: + target = max(current_qty - mag, 0.0) + else: + # No long position, keep unchanged + target = current_qty + elif item.action == LlmDecisionAction.CLOSE_SHORT: + if current_qty < 0: + target = min(current_qty + mag, 0.0) + else: + # No short position, keep unchanged + target = current_qty else: - # default to BUY semantics - target = abs(mag) + # Fallback: treat unknown action as NOOP + target = current_qty + # Clamp by max_position_qty (symmetric) if max_position_qty is not None: max_abs = abs(float(max_position_qty)) target = max(-max_abs, min(max_abs, target)) diff --git a/python/valuecell/agents/strategy_agent/decision/system_prompt.py b/python/valuecell/agents/strategy_agent/decision/system_prompt.py index 21e14b8b4..20b389453 100644 --- a/python/valuecell/agents/strategy_agent/decision/system_prompt.py +++ b/python/valuecell/agents/strategy_agent/decision/system_prompt.py @@ -13,14 +13,16 @@ You are an autonomous trading planner that outputs a structured plan for a crypto strategy executor. Your objective is to maximize risk-adjusted returns while preserving capital. You are stateless across cycles. ACTION SEMANTICS -- target_qty is the desired FINAL signed position quantity: >0 long, <0 short, 0 flat (close). The executor computes delta = target_qty − current_qty to create orders. -- To close, set target_qty to 0. Do not invent other action names. +- action must be one of: open_long, open_short, close_long, close_short, noop. +- target_qty is the OPERATION SIZE (units) for this action, not the final position. It is a positive magnitude; the executor computes target position from the action and current_qty, then derives delta and orders. +- For derivatives (one-way positions): opening on the opposite side implies first flattening to 0 then opening the requested side; the executor handles this split. +- For spot: only open_long/close_long are valid; open_short/close_short will be treated as reducing toward 0 or ignored. - One item per symbol at most. No hedging (never propose both long and short exposure on the same symbol). CONSTRAINTS & VALIDATION - Respect max_positions, max_leverage, max_position_qty, quantity_step, min_trade_qty, max_order_qty, min_notional, and available buying power. - Keep leverage positive if provided. Confidence must be in [0,1]. -- If arrays appear in Context, they are ordered: OLDEST → NEWEST (last isthe most recent). +- If arrays appear in Context, they are ordered: OLDEST → NEWEST (last is the most recent). - If risk_flags contain low_buying_power or high_leverage_usage, prefer reducing size or choosing noop. If approaching_max_positions is set, prioritize managing existing positions over opening new ones. - When estimating quantity, account for estimated fees (e.g., 1%) and potential market movement; reserve a small buffer so executed size does not exceed intended risk after fees/slippage. diff --git a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py index aad64fdbb..0f0303652 100644 --- a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py @@ -283,8 +283,23 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict: elif exid == "bybit": params.setdefault("reduce_only", False) - # In oneway mode, do not add positionSide/posSide by default - # Users can override via inst.meta if needed + # Enforce single-sided mode: strip positionSide/posSide if present + try: + mode = (self.position_mode or "oneway").lower() + if mode in ("oneway", "single", "net"): + removed = [] + if "positionSide" in params: + params.pop("positionSide", None) + removed.append("positionSide") + if "posSide" in params: + params.pop("posSide", None) + removed.append("posSide") + if removed: + logger.debug( + f"🧹 Oneway mode: stripped {removed} from order params" + ) + except Exception: + pass return params @@ -405,6 +420,30 @@ async def _execute_single( Returns: Transaction result with execution details """ + # Dispatch by high-level action if provided (prefer structured field) + action = (inst.action.value if getattr(inst, "action", None) else None) or str( + (inst.meta or {}).get("action") or "" + ).lower() + if action == "open_long": + return await self._exec_open_long(inst, exchange) + if action == "open_short": + return await self._exec_open_short(inst, exchange) + if action == "close_long": + return await self._exec_close_long(inst, exchange) + if action == "close_short": + return await self._exec_close_short(inst, exchange) + if action == "noop": + return await self._exec_noop(inst) + + # Fallback to generic submission + return await self._submit_order(inst, exchange) + + async def _submit_order( + self, + inst: TradeInstruction, + exchange: ccxt.Exchange, + params_override: Optional[Dict] = None, + ) -> TxResult: # Normalize symbol for CCXT symbol = self._normalize_symbol(inst.instrument.symbol) @@ -476,6 +515,29 @@ async def _execute_single( # Build order params with exchange-specific defaults params = self._build_order_params(inst, order_type) + if params_override: + try: + params.update(params_override) + except Exception: + pass + + # Enforce single-sided mode again after overrides + try: + mode = (self.position_mode or "oneway").lower() + if mode in ("oneway", "single", "net"): + removed = [] + if "positionSide" in params: + params.pop("positionSide", None) + removed.append("positionSide") + if "posSide" in params: + params.pop("posSide", None) + removed.append("posSide") + if removed: + logger.debug( + f"🧹 Oneway mode (post-override): stripped {removed} from order params" + ) + except Exception: + pass # Create order try: @@ -560,11 +622,46 @@ async def _execute_single( leverage=inst.leverage, status=status, reason=order.get("status") if status != TxStatus.FILLED else None, - meta={ - "order_id": order.get("id"), - "exchange_symbol": symbol, - **(inst.meta or {}), - }, + meta=inst.meta, + ) + + 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} + 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} + 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} + 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} + return await self._submit_order(inst, exchange, overrides) + + async def _exec_noop(self, inst: TradeInstruction) -> TxResult: + # No-op: return a rejected result with reason + return TxResult( + instruction_id=inst.instruction_id, + instrument=inst.instrument, + side=inst.side, + requested_qty=float(inst.quantity), + filled_qty=0.0, + status=TxStatus.REJECTED, + reason="noop", + meta=inst.meta, ) async def close(self) -> None: diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 0edc5a295..e148e0d8b 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -119,8 +119,8 @@ class MarketType(str, Enum): class MarginMode(str, Enum): """Margin mode for leverage trading.""" - ISOLATED = "isolated" # Isolated margin (逐仓) - CROSS = "cross" # Cross margin (全仓) + ISOLATED = "isolated" # Isolated margin + CROSS = "cross" # Cross margin class ExchangeConfig(BaseModel): @@ -152,7 +152,7 @@ class ExchangeConfig(BaseModel): ) margin_mode: MarginMode = Field( default=MarginMode.CROSS, - description="Margin mode: isolated (逐仓) or cross (全仓)", + description="Margin mode: isolated or cross", ) fee_bps: float = Field( default=10.0, @@ -440,33 +440,40 @@ class PortfolioView(BaseModel): class LlmDecisionAction(str, Enum): - """Normalized high-level action from LLM plan item. + """Position-oriented high-level actions produced by the LLM plan. Semantics: - - BUY/SELL: directional intent; final TradeSide is decided by delta (target - current) - - NOOP: target equals current (delta == 0), no instruction should be emitted + - OPEN_LONG: open/increase long; if currently short, flatten then open long + - OPEN_SHORT: open/increase short; if currently long, flatten then open short + - CLOSE_LONG: reduce/close long toward 0 + - CLOSE_SHORT: reduce/close short toward 0 + - NOOP: no operation """ - BUY = "buy" - SELL = "sell" + OPEN_LONG = "open_long" + OPEN_SHORT = "open_short" + CLOSE_LONG = "close_long" + CLOSE_SHORT = "close_short" NOOP = "noop" class LlmDecisionItem(BaseModel): - """One LLM plan item. Uses target_qty only (no delta). + """LLM plan item. Interprets target_qty as operation size (magnitude). - The composer will compute order quantity as: target_qty - current_qty. + Unlike the previous "final target position" semantics, target_qty here + is the size to operate (same unit as position quantity). The composer + derives the final target from the action and current quantity. """ instrument: InstrumentRef action: LlmDecisionAction target_qty: float = Field( - ..., description="Desired position quantity after execution" + ..., + description="Operation size for this action (units), e.g., open/close long/short", ) leverage: Optional[float] = Field( default=None, - description="Requested leverage multiple for this target (e.g., 1.0 = no leverage)." - " Composer will clamp to allowed constraints.", + description="Requested leverage multiple for this action (e.g., 1.0 = no leverage). The composer clamps to constraints.", ) confidence: Optional[float] = Field( default=None, description="Optional confidence score [0,1]" @@ -494,7 +501,10 @@ class PriceMode(str, Enum): class TradeInstruction(BaseModel): - """Executable instruction emitted by the composer after normalization.""" + """Executable instruction emitted by the composer after normalization. + + Includes optional action for executor dispatch (open_long/open_short/close_long/close_short/noop). + """ instruction_id: str = Field( ..., description="Deterministic id for idempotency (e.g., compose_id+symbol)" @@ -503,6 +513,10 @@ class TradeInstruction(BaseModel): ..., description="Decision cycle id to correlate instructions and history" ) instrument: InstrumentRef + action: Optional[LlmDecisionAction] = Field( + default=None, + description="High-level intent action for dispatch ('open_long'|'open_short'|'close_long'|'close_short'|'noop')", + ) side: TradeSide quantity: float = Field(..., description="Order quantity in instrument units") leverage: Optional[float] = Field( @@ -514,7 +528,7 @@ class TradeInstruction(BaseModel): ) limit_price: Optional[float] = Field(default=None) max_slippage_bps: Optional[float] = Field(default=None) - meta: Optional[Dict[str, str | float]] = Field( + meta: Optional[Dict[str, str | float | bool]] = Field( default=None, description="Optional metadata for auditing" ) @@ -556,7 +570,7 @@ class TxResult(BaseModel): reason: Optional[str] = Field( default=None, description="Message for rejects/errors" ) - meta: Optional[Dict[str, str | float]] = Field(default=None) + meta: Optional[Dict[str, str | float | bool]] = Field(default=None) class MetricPoint(BaseModel): From 97271a4c54b51e99eda444050dfa7e06d46e6c5d Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:29:15 +0800 Subject: [PATCH 2/8] feat: derive trade side from action in execution gateways and models --- .../strategy_agent/execution/ccxt_trading.py | 28 ++++++-- .../strategy_agent/execution/paper_trading.py | 12 +++- .../valuecell/agents/strategy_agent/models.py | 68 ++++++++++++++++++- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py index 0f0303652..ec9871ed7 100644 --- a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py @@ -21,6 +21,7 @@ TradeSide, TxResult, TxStatus, + derive_side_from_action, ) from .interfaces import ExecutionGateway @@ -385,8 +386,13 @@ async def execute( results: List[TxResult] = [] for inst in instructions: + side = ( + getattr(inst, "side", None) + or derive_side_from_action(getattr(inst, "action", None)) + or TradeSide.BUY + ) logger.info( - f" 📤 Processing {inst.instrument.symbol} {inst.side.value} qty={inst.quantity}" + f" 📤 Processing {inst.instrument.symbol} {side.value} qty={inst.quantity}" ) try: result = await self._execute_single(inst, exchange) @@ -397,7 +403,7 @@ async def execute( TxResult( instruction_id=inst.instruction_id, instrument=inst.instrument, - side=inst.side, + side=side, requested_qty=float(inst.quantity), filled_qty=0.0, status=TxStatus.ERROR, @@ -478,7 +484,12 @@ async def _submit_order( await self._setup_margin_mode(symbol, exchange) # Map instruction to CCXT parameters - side = "buy" if inst.side == TradeSide.BUY else "sell" + local_side = ( + getattr(inst, "side", None) + or derive_side_from_action(getattr(inst, "action", None)) + or TradeSide.BUY + ) + side = "buy" if local_side == TradeSide.BUY else "sell" order_type = "limit" if inst.price_mode == PriceMode.LIMIT else "market" amount = float(inst.quantity) price = float(inst.limit_price) if inst.limit_price else None @@ -505,7 +516,7 @@ async def _submit_order( return TxResult( instruction_id=inst.instruction_id, instrument=inst.instrument, - side=inst.side, + side=local_side, requested_qty=float(inst.quantity), filled_qty=0.0, status=TxStatus.REJECTED, @@ -613,7 +624,7 @@ async def _submit_order( return TxResult( instruction_id=inst.instruction_id, instrument=inst.instrument, - side=inst.side, + side=local_side, requested_qty=amount, filled_qty=filled_qty, avg_exec_price=avg_price if avg_price > 0 else None, @@ -653,10 +664,15 @@ async def _exec_close_short( async def _exec_noop(self, inst: TradeInstruction) -> TxResult: # No-op: return a rejected result with reason + side = ( + getattr(inst, "side", None) + or derive_side_from_action(getattr(inst, "action", None)) + or TradeSide.BUY + ) return TxResult( instruction_id=inst.instruction_id, instrument=inst.instrument, - side=inst.side, + side=side, requested_qty=float(inst.quantity), filled_qty=0.0, status=TxStatus.REJECTED, diff --git a/python/valuecell/agents/strategy_agent/execution/paper_trading.py b/python/valuecell/agents/strategy_agent/execution/paper_trading.py index a6dfbcec9..98292b800 100644 --- a/python/valuecell/agents/strategy_agent/execution/paper_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/paper_trading.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from ..models import TradeInstruction, TradeSide, TxResult +from ..models import TradeInstruction, TradeSide, TxResult, derive_side_from_action from .interfaces import ExecutionGateway @@ -28,7 +28,13 @@ async def execute( ref_price = float(price_map.get(inst.instrument.symbol, 0.0) or 0.0) slip_bps = float(inst.max_slippage_bps or 0.0) slip = slip_bps / 10_000.0 - if inst.side == TradeSide.BUY: + # Compute side from instruction or derive from action (future-proof for non-order actions) + side = ( + getattr(inst, "side", None) + or derive_side_from_action(getattr(inst, "action", None)) + or TradeSide.BUY + ) # default BUY only affects pricing on non-order/noop + if side == TradeSide.BUY: exec_price = ref_price * (1.0 + slip) else: exec_price = ref_price * (1.0 - slip) @@ -40,7 +46,7 @@ async def execute( TxResult( instruction_id=inst.instruction_id, instrument=inst.instrument, - side=inst.side, + side=side, requested_qty=float(inst.quantity), filled_qty=float(inst.quantity), avg_exec_price=float(exec_price) if exec_price else None, diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index e148e0d8b..279bcb2b6 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -29,7 +29,19 @@ class TradeType(str, Enum): class TradeSide(str, Enum): - """Side for executable trade instruction.""" + """Low-level execution side (exchange primitive). + + This remains distinct from `LlmDecisionAction` which encodes *intent* at a + position semantic level (open_long/close_short/etc). TradeSide is kept for: + - direct mapping to exchange APIs that require BUY/SELL + - conveying slippage/fee direction in execution records + + Removal consideration: if the pipeline fully normalizes around + LlmDecisionAction -> (final target delta), we can derive side on the fly: + OPEN_LONG, CLOSE_SHORT -> BUY + OPEN_SHORT, CLOSE_LONG -> SELL + For now we keep it explicit to avoid recomputation and ease auditing. + """ BUY = "BUY" SELL = "SELL" @@ -457,6 +469,23 @@ class LlmDecisionAction(str, Enum): NOOP = "noop" +def derive_side_from_action( + action: Optional[LlmDecisionAction], +) -> Optional["TradeSide"]: + """Derive execution side (BUY/SELL) from a high-level action. + + Returns None for non-order actions (e.g., noop, future amend/cancel types). + """ + if action is None: + return None + if action in (LlmDecisionAction.OPEN_LONG, LlmDecisionAction.CLOSE_SHORT): + return TradeSide.BUY + if action in (LlmDecisionAction.OPEN_SHORT, LlmDecisionAction.CLOSE_LONG): + return TradeSide.SELL + # NOOP or future adjust/cancel actions: no executable side + return None + + class LlmDecisionItem(BaseModel): """LLM plan item. Interprets target_qty as operation size (magnitude). @@ -517,7 +546,7 @@ class TradeInstruction(BaseModel): default=None, description="High-level intent action for dispatch ('open_long'|'open_short'|'close_long'|'close_short'|'noop')", ) - side: TradeSide + side: TradeSide # Derived execution direction (BUY/SELL) consistent with action quantity: float = Field(..., description="Order quantity in instrument units") leverage: Optional[float] = Field( default=None, @@ -532,6 +561,39 @@ class TradeInstruction(BaseModel): default=None, description="Optional metadata for auditing" ) + @model_validator(mode="after") + def _validate_action_side_alignment(self): + """Ensure action (if provided) aligns with the executable side. + + Mapping (state-independent after normalization): + - OPEN_LONG -> BUY + - CLOSE_SHORT-> BUY + - OPEN_SHORT -> SELL + - CLOSE_LONG -> SELL + - NOOP -> should not be emitted as an instruction + """ + act = self.action + if act is None: + return self + try: + if act == LlmDecisionAction.NOOP: + # Composer should not emit NOOP instructions; tolerate in lenient mode + return self + if act in (LlmDecisionAction.OPEN_LONG, LlmDecisionAction.CLOSE_SHORT): + expected = TradeSide.BUY + elif act in (LlmDecisionAction.OPEN_SHORT, LlmDecisionAction.CLOSE_LONG): + expected = TradeSide.SELL + else: + return self + if self.side != expected: + raise ValueError( + f"TradeInstruction.action={act} conflicts with side={self.side}; expected {expected}" + ) + except Exception: + # Be conservative: do not block pipeline on validator edge cases + return self + return self + class TxStatus(str, Enum): """Execution status of a submitted instruction.""" @@ -551,7 +613,7 @@ class TxResult(BaseModel): instruction_id: str = Field(..., description="Originating instruction id") instrument: InstrumentRef - side: TradeSide + side: TradeSide # Echo of execution direction for auditing requested_qty: float = Field(..., description="Requested order quantity") filled_qty: float = Field(..., description="Filled quantity (<= requested)") avg_exec_price: Optional[float] = Field( From 3e9fbc1686a94d94f4329b0a72aeb1c1e97e772f Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:10:15 +0800 Subject: [PATCH 3/8] fix: improve error logging in get_recent_candles method --- python/valuecell/agents/strategy_agent/data/market.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/data/market.py b/python/valuecell/agents/strategy_agent/data/market.py index 546b30ec8..6134d9baa 100644 --- a/python/valuecell/agents/strategy_agent/data/market.py +++ b/python/valuecell/agents/strategy_agent/data/market.py @@ -72,11 +72,12 @@ async def _fetch(symbol: str) -> List[List]: interval=interval, ) ) - except Exception: - logger.exception( - "Failed to fetch candles for {} from {}, return empty candles", + except Exception as exc: + logger.error( + "Failed to fetch candles for {} from {}, return empty candles. Error: {}", symbol, self._exchange_id, + exc, ) return candles From 3e01549212dd3df120f11c988c7ea789078f3683 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:11:56 +0800 Subject: [PATCH 4/8] refactor: update portfolio view to use _account_balance and free_cash attributes --- .../valuecell/agents/strategy_agent/core.py | 6 ++-- .../strategy_agent/decision/composer.py | 9 ++--- .../valuecell/agents/strategy_agent/models.py | 14 +++++++- .../strategy_agent/portfolio/in_memory.py | 36 +++++++++++++++---- .../server/services/strategy_persistence.py | 2 +- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index e7e7d3ba8..1acb0a791 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -163,16 +163,16 @@ async def run_once(self) -> DecisionCycleResult: else: for q in ("USDT", "USD", "USDC"): free_cash += float(free_map.get(q, 0.0) or 0.0) - portfolio.cash = float(free_cash) + portfolio._account_balance = float(free_cash) if self._request.exchange_config.market_type == MarketType.SPOT: - portfolio.buying_power = max(0.0, float(portfolio.cash)) + portfolio.buying_power = max(0.0, float(portfolio._account_balance)) except Exception: # If syncing fails, continue with existing portfolio view pass # 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: - portfolio.buying_power = max(0.0, float(portfolio.cash)) + portfolio.buying_power = max(0.0, float(portfolio._account_balance)) # Use fixed 1-second interval and lookback of 3 minutes (60 * 3 seconds) candles_1s = await self._market_data_source.get_recent_candles( diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 41bd54d18..d6951441e 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -163,10 +163,11 @@ def _build_summary(self, context: ComposeContext) -> Dict: for snap in pv.positions.values() if abs(float(getattr(snap, "quantity", 0.0) or 0.0)) > 0.0 ), - "total_value": getattr(pv, "total_value", None), - "cash": pv.cash, - "unrealized_pnl": getattr(pv, "total_unrealized_pnl", None), - "sharpe_ratio": getattr(context.digest, "sharpe_ratio", None), + "total_value": pv.total_value, + "account_balance": pv._account_balance, + "free_cash": pv.free_cash, + "unrealized_pnl": pv.total_unrealized_pnl, + "sharpe_ratio": context.digest.sharpe_ratio, } def _build_llm_prompt(self, context: ComposeContext) -> str: diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index d7b7566f2..2a4ac685c 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -424,7 +424,9 @@ class PortfolioView(BaseModel): default=None, description="Owning strategy id for this portfolio snapshot" ) ts: int - cash: float + _account_balance: float = Field( + ..., description="Account cash balance in quote currency" + ) positions: Dict[str, PositionSnapshot] = Field( default_factory=dict, description="Map symbol -> PositionSnapshot" ) @@ -449,6 +451,16 @@ class PortfolioView(BaseModel): default=None, description="Buying power: max(0, equity * max_leverage - gross_exposure)", ) + free_cash: Optional[float] = Field( + default=None, + description=( + "Approx available funds without tracking margin_used explicitly. " + "Definition: free_cash = max(0, equity - sum_i(notional_i / L_i)), " + "where equity = total_value (if provided) else (cash + total_unrealized_pnl or 0). " + "For spot/no-leverage positions L_i = 1; for leveraged positions L_i is each position's" + " effective leverage if available, otherwise falls back to constraints.max_leverage." + ), + ) class LlmDecisionAction(str, Enum): diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index d085cf06b..7e7269e80 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -27,6 +27,9 @@ class InMemoryPortfolioService(PortfolioService): - total_unrealized_pnl = sum((mark_price - avg_price) * qty) - buying_power: max(0, equity * max_leverage - gross_exposure) where max_leverage comes from portfolio.constraints (default 1.0) + - free_cash: per-position effective margin approximation without explicit margin_used + free_cash = max(0, equity - sum_i(notional_i / L_i)), + where L_i is position.leverage if present else constraints.max_leverage (>=1) """ def __init__( @@ -43,7 +46,7 @@ def __init__( self._view = PortfolioView( strategy_id=strategy_id, ts=int(datetime.now(timezone.utc).timestamp() * 1000), - cash=initial_capital, + _account_balance=initial_capital, positions={}, gross_exposure=0.0, net_exposure=0.0, @@ -168,12 +171,12 @@ def apply_trades( fee = trade.fee_cost or 0.0 if trade.side == TradeSide.BUY: # buying reduces cash by notional plus fees - self._view.cash -= notional - self._view.cash -= fee + self._view._account_balance -= notional + self._view._account_balance -= fee else: # selling increases cash by notional minus fees - self._view.cash += notional - self._view.cash -= fee + self._view._account_balance += notional + self._view._account_balance -= fee # Recompute per-position derived fields (if position still exists) pos = self._view.positions.get(symbol) @@ -232,13 +235,13 @@ def apply_trades( self._view.net_exposure = net self._view.total_unrealized_pnl = unreal # Equity is cash plus net exposure (correct for both long and short) - equity = self._view.cash + net + equity = self._view._account_balance + net self._view.total_value = equity # Approximate buying power using market type policy if self._market_type == MarketType.SPOT: # Spot: cash-only buying power - self._view.buying_power = max(0.0, float(self._view.cash)) + self._view.buying_power = max(0.0, float(self._view._account_balance)) else: # Derivatives: margin-based buying power max_lev = ( @@ -248,3 +251,22 @@ def apply_trades( ) buying_power = max(0.0, equity * max_lev - gross) self._view.buying_power = buying_power + + # Compute free_cash using per-position effective leverage + # Equity fallback: already computed as cash + net + if self._market_type == MarketType.SPOT: + # No leverage: free cash equals available cash + self._view.free_cash = max(0.0, float(self._view._account_balance)) + else: + # Derivatives: estimate required margin as sum(notional_i / L_i) + required_margin = 0.0 + for pos in self._view.positions.values(): + qty = float(pos.quantity) + mpx = float(pos.mark_price or 0.0) + if qty == 0.0 or mpx <= 0.0: + continue + notional_i = abs(qty) * mpx + lev_i = float(pos.leverage) if (pos.leverage and pos.leverage > 0) else 1.0 + lev_i = max(1.0, lev_i) + required_margin += notional_i / lev_i + self._view.free_cash = max(0.0, equity - required_margin) diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index e2a7d60b0..0a6e59011 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -96,7 +96,7 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: else None ) - cash = float(view.cash) + cash = float(view.free_cash) total_value = float(view.total_value) if view.total_value is not None else cash total_unrealized = ( float(view.total_unrealized_pnl) From eb5414462360d0e185494fd8bcda87dd21146321 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:17:58 +0800 Subject: [PATCH 5/8] make format --- python/valuecell/agents/strategy_agent/portfolio/in_memory.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 7e7269e80..9ee275589 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -266,7 +266,9 @@ def apply_trades( if qty == 0.0 or mpx <= 0.0: continue notional_i = abs(qty) * mpx - lev_i = float(pos.leverage) if (pos.leverage and pos.leverage > 0) else 1.0 + lev_i = ( + float(pos.leverage) if (pos.leverage and pos.leverage > 0) else 1.0 + ) lev_i = max(1.0, lev_i) required_margin += notional_i / lev_i self._view.free_cash = max(0.0, equity - required_margin) From c9946cdb1909cd2b1cbd1fc843955f0c9d99b153 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:23:25 +0800 Subject: [PATCH 6/8] refactor: replace _account_balance with account_balance in portfolio models and services --- python/valuecell/agents/strategy_agent/core.py | 6 +++--- .../agents/strategy_agent/decision/composer.py | 2 +- python/valuecell/agents/strategy_agent/models.py | 2 +- .../agents/strategy_agent/portfolio/in_memory.py | 16 ++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index 1acb0a791..79b98378f 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -163,16 +163,16 @@ async def run_once(self) -> DecisionCycleResult: 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) + portfolio.account_balance = float(free_cash) if self._request.exchange_config.market_type == MarketType.SPOT: - portfolio.buying_power = max(0.0, float(portfolio._account_balance)) + portfolio.buying_power = max(0.0, float(portfolio.account_balance)) except Exception: # If syncing fails, continue with existing portfolio view pass # 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: - portfolio.buying_power = max(0.0, float(portfolio._account_balance)) + portfolio.buying_power = max(0.0, float(portfolio.account_balance)) # Use fixed 1-second interval and lookback of 3 minutes (60 * 3 seconds) candles_1s = await self._market_data_source.get_recent_candles( diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index d6951441e..15d9bc286 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -164,7 +164,7 @@ def _build_summary(self, context: ComposeContext) -> Dict: if abs(float(getattr(snap, "quantity", 0.0) or 0.0)) > 0.0 ), "total_value": pv.total_value, - "account_balance": pv._account_balance, + "account_balance": pv.account_balance, "free_cash": pv.free_cash, "unrealized_pnl": pv.total_unrealized_pnl, "sharpe_ratio": context.digest.sharpe_ratio, diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 2a4ac685c..08468a467 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -424,7 +424,7 @@ class PortfolioView(BaseModel): default=None, description="Owning strategy id for this portfolio snapshot" ) ts: int - _account_balance: float = Field( + account_balance: float = Field( ..., description="Account cash balance in quote currency" ) positions: Dict[str, PositionSnapshot] = Field( diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 9ee275589..1e152c9b7 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -46,7 +46,7 @@ def __init__( self._view = PortfolioView( strategy_id=strategy_id, ts=int(datetime.now(timezone.utc).timestamp() * 1000), - _account_balance=initial_capital, + account_balance=initial_capital, positions={}, gross_exposure=0.0, net_exposure=0.0, @@ -171,12 +171,12 @@ def apply_trades( 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 + 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 + self._view.account_balance += notional + self._view.account_balance -= fee # Recompute per-position derived fields (if position still exists) pos = self._view.positions.get(symbol) @@ -235,13 +235,13 @@ def apply_trades( self._view.net_exposure = net self._view.total_unrealized_pnl = unreal # Equity is cash plus net exposure (correct for both long and short) - equity = self._view._account_balance + net + equity = self._view.account_balance + net self._view.total_value = equity # Approximate buying power using market type policy if self._market_type == MarketType.SPOT: # Spot: cash-only buying power - self._view.buying_power = max(0.0, float(self._view._account_balance)) + self._view.buying_power = max(0.0, float(self._view.account_balance)) else: # Derivatives: margin-based buying power max_lev = ( @@ -256,7 +256,7 @@ def apply_trades( # Equity fallback: already computed as cash + net if self._market_type == MarketType.SPOT: # No leverage: free cash equals available cash - self._view.free_cash = max(0.0, float(self._view._account_balance)) + self._view.free_cash = max(0.0, float(self._view.account_balance)) else: # Derivatives: estimate required margin as sum(notional_i / L_i) required_margin = 0.0 From 3cf2196c632805cc8dc63ffddb489bc86ce062ba Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:28:32 +0800 Subject: [PATCH 7/8] refactor: initialize free_cash in InMemoryPortfolioService constructor --- python/valuecell/agents/strategy_agent/portfolio/in_memory.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 1e152c9b7..de8fd4296 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -54,6 +54,7 @@ def __init__( total_value=initial_capital, total_unrealized_pnl=0.0, buying_power=initial_capital, + free_cash=initial_capital, ) self._trading_mode = trading_mode self._market_type = market_type From 84f6a513aa63f88decde7c97c367a26b50224fad Mon Sep 17 00:00:00 2001 From: paisley Date: Thu, 13 Nov 2025 18:37:52 +0800 Subject: [PATCH 8/8] fix submit order --- .../valuecell/agents/strategy_agent/agent.py | 14 ++ .../valuecell/agents/strategy_agent/core.py | 10 + .../strategy_agent/execution/ccxt_trading.py | 229 ++++++++++++++++++ 3 files changed, 253 insertions(+) diff --git a/python/valuecell/agents/strategy_agent/agent.py b/python/valuecell/agents/strategy_agent/agent.py index 0baa482ba..33ddc6b13 100644 --- a/python/valuecell/agents/strategy_agent/agent.py +++ b/python/valuecell/agents/strategy_agent/agent.py @@ -187,6 +187,20 @@ async def stream( logger.exception("StrategyAgent stream failed: {}", err) yield streaming.message_chunk(f"StrategyAgent error: {err}") finally: + # Close runtime resources (e.g., CCXT exchange) before marking stopped + try: + if hasattr(runtime, "coordinator") and hasattr( + runtime.coordinator, "close" + ): + await runtime.coordinator.close() + logger.info( + "Closed runtime coordinator resources for strategy {}", + strategy_id, + ) + except Exception: + logger.exception( + "Failed to close runtime resources for strategy {}", strategy_id + ) # Always mark strategy as stopped when stream ends for any reason try: strategy_persistence.mark_strategy_stopped(strategy_id) diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index e7e7d3ba8..8263d0140 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -559,3 +559,13 @@ def _create_history_records( payload={"trades": trade_payload}, ), ] + + async def close(self) -> None: + """Release resources for the execution gateway if it supports closing.""" + try: + close_fn = getattr(self._execution_gateway, "close", None) + if callable(close_fn): + await close_fn() + except Exception: + # Avoid bubbling cleanup errors + pass diff --git a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py index 07d168e73..f41a00120 100644 --- a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py @@ -362,6 +362,138 @@ async def _check_minimums( return f"notional<{min_cost}" return None + async def _estimate_required_margin_okx( + self, + symbol: str, + amount: float, + price: Optional[float], + leverage: Optional[float], + exchange: ccxt.Exchange, + ) -> Optional[float]: + """Estimate initial margin required for an OKX derivatives open. + + If `symbol` is a derivatives contract and `amount` is in contracts (sz), + multiply by the contract size (`contractSize` or `info.ctVal`) to convert + to notional units before dividing by leverage. + Falls back to ticker price when `price` is not provided. + """ + try: + lev = float(leverage or 1.0) + if lev <= 0: + lev = 1.0 + px = float(price or 0.0) + if px <= 0: + if exchange.has.get("fetchTicker"): + try: + ticker = await exchange.fetch_ticker(symbol) + px = float( + ticker.get("last") + or ticker.get("bid") + or ticker.get("ask") + or 0.0 + ) + except Exception: + px = 0.0 + if px <= 0: + return None + + # Detect contract size if symbol is derivatives (OKX swap/futures) + ct_val: Optional[float] = None + try: + market = (getattr(exchange, "markets", {}) or {}).get(symbol) or {} + if market.get("contract"): + try: + ct_val = float(market.get("contractSize") or 0.0) + except Exception: + ct_val = None + if not ct_val: + info = market.get("info") or {} + try: + ct_val = float(info.get("ctVal") or 0.0) + except Exception: + ct_val = None + except Exception: + ct_val = None + + # If ct_val is present and amount is sz (contracts), convert to notional + if ct_val and ct_val > 0: + notional = amount * ct_val * px + else: + # Fallback: treat amount as base units + notional = amount * px + + return notional / lev * 1.02 + except Exception: + return None + + async def _get_free_usdt_okx(self, exchange: ccxt.Exchange) -> Optional[float]: + """Read available USDT from OKX unified trading account. + + Explicitly queries trading balances and extracts free USDT. + """ + try: + bal = await exchange.fetch_balance({"type": "trading"}) + free = bal.get("free") or {} + usdt = free.get("USDT") + if usdt is None: + # Fallback: some ccxt versions expose totals differently + usdt = (bal.get("total") or {}).get("USDT") + return float(usdt) if usdt is not None else 0.0 + except Exception as e: + logger.warning(f"⚠️ Could not fetch OKX trading balance: {e}") + return None + + async def _estimate_required_margin_binance_linear( + self, + symbol: str, + amount: float, + price: Optional[float], + leverage: Optional[float], + exchange: ccxt.Exchange, + ) -> Optional[float]: + """Estimate initial margin for Binance USDT-M linear contracts. + + For USDT-M (linear), `amount` is base coin quantity. + Approximation: notional = amount * price; initial_margin = notional / leverage. + Adds a 2% buffer. If no price is provided, falls back to ticker last/bid/ask. + """ + try: + lev = float(leverage or 1.0) + if lev <= 0: + lev = 1.0 + px = float(price or 0.0) + if px <= 0: + if exchange.has.get("fetchTicker"): + try: + ticker = await exchange.fetch_ticker(symbol) + px = float( + ticker.get("last") + or ticker.get("bid") + or ticker.get("ask") + or 0.0 + ) + except Exception: + px = 0.0 + if px <= 0: + return None + notional = amount * px + return notional / lev * 1.02 + except Exception: + return None + + async def _get_free_usdt_binance(self, exchange: ccxt.Exchange) -> Optional[float]: + """Fetch available USDT balance from Binance USDT-M futures account.""" + try: + bal = await exchange.fetch_balance({"type": "future"}) + free = bal.get("free") or {} + usdt = free.get("USDT") + if usdt is None: + usdt = (bal.get("total") or {}).get("USDT") + return float(usdt) if usdt is not None else 0.0 + except Exception as e: + logger.warning(f"Could not fetch Binance futures balance: {e}") + return None + async def execute( self, instructions: List[TradeInstruction], @@ -495,6 +627,25 @@ async def _submit_order( amount = float(inst.quantity) price = float(inst.limit_price) if inst.limit_price else None + # For OKX derivatives, amount must be in contracts; convert from base units if needed + try: + market = (getattr(exchange, "markets", {}) or {}).get(symbol) or {} + if self.exchange_id == "okx" and market.get("contract"): + try: + ct_val = float(market.get("contractSize") or 0.0) + except Exception: + ct_val = None + if not ct_val: + info = market.get("info") or {} + try: + ct_val = float(info.get("ctVal") or 0.0) + except Exception: + ct_val = None + if ct_val and ct_val > 0: + amount = amount / ct_val + except Exception: + pass + # Align precision if supported try: amount = float(exchange.amount_to_precision(symbol, amount)) @@ -525,6 +676,84 @@ async def _submit_order( meta=inst.meta, ) + # OKX trading account margin precheck for open orders + if self.exchange_id == "okx": + try: + # Determine open vs close intent from default reduceOnly flags + provisional = self._build_order_params(inst, order_type) + is_close = bool( + provisional.get("reduceOnly") or provisional.get("reduce_only") + ) + if not is_close: + required = await self._estimate_required_margin_okx( + symbol, amount, price, inst.leverage, exchange + ) + free_usdt = await self._get_free_usdt_okx(exchange) + if ( + required is not None + and free_usdt is not None + and free_usdt < required + ): + reject_reason = f"insufficient_margin:need~{required:.6f}USDT,free~{free_usdt:.6f}USDT" + logger.warning(f" 🚫 Skipping order due to {reject_reason}") + return TxResult( + instruction_id=inst.instruction_id, + instrument=inst.instrument, + side=local_side, + requested_qty=float(inst.quantity), + filled_qty=0.0, + status=TxStatus.REJECTED, + reason=reject_reason, + meta=inst.meta, + ) + except Exception as e: + logger.warning( + f"⚠️ OKX margin precheck failed, proceeding without precheck: {e}" + ) + + # Binance USDT-M linear futures margin precheck for open orders + if self.exchange_id == "binance": + try: + provisional = self._build_order_params(inst, order_type) + is_close = bool( + provisional.get("reduceOnly") or provisional.get("reduce_only") + ) + if not is_close: + market = (getattr(exchange, "markets", {}) or {}).get(symbol) or {} + is_contract = bool(market.get("contract")) + is_linear = bool(market.get("linear")) + if not is_linear: + settle = str(market.get("settle") or "").upper() + is_linear = bool(is_contract and settle == "USDT") + if is_contract and is_linear: + required = await self._estimate_required_margin_binance_linear( + symbol, amount, price, inst.leverage, exchange + ) + free_usdt = await self._get_free_usdt_binance(exchange) + if ( + required is not None + and free_usdt is not None + and free_usdt < required + ): + reject_reason = f"insufficient_margin_binance_usdtm:need~{required:.6f}USDT,free~{free_usdt:.6f}USDT" + logger.warning( + f" 🚫 Skipping order due to {reject_reason}" + ) + return TxResult( + instruction_id=inst.instruction_id, + instrument=inst.instrument, + side=local_side, + requested_qty=float(inst.quantity), + filled_qty=0.0, + status=TxStatus.REJECTED, + reason=reject_reason, + meta=inst.meta, + ) + except Exception as e: + logger.warning( + f"⚠️ Binance USDT-M margin precheck failed, proceeding without precheck: {e}" + ) + # Build order params with exchange-specific defaults params = self._build_order_params(inst, order_type) if params_override: