diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index 9a8b82f59..458e1f334 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -17,6 +17,7 @@ ComposeContext, FeatureVector, HistoryRecord, + MarketType, PortfolioView, StrategyStatus, StrategySummary, @@ -25,6 +26,7 @@ TradeInstruction, TradeSide, TradeType, + TradingMode, TxResult, TxStatus, UserRequest, @@ -131,6 +133,57 @@ async def run_once(self) -> DecisionCycleResult: compose_id = generate_uuid("compose") portfolio = self._portfolio_service.get_view() + # LIVE mode: sync cash from exchange free balance; set buying power to cash + try: + if ( + self._request.exchange_config.trading_mode == TradingMode.LIVE + and hasattr(self._execution_gateway, "fetch_balance") + ): + balance = await self._execution_gateway.fetch_balance() + free_map = {} + free_section = ( + balance.get("free") if isinstance(balance, dict) else None + ) + if isinstance(free_section, dict): + free_map = { + str(k).upper(): float(v or 0.0) for k, v in free_section.items() + } + else: + # Handle nested per-currency dict shapes + iterable = balance.items() if isinstance(balance, dict) else [] + for k, v in iterable: + if isinstance(v, dict) and "free" in v: + try: + free_map[str(k).upper()] = float(v.get("free") or 0.0) + except Exception: + continue + # Derive quote currencies from symbols, fallback to common USD-stable quotes + quotes = [] + for sym in self._symbols or []: + s = str(sym).upper() + if "/" in s and len(s.split("/")) == 2: + quotes.append(s.split("/")[1]) + elif "-" in s and len(s.split("-")) == 2: + quotes.append(s.split("-")[1]) + # Deduplicate preserving order + quotes = list(dict.fromkeys(quotes)) + free_cash = 0.0 + if quotes: + for q in quotes: + free_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.cash = float(free_cash) + if self._request.exchange_config.market_type == MarketType.SPOT: + portfolio.buying_power = max(0.0, float(portfolio.cash)) + 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)) # Use fixed 1-minute interval and lookback of 4 hours (60 * 4 minutes) candles = await self._market_data_source.get_recent_candles( self._symbols, "1m", 60 * 4 @@ -414,10 +467,19 @@ def _build_summary( try: view = self._portfolio_service.get_view() unrealized = float(view.total_unrealized_pnl or 0.0) - equity = float(view.total_value 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) except Exception: # Fallback to internal tracking if portfolio service is unavailable unrealized = float(self._unrealized_pnl or 0.0) + # Fallback equity uses initial capital when view is unavailable equity = float(self._request.trading_config.initial_capital or 0.0) # Keep internal state in sync (allow negative unrealized PnL) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 295138bcb..774dbbb69 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -12,6 +12,7 @@ Constraints, LlmDecisionAction, LlmPlanProposal, + MarketType, PriceMode, TradeInstruction, TradeSide, @@ -146,19 +147,28 @@ def _init_buying_power_context( max_leverage=self._request.trading_config.max_leverage, ) - # Compute equity (prefer total_value, fallback to cash + net_exposure) - if getattr(context.portfolio, "total_value", None) is not None: - equity = float(context.portfolio.total_value or 0.0) + # Compute equity based on market type: + if self._request.exchange_config.market_type == MarketType.SPOT: + # Spot: use available cash as equity + equity = float(getattr(context.portfolio, "cash", 0.0) or 0.0) else: - cash = float(getattr(context.portfolio, "cash", 0.0) or 0.0) - net = float(getattr(context.portfolio, "net_exposure", 0.0) or 0.0) - equity = cash + net - - allowed_lev = ( - float(constraints.max_leverage) - if constraints.max_leverage is not None - else 1.0 - ) + # Derivatives: use portfolio equity (cash + net exposure), or total_value if provided + if getattr(context.portfolio, "total_value", None) is not None: + equity = float(context.portfolio.total_value or 0.0) + else: + cash = float(getattr(context.portfolio, "cash", 0.0) or 0.0) + net = float(getattr(context.portfolio, "net_exposure", 0.0) or 0.0) + equity = cash + net + + # Market-type leverage policy: SPOT -> 1.0; Derivatives -> constraints + if self._request.exchange_config.market_type == MarketType.SPOT: + allowed_lev = 1.0 + else: + allowed_lev = ( + float(constraints.max_leverage) + if constraints.max_leverage is not None + else 1.0 + ) # Initialize projected gross exposure price_map = context.market_snapshot or {} @@ -263,7 +273,12 @@ def _normalize_quantity( ) final_qty = qty else: - avail_bp = max(0.0, equity * allowed_lev - projected_gross) + if self._request.exchange_config.market_type == MarketType.SPOT: + # Spot: cash-only buying power + avail_bp = max(0.0, equity) + else: + # Derivatives: margin-based buying power + avail_bp = max(0.0, equity * allowed_lev - projected_gross) # When buying power is exhausted, we should still allow reductions/closures. # Set additional purchasable units to 0 but proceed with piecewise logic # so that de-risking trades are not blocked. @@ -353,6 +368,12 @@ def _count_active(pos_map: Dict[str, float]) -> int: target_qty = self._resolve_target_quantity( item, current_qty, max_position_qty ) + # SPOT long-only: do not allow negative target quantities + if ( + self._request.exchange_config.market_type == MarketType.SPOT + and target_qty < 0 + ): + target_qty = 0.0 # Enforce: single-lot per symbol and no direct flip. If target flips side, # split into two sub-steps: first flat to 0, then open to target side. sub_targets: List[float] = [] @@ -397,7 +418,11 @@ def _count_active(pos_map: Dict[str, float]) -> int: if constraints.max_leverage is not None else requested_lev ) - final_leverage = max(1.0, min(requested_lev, allowed_lev_item)) + if self._request.exchange_config.market_type == MarketType.SPOT: + # Spot: long-only, no leverage + final_leverage = 1.0 + else: + final_leverage = max(1.0, min(requested_lev, allowed_lev_item)) quantity = abs(delta) # Normalize quantity through all guardrails diff --git a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py index afb1599a1..aad64fdbb 100644 --- a/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py +++ b/python/valuecell/agents/strategy_agent/execution/ccxt_trading.py @@ -288,24 +288,18 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict: return params - async def _enforce_minimums( + async def _check_minimums( self, exchange: ccxt.Exchange, symbol: str, amount: float, price: Optional[float], - ) -> float: - """Ensure amount satisfies exchange minimums (amount and notional). - - - Checks markets[symbol].limits.amount.min and info.minSz (OKX) - - If limits.cost.min exists, uses price or fetches ticker to lift amount - - Returns adjusted amount aligned to precision - """ + ) -> Optional[str]: markets = getattr(exchange, "markets", {}) or {} market = markets.get(symbol, {}) limits = market.get("limits") or {} - # Minimum amount (contracts) + # amount minimum min_amount = None amt_limits = limits.get("amount") or {} if amt_limits.get("min") is not None: @@ -321,18 +315,10 @@ async def _enforce_minimums( min_amount = float(min_sz) except Exception: min_amount = None - if min_amount is not None and amount < min_amount: - logger.info( - f" ↗️ Amount {amount} below min {min_amount}; aligning to minimum" - ) - amount = min_amount - try: - amount = float(exchange.amount_to_precision(symbol, amount)) - except Exception: - pass + return f"amount<{min_amount}" - # Minimum notional (cost) + # notional minimum min_cost = None cost_limits = limits.get("cost") or {} if cost_limits.get("min") is not None: @@ -340,7 +326,6 @@ async def _enforce_minimums( min_cost = float(cost_limits["min"]) except Exception: min_cost = None - if min_cost is not None: est_price = price if est_price is None and exchange.has.get("fetchTicker"): @@ -357,18 +342,8 @@ async def _enforce_minimums( if est_price and est_price > 0: notional = amount * est_price if notional < min_cost: - required_amount = min_cost / est_price - logger.info( - f" ↗️ Notional {notional:.4f} below minCost {min_cost}; lifting amount" - ) - try: - amount = float( - exchange.amount_to_precision(symbol, required_amount) - ) - except Exception: - amount = required_amount - - return amount + return f"notional<{min_cost}" + return None async def execute( self, @@ -480,11 +455,24 @@ async def _execute_single( except Exception: pass - # Enforce exchange minimums (amount and notional) + # Reject orders below exchange minimums (do not lift to min) try: - amount = await self._enforce_minimums(exchange, symbol, amount, price) + reject_reason = await self._check_minimums(exchange, symbol, amount, price) except Exception as e: - logger.warning(f"⚠️ Could not align to minimums for {symbol}: {e}") + logger.warning(f"⚠️ Minimum check failed for {symbol}: {e}") + reject_reason = f"minimum_check_failed:{e}" + if reject_reason is not None: + logger.warning(f" 🚫 Skipping order due to {reject_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=reject_reason, + meta=inst.meta, + ) # Build order params with exchange-specific defaults params = self._build_order_params(inst, order_type) diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 220d69eca..4f087464d 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -233,6 +233,39 @@ class UserRequest(BaseModel): ..., description="Trading strategy configuration" ) + @model_validator(mode="before") + @classmethod + def _infer_market_type(cls, data): + """Infer market_type from trading_config.max_leverage when not provided. + + Rule: if market_type is missing (not present in request), then + - max_leverage <= 1.0 -> SPOT + - max_leverage > 1.0 -> SWAP + """ + if not isinstance(data, dict): + return data + values = dict(data) + ex_cfg = dict(values.get("exchange_config") or {}) + # Only infer when market_type is not provided by the user + mt_value = ex_cfg.get("market_type") + mt_missing = ( + ("market_type" not in ex_cfg) + or (mt_value is None) + or (str(mt_value).strip() == "") + ) + if mt_missing: + tr_cfg = dict(values.get("trading_config") or {}) + ml_raw = tr_cfg.get("max_leverage") + try: + ml = ( + float(ml_raw) if ml_raw is not None else float(DEFAULT_MAX_LEVERAGE) + ) + except Exception: + ml = float(DEFAULT_MAX_LEVERAGE) + ex_cfg["market_type"] = MarketType.SPOT if ml <= 1.0 else MarketType.SWAP + values["exchange_config"] = ex_cfg + return values + # ========================= # Minimal DTOs for Strategy Agent (LLM-driven composer, no StrategyHint) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 2e56c27d6..9dd527030 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -3,6 +3,7 @@ from ..models import ( Constraints, + MarketType, PortfolioView, PositionSnapshot, TradeHistoryEntry, @@ -30,6 +31,7 @@ def __init__( self, initial_capital: float, trading_mode: TradingMode, + market_type: MarketType, constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -49,6 +51,7 @@ def __init__( buying_power=initial_capital, ) self._trading_mode = trading_mode + self._market_type = market_type def get_view(self) -> PortfolioView: self._view.ts = int(datetime.now(timezone.utc).timestamp() * 1000) @@ -219,11 +222,16 @@ def apply_trades( equity = self._view.cash + net self._view.total_value = equity - # Approximate buying power using max leverage constraint - max_lev = ( - float(self._view.constraints.max_leverage) - if (self._view.constraints and self._view.constraints.max_leverage) - else 1.0 - ) - buying_power = max(0.0, equity * max_lev - gross) - self._view.buying_power = buying_power + # 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)) + else: + # Derivatives: margin-based buying power + max_lev = ( + float(self._view.constraints.max_leverage) + if (self._view.constraints and self._view.constraints.max_leverage) + else 1.0 + ) + buying_power = max(0.0, equity * max_lev - gross) + self._view.buying_power = buying_power diff --git a/python/valuecell/agents/strategy_agent/runtime.py b/python/valuecell/agents/strategy_agent/runtime.py index b066ed194..bdcc8199d 100644 --- a/python/valuecell/agents/strategy_agent/runtime.py +++ b/python/valuecell/agents/strategy_agent/runtime.py @@ -105,6 +105,7 @@ def create_strategy_runtime( portfolio_service = InMemoryPortfolioService( initial_capital=initial_capital, trading_mode=request.exchange_config.trading_mode, + market_type=request.exchange_config.market_type, constraints=constraints, strategy_id=strategy_id, ) @@ -157,6 +158,11 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime This function properly initializes CCXT exchange connections for live trading. It can also be used for paper trading. + In LIVE mode, it fetches the exchange balance and sets the + initial capital to the available (free) cash for the strategy's + quote currencies. Opening positions will therefore draw down cash + and cannot borrow (no financing). + Args: request: User request with strategy configuration @@ -186,5 +192,58 @@ async def create_strategy_runtime_async(request: UserRequest) -> StrategyRuntime # Create execution gateway asynchronously execution_gateway = await create_execution_gateway(request.exchange_config) + # In LIVE mode, fetch exchange balance and set initial capital from free cash + try: + if request.exchange_config.trading_mode == TradingMode.LIVE and hasattr( + execution_gateway, "fetch_balance" + ): + balance = await execution_gateway.fetch_balance() + free_map = {} + # ccxt balance may be shaped as: {'free': {...}, 'used': {...}, 'total': {...}} + try: + free_section = ( + balance.get("free") if isinstance(balance, dict) else None + ) + except Exception: + free_section = None + if isinstance(free_section, dict): + free_map = { + str(k).upper(): float(v or 0.0) for k, v in free_section.items() + } + else: + # fallback: per-ccy dicts: balance['USDT'] = {'free': x, 'used': y, 'total': z} + for k, v in balance.items() if isinstance(balance, dict) else []: + if isinstance(v, dict) and "free" in v: + try: + free_map[str(k).upper()] = float(v.get("free") or 0.0) + except Exception: + continue + # collect quote currencies from configured symbols + quotes: list[str] = [] + for sym in request.trading_config.symbols or []: + s = str(sym).upper() + if "/" in s: + parts = s.split("/") + if len(parts) == 2: + quotes.append(parts[1]) + elif "-" in s: + parts = s.split("-") + if len(parts) == 2: + quotes.append(parts[1]) + quotes = list(dict.fromkeys(quotes)) # unique order-preserving + free_cash = 0.0 + if quotes: + for q in quotes: + free_cash += float(free_map.get(q, 0.0) or 0.0) + else: + # fallback to common stablecoins + 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 + request.trading_config.initial_capital = float(free_cash) + except Exception: + # Do not fail runtime creation if balance fetch or parsing fails + pass + # Use the sync function with the pre-initialized gateway return create_strategy_runtime(request, execution_gateway=execution_gateway)