Skip to content

Commit 8f30aa2

Browse files
committed
feat: Initialize Portfolio with Existing Positions and Enhance Data Fetching Reliability
This pull request introduces two major enhancements to the portfolio initialization process: 1. Initial Position Loading: The system now correctly loads and integrates any existing open positions into the PortfolioView at startup. 2. Live Position Syncing: The system would sync the current position on each cycle for live tradings, to enable human interventions during the strategy execution. 3. Robust Data Fetching: A retry mechanism has been implemented for fetching portfolio data to improve reliability against transient network or API errors.
1 parent d5a192c commit 8f30aa2

File tree

11 files changed

+247
-90
lines changed

11 files changed

+247
-90
lines changed

python/valuecell/agents/common/trading/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ UserRequest
120120
TradingConfig
121121

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

461462
- Fetches real account balance
462-
- Sets `initial_capital` to available cash
463+
- Sets `initial_capital` to account total cash
464+
- Sets `initial_free_cash` to available cash
463465
- Uses `CCXTExecutionGateway` for order submission
464466

465467
**Always test on testnet first** before using real funds

python/valuecell/agents/common/trading/_internal/coordinator.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ..utils import (
3939
extract_market_snapshot_features,
4040
fetch_free_cash_from_gateway,
41+
fetch_positions_from_gateway,
4142
)
4243

4344
# Core interfaces for orchestration and portfolio service.
@@ -132,7 +133,11 @@ async def run_once(self) -> DecisionCycleResult:
132133
portfolio.buying_power = float(free_cash)
133134
# Also update free_cash field in view if it exists
134135
portfolio.free_cash = float(free_cash)
135-
136+
positions = await fetch_positions_from_gateway(self._execution_gateway)
137+
if len(positions) == 0:
138+
logger.warning("No position available, skipping sync")
139+
else:
140+
portfolio.positions = positions
136141
except Exception:
137142
# If syncing fails, continue with existing portfolio view
138143
logger.warning(
@@ -466,16 +471,17 @@ def build_summary(
466471
# Fallback to internal tracking if portfolio service is unavailable
467472
unrealized = float(self._unrealized_pnl or 0.0)
468473
# Fallback equity uses initial capital when view is unavailable
469-
equity = float(self._request.trading_config.initial_capital or 0.0)
474+
equity = float(
475+
(self._request.trading_config.initial_capital + unrealized)
476+
if self._request.trading_config.initial_capital is not None
477+
else 0.0
478+
)
470479

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

474-
initial_capital = self._request.trading_config.initial_capital or 0.0
475483
pnl_pct = (
476-
(self._realized_pnl + self._unrealized_pnl) / initial_capital
477-
if initial_capital
478-
else None
484+
(self._realized_pnl + self._unrealized_pnl) / equity if equity else None
479485
)
480486

481487
# Strategy-level unrealized percent: percent of equity (if equity is available)

python/valuecell/agents/common/trading/_internal/runtime.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from ..models import Constraints, DecisionCycleResult, TradingMode, UserRequest
1919
from ..portfolio.in_memory import InMemoryPortfolioService
20-
from ..utils import fetch_free_cash_from_gateway
20+
from ..utils import fetch_free_cash_from_gateway, fetch_positions_from_gateway
2121
from .coordinator import DefaultDecisionCoordinator
2222

2323

@@ -28,22 +28,26 @@ async def _create_execution_gateway(request: UserRequest) -> BaseExecutionGatewa
2828
# In LIVE mode, fetch exchange balance and set initial capital from free cash
2929
try:
3030
if request.exchange_config.trading_mode == TradingMode.LIVE:
31-
free_cash, _ = await fetch_free_cash_from_gateway(
31+
free_cash, total_cash = await fetch_free_cash_from_gateway(
3232
execution_gateway, request.trading_config.symbols
3333
)
34-
request.trading_config.initial_capital = float(free_cash)
34+
request.trading_config.initial_free_cash = float(free_cash)
35+
request.trading_config.initial_capital = float(total_cash)
36+
request.trading_config.initial_positions = (
37+
await fetch_positions_from_gateway(execution_gateway)
38+
)
3539
except Exception:
36-
# Log the error but continue - user might have set initial_capital manually
40+
# Log the error but continue - user might have set initial portfolio manually
3741
logger.exception(
38-
"Failed to fetch exchange balance for LIVE mode. Will use configured initial_capital instead."
42+
"Failed to fetch exchange balance for LIVE mode. Will use configured initial portfolio instead."
3943
)
4044

4145
# Validate initial capital for LIVE mode
4246
if request.exchange_config.trading_mode == TradingMode.LIVE:
43-
initial_cap = request.trading_config.initial_capital or 0.0
44-
if initial_cap <= 0:
47+
initial_total_cash = request.trading_config.initial_capital or 0.0
48+
if initial_total_cash <= 0:
4549
logger.error(
46-
f"LIVE trading mode has initial_capital={initial_cap}. "
50+
f"LIVE trading mode has initial_total_cash={initial_total_cash}. "
4751
"This usually means balance fetch failed or account has no funds. "
4852
"Strategy will not be able to trade without capital."
4953
)
@@ -100,6 +104,7 @@ async def create_strategy_runtime(
100104
... trading_config=TradingConfig(
101105
... symbols=['BTC-USDT', 'ETH-USDT'],
102106
... initial_capital=10000.0,
107+
... initial_free_cash=10000.0,
103108
... max_leverage=10.0,
104109
... max_positions=5,
105110
... )
@@ -112,36 +117,45 @@ async def create_strategy_runtime(
112117
# Create strategy runtime components
113118
strategy_id = strategy_id_override or generate_uuid("strategy")
114119

115-
# If an initial capital override wasn't provided, and this is a resume
116-
# of an existing strategy, attempt to initialize from the persisted
117-
# portfolio snapshot so the in-memory portfolio starts with the
118-
# previously recorded equity.
119-
initial_capital_override = None
120+
# If this is a resume of an existing strategy,
121+
# attempt to initialize from the persisted portfolio snapshot
122+
# so the in-memory portfolio starts with the previously recorded equity.
123+
free_cash_override = None
124+
total_cash_override = None
120125
if strategy_id_override:
121126
try:
122127
repo = get_strategy_repository()
123128
snap = repo.get_latest_portfolio_snapshot(strategy_id_override)
124129
if snap is not None:
125-
initial_capital_override = float(snap.total_value or snap.cash or 0.0)
130+
free_cash_override = float(snap.cash or 0.0)
131+
total_cash_override = float(
132+
snap.total_value - snap.total_unrealized_pnl
133+
if (
134+
snap.total_value is not None
135+
and snap.total_unrealized_pnl is not None
136+
)
137+
else 0.0
138+
)
126139
logger.info(
127-
"Initialized runtime initial_capital from persisted snapshot for strategy_id=%s",
140+
"Initialized runtime initial capital from persisted snapshot for strategy_id=%s",
128141
strategy_id_override,
129142
)
130143
except Exception:
131144
logger.exception(
132-
"Failed to initialize initial_capital from persisted snapshot for strategy_id=%s",
145+
"Failed to initialize initial capital from persisted snapshot for strategy_id=%s",
133146
strategy_id_override,
134147
)
135148

136-
initial_capital = (
137-
initial_capital_override or request.trading_config.initial_capital or 0.0
138-
)
149+
free_cash = free_cash_override or request.trading_config.initial_free_cash or 0.0
150+
total_cash = total_cash_override or request.trading_config.initial_capital or 0.0
139151
constraints = Constraints(
140152
max_positions=request.trading_config.max_positions,
141153
max_leverage=request.trading_config.max_leverage,
142154
)
143155
portfolio_service = InMemoryPortfolioService(
144-
initial_capital=initial_capital,
156+
free_cash=free_cash,
157+
total_cash=total_cash,
158+
initial_positions=request.trading_config.initial_positions,
145159
trading_mode=request.exchange_config.trading_mode,
146160
market_type=request.exchange_config.market_type,
147161
constraints=constraints,

python/valuecell/agents/common/trading/_internal/stream_controller.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> None:
123123
self.strategy_id,
124124
)
125125

126-
# When running in LIVE mode, update DB config.initial_capital to exchange available balance
126+
# When running in LIVE mode, update DB config.initial_free_cash to exchange available balance
127127
# and record initial capital into strategy metadata for fast access by APIs.
128128
# Only perform this on the first snapshot to avoid overwriting user edits or restarts.
129129
try:
@@ -132,51 +132,54 @@ def persist_initial_state(self, runtime: StrategyRuntime) -> None:
132132
)
133133
is_live = trading_mode == agent_models.TradingMode.LIVE
134134
if is_live and is_first_snapshot:
135-
initial_cash = getattr(initial_portfolio, "free_cash", None)
136-
if initial_cash is None:
137-
initial_cash = getattr(
138-
initial_portfolio, "account_balance", None
139-
)
140-
if initial_cash is None:
141-
initial_cash = getattr(
142-
runtime.request.trading_config, "initial_capital", None
135+
initial_free_cash = (
136+
initial_portfolio.free_cash
137+
or initial_portfolio.account_balance
138+
or runtime.request.trading_config.initial_free_cash
139+
)
140+
initial_total_cash = (
141+
initial_portfolio.total_value
142+
- initial_portfolio.total_unrealized_pnl
143+
if (
144+
initial_portfolio.total_value is not None
145+
and initial_portfolio.total_unrealized_pnl is not None
143146
)
144-
145-
if initial_cash is not None:
147+
else runtime.request.trading_config.initial_free_cash
148+
)
149+
if initial_free_cash is not None:
146150
if strategy_persistence.update_initial_capital(
147-
self.strategy_id, float(initial_cash)
151+
self.strategy_id,
152+
float(initial_free_cash),
153+
float(initial_total_cash),
148154
):
149-
logger.info(
150-
"Updated DB initial_capital to {} for strategy={} (LIVE mode)",
151-
initial_cash,
152-
self.strategy_id,
153-
)
154155
try:
155156
# Also persist metadata for initial capital to avoid repeated first-snapshot queries
156157
strategy_persistence.set_initial_capital_metadata(
157158
strategy_id=self.strategy_id,
158-
initial_capital=float(initial_cash),
159+
initial_free_cash=float(initial_free_cash),
160+
initial_total_cash=float(initial_total_cash),
159161
source="live_snapshot_cash",
160162
ts_ms=timestamp_ms,
161163
)
162164
logger.info(
163-
"Recorded initial_capital_live={} (source=live_snapshot_cash) in metadata for strategy={}",
164-
initial_cash,
165+
"Recorded initial_free_cash={} initial_total_cash={} (source=live_snapshot_cash) in metadata for strategy={}",
166+
initial_free_cash,
167+
initial_total_cash,
165168
self.strategy_id,
166169
)
167170
except Exception:
168171
logger.exception(
169-
"Failed to set initial_capital metadata for {}",
172+
"Failed to set initial capital metadata for {}",
170173
self.strategy_id,
171174
)
172175
else:
173176
logger.warning(
174-
"Failed to update DB initial_capital for strategy={} (LIVE mode)",
177+
"Failed to update DB initial capital for strategy={} (LIVE mode)",
175178
self.strategy_id,
176179
)
177180
except Exception:
178181
logger.exception(
179-
"Error while updating DB initial_capital from live balance for {}",
182+
"Error while updating DB initial capital from live balance for {}",
180183
self.strategy_id,
181184
)
182185
except Exception:

python/valuecell/agents/common/trading/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,18 @@ class TradingConfig(BaseModel):
210210
)
211211
initial_capital: Optional[float] = Field(
212212
default=DEFAULT_INITIAL_CAPITAL,
213-
description="Initial capital for trading in USD",
213+
description="Initial total cash for trading in USD",
214214
gt=0,
215215
)
216+
initial_free_cash: Optional[float] = Field(
217+
default=DEFAULT_INITIAL_CAPITAL,
218+
description="Initial free cash for trading in USD",
219+
gt=0,
220+
)
221+
initial_positions: Dict[str, "PositionSnapshot"] = Field(
222+
default={},
223+
description="Initial positions in portfolio",
224+
)
216225
max_leverage: float = Field(
217226
default=DEFAULT_MAX_LEVERAGE,
218227
description="Maximum leverage",

python/valuecell/agents/common/trading/portfolio/in_memory.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime, timezone
2-
from typing import List, Optional
2+
from typing import Dict, List, Optional
33

44
from valuecell.agents.common.trading.models import (
55
Constraints,
@@ -36,7 +36,9 @@ class InMemoryPortfolioService(BasePortfolioService):
3636

3737
def __init__(
3838
self,
39-
initial_capital: float,
39+
free_cash: float,
40+
total_cash: float,
41+
initial_positions: Dict[str, PositionSnapshot],
4042
trading_mode: TradingMode,
4143
market_type: MarketType,
4244
constraints: Optional[Constraints] = None,
@@ -45,19 +47,34 @@ def __init__(
4547
# Store owning strategy id on the view so downstream components
4648
# always see which strategy this portfolio belongs to.
4749
self._strategy_id = strategy_id
50+
net_exposure = sum(
51+
value.notional
52+
for value in initial_positions.values()
53+
if value.notional is not None
54+
)
55+
gross_exposure = sum(
56+
abs(value.notional)
57+
for value in initial_positions.values()
58+
if value.notional is not None
59+
)
60+
total_unrealized_pnl = sum(
61+
value.unrealized_pnl
62+
for value in initial_positions.values()
63+
if value.unrealized_pnl is not None
64+
)
4865
self._view = PortfolioView(
4966
strategy_id=strategy_id,
5067
ts=int(datetime.now(timezone.utc).timestamp() * 1000),
51-
account_balance=initial_capital,
52-
positions={},
53-
gross_exposure=0.0,
54-
net_exposure=0.0,
68+
account_balance=free_cash,
69+
positions=initial_positions,
70+
gross_exposure=gross_exposure,
71+
net_exposure=net_exposure,
5572
constraints=constraints or None,
56-
total_value=initial_capital,
57-
total_unrealized_pnl=0.0,
73+
total_value=total_cash + total_unrealized_pnl,
74+
total_unrealized_pnl=total_unrealized_pnl,
5875
total_realized_pnl=0.0,
59-
buying_power=initial_capital,
60-
free_cash=initial_capital,
76+
buying_power=free_cash,
77+
free_cash=free_cash,
6178
)
6279
self._trading_mode = trading_mode
6380
self._market_type = market_type

0 commit comments

Comments
 (0)