From f1dbee6b4cc002e546655ad5dda1996beafd670f Mon Sep 17 00:00:00 2001 From: paisley Date: Sun, 30 Nov 2025 19:48:15 +0800 Subject: [PATCH] fix: fix initial_capital in live mode --- .../trading/_internal/stream_controller.py | 59 ++++++++++ .../db/repositories/strategy_repository.py | 22 +++- .../server/services/strategy_persistence.py | 107 ++++++++++++++++++ .../server/services/strategy_service.py | 24 +++- 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/python/valuecell/agents/common/trading/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py index c623e7c97..ddbc35009 100644 --- a/python/valuecell/agents/common/trading/_internal/stream_controller.py +++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py @@ -100,6 +100,8 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> None: Logs and swallows errors to keep controller resilient. """ try: + # Check if this is the first-ever snapshot before persisting + is_first_snapshot = not self.has_initial_state() initial_portfolio = runtime.coordinator.portfolio_service.get_view() try: initial_portfolio.strategy_id = self.strategy_id @@ -120,6 +122,63 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> None: "Persisted initial strategy summary for strategy={}", self.strategy_id, ) + + # When running in LIVE mode, update DB config.initial_capital to exchange available balance + # and record initial capital into strategy metadata for fast access by APIs. + # Only perform this on the first snapshot to avoid overwriting user edits or restarts. + try: + trading_mode = getattr( + runtime.request.exchange_config, "trading_mode", None + ) + is_live = trading_mode == agent_models.TradingMode.LIVE + if is_live and is_first_snapshot: + initial_cash = getattr(initial_portfolio, "free_cash", None) + if initial_cash is None: + initial_cash = getattr( + initial_portfolio, "account_balance", None + ) + if initial_cash is None: + initial_cash = getattr( + runtime.request.trading_config, "initial_capital", None + ) + + if initial_cash is not None: + if strategy_persistence.update_initial_capital( + self.strategy_id, float(initial_cash) + ): + logger.info( + "Updated DB initial_capital to {} for strategy={} (LIVE mode)", + initial_cash, + self.strategy_id, + ) + try: + # Also persist metadata for initial capital to avoid repeated first-snapshot queries + strategy_persistence.set_initial_capital_metadata( + strategy_id=self.strategy_id, + initial_capital=float(initial_cash), + source="live_snapshot_cash", + ts_ms=timestamp_ms, + ) + logger.info( + "Recorded initial_capital_live={} (source=live_snapshot_cash) in metadata for strategy={}", + initial_cash, + self.strategy_id, + ) + except Exception: + logger.exception( + "Failed to set initial_capital metadata for {}", + self.strategy_id, + ) + else: + logger.warning( + "Failed to update DB initial_capital for strategy={} (LIVE mode)", + self.strategy_id, + ) + except Exception: + logger.exception( + "Error while updating DB initial_capital from live balance for {}", + self.strategy_id, + ) except Exception: logger.exception( "Failed to persist initial portfolio/summary for {}", self.strategy_id diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index f212fa766..bc4b46f90 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import List, Optional -from sqlalchemy import desc, func +from sqlalchemy import asc, desc, func from sqlalchemy.orm import Session from ..connection import get_database_manager @@ -251,6 +251,26 @@ def get_portfolio_snapshots( if not self.db_session: session.close() + def get_first_portfolio_snapshot( + self, strategy_id: str + ) -> Optional[StrategyPortfolioView]: + """Convenience: return the earliest portfolio snapshot or None.""" + session = self._get_session() + try: + item = ( + session.query(StrategyPortfolioView) + .filter(StrategyPortfolioView.strategy_id == strategy_id) + .order_by(asc(StrategyPortfolioView.snapshot_ts)) + .limit(1) + .first() + ) + if item: + session.expunge(item) + return item + finally: + if not self.db_session: + session.close() + def get_latest_portfolio_snapshot( self, strategy_id: str ) -> Optional[StrategyPortfolioView]: diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index 70c6b9b0e..49b882b53 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -369,3 +369,110 @@ def persist_instructions( ) continue return inserted + + +def update_initial_capital(strategy_id: str, new_initial_capital: float) -> bool: + """Update `strategies.config.trading_config.initial_capital` for a strategy. + + This writes the provided value into the persisted strategy config so that + downstream performance APIs that read from `strategies.config` reflect the + actual initial capital used at runtime (e.g., LIVE mode free balance). + + Returns True on success, False on failure or if the strategy is missing. + """ + repo = get_strategy_repository() + try: + strategy = repo.get_strategy_by_strategy_id(strategy_id) + if strategy is None: + logger.info( + "Skip updating initial_capital: strategy={} not found (possibly deleted)", + strategy_id, + ) + return False + + # Ensure config and nested trading_config dict exist + cfg = dict(strategy.config or {}) + tr = dict((cfg.get("trading_config") or {}) or {}) + + try: + tr["initial_capital"] = float(new_initial_capital) + except Exception: + # If conversion fails, keep raw value + tr["initial_capital"] = new_initial_capital + + cfg["trading_config"] = tr + + updated = repo.upsert_strategy(strategy_id=strategy_id, config=cfg) + if updated is None: + logger.warning( + "Failed to update initial_capital in config for strategy={}", + strategy_id, + ) + return False + + logger.info( + "Updated strategies.config.trading_config.initial_capital to {} for strategy={}", + tr.get("initial_capital"), + strategy_id, + ) + return True + except Exception: + logger.exception("update_initial_capital failed for {}", strategy_id) + return False + + +def set_initial_capital_metadata( + strategy_id: str, + initial_capital: float, + *, + source: str | None = None, + ts_ms: int | None = None, +) -> bool: + """Record initial capital info into strategy.strategy_metadata. + + Fields stored: + - initial_capital_live: float value used at start in LIVE mode + - initial_capital_source: optional descriptor (e.g., 'live_snapshot_cash') + - initial_capital_ts_ms: optional timestamp in milliseconds + """ + repo = get_strategy_repository() + try: + strategy = repo.get_strategy_by_strategy_id(strategy_id) + if strategy is None: + logger.info( + "Skip setting initial_capital metadata: strategy={} not found", + strategy_id, + ) + return False + + meta = dict(strategy.strategy_metadata or {}) + try: + meta["initial_capital_live"] = float(initial_capital) + except Exception: + meta["initial_capital_live"] = initial_capital + if source is not None: + meta["initial_capital_source"] = source + if ts_ms is not None: + try: + meta["initial_capital_ts_ms"] = int(ts_ms) + except Exception: + meta["initial_capital_ts_ms"] = ts_ms + + updated = repo.upsert_strategy(strategy_id=strategy_id, metadata=meta) + if updated is None: + logger.warning( + "Failed to set initial_capital metadata for strategy={}", + strategy_id, + ) + return False + + logger.info( + "Set initial_capital_live={} (source={}) in metadata for strategy={}", + meta.get("initial_capital_live"), + meta.get("initial_capital_source"), + strategy_id, + ) + return True + except Exception: + logger.exception("set_initial_capital_metadata failed for {}", strategy_id) + return False diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index 2e1883bba..564d477ce 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -197,7 +197,29 @@ async def get_strategy_performance( ) exchange_id = ex.get("exchange_id") or meta.get("exchange_id") strategy_type = StrategyService._normalize_strategy_type(meta, cfg) - initial_capital = _to_optional_float(tr.get("initial_capital")) + # Determine initial capital source: in LIVE mode, prefer metadata (initial_capital_live), + # falling back to first snapshot cash only when metadata is missing; non-LIVE uses config. + trading_mode_raw = str(ex.get("trading_mode") or "").strip().lower() + if trading_mode_raw.startswith("tradingmode."): + trading_mode_raw = trading_mode_raw.split(".", 1)[1] + is_live_mode = trading_mode_raw == "live" + + if is_live_mode: + # Fast path: read from metadata set on first LIVE snapshot + initial_capital = _to_optional_float(meta.get("initial_capital_live")) + if initial_capital is None: + # Rare path: metadata missing (older strategies); query first snapshot once + try: + first_snapshot = repo.get_first_portfolio_snapshot(strategy_id) + initial_capital = ( + _to_optional_float(getattr(first_snapshot, "cash", None)) + if first_snapshot + else None + ) + except Exception: + initial_capital = None + else: + initial_capital = _to_optional_float(tr.get("initial_capital")) max_leverage = _to_optional_float(tr.get("max_leverage")) symbols = tr.get("symbols") if tr.get("symbols") is not None else None # Resolve final prompt strictly via template_id from strategy_prompts (no fallback)