diff --git a/frontend/src/app/agent/components/strategy-items/trade-strategy-group.tsx b/frontend/src/app/agent/components/strategy-items/trade-strategy-group.tsx index c01c0b49e..24b8c6fd7 100644 --- a/frontend/src/app/agent/components/strategy-items/trade-strategy-group.tsx +++ b/frontend/src/app/agent/components/strategy-items/trade-strategy-group.tsx @@ -50,7 +50,7 @@ const TradeStrategyCard: FC = ({ onDelete, }) => { const stockColors = useStockColors(); - const changeType = getChangeType(strategy.unrealized_pnl_pct); + const changeType = getChangeType(strategy.total_pnl_pct); return (
= ({ className="font-medium text-sm" style={{ color: stockColors[changeType] }} > - {formatChange(strategy.unrealized_pnl, "", 2)} ( - {formatChange(strategy.unrealized_pnl_pct, "%", 2)}) + {formatChange(strategy.total_pnl, "", 2)} ( + {formatChange(strategy.total_pnl_pct, "%", 2)})

{/* Status Badge */} diff --git a/frontend/src/types/strategy.ts b/frontend/src/types/strategy.ts index c3a2346bf..fb9965188 100644 --- a/frontend/src/types/strategy.ts +++ b/frontend/src/types/strategy.ts @@ -7,8 +7,8 @@ export interface Strategy { status: "running" | "stopped"; stop_reason?: string; trading_mode: "live" | "virtual"; - unrealized_pnl: number; - unrealized_pnl_pct: number; + total_pnl: number; + total_pnl_pct: number; created_at: string; exchange_id: string; model_id: string; diff --git a/python/valuecell/server/api/routers/strategy.py b/python/valuecell/server/api/routers/strategy.py index 1317c4ce6..001c63ea9 100644 --- a/python/valuecell/server/api/routers/strategy.py +++ b/python/valuecell/server/api/routers/strategy.py @@ -160,6 +160,18 @@ def normalize_strategy_type( f"{stop_reason_detail if stop_reason_detail else ''}".strip() ) or "..." + total_pnl, total_pnl_pct = 0.0, 0.0 + if ( + portfolio_summary + := await StrategyService.get_strategy_portfolio_summary( + s.strategy_id + ) + ): + total_pnl = to_optional_float(portfolio_summary.total_pnl) or 0.0 + total_pnl_pct = ( + to_optional_float(portfolio_summary.total_pnl_pct) or 0.0 + ) + item = StrategySummaryData( strategy_id=s.strategy_id, strategy_name=s.name, @@ -167,10 +179,8 @@ def normalize_strategy_type( status=status, stop_reason=stop_reason_display, trading_mode=normalize_trading_mode(meta, cfg), - unrealized_pnl=to_optional_float(meta.get("unrealized_pnl", 0.0)), - unrealized_pnl_pct=to_optional_float( - meta.get("unrealized_pnl_pct", 0.0) - ), + total_pnl=total_pnl, + total_pnl_pct=total_pnl_pct, created_at=s.created_at, exchange_id=(meta.get("exchange_id") or cfg.get("exchange_id")), model_id=( diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index f8ae88df5..5ba53d5b6 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -32,10 +32,8 @@ class StrategySummaryData(BaseModel): trading_mode: Optional[Literal["live", "virtual"]] = Field( None, description="Trading mode: live or virtual" ) - unrealized_pnl: Optional[float] = Field(None, description="Unrealized PnL value") - unrealized_pnl_pct: Optional[float] = Field( - None, description="Unrealized PnL percentage" - ) + total_pnl: Optional[float] = Field(None, description="Total PnL value") + total_pnl_pct: Optional[float] = Field(None, description="Total PnL percentage") created_at: Optional[datetime] = Field(None, description="Creation timestamp") exchange_id: Optional[str] = Field( None, description="Associated exchange identifier" @@ -117,6 +115,9 @@ class StrategyPortfolioSummaryData(BaseModel): None, description="Combined realized and unrealized PnL for the snapshot", ) + total_pnl_pct: Optional[float] = Field( + None, description="Total PnL percentage for the snapshot" + ) gross_exposure: Optional[float] = Field( None, description="Aggregate gross exposure at snapshot" ) diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index bc4b46f90..8ba8f3fd6 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -231,7 +231,7 @@ def get_latest_holdings(self, strategy_id: str) -> List[StrategyHolding]: session.close() def get_portfolio_snapshots( - self, strategy_id: str, limit: Optional[int] = None + self, strategy_id: str, limit: Optional[int] = None, descending: bool = True ) -> List[StrategyPortfolioView]: """Get aggregated portfolio snapshots for a strategy ordered by snapshot_ts desc.""" session = self._get_session() @@ -239,7 +239,11 @@ def get_portfolio_snapshots( query = ( session.query(StrategyPortfolioView) .filter(StrategyPortfolioView.strategy_id == strategy_id) - .order_by(desc(StrategyPortfolioView.snapshot_ts)) + .order_by( + desc(StrategyPortfolioView.snapshot_ts) + if descending + else asc(StrategyPortfolioView.snapshot_ts) + ) ) if limit: query = query.limit(limit) @@ -278,6 +282,13 @@ def get_latest_portfolio_snapshot( items = self.get_portfolio_snapshots(strategy_id, limit=1) return items[0] if items else None + def get_earliest_portfolio_snapshot( + self, strategy_id: str + ) -> Optional[StrategyPortfolioView]: + """Convenience: return the earliest portfolio snapshot or None.""" + items = self.get_portfolio_snapshots(strategy_id, limit=1, descending=False) + return items[0] if items else None + def get_holdings_by_snapshot( self, strategy_id: str, snapshot_ts: datetime ) -> List[StrategyHolding]: diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index c33d22319..1e9aaa295 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -110,14 +110,27 @@ async def get_strategy_portfolio_summary( if not snapshot: return None - ts = snapshot.snapshot_ts or datetime.utcnow() + first_snapshot = repo.get_first_portfolio_snapshot(strategy_id) + if not first_snapshot: + return None + + ts = snapshot.snapshot_ts or datetime.now(datetime.timezone.utc) + total_value = _to_optional_float(snapshot.total_value) + total_pnl = StrategyService._combine_realized_unrealized(snapshot) + total_pnl_pct = ( + total_pnl / (total_value - total_pnl) if total_pnl is not None else 0.0 + ) + if baseline := _to_optional_float(first_snapshot.total_value): + total_pnl = total_value - baseline + total_pnl_pct = total_pnl / baseline return StrategyPortfolioSummaryData( strategy_id=strategy_id, ts=int(ts.timestamp() * 1000), cash=_to_optional_float(snapshot.cash), - total_value=_to_optional_float(snapshot.total_value), - total_pnl=StrategyService._combine_realized_unrealized(snapshot), + total_value=total_value, + total_pnl=total_pnl, + total_pnl_pct=total_pnl_pct, gross_exposure=_to_optional_float( getattr(snapshot, "gross_exposure", None) ),