Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion python/valuecell/server/db/repositories/strategy_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
107 changes: 107 additions & 0 deletions python/valuecell/server/services/strategy_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 23 additions & 1 deletion python/valuecell/server/services/strategy_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down