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
6 changes: 4 additions & 2 deletions python/valuecell/agents/common/trading/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ UserRequest
TradingConfig

- `strategy_name?: str` — Display name
- `initial_capital: float` — Starting capital (USD)
- `initial_capital: float` — Starting total cash in USD
- `initial_free_cash: float` — Starting free cash in USD (same as initial_capital if there's no initial positions)
- `max_leverage: float` — Maximum leverage allowed
- `max_positions: int` — Concurrent position limit
- `symbols: List[str]` — Instruments to trade (e.g., `["BTC-USDT", "ETH-USDT"]`)
Expand Down Expand Up @@ -459,7 +460,8 @@ For live trading with real exchanges:
**The runtime automatically:**

- Fetches real account balance
- Sets `initial_capital` to available cash
- Sets `initial_capital` to account total cash
- Sets `initial_free_cash` to available cash
- Uses `CCXTExecutionGateway` for order submission

**Always test on testnet first** before using real funds
Expand Down
31 changes: 25 additions & 6 deletions python/valuecell/agents/common/trading/_internal/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from ..utils import (
extract_market_snapshot_features,
fetch_free_cash_from_gateway,
fetch_positions_from_gateway,
)

# Core interfaces for orchestration and portfolio service.
Expand Down Expand Up @@ -132,7 +133,24 @@ async def run_once(self) -> DecisionCycleResult:
portfolio.buying_power = float(free_cash)
# Also update free_cash field in view if it exists
portfolio.free_cash = float(free_cash)

try:
positions = await fetch_positions_from_gateway(
self._execution_gateway
)
except Exception as e:
logger.warning(
"Failed to sync live positions: %s - skipping sync", e
)
else:
portfolio.positions = positions
total_unrealized_pnl = sum(
value.unrealized_pnl
for value in positions.values()
if value.unrealized_pnl is not None
)
portfolio.total_unrealized_pnl = total_unrealized_pnl
if total_cash > 0:
portfolio.total_value = total_cash + total_unrealized_pnl
except Exception:
# If syncing fails, continue with existing portfolio view
logger.warning(
Expand Down Expand Up @@ -466,16 +484,17 @@ def build_summary(
# 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)
equity = float(
(self._request.trading_config.initial_capital + unrealized)
if self._request.trading_config.initial_capital is not None
else 0.0
)

# Keep internal state in sync (allow negative unrealized PnL)
self._unrealized_pnl = float(unrealized)

initial_capital = self._request.trading_config.initial_capital or 0.0
pnl_pct = (
(self._realized_pnl + self._unrealized_pnl) / initial_capital
if initial_capital
else None
(self._realized_pnl + self._unrealized_pnl) / equity if equity else None
)

# Strategy-level unrealized percent: percent of equity (if equity is available)
Expand Down
54 changes: 34 additions & 20 deletions python/valuecell/agents/common/trading/_internal/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
)
from ..models import Constraints, DecisionCycleResult, TradingMode, UserRequest
from ..portfolio.in_memory import InMemoryPortfolioService
from ..utils import fetch_free_cash_from_gateway
from ..utils import fetch_free_cash_from_gateway, fetch_positions_from_gateway
from .coordinator import DefaultDecisionCoordinator


Expand All @@ -28,22 +28,26 @@ async def _create_execution_gateway(request: UserRequest) -> BaseExecutionGatewa
# In LIVE mode, fetch exchange balance and set initial capital from free cash
try:
if request.exchange_config.trading_mode == TradingMode.LIVE:
free_cash, _ = await fetch_free_cash_from_gateway(
free_cash, total_cash = await fetch_free_cash_from_gateway(
execution_gateway, request.trading_config.symbols
)
request.trading_config.initial_capital = float(free_cash)
request.trading_config.initial_free_cash = float(free_cash)
request.trading_config.initial_capital = float(total_cash)
request.trading_config.initial_positions = (
await fetch_positions_from_gateway(execution_gateway)
)
except Exception:
# Log the error but continue - user might have set initial_capital manually
# Log the error but continue - user might have set initial portfolio manually
logger.exception(
"Failed to fetch exchange balance for LIVE mode. Will use configured initial_capital instead."
"Failed to fetch exchange portfolio for LIVE mode. Will use configured initial portfolio 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:
initial_total_cash = request.trading_config.initial_capital or 0.0
if initial_total_cash <= 0:
logger.error(
f"LIVE trading mode has initial_capital={initial_cap}. "
f"LIVE trading mode has initial_total_cash={initial_total_cash}. "
"This usually means balance fetch failed or account has no funds. "
"Strategy will not be able to trade without capital."
)
Expand Down Expand Up @@ -100,6 +104,7 @@ async def create_strategy_runtime(
... trading_config=TradingConfig(
... symbols=['BTC-USDT', 'ETH-USDT'],
... initial_capital=10000.0,
... initial_free_cash=10000.0,
... max_leverage=10.0,
... max_positions=5,
... )
Expand All @@ -112,36 +117,45 @@ async def create_strategy_runtime(
# Create strategy runtime components
strategy_id = strategy_id_override or generate_uuid("strategy")

# If an initial capital override wasn't provided, and this is a resume
# of an existing strategy, attempt to initialize from the persisted
# portfolio snapshot so the in-memory portfolio starts with the
# previously recorded equity.
initial_capital_override = None
# If this is a resume of an existing strategy,
# attempt to initialize from the persisted portfolio snapshot
# so the in-memory portfolio starts with the previously recorded equity.
free_cash_override = None
total_cash_override = None
if strategy_id_override:
try:
repo = get_strategy_repository()
snap = repo.get_latest_portfolio_snapshot(strategy_id_override)
if snap is not None:
initial_capital_override = float(snap.total_value or snap.cash or 0.0)
free_cash_override = float(snap.cash or 0.0)
total_cash_override = float(
snap.total_value - snap.total_unrealized_pnl
if (
snap.total_value is not None
and snap.total_unrealized_pnl is not None
)
else 0.0
)
logger.info(
"Initialized runtime initial_capital from persisted snapshot for strategy_id=%s",
"Initialized runtime initial capital from persisted snapshot for strategy_id=%s",
strategy_id_override,
)
except Exception:
logger.exception(
"Failed to initialize initial_capital from persisted snapshot for strategy_id=%s",
"Failed to initialize initial capital from persisted snapshot for strategy_id=%s",
strategy_id_override,
)

initial_capital = (
initial_capital_override or request.trading_config.initial_capital or 0.0
)
free_cash = free_cash_override or request.trading_config.initial_free_cash or 0.0
total_cash = total_cash_override or request.trading_config.initial_capital or 0.0
constraints = Constraints(
max_positions=request.trading_config.max_positions,
max_leverage=request.trading_config.max_leverage,
)
portfolio_service = InMemoryPortfolioService(
initial_capital=initial_capital,
free_cash=free_cash,
total_cash=total_cash,
initial_positions=request.trading_config.initial_positions,
trading_mode=request.exchange_config.trading_mode,
market_type=request.exchange_config.market_type,
constraints=constraints,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> None:
self.strategy_id,
)

# When running in LIVE mode, update DB config.initial_capital to exchange available balance
# When running in LIVE mode, update DB config.initial_free_cash 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:
Expand All @@ -132,51 +132,54 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> 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
initial_free_cash = (
initial_portfolio.free_cash
or initial_portfolio.account_balance
or runtime.request.trading_config.initial_free_cash
)
initial_total_cash = (
initial_portfolio.total_value
- initial_portfolio.total_unrealized_pnl
if (
initial_portfolio.total_value is not None
and initial_portfolio.total_unrealized_pnl is not None
)

if initial_cash is not None:
else runtime.request.trading_config.initial_free_cash
)
if initial_free_cash is not None:
if strategy_persistence.update_initial_capital(
self.strategy_id, float(initial_cash)
self.strategy_id,
float(initial_free_cash),
float(initial_total_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),
initial_free_cash=float(initial_free_cash),
initial_total_cash=float(initial_total_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,
"Recorded initial_free_cash={} initial_total_cash={} (source=live_snapshot_cash) in metadata for strategy={}",
initial_free_cash,
initial_total_cash,
self.strategy_id,
)
except Exception:
logger.exception(
"Failed to set initial_capital metadata for {}",
"Failed to set initial capital metadata for {}",
self.strategy_id,
)
else:
logger.warning(
"Failed to update DB initial_capital for strategy={} (LIVE mode)",
"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 {}",
"Error while updating DB initial capital from live balance for {}",
self.strategy_id,
)
except Exception:
Expand Down
11 changes: 10 additions & 1 deletion python/valuecell/agents/common/trading/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,18 @@ class TradingConfig(BaseModel):
)
initial_capital: Optional[float] = Field(
default=DEFAULT_INITIAL_CAPITAL,
description="Initial capital for trading in USD",
description="Initial total cash for trading in USD",
gt=0,
)
initial_free_cash: Optional[float] = Field(
default=DEFAULT_INITIAL_CAPITAL,
description="Initial free cash for trading in USD",
gt=0,
)
initial_positions: Dict[str, "PositionSnapshot"] = Field(
default={},
description="Initial positions in portfolio",
)
max_leverage: float = Field(
default=DEFAULT_MAX_LEVERAGE,
description="Maximum leverage",
Expand Down
37 changes: 27 additions & 10 deletions python/valuecell/agents/common/trading/portfolio/in_memory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from typing import List, Optional
from typing import Dict, List, Optional

from valuecell.agents.common.trading.models import (
Constraints,
Expand Down Expand Up @@ -36,7 +36,9 @@ class InMemoryPortfolioService(BasePortfolioService):

def __init__(
self,
initial_capital: float,
free_cash: float,
total_cash: float,
initial_positions: Dict[str, PositionSnapshot],
trading_mode: TradingMode,
market_type: MarketType,
constraints: Optional[Constraints] = None,
Expand All @@ -45,19 +47,34 @@ def __init__(
# Store owning strategy id on the view so downstream components
# always see which strategy this portfolio belongs to.
self._strategy_id = strategy_id
net_exposure = sum(
value.notional
for value in initial_positions.values()
if value.notional is not None
)
gross_exposure = sum(
abs(value.notional)
for value in initial_positions.values()
if value.notional is not None
)
total_unrealized_pnl = sum(
value.unrealized_pnl
for value in initial_positions.values()
if value.unrealized_pnl is not None
)
self._view = PortfolioView(
strategy_id=strategy_id,
ts=int(datetime.now(timezone.utc).timestamp() * 1000),
account_balance=initial_capital,
positions={},
gross_exposure=0.0,
net_exposure=0.0,
account_balance=free_cash,
positions=initial_positions,
gross_exposure=gross_exposure,
net_exposure=net_exposure,
constraints=constraints or None,
total_value=initial_capital,
total_unrealized_pnl=0.0,
total_value=total_cash + total_unrealized_pnl,
total_unrealized_pnl=total_unrealized_pnl,
total_realized_pnl=0.0,
buying_power=initial_capital,
free_cash=initial_capital,
buying_power=free_cash,
free_cash=free_cash,
)
self._trading_mode = trading_mode
self._market_type = market_type
Expand Down
Loading