From c7308be41d7f8e979c9224d38a76cf3b3f9551cb Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:06:51 +0800 Subject: [PATCH 01/12] feature: enhance decision framework and context summary in system prompt --- .../strategy_agent/decision/system_prompt.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/system_prompt.py b/python/valuecell/agents/strategy_agent/decision/system_prompt.py index efbba017d..f477dc669 100644 --- a/python/valuecell/agents/strategy_agent/decision/system_prompt.py +++ b/python/valuecell/agents/strategy_agent/decision/system_prompt.py @@ -27,10 +27,11 @@ - When estimating quantity, account for estimated fees (e.g., 1%) and potential market movement; reserve a small buffer so executed size does not exceed intended risk after fees/slippage. DECISION FRAMEWORK -1) Manage current positions first (reduce risk, close invalidated trades). -2) Only propose new exposure when constraints and buying power allow. -3) Prefer fewer, higher-quality actions when signals are mixed. -4) When in doubt or edge is weak, choose noop. +- Manage current positions first (reduce risk, close invalidated trades). +- Only propose new exposure when constraints and buying power allow. +- Prefer fewer, higher-quality actions; choose noop when edge is weak. +- Consider existing position entry times when deciding new actions. Use each position's `entry_ts` (entry timestamp) as a signal: avoid opening, flipping, or repeatedly scaling the same instrument shortly after its entry unless the new signal is strong (confidence near 1.0) and constraints allow it. +- Treat recent entries as a deterrent to new opens to reduce churn — do not re-enter or flip a position within a short holding window unless there is a clear, high-confidence reason. This rule supplements Sharpe-based and other risk heuristics to prevent overtrading. MARKET SNAPSHOT The `market_snapshot` provided in the Context is an authoritative, per-cycle reference issued by the data source. It is a mapping of symbol -> object with lightweight numeric fields (when available): @@ -39,6 +40,20 @@ - `open_interest`: open interest value (float) when available from the exchange (contracts or quote-ccy depending on exchange). Use it as a signal for liquidity and positioning interest, but treat units as exchange-specific. - `funding_rate`: latest funding rate (decimal, e.g., 0.0001) when available. Use it to reason about carry costs for leveraged positions. +CONTEXT SUMMARY +The `summary` object contains the key portfolio fields used to decide sizing and risk: +- `active_positions`: count of non-zero positions +- `total_value`: total portfolio value, i.e. account_balance + net exposure; use this for current equity +- `account_balance`: account cash balance after financing. May be negative when the account has net borrowing from leveraged trades (reflects net borrowed amount) +- `free_cash`: immediately available cash for new exposure; use this as the primary sizing budget +- `unrealized_pnl`: aggregate unrealized P&L + +Guidelines: +- Use `free_cash` for sizing new exposure; do not exceed it. +- Treat `account_balance` as the post-financing cash buffer (it may be negative if leverage/borrowing occurred); avoid depleting it further when possible. +- If `unrealized_pnl` is materially negative, prefer de-risking or `noop`. +- Always respect `constraints` when sizing or opening positions. + PERFORMANCE FEEDBACK & ADAPTIVE BEHAVIOR You will receive a Sharpe Ratio at each invocation (in Context.summary.sharpe_ratio): @@ -53,7 +68,7 @@ Behavioral Guidelines Based on Sharpe Ratio: - Sharpe < -0.5: - STOP trading immediately. Choose noop for at least 6 cycles (18+ minutes). - - Reflect deeply: Are you overtrading (>2 trades/hour)? Exiting too early (<30min hold)? Using weak signals (confidence <75)? + - Reflect on strategy: overtrading (>2 trades/hour), premature exits (<30min), or weak signals (confidence <0.75). - Sharpe -0.5 to 0: - Tighten entry criteria: only trade when confidence >80. From 75617c7a8d08648c7b9e68f52d3fdd70e3045936 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:07:05 +0800 Subject: [PATCH 02/12] refactor: update equity calculation to use account_balance instead of cash --- .../agents/strategy_agent/decision/composer.py | 18 ++++++++---------- .../strategy_agent/portfolio/in_memory.py | 1 + 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 15d9bc286..dc95dd0b8 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -192,8 +192,8 @@ def _build_llm_prompt(self, context: ComposeContext) -> str: { "symbol": sym, "qty": float(snap.quantity), - "avg_px": snap.avg_price, "unrealized_pnl": snap.unrealized_pnl, + "entry_ts": snap.entry_ts, } for sym, snap in pv.positions.items() if abs(float(snap.quantity)) > 0 @@ -277,16 +277,16 @@ def _init_buying_power_context( # Compute equity based on market type: if self._request.exchange_config.market_type == MarketType.SPOT: - # Spot: use available cash as equity - equity = float(getattr(context.portfolio, "cash", 0.0) or 0.0) + # Spot: use available account_balance as equity + equity = float(context.portfolio.account_balance or 0.0) else: - # Derivatives: use portfolio equity (cash + net exposure), or total_value if provided + # Derivatives: use portfolio equity (account_balance + net exposure), or total_value if provided if getattr(context.portfolio, "total_value", None) is not None: equity = float(context.portfolio.total_value or 0.0) else: - cash = float(getattr(context.portfolio, "cash", 0.0) or 0.0) - net = float(getattr(context.portfolio, "net_exposure", 0.0) or 0.0) - equity = cash + net + account_balance = float(context.portfolio.account_balance or 0.0) + net = float(context.portfolio.net_exposure or 0.0) + equity = account_balance + net # Market-type leverage policy: SPOT -> 1.0; Derivatives -> constraints if self._request.exchange_config.market_type == MarketType.SPOT: @@ -355,9 +355,7 @@ def _normalize_quantity( if price is not None and price > 0: # cap_factor controls how aggressively we allow position sizing by notional. # Make it configurable via trading_config.cap_factor (strategy parameter). - cap_factor = float( - getattr(self._request.trading_config, "cap_factor", 1.5) or 1.5 - ) + cap_factor = float(self._request.trading_config.cap_factor or 1.5) if constraints.quantity_step and constraints.quantity_step > 0: cap_factor = max(cap_factor, 1.5) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index de8fd4296..702156fe3 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -129,6 +129,7 @@ def apply_trades( or trade.trade_ts or int(datetime.now(timezone.utc).timestamp() * 1000) ) + position.closed_ts = None position.trade_type = TradeType.LONG if new_qty > 0 else TradeType.SHORT # Initialize leverage from trade if provided if trade.leverage is not None: From 487374051fee174dfd3ffcb8d27c455640e90cf7 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:18:12 +0800 Subject: [PATCH 03/12] refactor: remove obsolete test for StrategyAgent stream method --- .../agents/strategy_agent/tests/test_agent.py | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 python/valuecell/agents/strategy_agent/tests/test_agent.py diff --git a/python/valuecell/agents/strategy_agent/tests/test_agent.py b/python/valuecell/agents/strategy_agent/tests/test_agent.py deleted file mode 100644 index ba11f0429..000000000 --- a/python/valuecell/agents/strategy_agent/tests/test_agent.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio -import json -import os -from pprint import pprint - -from valuecell.agents.strategy_agent.agent import StrategyAgent - - -# @pytest.mark.asyncio -async def strategy_agent_basic_stream(): - """Test basic functionality of StrategyAgent stream method.""" - agent = StrategyAgent() - - # Prepare a valid JSON query based on UserRequest structure - query = json.dumps( - { - "llm_model_config": { - "provider": "openrouter", - "model_id": "deepseek/deepseek-v3.1-terminus", - "api_key": os.getenv("OPENROUTER_API_KEY"), - }, - "exchange_config": { - "exchange_id": "binance", - "trading_mode": "virtual", - "api_key": "test-exchange-key", - "secret_key": "test-secret-key", - }, - "trading_config": { - "strategy_name": "Test Strategy", - "initial_capital": 10000.0, - "max_leverage": 5.0, - "max_positions": 5, - "symbols": ["BTC/USDT", "ETH/USDT", "SOL/USDT"], - "decide_interval": 60, - "template_id": "aggressive", - "custom_prompt": "no custom prompt", - }, - } - ) - - async for response in agent.stream(query, "test-conversation", "test-task"): - pprint(response.metadata) - pprint(json.loads(response.content)) - print("\n\n") - - -asyncio.run(strategy_agent_basic_stream()) From 61cf71e67bb9101a93beb7132426b4e3bde3dc25 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:41:25 +0800 Subject: [PATCH 04/12] feat: enhance strategy data structure with compose cycle and instruction models --- .../valuecell/agents/strategy_agent/agent.py | 28 ++++ .../valuecell/agents/strategy_agent/core.py | 18 +- .../strategy_agent/decision/composer.py | 13 +- .../strategy_agent/decision/interfaces.py | 14 +- .../valuecell/agents/strategy_agent/models.py | 6 + .../valuecell/server/api/schemas/strategy.py | 57 +++++-- python/valuecell/server/db/models/__init__.py | 4 + .../db/models/strategy_compose_cycle.py | 61 +++++++ .../server/db/models/strategy_detail.py | 51 +++++- .../server/db/models/strategy_instruction.py | 74 +++++++++ .../db/repositories/strategy_repository.py | 154 +++++++++++++++++- .../server/services/strategy_persistence.py | 125 +++++++++++++- .../server/services/strategy_service.py | 149 ++++++++++++----- 13 files changed, 677 insertions(+), 77 deletions(-) create mode 100644 python/valuecell/server/db/models/strategy_compose_cycle.py create mode 100644 python/valuecell/server/db/models/strategy_instruction.py diff --git a/python/valuecell/agents/strategy_agent/agent.py b/python/valuecell/agents/strategy_agent/agent.py index 33ddc6b13..39856ad62 100644 --- a/python/valuecell/agents/strategy_agent/agent.py +++ b/python/valuecell/agents/strategy_agent/agent.py @@ -88,6 +88,34 @@ def _persist_cycle_results(self, strategy_id: str, result) -> None: Errors are logged but not raised to keep the decision loop resilient. """ try: + # Persist compose cycle and instructions first (NOOP included) + try: + strategy_persistence.persist_compose_cycle( + strategy_id=strategy_id, + compose_id=result.compose_id, + ts_ms=result.timestamp_ms, + rationale=getattr(result, "rationale", None), + ) + except Exception: + logger.warning( + "Failed to persist compose cycle for strategy={} compose_id={}", + strategy_id, + getattr(result, "compose_id", None), + ) + + try: + strategy_persistence.persist_instructions( + strategy_id=strategy_id, + compose_id=result.compose_id, + instructions=list(result.instructions or []), + ) + except Exception: + logger.warning( + "Failed to persist compose instructions for strategy={} compose_id={}", + strategy_id, + getattr(result, "compose_id", None), + ) + for trade in result.trades: item = strategy_persistence.persist_trade_history(strategy_id, trade) if item: diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index 6ed0c5454..9dfc1c39b 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime, timezone -from typing import Callable, List +from typing import Callable, List, Optional from loguru import logger @@ -41,6 +41,7 @@ class DecisionCycleResult: compose_id: str timestamp_ms: int + rationale: Optional[str] strategy_summary: StrategySummary instructions: List[TradeInstruction] trades: List[TradeHistoryEntry] @@ -209,7 +210,9 @@ async def run_once(self) -> DecisionCycleResult: market_snapshot=market_snapshot, ) - instructions = await self._composer.compose(context) + compose_result = await self._composer.compose(context) + instructions = compose_result.instructions + rationale = compose_result.rationale logger.info(f"🔍 Composer returned {len(instructions)} instructions") for idx, inst in enumerate(instructions): logger.info( @@ -254,6 +257,7 @@ async def run_once(self) -> DecisionCycleResult: return DecisionCycleResult( compose_id=compose_id, timestamp_ms=timestamp_ms, + rationale=rationale, strategy_summary=summary, instructions=instructions, trades=trades, @@ -363,6 +367,11 @@ def _create_trades( ), quantity=qty_closed or qty, entry_price=entry_px or None, + avg_exec_price=( + float(tx.avg_exec_price) + if tx.avg_exec_price is not None + else (exit_px or None) + ), exit_price=exit_px, notional_entry=notional_entry, notional_exit=notional_exit, @@ -395,6 +404,11 @@ def _create_trades( ), quantity=qty, entry_price=price or None, + avg_exec_price=( + float(tx.avg_exec_price) + if tx.avg_exec_price is not None + else (price or None) + ), exit_price=None, notional_entry=notional or None, notional_exit=None, diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index dc95dd0b8..c5c7c003e 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -22,7 +22,7 @@ UserRequest, ) from ..utils import extract_price_map -from .interfaces import Composer +from .interfaces import Composer, ComposeResult from .system_prompt import SYSTEM_PROMPT @@ -53,7 +53,7 @@ def __init__( self._default_slippage_bps = default_slippage_bps self._quantity_precision = quantity_precision - async def compose(self, context: ComposeContext) -> List[TradeInstruction]: + async def compose(self, context: ComposeContext) -> ComposeResult: prompt = self._build_llm_prompt(context) try: plan = await self._call_llm(prompt) @@ -63,15 +63,16 @@ async def compose(self, context: ComposeContext) -> List[TradeInstruction]: context.compose_id, plan.rationale, ) - return [] + return ComposeResult(instructions=[], rationale=plan.rationale) except ValidationError as exc: logger.error("LLM output failed validation: {}", exc) - return [] + return ComposeResult(instructions=[], rationale=None) except Exception: # noqa: BLE001 logger.exception("LLM invocation failed") - return [] + return ComposeResult(instructions=[], rationale=None) - return self._normalize_plan(context, plan) + normalized = self._normalize_plan(context, plan) + return ComposeResult(instructions=normalized, rationale=plan.rationale) # ------------------------------------------------------------------ # Prompt + LLM helpers diff --git a/python/valuecell/agents/strategy_agent/decision/interfaces.py b/python/valuecell/agents/strategy_agent/decision/interfaces.py index fc3b833a2..22bd195a3 100644 --- a/python/valuecell/agents/strategy_agent/decision/interfaces.py +++ b/python/valuecell/agents/strategy_agent/decision/interfaces.py @@ -1,10 +1,18 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List +from dataclasses import dataclass +from typing import List, Optional from ..models import ComposeContext, TradeInstruction + +@dataclass +class ComposeResult: + instructions: List[TradeInstruction] + rationale: Optional[str] = None + + # Contracts for decision making (module-local abstract interfaces). # Composer hosts the LLM call and guardrails, producing executable instructions. @@ -17,11 +25,11 @@ class Composer(ABC): """ @abstractmethod - async def compose(self, context: ComposeContext) -> List[TradeInstruction]: + async def compose(self, context: ComposeContext) -> ComposeResult: """Produce normalized trade instructions given the current context. This method is async because LLM providers and agent wrappers are often asynchronous. Implementations should perform any network/IO and return - a validated list of TradeInstruction objects. + a validated ComposeResult containing instructions and optional rationale. """ raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 08468a467..5b4c65ac6 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -732,12 +732,18 @@ class TradeHistoryEntry(BaseModel): quantity: float entry_price: Optional[float] = Field(default=None) exit_price: Optional[float] = Field(default=None) + avg_exec_price: Optional[float] = Field( + default=None, description="Average execution price for fills" + ) notional_entry: Optional[float] = Field(default=None) notional_exit: Optional[float] = Field(default=None) entry_ts: Optional[int] = Field(default=None, description="Entry timestamp ms") exit_ts: Optional[int] = Field(default=None, description="Exit timestamp ms") trade_ts: Optional[int] = Field(default=None, description="Trade timestamp in ms") holding_ms: Optional[int] = Field(default=None, description="Holding time in ms") + unrealized_pnl: Optional[float] = Field( + default=None, description="Unrealized PnL in quote currency" + ) realized_pnl: Optional[float] = Field(default=None) realized_pnl_pct: Optional[float] = Field(default=None) # Total fees charged for this trade in quote currency (if available) diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index 8e31d28b5..f77b14eeb 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -86,24 +86,55 @@ class StrategyHoldingData(BaseModel): StrategyHoldingResponse = SuccessResponse[StrategyHoldingData] -class StrategyDetailItem(BaseModel): - trade_id: str = Field(..., description="Unique trade identifier") +class StrategyActionCard(BaseModel): + instruction_id: str = Field(..., description="Instruction identifier (NOT NULL)") symbol: str = Field(..., description="Instrument symbol") - type: Literal["LONG", "SHORT"] = Field(..., description="Trade type") - side: Literal["BUY", "SELL"] = Field(..., description="Entry side") - leverage: Optional[float] = Field(None, description="Leverage applied") - quantity: float = Field(..., description="Trade quantity") - unrealized_pnl: Optional[float] = Field(None, description="Unrealized PnL value") + action: Optional[ + Literal["open_long", "open_short", "close_long", "close_short", "noop"] + ] = Field(None, description="LLM action (includes noop)") + action_display: Optional[str] = Field( + None, description="Human-friendly action label for display, e.g. 'OPEN LONG'" + ) + side: Optional[Literal["BUY", "SELL"]] = Field( + None, description="Derived execution side" + ) + quantity: Optional[float] = Field(None, description="Order quantity (units)") + leverage: Optional[float] = Field( + None, description="Leverage applied to the instruction (if any)" + ) + avg_exec_price: Optional[float] = Field( + None, description="Average execution price for fills" + ) entry_price: Optional[float] = Field(None, description="Entry price") - exit_price: Optional[float] = Field(None, description="Exit price if closed") - holding_ms: Optional[int] = Field( - None, description="Holding duration in milliseconds" + exit_price: Optional[float] = Field(None, description="Exit price (if closed)") + entry_ts: Optional[int] = Field(None, description="Entry timestamp in ms") + exit_ts: Optional[int] = Field(None, description="Exit timestamp in ms") + notional_entry: Optional[float] = Field( + None, description="Entry notional in quote currency" + ) + notional_exit: Optional[float] = Field( + None, description="Exit notional in quote currency" + ) + fee_cost: Optional[float] = Field( + None, description="Total fees charged in quote currency" + ) + realized_pnl: Optional[float] = Field(None, description="Realized PnL on close") + realized_pnl_pct: Optional[float] = Field( + None, description="Realized PnL percentage on close" + ) + rationale: Optional[str] = Field(None, description="LLM rationale text") + + +class StrategyCycleDetail(BaseModel): + compose_id: str = Field(..., description="Compose cycle identifier") + ts: int = Field(..., description="Compose timestamp in ms") + rationale: Optional[str] = Field(None, description="LLM rationale text") + actions: List[StrategyActionCard] = Field( + default_factory=list, description="Instruction/action cards for this cycle" ) - time: Optional[str] = Field(None, description="Entry time in UTC ISO8601") - note: Optional[str] = Field(None, description="Additional note") -StrategyDetailResponse = SuccessResponse[List[StrategyDetailItem]] +StrategyDetailResponse = SuccessResponse[List[StrategyCycleDetail]] class StrategyHoldingFlatItem(BaseModel): diff --git a/python/valuecell/server/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index 62e987e5d..e8d8688e9 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -12,8 +12,10 @@ # Import base model from .base import Base from .strategy import Strategy +from .strategy_compose_cycle import StrategyComposeCycle from .strategy_detail import StrategyDetail from .strategy_holding import StrategyHolding +from .strategy_instruction import StrategyInstruction from .strategy_portfolio import StrategyPortfolioView from .user_profile import ProfileCategory, UserProfile from .watchlist import Watchlist, WatchlistItem @@ -31,4 +33,6 @@ "StrategyHolding", "StrategyDetail", "StrategyPortfolioView", + "StrategyComposeCycle", + "StrategyInstruction", ] diff --git a/python/valuecell/server/db/models/strategy_compose_cycle.py b/python/valuecell/server/db/models/strategy_compose_cycle.py new file mode 100644 index 000000000..46cd137fe --- /dev/null +++ b/python/valuecell/server/db/models/strategy_compose_cycle.py @@ -0,0 +1,61 @@ +""" +ValueCell Server - Strategy Compose Cycle Model + +Represents a compose cycle aggregation for a strategy. Does NOT store prompts. +""" + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.sql import func + +from .base import Base + + +class StrategyComposeCycle(Base): + __tablename__ = "strategy_compose_cycles" + + id = Column(Integer, primary_key=True, index=True) + + strategy_id = Column( + String(100), + ForeignKey("strategies.strategy_id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Runtime strategy identifier", + ) + + compose_id = Column( + String(200), nullable=False, index=True, comment="Compose cycle identifier" + ) + + # Compose timestamp + compose_time = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + # Optional rationale provided by LLM + rationale = Column(Text, nullable=True, comment="Optional rationale text") + + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint("strategy_id", "compose_id", name="uq_strategy_compose_cycle"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/python/valuecell/server/db/models/strategy_detail.py b/python/valuecell/server/db/models/strategy_detail.py index f359f6d7f..9c0972376 100644 --- a/python/valuecell/server/db/models/strategy_detail.py +++ b/python/valuecell/server/db/models/strategy_detail.py @@ -39,8 +39,15 @@ class StrategyDetail(Base): comment="Runtime strategy identifier", ) + # Linkage identifiers + compose_id = Column( + String(200), nullable=True, index=True, comment="Compose cycle identifier" + ) # Trade identifier (unique per strategy) trade_id = Column(String(200), nullable=False, comment="Unique trade identifier") + instruction_id = Column( + String(200), nullable=True, index=True, comment="Originating instruction id" + ) # Instrument and trade info symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") @@ -54,17 +61,38 @@ class StrategyDetail(Base): # Prices and PnL entry_price = Column(Numeric(20, 8), nullable=True, comment="Entry price") exit_price = Column(Numeric(20, 8), nullable=True, comment="Exit price (if closed)") + avg_exec_price = Column( + Numeric(20, 8), nullable=True, comment="Average execution price for fills" + ) unrealized_pnl = Column( Numeric(20, 8), nullable=True, comment="Unrealized PnL value" ) + realized_pnl = Column( + Numeric(20, 8), nullable=True, comment="Realized PnL value (on close)" + ) + realized_pnl_pct = Column( + Numeric(10, 6), nullable=True, comment="Realized PnL percentage" + ) + notional_entry = Column( + Numeric(20, 8), nullable=True, comment="Entry notional in quote currency" + ) + notional_exit = Column( + Numeric(20, 8), nullable=True, comment="Exit notional in quote currency" + ) + fee_cost = Column( + Numeric(20, 8), nullable=True, comment="Total fees charged in quote currency" + ) # Timing holding_ms = Column( Integer, nullable=True, comment="Holding duration in milliseconds" ) - event_time = Column( + entry_time = Column( DateTime(timezone=True), nullable=True, comment="Entry time (UTC)" ) + exit_time = Column( + DateTime(timezone=True), nullable=True, comment="Exit time (UTC)" + ) # Notes note = Column(Text, nullable=True, comment="Optional note") @@ -101,17 +129,36 @@ def to_dict(self) -> Dict[str, Any]: "side": self.side, "leverage": float(self.leverage) if self.leverage is not None else None, "quantity": float(self.quantity) if self.quantity is not None else None, + "compose_id": self.compose_id, + "instruction_id": self.instruction_id, "entry_price": float(self.entry_price) if self.entry_price is not None else None, "exit_price": float(self.exit_price) if self.exit_price is not None else None, + "avg_exec_price": float(self.avg_exec_price) + if self.avg_exec_price is not None + else None, "unrealized_pnl": float(self.unrealized_pnl) if self.unrealized_pnl is not None else None, + "realized_pnl": float(self.realized_pnl) + if self.realized_pnl is not None + else None, + "realized_pnl_pct": float(self.realized_pnl_pct) + if self.realized_pnl_pct is not None + else None, + "notional_entry": float(self.notional_entry) + if self.notional_entry is not None + else None, + "notional_exit": float(self.notional_exit) + if self.notional_exit is not None + else None, + "fee_cost": float(self.fee_cost) if self.fee_cost is not None else None, "holding_ms": int(self.holding_ms) if self.holding_ms is not None else None, - "time": self.event_time.isoformat() if self.event_time else None, + "entry_time": self.entry_time.isoformat() if self.entry_time else None, + "exit_time": self.exit_time.isoformat() if self.exit_time else None, "note": self.note, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, diff --git a/python/valuecell/server/db/models/strategy_instruction.py b/python/valuecell/server/db/models/strategy_instruction.py new file mode 100644 index 000000000..b51fe973a --- /dev/null +++ b/python/valuecell/server/db/models/strategy_instruction.py @@ -0,0 +1,74 @@ +""" +ValueCell Server - Strategy Instruction Model + +Represents an instruction produced in a compose cycle. Includes NOOP. +""" + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.sql import func + +from .base import Base + + +class StrategyInstruction(Base): + __tablename__ = "strategy_instructions" + + id = Column(Integer, primary_key=True, index=True) + + strategy_id = Column( + String(100), + ForeignKey("strategies.strategy_id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Runtime strategy identifier", + ) + + compose_id = Column( + String(200), nullable=False, index=True, comment="Compose cycle identifier" + ) + + instruction_id = Column( + String(200), nullable=False, index=True, comment="Deterministic instruction id" + ) + + # Minimal instruction payload for aggregation + symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") + action = Column(String(50), nullable=True, comment="LLM action (open/close/noop)") + side = Column(String(20), nullable=True, comment="Derived execution side BUY/SELL") + quantity = Column(Numeric(20, 8), nullable=True, comment="Order quantity") + leverage = Column(Numeric(10, 4), nullable=True, comment="Leverage multiple") + + note = Column(Text, nullable=True, comment="Optional instruction note") + + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint( + "strategy_id", + "instruction_id", + name="uq_strategy_instruction_id", + ), + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 07c5d0143..cc400ab11 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -13,8 +13,10 @@ from ..connection import get_database_manager from ..models.strategy import Strategy +from ..models.strategy_compose_cycle import StrategyComposeCycle from ..models.strategy_detail import StrategyDetail from ..models.strategy_holding import StrategyHolding +from ..models.strategy_instruction import StrategyInstruction from ..models.strategy_portfolio import StrategyPortfolioView from ..models.strategy_prompt import StrategyPrompt @@ -263,13 +265,26 @@ def add_detail_item( holding_ms: Optional[int], event_time: Optional[datetime], note: Optional[str] = None, + *, + compose_id: Optional[str] = None, + instruction_id: Optional[str] = None, + avg_exec_price: Optional[float] = None, + realized_pnl: Optional[float] = None, + realized_pnl_pct: Optional[float] = None, + notional_entry: Optional[float] = None, + notional_exit: Optional[float] = None, + fee_cost: Optional[float] = None, + entry_time: Optional[datetime] = None, + exit_time: Optional[datetime] = None, ) -> Optional[StrategyDetail]: """Insert one strategy detail record.""" session = self._get_session() try: item = StrategyDetail( strategy_id=strategy_id, + compose_id=compose_id, trade_id=trade_id, + instruction_id=instruction_id, symbol=symbol, type=type, side=side, @@ -277,9 +292,16 @@ def add_detail_item( quantity=quantity, entry_price=entry_price, exit_price=exit_price, + avg_exec_price=avg_exec_price, unrealized_pnl=unrealized_pnl, + realized_pnl=realized_pnl, + realized_pnl_pct=realized_pnl_pct, + notional_entry=notional_entry, + notional_exit=notional_exit, + fee_cost=fee_cost, holding_ms=holding_ms, - event_time=event_time, + entry_time=entry_time or event_time, + exit_time=exit_time, note=note, ) session.add(item) @@ -294,6 +316,134 @@ def add_detail_item( if not self.db_session: session.close() + # Compose cycles and instructions + def add_compose_cycle( + self, + strategy_id: str, + compose_id: str, + compose_time: Optional[datetime] = None, + rationale: Optional[str] = None, + ) -> Optional[StrategyComposeCycle]: + session = self._get_session() + try: + item = StrategyComposeCycle( + strategy_id=strategy_id, + compose_id=compose_id, + compose_time=compose_time or datetime.utcnow(), + rationale=rationale, + ) + session.add(item) + session.commit() + session.refresh(item) + session.expunge(item) + return item + except Exception: + session.rollback() + return None + finally: + if not self.db_session: + session.close() + + def add_instruction( + self, + strategy_id: str, + compose_id: str, + instruction_id: str, + symbol: str, + action: Optional[str], + side: Optional[str], + quantity: Optional[float], + leverage: Optional[float] = None, + note: Optional[str] = None, + ) -> Optional[StrategyInstruction]: + session = self._get_session() + try: + item = StrategyInstruction( + strategy_id=strategy_id, + compose_id=compose_id, + instruction_id=instruction_id, + symbol=symbol, + action=action, + side=side, + quantity=quantity, + leverage=leverage, + note=note, + ) + session.add(item) + session.commit() + session.refresh(item) + session.expunge(item) + return item + except Exception: + session.rollback() + return None + finally: + if not self.db_session: + session.close() + + def get_cycles( + self, strategy_id: str, limit: Optional[int] = None + ) -> List[StrategyComposeCycle]: + session = self._get_session() + try: + query = ( + session.query(StrategyComposeCycle) + .filter(StrategyComposeCycle.strategy_id == strategy_id) + .order_by(desc(StrategyComposeCycle.compose_time)) + ) + if limit: + query = query.limit(limit) + items = query.all() + for item in items: + session.expunge(item) + return items + finally: + if not self.db_session: + session.close() + + def get_instructions_by_compose( + self, strategy_id: str, compose_id: str + ) -> List[StrategyInstruction]: + session = self._get_session() + try: + items = ( + session.query(StrategyInstruction) + .filter( + StrategyInstruction.strategy_id == strategy_id, + StrategyInstruction.compose_id == compose_id, + ) + .order_by(StrategyInstruction.symbol.asc()) + .all() + ) + for item in items: + session.expunge(item) + return items + finally: + if not self.db_session: + session.close() + + def get_details_by_instruction_ids( + self, strategy_id: str, instruction_ids: List[str] + ) -> List[StrategyDetail]: + if not instruction_ids: + return [] + session = self._get_session() + try: + items = ( + session.query(StrategyDetail) + .filter( + StrategyDetail.strategy_id == strategy_id, + StrategyDetail.instruction_id.in_(instruction_ids), + ) + .all() + ) + for item in items: + session.expunge(item) + return items + finally: + if not self.db_session: + session.close() + def get_details( self, strategy_id: str, limit: Optional[int] = None ) -> List[StrategyDetail]: @@ -304,7 +454,7 @@ def get_details( StrategyDetail.strategy_id == strategy_id ) query = query.order_by( - desc(StrategyDetail.event_time), desc(StrategyDetail.created_at) + desc(StrategyDetail.entry_time), desc(StrategyDetail.created_at) ) if limit: query = query.limit(limit) diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index 0a6e59011..00e702732 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -19,18 +19,35 @@ def persist_trade_history( repo = get_strategy_repository() try: # map direction and type - ttype = trade.type.value if getattr(trade, "type", None) is not None else None - side = trade.side.value if getattr(trade, "side", None) is not None else None + ttype = trade.type.value if trade.type is not None else None + side = trade.side.value if trade.side is not None else None - event_time = ( - datetime.fromtimestamp(trade.trade_ts / 1000.0, tz=timezone.utc) - if trade.trade_ts + # Prefer explicit entry/exit timestamps if provided; fall back to trade_ts + if trade.entry_ts is not None: + entry_time = datetime.fromtimestamp( + trade.entry_ts / 1000.0, tz=timezone.utc + ) + elif trade.trade_ts is not None: + entry_time = datetime.fromtimestamp( + trade.trade_ts / 1000.0, tz=timezone.utc + ) + else: + entry_time = None + + exit_time = ( + datetime.fromtimestamp(trade.exit_ts / 1000.0, tz=timezone.utc) + if trade.exit_ts is not None else None ) + # Event time for repository: prefer entry_time, then exit_time, otherwise now + event_time = entry_time or exit_time or datetime.now(timezone.utc) + item = repo.add_detail_item( strategy_id=strategy_id, + compose_id=trade.compose_id, trade_id=trade.trade_id, + instruction_id=trade.instruction_id, symbol=trade.instrument.symbol, type=ttype or ("LONG" if (trade.quantity or 0) > 0 else "SHORT"), side=side or ("BUY" if (trade.quantity or 0) > 0 else "SELL"), @@ -42,21 +59,45 @@ def persist_trade_history( exit_price=( float(trade.exit_price) if trade.exit_price is not None else None ), + avg_exec_price=( + float(trade.avg_exec_price) + if trade.avg_exec_price is not None + else None + ), unrealized_pnl=( float(trade.unrealized_pnl) - if getattr(trade, "unrealized_pnl", None) is not None + if trade.unrealized_pnl is not None else ( float(trade.realized_pnl) - if getattr(trade, "realized_pnl", None) is not None + if trade.realized_pnl is not None else None ) ), + realized_pnl=( + float(trade.realized_pnl) if trade.realized_pnl is not None else None + ), + realized_pnl_pct=( + float(trade.realized_pnl_pct) + if trade.realized_pnl_pct is not None + else None + ), + notional_entry=( + float(trade.notional_entry) + if trade.notional_entry is not None + else None + ), + notional_exit=( + float(trade.notional_exit) if trade.notional_exit is not None else None + ), + fee_cost=(float(trade.fee_cost) if trade.fee_cost is not None else None), # Note: store unrealized_pnl separately if available on the DTO # (some callers may populate unrealized vs realized differently) # Keep backward-compatibility: prefer trade.unrealized_pnl when present # If both present, the DTO should include both; StrategyDetail currently only stores unrealized_pnl. holding_ms=int(trade.holding_ms) if trade.holding_ms is not None else None, event_time=event_time, + entry_time=entry_time, + exit_time=exit_time, note=trade.note, ) @@ -73,7 +114,7 @@ def persist_trade_history( logger.exception( "persist_trade_history failed for {} {}", strategy_id, - getattr(trade, "trade_id", None), + trade.trade_id, ) return None @@ -202,3 +243,71 @@ def mark_strategy_stopped(strategy_id: str) -> bool: except Exception: logger.exception("mark_strategy_stopped failed for {}", strategy_id) return False + + +def persist_compose_cycle( + strategy_id: str, + compose_id: str, + ts_ms: Optional[int], + rationale: Optional[str], +) -> bool: + """Persist a compose cycle metadata record. + + Does not store prompts, only timing and optional rationale. + """ + repo = get_strategy_repository() + try: + compose_time = ( + datetime.fromtimestamp(ts_ms / 1000.0, tz=timezone.utc) + if ts_ms is not None + else None + ) + item = repo.add_compose_cycle( + strategy_id=strategy_id, + compose_id=compose_id, + compose_time=compose_time, + rationale=rationale, + ) + return item is not None + except Exception: + logger.exception( + "persist_compose_cycle failed for {} {}", strategy_id, compose_id + ) + return False + + +def persist_instructions( + strategy_id: str, + compose_id: str, + instructions: list[agent_models.TradeInstruction], +) -> int: + """Persist a list of TradeInstruction (including NOOP) for a compose cycle. + + Returns number of successfully inserted rows. + """ + repo = get_strategy_repository() + inserted = 0 + for ins in instructions: + try: + ok = repo.add_instruction( + strategy_id=strategy_id, + compose_id=compose_id, + instruction_id=ins.instruction_id, + symbol=ins.instrument.symbol, + action=(ins.action.value if ins.action is not None else None), + side=(ins.side.value if ins.side is not None else None), + quantity=float(ins.quantity) if ins.quantity is not None else None, + leverage=float(ins.leverage) if ins.leverage is not None else None, + note=(ins.meta.get("rationale") if ins.meta else None), + ) + if ok: + inserted += 1 + except Exception: + logger.warning( + "Failed to persist instruction {} for {} {}", + ins.instruction_id, + strategy_id, + compose_id, + ) + continue + return inserted diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index da9f79ce5..7505fae03 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -3,7 +3,8 @@ from valuecell.server.api.schemas.strategy import ( PositionHoldingItem, - StrategyDetailItem, + StrategyActionCard, + StrategyCycleDetail, StrategyHoldingData, ) from valuecell.server.db.repositories import get_strategy_repository @@ -37,16 +38,20 @@ async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData symbol=h.symbol, exchange_id=None, quantity=qty if t == "LONG" else -qty if t == "SHORT" else qty, - avg_price=float(h.entry_price) - if h.entry_price is not None - else None, + avg_price=( + float(h.entry_price) if h.entry_price is not None else None + ), mark_price=None, - unrealized_pnl=float(h.unrealized_pnl) - if h.unrealized_pnl is not None - else None, - unrealized_pnl_pct=float(h.unrealized_pnl_pct) - if h.unrealized_pnl_pct is not None - else None, + unrealized_pnl=( + float(h.unrealized_pnl) + if h.unrealized_pnl is not None + else None + ), + unrealized_pnl_pct=( + float(h.unrealized_pnl_pct) + if h.unrealized_pnl_pct is not None + else None + ), notional=None, leverage=float(h.leverage) if h.leverage is not None else None, entry_ts=None, @@ -69,40 +74,102 @@ async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData @staticmethod async def get_strategy_detail( strategy_id: str, - ) -> Optional[List[StrategyDetailItem]]: + ) -> Optional[List[StrategyCycleDetail]]: repo = get_strategy_repository() - details = repo.get_details(strategy_id) - if not details: + cycles = repo.get_cycles(strategy_id) + if not cycles: return None - items: List[StrategyDetailItem] = [] - for d in details: - try: - items.append( - StrategyDetailItem( - trade_id=d.trade_id, - symbol=d.symbol, - type=d.type, - side=d.side, - leverage=float(d.leverage) if d.leverage is not None else None, - quantity=float(d.quantity) if d.quantity is not None else 0.0, - unrealized_pnl=float(d.unrealized_pnl) - if d.unrealized_pnl is not None - else None, - entry_price=float(d.entry_price) - if d.entry_price is not None - else None, - exit_price=float(d.exit_price) - if d.exit_price is not None - else None, - holding_ms=int(d.holding_ms) - if d.holding_ms is not None - else None, - time=d.event_time.isoformat() if d.event_time else None, - note=d.note, + cycle_details: List[StrategyCycleDetail] = [] + for c in cycles: + # fetch instructions for this cycle + instrs = repo.get_instructions_by_compose(strategy_id, c.compose_id) + instr_ids = [i.instruction_id for i in instrs if i.instruction_id] + details = repo.get_details_by_instruction_ids(strategy_id, instr_ids) + detail_map = {d.instruction_id: d for d in details if d.instruction_id} + + cards: List[StrategyActionCard] = [] + for i in instrs: + d = detail_map.get(i.instruction_id) + # Construct card combining instruction (always present) with optional execution detail + entry_ts = None + exit_ts = None + if d and d.entry_price: + entry_ts = int(d.entry_time.timestamp() * 1000) + if d and d.exit_time: + exit_ts = int(d.exit_time.timestamp() * 1000) + + # Human-friendly display label for the action + action_display = i.action + if action_display is not None: + # canonicalize values like 'open_long' -> 'OPEN LONG' + action_display = str(i.action).replace("_", " ").upper() + + cards.append( + StrategyActionCard( + instruction_id=i.instruction_id, + symbol=i.symbol, + action=i.action, + action_display=action_display, + side=i.side, + quantity=float(i.quantity) if i.quantity is not None else None, + leverage=( + float(i.leverage) if i.leverage is not None else None + ), + avg_exec_price=( + float(d.avg_exec_price) + if (d and d.avg_exec_price is not None) + else None + ), + entry_price=( + float(d.entry_price) + if (d and d.entry_price is not None) + else None + ), + exit_price=( + float(d.exit_price) + if (d and d.exit_price is not None) + else None + ), + entry_ts=entry_ts, + exit_ts=exit_ts, + notional_entry=( + float(d.notional_entry) + if (d and d.notional_entry is not None) + else None + ), + notional_exit=( + float(d.notional_exit) + if (d and d.notional_exit is not None) + else None + ), + fee_cost=( + float(d.fee_cost) + if (d and d.fee_cost is not None) + else None + ), + realized_pnl=( + float(d.realized_pnl) + if (d and d.realized_pnl is not None) + else None + ), + realized_pnl_pct=( + float(d.realized_pnl_pct) + if (d and d.realized_pnl_pct is not None) + else None + ), + rationale=i.note, ) ) - except Exception: - continue - return items + ts_ms = int((c.compose_time or datetime.utcnow()).timestamp() * 1000) + cycle_details.append( + StrategyCycleDetail( + compose_id=c.compose_id, + ts=ts_ms, + rationale=c.rationale, + actions=cards, + ) + ) + + return cycle_details From d930313282df5a5263eedccfa39b51d1ab732fe1 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:18:09 +0800 Subject: [PATCH 05/12] feat: enhance strategy portfolio data structure with realized PnL and exposure metrics --- .../valuecell/agents/strategy_agent/models.py | 3 + .../strategy_agent/portfolio/in_memory.py | 52 ++++++++++++ .../valuecell/server/api/routers/strategy.py | 33 ++++++++ .../valuecell/server/api/schemas/strategy.py | 31 ++++++++ .../server/db/models/strategy_portfolio.py | 23 +++++- .../db/repositories/strategy_repository.py | 6 ++ .../server/services/strategy_persistence.py | 14 ++++ .../server/services/strategy_service.py | 79 ++++++++++++++++--- 8 files changed, 230 insertions(+), 11 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py index 5b4c65ac6..148e5d1db 100644 --- a/python/valuecell/agents/strategy_agent/models.py +++ b/python/valuecell/agents/strategy_agent/models.py @@ -447,6 +447,9 @@ class PortfolioView(BaseModel): total_unrealized_pnl: Optional[float] = Field( default=None, description="Sum of unrealized PnL across positions" ) + total_realized_pnl: Optional[float] = Field( + default=None, description="Sum of realized PnL from closed position deltas" + ) buying_power: Optional[float] = Field( default=None, description="Buying power: max(0, equity * max_leverage - gross_exposure)", diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 702156fe3..758543c57 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -25,6 +25,7 @@ class InMemoryPortfolioService(PortfolioService): - net_exposure = sum(qty * mark_price) - equity (total_value) = cash + net_exposure [correct for both long and short] - total_unrealized_pnl = sum((mark_price - avg_price) * qty) + - total_realized_pnl accumulates realized gains/losses as positions close - buying_power: max(0, equity * max_leverage - gross_exposure) where max_leverage comes from portfolio.constraints (default 1.0) - free_cash: per-position effective margin approximation without explicit margin_used @@ -53,6 +54,7 @@ def __init__( constraints=constraints or None, total_value=initial_capital, total_unrealized_pnl=0.0, + total_realized_pnl=0.0, buying_power=initial_capital, free_cash=initial_capital, ) @@ -83,6 +85,7 @@ def apply_trades( """ # Extract price map from new market snapshot structure price_map = extract_price_map(market_snapshot) + total_realized = float(self._view.total_realized_pnl or 0.0) for trade in trades: symbol = trade.instrument.symbol @@ -103,6 +106,13 @@ def apply_trades( current_qty = float(position.quantity) avg_price = float(position.avg_price or 0.0) + realized_delta = self._compute_realized_delta( + trade=trade, + current_qty=current_qty, + quantity_delta=quantity_delta, + avg_price=avg_price, + fill_price=price, + ) new_qty = current_qty + quantity_delta # Update mark price @@ -180,6 +190,8 @@ def apply_trades( self._view.account_balance += notional self._view.account_balance -= fee + total_realized += realized_delta + # Recompute per-position derived fields (if position still exists) pos = self._view.positions.get(symbol) if pos is not None: @@ -236,6 +248,7 @@ def apply_trades( self._view.gross_exposure = gross self._view.net_exposure = net self._view.total_unrealized_pnl = unreal + self._view.total_realized_pnl = total_realized # Equity is cash plus net exposure (correct for both long and short) equity = self._view.account_balance + net self._view.total_value = equity @@ -274,3 +287,42 @@ def apply_trades( lev_i = max(1.0, lev_i) required_margin += notional_i / lev_i self._view.free_cash = max(0.0, equity - required_margin) + + def _compute_realized_delta( + self, + *, + trade: TradeHistoryEntry, + current_qty: float, + quantity_delta: float, + avg_price: float, + fill_price: float, + ) -> float: + """Estimate realized PnL contribution for a trade. + + Prefer explicit realized_pnl on the trade when available; otherwise + approximate based on position deltas. Fees are allocated proportionally + to the quantity that actually closes existing exposure. + """ + + if trade.realized_pnl is not None: + try: + return float(trade.realized_pnl) + except Exception: + return 0.0 + + realized = 0.0 + reduction = 0.0 + + if current_qty > 0 and quantity_delta < 0: + reduction = min(abs(quantity_delta), abs(current_qty)) + realized = (fill_price - avg_price) * reduction + elif current_qty < 0 and quantity_delta > 0: + reduction = min(abs(quantity_delta), abs(current_qty)) + realized = (avg_price - fill_price) * reduction + + if reduction > 0 and trade.fee_cost: + executed = abs(quantity_delta) + allocation = reduction / executed if executed > 0 else 1.0 + realized -= float(trade.fee_cost or 0.0) * allocation + + return realized diff --git a/python/valuecell/server/api/routers/strategy.py b/python/valuecell/server/api/routers/strategy.py index 5d4d8db6a..78b07d2bd 100644 --- a/python/valuecell/server/api/routers/strategy.py +++ b/python/valuecell/server/api/routers/strategy.py @@ -17,6 +17,7 @@ StrategyHoldingFlatResponse, StrategyListData, StrategyListResponse, + StrategyPortfolioSummaryResponse, StrategyStatusSuccessResponse, StrategyStatusUpdateResponse, StrategySummaryData, @@ -190,6 +191,38 @@ async def get_strategy_holding( status_code=500, detail=f"Failed to retrieve holdings: {str(e)}" ) + @router.get( + "/portfolio_summary", + response_model=StrategyPortfolioSummaryResponse, + summary="Get latest portfolio summary for a strategy", + description=( + "Return aggregated portfolio metrics (cash, total value, unrealized PnL)" + " for the most recent snapshot." + ), + ) + async def get_strategy_portfolio_summary( + id: str = Query(..., description="Strategy ID"), + ) -> StrategyPortfolioSummaryResponse: + try: + data = await StrategyService.get_strategy_portfolio_summary(id) + if not data: + return SuccessResponse.create( + data=None, + msg="No portfolio summary found for strategy", + ) + + return SuccessResponse.create( + data=data, + msg="Successfully retrieved strategy portfolio summary", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve portfolio summary: {str(e)}", + ) + @router.get( "/detail", response_model=StrategyDetailResponse, diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index f77b14eeb..03eea09e2 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -78,6 +78,15 @@ class StrategyHoldingData(BaseModel): total_unrealized_pnl: Optional[float] = Field( None, description="Sum of unrealized PnL across positions" ) + total_realized_pnl: Optional[float] = Field( + None, description="Sum of realized PnL from closed positions" + ) + gross_exposure: Optional[float] = Field( + None, description="Aggregate gross exposure at snapshot" + ) + net_exposure: Optional[float] = Field( + None, description="Aggregate net exposure at snapshot" + ) available_cash: Optional[float] = Field( None, description="Cash available for new positions" ) @@ -86,6 +95,28 @@ class StrategyHoldingData(BaseModel): StrategyHoldingResponse = SuccessResponse[StrategyHoldingData] +class StrategyPortfolioSummaryData(BaseModel): + strategy_id: str = Field(..., description="Strategy identifier") + ts: int = Field(..., description="Snapshot timestamp in ms") + cash: Optional[float] = Field(None, description="Cash balance from snapshot") + total_value: Optional[float] = Field( + None, description="Total portfolio value (cash + positions)" + ) + total_pnl: Optional[float] = Field( + None, + description="Combined realized and unrealized PnL for the snapshot", + ) + gross_exposure: Optional[float] = Field( + None, description="Aggregate gross exposure at snapshot" + ) + net_exposure: Optional[float] = Field( + None, description="Aggregate net exposure at snapshot" + ) + + +StrategyPortfolioSummaryResponse = SuccessResponse[StrategyPortfolioSummaryData] + + class StrategyActionCard(BaseModel): instruction_id: str = Field(..., description="Instruction identifier (NOT NULL)") symbol: str = Field(..., description="Instrument symbol") diff --git a/python/valuecell/server/db/models/strategy_portfolio.py b/python/valuecell/server/db/models/strategy_portfolio.py index 79ac735ca..5baa472b4 100644 --- a/python/valuecell/server/db/models/strategy_portfolio.py +++ b/python/valuecell/server/db/models/strategy_portfolio.py @@ -44,6 +44,15 @@ class StrategyPortfolioView(Base): total_unrealized_pnl = Column( Numeric(20, 8), nullable=True, comment="Total unrealized PnL" ) + total_realized_pnl = Column( + Numeric(20, 8), nullable=True, comment="Total realized PnL" + ) + gross_exposure = Column( + Numeric(20, 8), nullable=True, comment="Aggregate gross exposure at snapshot" + ) + net_exposure = Column( + Numeric(20, 8), nullable=True, comment="Aggregate net exposure at snapshot" + ) snapshot_ts = Column( DateTime(timezone=True), @@ -73,13 +82,16 @@ class StrategyPortfolioView(Base): def __repr__(self) -> str: return ( "" + "total_unrealized_pnl={}, total_realized_pnl={}, gross_exposure={}, net_exposure={}, snapshot_ts={})>" ).format( self.id, self.strategy_id, self.cash, self.total_value, self.total_unrealized_pnl, + self.total_realized_pnl, + self.gross_exposure, + self.net_exposure, self.snapshot_ts, ) @@ -94,6 +106,15 @@ def to_dict(self) -> Dict[str, Any]: "total_unrealized_pnl": float(self.total_unrealized_pnl) if self.total_unrealized_pnl is not None else None, + "total_realized_pnl": float(self.total_realized_pnl) + if self.total_realized_pnl is not None + else None, + "gross_exposure": float(self.gross_exposure) + if self.gross_exposure is not None + else None, + "net_exposure": float(self.net_exposure) + if self.net_exposure is not None + else None, "snapshot_ts": self.snapshot_ts.isoformat() if self.snapshot_ts else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index cc400ab11..b6e33d810 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -146,6 +146,9 @@ def add_portfolio_snapshot( cash: float, total_value: float, total_unrealized_pnl: Optional[float], + total_realized_pnl: Optional[float] = None, + gross_exposure: Optional[float] = None, + net_exposure: Optional[float] = None, snapshot_ts: Optional[datetime] = None, ) -> Optional[StrategyPortfolioView]: """Insert one aggregated portfolio snapshot.""" @@ -156,6 +159,9 @@ def add_portfolio_snapshot( cash=cash, total_value=total_value, total_unrealized_pnl=total_unrealized_pnl, + total_realized_pnl=total_realized_pnl, + gross_exposure=gross_exposure, + net_exposure=net_exposure, snapshot_ts=snapshot_ts or datetime.utcnow(), ) session.add(item) diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index 00e702732..f19d1836b 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -144,12 +144,26 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: if view.total_unrealized_pnl is not None else None ) + total_realized = ( + float(view.total_realized_pnl) + if view.total_realized_pnl is not None + else None + ) + gross_exposure = ( + float(view.gross_exposure) if view.gross_exposure is not None else None + ) + net_exposure = ( + float(view.net_exposure) if view.net_exposure is not None else None + ) portfolio_item = repo.add_portfolio_snapshot( strategy_id=strategy_id, cash=cash, total_value=total_value, total_unrealized_pnl=total_unrealized, + total_realized_pnl=total_realized, + gross_exposure=gross_exposure, + net_exposure=net_exposure, snapshot_ts=snapshot_ts, ) if portfolio_item is None: diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index 7505fae03..77c4c4f49 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -6,10 +6,20 @@ StrategyActionCard, StrategyCycleDetail, StrategyHoldingData, + StrategyPortfolioSummaryData, ) from valuecell.server.db.repositories import get_strategy_repository +def _to_optional_float(value: Optional[float]) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except Exception: + return None + + class StrategyService: @staticmethod async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData]: @@ -18,12 +28,9 @@ async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData if not holdings: return None - snapshot_ts = holdings[0].snapshot_ts - ts_ms = ( - int(snapshot_ts.timestamp() * 1000) - if snapshot_ts - else int(datetime.utcnow().timestamp() * 1000) - ) + snapshot = repo.get_latest_portfolio_snapshot(strategy_id) + snapshot_ts = snapshot.snapshot_ts if snapshot else None + holding_ts = holdings[0].snapshot_ts if holdings else None positions: List[PositionHoldingItem] = [] for h in holdings: @@ -61,16 +68,68 @@ async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData except Exception: continue + ts_source = snapshot_ts or holding_ts + ts_ms = ( + int(ts_source.timestamp() * 1000) + if ts_source + else int(datetime.utcnow().timestamp() * 1000) + ) + + cash_value = _to_optional_float(snapshot.cash) if snapshot else None + cash = cash_value if cash_value is not None else 0.0 + gross_exposure = ( + _to_optional_float(snapshot.gross_exposure) if snapshot else None + ) + net_exposure = ( + _to_optional_float(snapshot.net_exposure) if snapshot else None + ) + return StrategyHoldingData( strategy_id=strategy_id, ts=ts_ms, - cash=0.0, + cash=cash, positions=positions, - total_value=None, - total_unrealized_pnl=None, - available_cash=None, + total_value=_to_optional_float(snapshot.total_value) if snapshot else None, + total_unrealized_pnl=( + _to_optional_float(snapshot.total_unrealized_pnl) if snapshot else None + ), + total_realized_pnl=( + _to_optional_float(snapshot.total_realized_pnl) if snapshot else None + ), + gross_exposure=gross_exposure, + net_exposure=net_exposure, + available_cash=cash, ) + @staticmethod + async def get_strategy_portfolio_summary( + strategy_id: str, + ) -> Optional[StrategyPortfolioSummaryData]: + repo = get_strategy_repository() + snapshot = repo.get_latest_portfolio_snapshot(strategy_id) + if not snapshot: + return None + + ts = snapshot.snapshot_ts or datetime.utcnow() + + 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), + gross_exposure=_to_optional_float(getattr(snapshot, "gross_exposure", None)), + net_exposure=_to_optional_float(getattr(snapshot, "net_exposure", None)), + ) + + @staticmethod + def _combine_realized_unrealized(snapshot) -> Optional[float]: + realized = _to_optional_float(getattr(snapshot, "total_realized_pnl", None)) + unrealized = _to_optional_float(getattr(snapshot, "total_unrealized_pnl", None)) + if realized is None and unrealized is None: + return None + return (realized or 0.0) + (unrealized or 0.0) + @staticmethod async def get_strategy_detail( strategy_id: str, From b23f15ed866f80e6c2913beb1d751383496ca8b4 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:18:14 +0800 Subject: [PATCH 06/12] feat: refine buying power assessment to allow only de-risking trades without valid price --- .../strategy_agent/decision/composer.py | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index c5c7c003e..2a9977ff4 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -394,11 +394,28 @@ def _normalize_quantity( # Step 3: buying power clamp px = price_map.get(symbol) if px is None or px <= 0: - logger.debug( - "No price for {} to evaluate buying power; using full quantity", - symbol, + # Without a valid price, we cannot safely assess notional or buying power. + # Allow only de-risking (reductions/closures); block new/exposure-increasing trades. + is_reduction = (side is TradeSide.BUY and current_qty < 0) or ( + side is TradeSide.SELL and current_qty > 0 ) - final_qty = qty + if is_reduction: + # Clamp to the current absolute position to avoid overshooting zero + final_qty = min(qty, abs(current_qty)) + logger.warning( + "Missing price for {} — allowing reduce-only trade: final_qty={} (current_qty={})", + symbol, + final_qty, + current_qty, + ) + else: + logger.warning( + "Missing price for {} — blocking exposure-increasing trade (side={}, qty={})", + symbol, + side, + qty, + ) + return 0.0, 0.0 else: if self._request.exchange_config.market_type == MarketType.SPOT: # Spot: cash-only buying power @@ -454,9 +471,17 @@ def _normalize_quantity( current_qty + (final_qty if side is TradeSide.BUY else -final_qty) ) delta_abs = abs_after - abs_before - consumed_bp_delta = ( - delta_abs * price_map.get(symbol, 0.0) if delta_abs > 0 else 0.0 - ) + # Use effective price (with slippage) for consumed buying power to stay conservative + # If px was missing, we would have returned earlier for exposure-increasing trades; + # for reduction-only trades, treat consumed buying power as 0. + if px is None or px <= 0: + consumed_bp_delta = 0.0 + else: + # Recompute effective price consistently with the clamp + slip_bps = float(self._default_slippage_bps or 0.0) + slip = slip_bps / 10000.0 + effective_px = float(px) * (1.0 + slip) + consumed_bp_delta = (delta_abs * effective_px) if delta_abs > 0 else 0.0 return final_qty, consumed_bp_delta From 4d576d4c178643ea8bc8b610f5903311846b8942 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:04:26 +0800 Subject: [PATCH 07/12] feat: enhance trade execution price handling for accurate settlement and marking --- .../strategy_agent/portfolio/in_memory.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py index 758543c57..07f632564 100644 --- a/python/valuecell/agents/strategy_agent/portfolio/in_memory.py +++ b/python/valuecell/agents/strategy_agent/portfolio/in_memory.py @@ -89,7 +89,18 @@ def apply_trades( for trade in trades: symbol = trade.instrument.symbol - price = float(trade.entry_price or price_map.get(symbol, 0.0) or 0.0) + # Use execution price for settlement and marking. Fallback sensibly. + exec_price = None + try: + if trade.avg_exec_price is not None: + exec_price = float(trade.avg_exec_price) + elif trade.exit_price is not None: + exec_price = float(trade.exit_price) + elif trade.entry_price is not None: + exec_price = float(trade.entry_price) + except Exception: + exec_price = None + price = float(exec_price or price_map.get(symbol, 0.0) or 0.0) delta = float(trade.quantity or 0.0) quantity_delta = delta if trade.side == TradeSide.BUY else -delta @@ -115,7 +126,7 @@ def apply_trades( ) new_qty = current_qty + quantity_delta - # Update mark price + # Update mark price to execution reference position.mark_price = price # Handle position quantity transitions and avg price @@ -177,7 +188,7 @@ def apply_trades( if trade.leverage is not None: position.leverage = float(trade.leverage) - # Update cash by trade notional + # Update cash by trade notional at execution price notional = price * delta # Deduct fees from cash as well. Trade may include fee_cost (in quote ccy). fee = trade.fee_cost or 0.0 From 9b8b6759f044a43c50309aeb8f808c8a5c495503 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:31:38 +0800 Subject: [PATCH 08/12] feat: improve error handling in LLM invocation and response validation --- .../strategy_agent/decision/composer.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py index 2a9977ff4..d57d1164f 100644 --- a/python/valuecell/agents/strategy_agent/decision/composer.py +++ b/python/valuecell/agents/strategy_agent/decision/composer.py @@ -2,11 +2,11 @@ import json import math +from datetime import datetime, timezone from typing import Dict, List, Optional from agno.agent import Agent as AgnoAgent from loguru import logger -from pydantic import ValidationError from valuecell.utils import env as env_utils from valuecell.utils import model as model_utils @@ -64,12 +64,9 @@ async def compose(self, context: ComposeContext) -> ComposeResult: plan.rationale, ) return ComposeResult(instructions=[], rationale=plan.rationale) - except ValidationError as exc: - logger.error("LLM output failed validation: {}", exc) - return ComposeResult(instructions=[], rationale=None) - except Exception: # noqa: BLE001 - logger.exception("LLM invocation failed") - return ComposeResult(instructions=[], rationale=None) + except Exception as exc: # noqa: BLE001 + logger.error("LLM invocation failed: {}", exc) + return ComposeResult(instructions=[], rationale="LLM invocation failed") normalized = self._normalize_plan(context, plan) return ComposeResult(instructions=normalized, rationale=plan.rationale) @@ -255,9 +252,19 @@ async def _call_llm(self, prompt: str) -> LlmPlanProposal: debug_mode=env_utils.agent_debug_mode_enabled(), ) response = await agent.arun(prompt) + # Agent may return a raw object or a wrapper with `.content`. content = getattr(response, "content", None) or response logger.debug("Received LLM response {}", content) - return content + # If the agent already returned a validated model, return it directly + if isinstance(content, LlmPlanProposal): + return content + + logger.error("LLM output failed validation: {}", content) + return LlmPlanProposal( + ts=int(datetime.now(timezone.utc).timestamp() * 1000), + items=[], + rationale="LLM output failed validation", + ) # ------------------------------------------------------------------ # Normalization / guardrails helpers From 824756c666d6a9d24e2658331e4d67e6fe0fd6a4 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:31:44 +0800 Subject: [PATCH 09/12] make format --- python/valuecell/server/services/strategy_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index 77c4c4f49..effb3c184 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -80,9 +80,7 @@ async def get_strategy_holding(strategy_id: str) -> Optional[StrategyHoldingData gross_exposure = ( _to_optional_float(snapshot.gross_exposure) if snapshot else None ) - net_exposure = ( - _to_optional_float(snapshot.net_exposure) if snapshot else None - ) + net_exposure = _to_optional_float(snapshot.net_exposure) if snapshot else None return StrategyHoldingData( strategy_id=strategy_id, @@ -118,7 +116,9 @@ async def get_strategy_portfolio_summary( cash=_to_optional_float(snapshot.cash), total_value=_to_optional_float(snapshot.total_value), total_pnl=StrategyService._combine_realized_unrealized(snapshot), - gross_exposure=_to_optional_float(getattr(snapshot, "gross_exposure", None)), + gross_exposure=_to_optional_float( + getattr(snapshot, "gross_exposure", None) + ), net_exposure=_to_optional_float(getattr(snapshot, "net_exposure", None)), ) From abb5ec121461770697322077d8daba04880e3bec Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:53:12 +0800 Subject: [PATCH 10/12] feat: add cycle index to strategy compose cycle and related persistence logic --- python/valuecell/agents/strategy_agent/agent.py | 9 +++++---- python/valuecell/agents/strategy_agent/core.py | 2 ++ python/valuecell/server/api/schemas/strategy.py | 1 + .../server/db/models/strategy_compose_cycle.py | 16 +++++++++++++++- .../db/repositories/strategy_repository.py | 2 ++ .../server/services/strategy_persistence.py | 2 ++ .../server/services/strategy_service.py | 1 + 7 files changed, 28 insertions(+), 5 deletions(-) diff --git a/python/valuecell/agents/strategy_agent/agent.py b/python/valuecell/agents/strategy_agent/agent.py index 39856ad62..5bcb93c79 100644 --- a/python/valuecell/agents/strategy_agent/agent.py +++ b/python/valuecell/agents/strategy_agent/agent.py @@ -94,13 +94,14 @@ def _persist_cycle_results(self, strategy_id: str, result) -> None: strategy_id=strategy_id, compose_id=result.compose_id, ts_ms=result.timestamp_ms, - rationale=getattr(result, "rationale", None), + cycle_index=result.cycle_index, + rationale=result.rationale, ) except Exception: logger.warning( "Failed to persist compose cycle for strategy={} compose_id={}", strategy_id, - getattr(result, "compose_id", None), + result.compose_id, ) try: @@ -113,7 +114,7 @@ def _persist_cycle_results(self, strategy_id: str, result) -> None: logger.warning( "Failed to persist compose instructions for strategy={} compose_id={}", strategy_id, - getattr(result, "compose_id", None), + result.compose_id, ) for trade in result.trades: @@ -121,7 +122,7 @@ def _persist_cycle_results(self, strategy_id: str, result) -> None: if item: logger.info( "Persisted trade {} for strategy={}", - getattr(trade, "trade_id", None), + trade.trade_id, strategy_id, ) diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py index 9dfc1c39b..908e4f796 100644 --- a/python/valuecell/agents/strategy_agent/core.py +++ b/python/valuecell/agents/strategy_agent/core.py @@ -41,6 +41,7 @@ class DecisionCycleResult: compose_id: str timestamp_ms: int + cycle_index: int rationale: Optional[str] strategy_summary: StrategySummary instructions: List[TradeInstruction] @@ -257,6 +258,7 @@ async def run_once(self) -> DecisionCycleResult: return DecisionCycleResult( compose_id=compose_id, timestamp_ms=timestamp_ms, + cycle_index=self._cycle_index, rationale=rationale, strategy_summary=summary, instructions=instructions, diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index 03eea09e2..82cbbf824 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -158,6 +158,7 @@ class StrategyActionCard(BaseModel): class StrategyCycleDetail(BaseModel): compose_id: str = Field(..., description="Compose cycle identifier") + cycle_index: int = Field(..., description="Cycle index (1-based)") ts: int = Field(..., description="Compose timestamp in ms") rationale: Optional[str] = Field(None, description="LLM rationale text") actions: List[StrategyActionCard] = Field( diff --git a/python/valuecell/server/db/models/strategy_compose_cycle.py b/python/valuecell/server/db/models/strategy_compose_cycle.py index 46cd137fe..fa0a76c9b 100644 --- a/python/valuecell/server/db/models/strategy_compose_cycle.py +++ b/python/valuecell/server/db/models/strategy_compose_cycle.py @@ -40,6 +40,12 @@ class StrategyComposeCycle(Base): DateTime(timezone=True), server_default=func.now(), nullable=False ) + cycle_index = Column( + Integer, + nullable=True, + comment="1-based compose cycle index captured from the coordinator", + ) + # Optional rationale provided by LLM rationale = Column(Text, nullable=True, comment="Optional rationale text") @@ -58,4 +64,12 @@ class StrategyComposeCycle(Base): ) def __repr__(self) -> str: - return f"" + return ( + "" + ).format( + id=self.id, + strategy_id=self.strategy_id, + compose_id=self.compose_id, + cycle_index=self.cycle_index, + ) diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 11f37a889..970b0694b 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -328,6 +328,7 @@ def add_compose_cycle( strategy_id: str, compose_id: str, compose_time: Optional[datetime] = None, + cycle_index: Optional[int] = None, rationale: Optional[str] = None, ) -> Optional[StrategyComposeCycle]: session = self._get_session() @@ -336,6 +337,7 @@ def add_compose_cycle( strategy_id=strategy_id, compose_id=compose_id, compose_time=compose_time or datetime.utcnow(), + cycle_index=cycle_index, rationale=rationale, ) session.add(item) diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index f19d1836b..a33b6b85c 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -263,6 +263,7 @@ def persist_compose_cycle( strategy_id: str, compose_id: str, ts_ms: Optional[int], + cycle_index: Optional[int], rationale: Optional[str], ) -> bool: """Persist a compose cycle metadata record. @@ -280,6 +281,7 @@ def persist_compose_cycle( strategy_id=strategy_id, compose_id=compose_id, compose_time=compose_time, + cycle_index=cycle_index, rationale=rationale, ) return item is not None diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index effb3c184..cb311fad0 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -225,6 +225,7 @@ async def get_strategy_detail( cycle_details.append( StrategyCycleDetail( compose_id=c.compose_id, + cycle_index=c.cycle_index, ts=ts_ms, rationale=c.rationale, actions=cards, From 7a7357ec6a2abda13da71793c147db280627d449 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:51:32 +0800 Subject: [PATCH 11/12] feat: refine StrategyCycleDetail to use created_at instead of timestamp --- python/valuecell/server/api/schemas/strategy.py | 2 +- python/valuecell/server/services/strategy_service.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index 82cbbf824..9a81d5def 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -159,7 +159,7 @@ class StrategyActionCard(BaseModel): class StrategyCycleDetail(BaseModel): compose_id: str = Field(..., description="Compose cycle identifier") cycle_index: int = Field(..., description="Cycle index (1-based)") - ts: int = Field(..., description="Compose timestamp in ms") + created_at: datetime = Field(..., description="Compose datetime") rationale: Optional[str] = Field(None, description="LLM rationale text") actions: List[StrategyActionCard] = Field( default_factory=list, description="Instruction/action cards for this cycle" diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index cb311fad0..a325bf785 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -221,12 +221,12 @@ async def get_strategy_detail( ) ) - ts_ms = int((c.compose_time or datetime.utcnow()).timestamp() * 1000) + created_at = c.compose_time or datetime.utcnow() cycle_details.append( StrategyCycleDetail( compose_id=c.compose_id, cycle_index=c.cycle_index, - ts=ts_ms, + created_at=created_at, rationale=c.rationale, actions=cards, ) From 0193b4bbd517f9ebec26125882249b6015355742 Mon Sep 17 00:00:00 2001 From: Zhaofeng Zhang <24791380+vcfgv@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:02:33 +0800 Subject: [PATCH 12/12] feat: refine StrategyActionCard to use datetime for entry and exit timestamps --- .../valuecell/server/api/schemas/strategy.py | 7 +++-- .../server/services/strategy_service.py | 26 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/python/valuecell/server/api/schemas/strategy.py b/python/valuecell/server/api/schemas/strategy.py index 9a81d5def..63a03c65b 100644 --- a/python/valuecell/server/api/schemas/strategy.py +++ b/python/valuecell/server/api/schemas/strategy.py @@ -138,8 +138,11 @@ class StrategyActionCard(BaseModel): ) entry_price: Optional[float] = Field(None, description="Entry price") exit_price: Optional[float] = Field(None, description="Exit price (if closed)") - entry_ts: Optional[int] = Field(None, description="Entry timestamp in ms") - exit_ts: Optional[int] = Field(None, description="Exit timestamp in ms") + entry_at: Optional[datetime] = Field(None, description="Entry timestamp") + exit_at: Optional[datetime] = Field(None, description="Exit timestamp") + holding_time_ms: Optional[int] = Field( + None, description="Holding time in milliseconds" + ) notional_entry: Optional[float] = Field( None, description="Entry notional in quote currency" ) diff --git a/python/valuecell/server/services/strategy_service.py b/python/valuecell/server/services/strategy_service.py index a325bf785..88a089ab4 100644 --- a/python/valuecell/server/services/strategy_service.py +++ b/python/valuecell/server/services/strategy_service.py @@ -151,12 +151,21 @@ async def get_strategy_detail( for i in instrs: d = detail_map.get(i.instruction_id) # Construct card combining instruction (always present) with optional execution detail - entry_ts = None - exit_ts = None - if d and d.entry_price: - entry_ts = int(d.entry_time.timestamp() * 1000) - if d and d.exit_time: - exit_ts = int(d.exit_time.timestamp() * 1000) + entry_at: Optional[datetime] = None + exit_at: Optional[datetime] = None + holding_time_ms: Optional[int] = None + if d: + entry_at = d.entry_time + exit_at = d.exit_time + if d.holding_ms is not None: + holding_time_ms = int(d.holding_ms) + elif entry_at and exit_at: + try: + delta_ms = int((exit_at - entry_at).total_seconds() * 1000) + except TypeError: + delta_ms = None + if delta_ms is not None: + holding_time_ms = max(delta_ms, 0) # Human-friendly display label for the action action_display = i.action @@ -190,8 +199,9 @@ async def get_strategy_detail( if (d and d.exit_price is not None) else None ), - entry_ts=entry_ts, - exit_ts=exit_ts, + entry_at=entry_at, + exit_at=exit_at, + holding_time_ms=holding_time_ms, notional_entry=( float(d.notional_entry) if (d and d.notional_entry is not None)