Skip to content

Commit 1d3257c

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. Robust Data Fetching: A retry mechanism has been implemented for fetching portfolio data to improve reliability against transient network or API errors.
1 parent f13d662 commit 1d3257c

File tree

14 files changed

+240
-101
lines changed

14 files changed

+240
-101
lines changed

frontend/src/app/agent/components/strategy-items/forms/trading-strategy-form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const TradingStrategyForm = withForm({
2525
defaultValues: {
2626
strategy_type: "" as Strategy["strategy_type"],
2727
strategy_name: "",
28-
initial_capital: 1000,
28+
initial_capital_input: 1000,
2929
max_leverage: 2,
3030
decide_interval: 60,
3131
symbols: TRADING_SYMBOLS,
@@ -73,7 +73,7 @@ export const TradingStrategyForm = withForm({
7373

7474
<FieldGroup className="flex flex-row gap-4">
7575
{tradingMode === "virtual" && (
76-
<form.AppField name="initial_capital">
76+
<form.AppField name="initial_capital_input">
7777
{(field) => (
7878
<field.NumberField
7979
className="flex-1"

frontend/src/app/agent/components/strategy-items/modals/create-strategy-modal.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const step2Schema = z.union([
9393
const step3Schema = z.object({
9494
strategy_type: z.enum(["PromptBasedStrategy", "GridStrategy"]),
9595
strategy_name: z.string().min(1, "Strategy name is required"),
96-
initial_capital: z.number().min(1, "Initial capital must be at least 1"),
96+
initial_capital_input: z.number().min(1, "Initial capital must be at least 1"),
9797
max_leverage: z
9898
.number()
9999
.min(1, "Leverage must be at least 1")
@@ -265,7 +265,7 @@ const CreateStrategyModal: FC<CreateStrategyModalProps> = ({ children }) => {
265265
defaultValues: {
266266
strategy_type: "PromptBasedStrategy" as Strategy["strategy_type"],
267267
strategy_name: "",
268-
initial_capital: 1000,
268+
initial_capital_input: 1000,
269269
max_leverage: 2,
270270
decide_interval: 60,
271271
symbols: TRADING_SYMBOLS,
@@ -275,10 +275,16 @@ const CreateStrategyModal: FC<CreateStrategyModalProps> = ({ children }) => {
275275
onSubmit: step3Schema,
276276
},
277277
onSubmit: async ({ value }) => {
278+
const { initial_capital_input, ...restOfTradingConfig } = value;
279+
const trading_config = {
280+
...restOfTradingConfig,
281+
initial_free_cash: initial_capital_input,
282+
initial_total_cash: initial_capital_input,
283+
};
278284
const payload = {
279285
llm_model_config: form1.state.values,
280286
exchange_config: form2.state.values,
281-
trading_config: value,
287+
trading_config: trading_config,
282288
};
283289

284290
await createStrategy(payload);

frontend/src/types/strategy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface Strategy {
1717
// Strategy Performance types
1818
export type StrategyPerformance = {
1919
strategy_id: string;
20-
initial_capital: number;
20+
initial_total_cash: number;
2121
return_rate_pct: number;
2222
llm_provider: string;
2323
llm_model_id: string;
@@ -96,7 +96,8 @@ export interface CreateStrategyRequest {
9696
// Trading Strategy Configuration
9797
trading_config: {
9898
strategy_name: string;
99-
initial_capital: number;
99+
initial_free_cash: number;
100+
initial_total_cash: number;
100101
max_leverage: number;
101102
symbols: string[]; // e.g. ['BTC', 'ETH', ...]
102103
template_id: string;

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_free_cash: float` — Starting free cash in USD
124+
- `initial_total_cash: float` — Starting total cash in USD (same as initial_free_cash without 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_free_cash` to available cash
464+
- Sets `initial_total_cash` to account total 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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -466,16 +466,15 @@ def build_summary(
466466
# Fallback to internal tracking if portfolio service is unavailable
467467
unrealized = float(self._unrealized_pnl or 0.0)
468468
# Fallback equity uses initial capital when view is unavailable
469-
equity = float(self._request.trading_config.initial_capital or 0.0)
469+
equity = float(
470+
self._request.trading_config.initial_total_cash + unrealized or 0.0
471+
)
470472

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

474-
initial_capital = self._request.trading_config.initial_capital or 0.0
475476
pnl_pct = (
476-
(self._realized_pnl + self._unrealized_pnl) / initial_capital
477-
if initial_capital
478-
else None
477+
(self._realized_pnl + self._unrealized_pnl) / equity if equity else None
479478
)
480479

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

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

Lines changed: 30 additions & 21 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_total_cash = 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_total_cash 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
)
@@ -99,7 +103,8 @@ async def create_strategy_runtime(
99103
... ),
100104
... trading_config=TradingConfig(
101105
... symbols=['BTC-USDT', 'ETH-USDT'],
102-
... initial_capital=10000.0,
106+
... initial_free_cash=10000.0,
107+
... initial_total_cash=10000.0,
103108
... max_leverage=10.0,
104109
... max_positions=5,
105110
... )
@@ -112,36 +117,40 @@ 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 or 0.0
133+
)
126134
logger.info(
127-
"Initialized runtime initial_capital from persisted snapshot for strategy_id=%s",
135+
"Initialized runtime initial capital from persisted snapshot for strategy_id=%s",
128136
strategy_id_override,
129137
)
130138
except Exception:
131139
logger.exception(
132-
"Failed to initialize initial_capital from persisted snapshot for strategy_id=%s",
140+
"Failed to initialize initial capital from persisted snapshot for strategy_id=%s",
133141
strategy_id_override,
134142
)
135143

136-
initial_capital = (
137-
initial_capital_override or request.trading_config.initial_capital or 0.0
138-
)
144+
free_cash = free_cash_override or request.trading_config.initial_free_cash or 0.0
145+
total_cash = total_cash_override or request.trading_config.initial_total_cash or 0.0
139146
constraints = Constraints(
140147
max_positions=request.trading_config.max_positions,
141148
max_leverage=request.trading_config.max_leverage,
142149
)
143150
portfolio_service = InMemoryPortfolioService(
144-
initial_capital=initial_capital,
151+
free_cash=free_cash,
152+
total_cash=total_cash,
153+
initial_positions=request.trading_config.initial_positions,
145154
trading_mode=request.exchange_config.trading_mode,
146155
market_type=request.exchange_config.market_type,
147156
constraints=constraints,

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

Lines changed: 23 additions & 24 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,50 @@ 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
143-
)
144-
145-
if initial_cash is not 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+
or runtime.request.trading_config.initial_free_cash
144+
)
145+
if initial_free_cash is not None:
146146
if strategy_persistence.update_initial_capital(
147-
self.strategy_id, float(initial_cash)
147+
self.strategy_id,
148+
float(initial_free_cash),
149+
float(initial_total_cash),
148150
):
149-
logger.info(
150-
"Updated DB initial_capital to {} for strategy={} (LIVE mode)",
151-
initial_cash,
152-
self.strategy_id,
153-
)
154151
try:
155152
# Also persist metadata for initial capital to avoid repeated first-snapshot queries
156153
strategy_persistence.set_initial_capital_metadata(
157154
strategy_id=self.strategy_id,
158-
initial_capital=float(initial_cash),
155+
initial_free_cash=float(initial_free_cash),
156+
initial_total_cash=float(initial_total_cash),
159157
source="live_snapshot_cash",
160158
ts_ms=timestamp_ms,
161159
)
162160
logger.info(
163-
"Recorded initial_capital_live={} (source=live_snapshot_cash) in metadata for strategy={}",
164-
initial_cash,
161+
"Recorded initial_free_cash={} initial_total_cash={} (source=live_snapshot_cash) in metadata for strategy={}",
162+
initial_free_cash,
163+
initial_total_cash,
165164
self.strategy_id,
166165
)
167166
except Exception:
168167
logger.exception(
169-
"Failed to set initial_capital metadata for {}",
168+
"Failed to set initial capital metadata for {}",
170169
self.strategy_id,
171170
)
172171
else:
173172
logger.warning(
174-
"Failed to update DB initial_capital for strategy={} (LIVE mode)",
173+
"Failed to update DB initial capital for strategy={} (LIVE mode)",
175174
self.strategy_id,
176175
)
177176
except Exception:
178177
logger.exception(
179-
"Error while updating DB initial_capital from live balance for {}",
178+
"Error while updating DB initial capital from live balance for {}",
180179
self.strategy_id,
181180
)
182181
except Exception:

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,20 @@ class TradingConfig(BaseModel):
208208
default=None,
209209
description="Reuse existing strategy id to continue execution (resume semantics without extra flags)",
210210
)
211-
initial_capital: Optional[float] = Field(
211+
initial_free_cash: Optional[float] = Field(
212212
default=DEFAULT_INITIAL_CAPITAL,
213-
description="Initial capital for trading in USD",
213+
description="Initial free cash for trading in USD",
214214
gt=0,
215215
)
216+
initial_total_cash: Optional[float] = Field(
217+
default=DEFAULT_INITIAL_CAPITAL,
218+
description="Initial total 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)