From ef6b3dc65b1ebe438c5eb808c6ba553fd415230a Mon Sep 17 00:00:00 2001 From: Kelu Date: Mon, 15 Dec 2025 15:01:14 +0700 Subject: [PATCH 1/2] feat: Maintain Stop Gain/Loss Prices Across LLM Decisions This PR implements robust management for Stop Gain/Loss prices, ensuring these critical protective limits are **maintained and consistently applied** across trading sessions and subsequent LLM decisions. * **LLM Definition:** LLM response extended to include new Stop Gain/Loss price fields. * **Persistent Storage:** Defined stop prices are stored in session memory and persisted via database snapshot. * **Session Continuity:** Loads persisted stop prices when resuming a session, restoring trading strategy limits. * **Informed Decisions:** Existing stop prices are fed back into the LLM context to inform and assist subsequent trading actions. --- .../common/trading/_internal/coordinator.py | 3 + .../common/trading/_internal/runtime.py | 23 ++++- .../trading/_internal/stream_controller.py | 6 ++ .../trading/decision/prompt_based/composer.py | 27 +++++- .../decision/prompt_based/system_prompt.py | 3 + .../valuecell/agents/common/trading/models.py | 22 +++++ .../common/trading/portfolio/in_memory.py | 20 ++++ .../common/trading/portfolio/interfaces.py | 12 +++ python/valuecell/server/db/models/__init__.py | 2 + .../server/db/models/strategy_stop_price.py | 94 +++++++++++++++++++ .../db/repositories/strategy_repository.py | 70 ++++++++++++++ .../server/services/strategy_persistence.py | 18 +++- 12 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 python/valuecell/server/db/models/strategy_stop_price.py diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py index 40f04ccec..5fc555404 100644 --- a/python/valuecell/agents/common/trading/_internal/coordinator.py +++ b/python/valuecell/agents/common/trading/_internal/coordinator.py @@ -179,6 +179,7 @@ async def run_once(self) -> DecisionCycleResult: compose_result = await self._composer.compose(context) instructions = compose_result.instructions rationale = compose_result.rationale + stop_prices = compose_result.stop_prices logger.info(f"🔍 Composer returned {len(instructions)} instructions") for idx, inst in enumerate(instructions): logger.info( @@ -229,6 +230,7 @@ async def run_once(self) -> DecisionCycleResult: trades = self._create_trades(tx_results, compose_id, timestamp_ms) self.portfolio_service.apply_trades(trades, market_features) + self.portfolio_service.update_stop_prices(stop_prices) summary = self.build_summary(timestamp_ms, trades) history_records = self._create_history_records( @@ -253,6 +255,7 @@ async def run_once(self) -> DecisionCycleResult: history_records=history_records, digest=digest, portfolio_view=portfolio, + stop_prices=stop_prices, ) def _create_trades( diff --git a/python/valuecell/agents/common/trading/_internal/runtime.py b/python/valuecell/agents/common/trading/_internal/runtime.py index 515fb1ab0..3950b8fc4 100644 --- a/python/valuecell/agents/common/trading/_internal/runtime.py +++ b/python/valuecell/agents/common/trading/_internal/runtime.py @@ -15,7 +15,13 @@ InMemoryHistoryRecorder, RollingDigestBuilder, ) -from ..models import Constraints, DecisionCycleResult, TradingMode, UserRequest +from ..models import ( + Constraints, + DecisionCycleResult, + StopPrice, + TradingMode, + UserRequest, +) from ..portfolio.in_memory import InMemoryPortfolioService from ..utils import fetch_free_cash_from_gateway, fetch_positions_from_gateway from .coordinator import DefaultDecisionCoordinator @@ -122,6 +128,7 @@ async def create_strategy_runtime( # so the in-memory portfolio starts with the previously recorded equity. free_cash_override = None total_cash_override = None + stop_prices = {} if strategy_id_override: try: repo = get_strategy_repository() @@ -140,6 +147,19 @@ async def create_strategy_runtime( "Initialized runtime initial capital from persisted snapshot for strategy_id=%s", strategy_id_override, ) + stop_prices = { + stop_price.symbol: StopPrice( + symbol=stop_price.symbol, + stop_gain_price=stop_price.stop_gain_price, + stop_loss_price=stop_price.stop_loss_price, + ) + for stop_price in repo.get_stop_prices(strategy_id_override) + } + logger.info( + "Initialized runtime stop prices {} from persisted snapshot for strategy_id {}", + stop_prices, + strategy_id_override, + ) except Exception: logger.exception( "Failed to initialize initial capital from persisted snapshot for strategy_id=%s", @@ -160,6 +180,7 @@ async def create_strategy_runtime( market_type=request.exchange_config.market_type, constraints=constraints, strategy_id=strategy_id, + stop_prices=stop_prices, ) # Use custom composer if provided, otherwise default to LlmComposer diff --git a/python/valuecell/agents/common/trading/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py index 5f692fcdf..d9aa5dc36 100644 --- a/python/valuecell/agents/common/trading/_internal/stream_controller.py +++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py @@ -271,6 +271,12 @@ def persist_cycle_results(self, result: DecisionCycleResult) -> None: "Persisted portfolio view for strategy={}", self.strategy_id ) + ok = strategy_persistence.persist_stop_prices( + self.strategy_id, result.stop_prices + ) + if ok: + logger.info("Persisted stop prices for strategy={}", self.strategy_id) + ok = strategy_persistence.persist_strategy_summary(result.strategy_summary) if ok: logger.info( diff --git a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py index 0a9d9fb42..b5aab3b72 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py @@ -111,7 +111,11 @@ async def compose(self, context: ComposeContext) -> ComposeResult: logger.error("Failed sending plan to Discord: {}", exc) normalized = self._normalize_plan(context, plan) - return ComposeResult(instructions=normalized, rationale=plan.rationale) + return ComposeResult( + instructions=normalized, + rationale=plan.rationale, + stop_prices=plan.stop_prices, + ) # ------------------------------------------------------------------ @@ -150,16 +154,21 @@ def _build_llm_prompt(self, context: ComposeContext) -> str: market = extract_market_section(features.get("market_snapshot", [])) # Portfolio positions - positions = [ - { - "symbol": sym, + positions = { + sym: { + "avg_price": snap.avg_price, "qty": float(snap.quantity), "unrealized_pnl": snap.unrealized_pnl, "entry_ts": snap.entry_ts, } for sym, snap in pv.positions.items() if abs(float(snap.quantity)) > 0 - ] + } + for symbol, stop_price in pv.stop_prices.items(): + if symbol not in positions: + continue + positions[symbol]["stop_gain_price"] = stop_price.stop_gain_price + positions[symbol]["stop_loss_price"] = stop_price.stop_loss_price # Constraints constraints = ( @@ -200,6 +209,7 @@ async def _call_llm(self, prompt: str) -> TradePlanProposal: agent's `response.content` is returned (or validated) as a `LlmPlanProposal`. """ + logger.debug("LLM prompt {}", prompt) response = await self.agent.arun(prompt) # Agent may return a raw object or a wrapper with `.content`. content = getattr(response, "content", None) or response @@ -240,6 +250,13 @@ async def _send_plan_to_discord(self, plan: TradePlanProposal) -> None: if top_r: parts.append("**Overall rationale:**\n") parts.append(f"{top_r}\n") + if len(plan.stop_prices) > 0: + parts.append("**Updated stop prices:**") + for stop_price in plan.stop_prices: + parts.append( + f"{stop_price.symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" + ) + parts.append("") parts.append("**Items:**\n") for it in actionable: diff --git a/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py b/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py index bd0532924..3a6b6aff3 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py @@ -18,6 +18,7 @@ - For derivatives (one-way positions): opening on the opposite side implies first flattening to 0 then opening the requested side; the executor handles this split. - For spot: only open_long/close_long are valid; open_short/close_short will be treated as reducing toward 0 or ignored. - One item per symbol at most. No hedging (never propose both long and short exposure on the same symbol). +- Upon the market price closes above the nearest minor resistance level, move the stop loss to the break-even point (entry price + costs) to eliminate the risk of loss on the trade. After the stop has been moved to break-even, implement a trailing stop to protect any further accumulated profit. CONSTRAINTS & VALIDATION - Respect max_positions, max_leverage, max_position_qty, quantity_step, min_trade_qty, max_order_qty, min_notional, and available buying power. @@ -32,11 +33,13 @@ - 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. +- Respect the stop prices - do not close position if stop prices are not hit OUTPUT & EXPLANATION - Always include a brief top-level rationale summarizing your decision basis. - Your rationale must transparently reveal your thinking process (signals evaluated, thresholds, trade-offs) and the operational steps (how sizing is derived, which constraints/normalization will be applied). - If no actions are emitted (noop), your rationale must explain specific reasons: reference current prices and price.change_pct relative to your thresholds, and note any constraints or risk flags that caused noop. +- For open_long and open_short actions, always include stop loss and stop gain prices for the symbol. MARKET FEATURES The Context includes `features.market_snapshot`: a compact, per-cycle bundle of references derived from the latest exchange snapshot. Each item corresponds to a tradable symbol and may include: diff --git a/python/valuecell/agents/common/trading/models.py b/python/valuecell/agents/common/trading/models.py index e89fca469..c2268a6f4 100644 --- a/python/valuecell/agents/common/trading/models.py +++ b/python/valuecell/agents/common/trading/models.py @@ -550,6 +550,10 @@ class PortfolioView(BaseModel): " effective leverage if available, otherwise falls back to constraints.max_leverage." ), ) + stop_prices: Dict[str, "StopPrice"] = Field( + default_factory=list, + description="List of stop prices for existing positions and positions to open.", + ) class TradeDecisionAction(str, Enum): @@ -587,6 +591,18 @@ def derive_side_from_action( return None +class StopPrice(BaseModel): + symbol: str = Field(..., description="Exchange symbol, e.g., BTC/USDT") + stop_gain_price: Optional[float] = Field( + ..., + description="Stop gain price for this position.", + ) + stop_loss_price: Optional[float] = Field( + ..., + description="Stop loss price for this position.", + ) + + class TradeDecisionItem(BaseModel): """Trade plan item. Interprets target_qty as operation size (magnitude). @@ -641,6 +657,10 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) + stop_prices: List[StopPrice] = Field( + default_factory=list, + description="List of stop prices for existing positions and positions to open.", + ) class PriceMode(str, Enum): @@ -934,6 +954,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None + stop_prices: List[StopPrice] = [] class FeaturesPipelineResult(BaseModel): @@ -956,3 +977,4 @@ class DecisionCycleResult: history_records: List[HistoryRecord] digest: TradeDigest portfolio_view: PortfolioView + stop_prices: List[StopPrice] diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 857bb8d18..7b8bfa9f7 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -7,12 +7,14 @@ MarketType, PortfolioView, PositionSnapshot, + StopPrice, TradeHistoryEntry, TradeSide, TradeType, TradingMode, ) from valuecell.agents.common.trading.utils import extract_price_map +from valuecell.server.db.models import StrategyStopPrices from .interfaces import BasePortfolioService @@ -41,6 +43,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, + stop_prices: Dict[str, StrategyStopPrices], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -75,6 +78,7 @@ def __init__( total_realized_pnl=0.0, buying_power=free_cash, free_cash=free_cash, + stop_prices=stop_prices, ) self._trading_mode = trading_mode self._market_type = market_type @@ -89,6 +93,22 @@ def get_view(self) -> PortfolioView: pass return self._view + def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: + for stop_price in stop_prices: + if stop_price.symbol in self._view.stop_prices: + self._view.stop_prices[stop_price.symbol].stop_gain_price = ( + stop_price.stop_gain_price + if stop_price.stop_gain_price is not None + else self._view.stop_prices[stop_price.symbol].stop_gain_price + ) + self._view.stop_prices[stop_price.symbol].stop_loss_price = ( + stop_price.stop_loss_price + if stop_price.stop_loss_price is not None + else self._view.stop_prices[stop_price.symbol].stop_loss_price + ) + else: + self._view.stop_prices[stop_price.symbol] = stop_price + def apply_trades( self, trades: List[TradeHistoryEntry], market_features: List[FeatureVector] ) -> None: diff --git a/python/valuecell/agents/common/trading/portfolio/interfaces.py b/python/valuecell/agents/common/trading/portfolio/interfaces.py index 3471ef4c4..669a5d0bb 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -6,6 +6,7 @@ from valuecell.agents.common.trading.models import ( FeatureVector, PortfolioView, + StopPrice, TradeHistoryEntry, ) @@ -34,6 +35,17 @@ def apply_trades( """ raise NotImplementedError + def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: + """Update the stop prices to the portfolio view. + + Implementations that support state changes (paper trading, backtests) + should update their internal view accordingly. `stop_prices` + a vector of stop (gain/loss) prices for each symbol. This method + is optional for read-only portfolio services, but providing it here + makes the contract explicit to callers. + """ + raise NotImplementedError + class BasePortfolioSnapshotStore(ABC): """Persist/load portfolio snapshots (optional for paper/backtest modes).""" diff --git a/python/valuecell/server/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index e8d8688e9..c9885b864 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -17,6 +17,7 @@ from .strategy_holding import StrategyHolding from .strategy_instruction import StrategyInstruction from .strategy_portfolio import StrategyPortfolioView +from .strategy_stop_price import StrategyStopPrices from .user_profile import ProfileCategory, UserProfile from .watchlist import Watchlist, WatchlistItem @@ -35,4 +36,5 @@ "StrategyPortfolioView", "StrategyComposeCycle", "StrategyInstruction", + "StrategyStopPrices", ] diff --git a/python/valuecell/server/db/models/strategy_stop_price.py b/python/valuecell/server/db/models/strategy_stop_price.py new file mode 100644 index 000000000..022cafafb --- /dev/null +++ b/python/valuecell/server/db/models/strategy_stop_price.py @@ -0,0 +1,94 @@ +""" +ValueCell Server - Strategy Stop Prices Model + +This module defines the database model for strategy stop price records. +Each row represents a stop gain & loss info associated with a strategy and symbol. +""" + +from typing import Any, Dict + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.sql import func + +from .base import Base + + +class StrategyStopPrices(Base): + """Strategy detail record for trades/positions associated with a strategy.""" + + __tablename__ = "strategy_stop_prices" + + # Primary key + id = Column(Integer, primary_key=True, index=True) + + # Foreign key to strategies (uses unique strategy_id) + strategy_id = Column( + String(100), + ForeignKey("strategies.strategy_id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Runtime strategy identifier", + ) + + # Instrument and trade info + symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") + stop_gain_price = Column( + Numeric(20, 8), nullable=True, comment="Price to stop gain" + ) + stop_loss_price = Column( + Numeric(20, 8), nullable=True, comment="Price to stop loss" + ) + + # Notes + note = Column(Text, nullable=True, comment="Optional note") + + # Timestamps + 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, + ) + + # Uniqueness: strategy_id + trade_id must be unique + __table_args__ = ( + UniqueConstraint("strategy_id", "symbol", name="uq_strategy_id_symbol"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "strategy_id": self.strategy_id, + "symbol": self.symbol, + "stop_gain_price": ( + float(self.stop_gain_price) + if self.stop_gain_price is not None + else None + ), + "stop_loss_price": ( + float(self.stop_loss_price) + if self.stop_loss_price is not None + 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/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 861913ade..9b54865a4 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -11,6 +11,7 @@ from sqlalchemy import asc, desc, func from sqlalchemy.orm import Session +from ....agents.common.trading.models import StopPrice from ..connection import get_database_manager from ..models.strategy import Strategy from ..models.strategy_compose_cycle import StrategyComposeCycle @@ -19,6 +20,7 @@ from ..models.strategy_instruction import StrategyInstruction from ..models.strategy_portfolio import StrategyPortfolioView from ..models.strategy_prompt import StrategyPrompt +from ..models.strategy_stop_price import StrategyStopPrices class StrategyRepository: @@ -445,6 +447,59 @@ def add_instruction( if not self.db_session: session.close() + # Stop price operations + def upsert_stop_price( + self, + strategy_id: str, + stop_prices: List[StopPrice], + note: Optional[str] = None, + ) -> List[StrategyStopPrices]: + """Insert one strategy detail record.""" + session = self._get_session() + upserted_items = [] + try: + for stop_price in stop_prices: + existing_item = ( + session.query(StrategyStopPrices) + .filter( + StrategyStopPrices.strategy_id == strategy_id, + StrategyStopPrices.symbol == stop_price.symbol, + ) + .one_or_none() + ) + + if existing_item: + existing_item.stop_gain_price = stop_price.stop_gain_price + existing_item.stop_loss_price = stop_price.stop_loss_price + existing_item.note = note + item_to_return = existing_item + else: + new_item = StrategyStopPrices( + strategy_id=strategy_id, + symbol=stop_price.symbol, + stop_gain_price=stop_price.stop_gain_price, + stop_loss_price=stop_price.stop_loss_price, + note=note, + ) + session.add(new_item) + item_to_return = new_item + + session.add(item_to_return) + upserted_items.append(item_to_return) + + session.commit() + for item in upserted_items: + session.refresh(item) + session.expunge(item) + except Exception: + session.rollback() + return [] + finally: + if not self.db_session: + session.close() + + return upserted_items + def get_cycles( self, strategy_id: str, limit: Optional[int] = None ) -> List[StrategyComposeCycle]: @@ -530,6 +585,21 @@ def get_details( if not self.db_session: session.close() + def get_stop_prices(self, strategy_id: str) -> List[StrategyStopPrices]: + """Get detail records for a strategy ordered by event_time desc.""" + session = self._get_session() + try: + query = session.query(StrategyStopPrices).filter( + StrategyDetail.strategy_id == strategy_id + ) + items = query.all() + for item in items: + session.expunge(item) + return items + finally: + if not self.db_session: + session.close() + # Prompts operations (kept under strategy namespace) def list_prompts(self) -> List[StrategyPrompt]: """Return all prompts ordered by updated_at desc.""" diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index 0526bca90..d8fd096d2 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Optional +from typing import List, Optional from loguru import logger @@ -225,6 +225,22 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: return False +def persist_stop_prices( + strategy_id: str, stop_prices: List[agent_models.StopPrice] +) -> bool: + """Persist a StrategySummary into the Strategy.strategy_metadata JSON. + + Returns True on success, False on failure. + """ + repo = get_strategy_repository() + try: + updated = repo.upsert_stop_price(strategy_id, stop_prices=stop_prices) + return updated is not None + except Exception as e: + logger.exception("persist_stop_prices failed for {}, error: {}", strategy_id, e) + return False + + def persist_strategy_summary(summary: agent_models.StrategySummary) -> bool: """Persist a StrategySummary into the Strategy.strategy_metadata JSON. From 2f854e5115d62b1aa83d1ea639e43f627ee7d3ef Mon Sep 17 00:00:00 2001 From: Kelu Date: Wed, 17 Dec 2025 23:11:15 +0700 Subject: [PATCH 2/2] feat: Move stop prices into strategy metadata --- .../common/trading/_internal/coordinator.py | 4 +- .../common/trading/_internal/runtime.py | 16 ++-- .../trading/_internal/stream_controller.py | 6 -- .../trading/decision/prompt_based/composer.py | 10 +- .../valuecell/agents/common/trading/models.py | 18 ++-- .../common/trading/portfolio/in_memory.py | 25 ++--- .../common/trading/portfolio/interfaces.py | 4 +- python/valuecell/server/db/models/__init__.py | 2 - .../server/db/models/strategy_stop_price.py | 94 ------------------- .../db/repositories/strategy_repository.py | 70 -------------- .../server/services/strategy_persistence.py | 18 +--- 11 files changed, 40 insertions(+), 227 deletions(-) delete mode 100644 python/valuecell/server/db/models/strategy_stop_price.py diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py index 5fc555404..c934077fa 100644 --- a/python/valuecell/agents/common/trading/_internal/coordinator.py +++ b/python/valuecell/agents/common/trading/_internal/coordinator.py @@ -255,7 +255,6 @@ async def run_once(self) -> DecisionCycleResult: history_records=history_records, digest=digest, portfolio_view=portfolio, - stop_prices=stop_prices, ) def _create_trades( @@ -483,6 +482,7 @@ def build_summary( # Use the portfolio view's total_value which now correctly reflects Equity # (whether simulated or synced from exchange) equity = float(view.total_value or 0.0) + stop_prices = view.stop_prices except Exception: # Fallback to internal tracking if portfolio service is unavailable unrealized = float(self._unrealized_pnl or 0.0) @@ -492,6 +492,7 @@ def build_summary( if self._request.trading_config.initial_capital is not None else 0.0 ) + stop_prices = {} # Keep internal state in sync (allow negative unrealized PnL) self._unrealized_pnl = float(unrealized) @@ -516,6 +517,7 @@ def build_summary( unrealized_pnl_pct=unrealized_pnl_pct, pnl_pct=pnl_pct, total_value=equity, + stop_prices=stop_prices, last_updated_ts=timestamp_ms, ) diff --git a/python/valuecell/agents/common/trading/_internal/runtime.py b/python/valuecell/agents/common/trading/_internal/runtime.py index 3950b8fc4..e9574a605 100644 --- a/python/valuecell/agents/common/trading/_internal/runtime.py +++ b/python/valuecell/agents/common/trading/_internal/runtime.py @@ -147,14 +147,14 @@ async def create_strategy_runtime( "Initialized runtime initial capital from persisted snapshot for strategy_id=%s", strategy_id_override, ) - stop_prices = { - stop_price.symbol: StopPrice( - symbol=stop_price.symbol, - stop_gain_price=stop_price.stop_gain_price, - stop_loss_price=stop_price.stop_loss_price, - ) - for stop_price in repo.get_stop_prices(strategy_id_override) - } + stop_prices = {} + strategy = repo.get_strategy_by_strategy_id(strategy_id_override) + if strategy and strategy.strategy_metadata: + raw_stops = strategy.strategy_metadata.get("stop_prices", {}) + stop_prices = { + symbol: StopPrice.model_validate(data) + for symbol, data in raw_stops.items() + } logger.info( "Initialized runtime stop prices {} from persisted snapshot for strategy_id {}", stop_prices, diff --git a/python/valuecell/agents/common/trading/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py index d9aa5dc36..5f692fcdf 100644 --- a/python/valuecell/agents/common/trading/_internal/stream_controller.py +++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py @@ -271,12 +271,6 @@ def persist_cycle_results(self, result: DecisionCycleResult) -> None: "Persisted portfolio view for strategy={}", self.strategy_id ) - ok = strategy_persistence.persist_stop_prices( - self.strategy_id, result.stop_prices - ) - if ok: - logger.info("Persisted stop prices for strategy={}", self.strategy_id) - ok = strategy_persistence.persist_strategy_summary(result.strategy_summary) if ok: logger.info( diff --git a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py index b5aab3b72..d246aa3d6 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py @@ -97,7 +97,11 @@ async def compose(self, context: ComposeContext) -> ComposeResult: context.compose_id, plan.rationale, ) - return ComposeResult(instructions=[], rationale=plan.rationale) + return ComposeResult( + instructions=[], + rationale=plan.rationale, + stop_prices=plan.stop_prices, + ) except Exception as exc: # noqa: BLE001 logger.error("LLM invocation failed: {}", exc) return ComposeResult( @@ -252,9 +256,9 @@ async def _send_plan_to_discord(self, plan: TradePlanProposal) -> None: parts.append(f"{top_r}\n") if len(plan.stop_prices) > 0: parts.append("**Updated stop prices:**") - for stop_price in plan.stop_prices: + for symbol, stop_price in plan.stop_prices.items(): parts.append( - f"{stop_price.symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" + f"{symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" ) parts.append("") diff --git a/python/valuecell/agents/common/trading/models.py b/python/valuecell/agents/common/trading/models.py index c2268a6f4..f10a5ea8f 100644 --- a/python/valuecell/agents/common/trading/models.py +++ b/python/valuecell/agents/common/trading/models.py @@ -551,8 +551,8 @@ class PortfolioView(BaseModel): ), ) stop_prices: Dict[str, "StopPrice"] = Field( - default_factory=list, - description="List of stop prices for existing positions and positions to open.", + default_factory=dict, + description="Dictionary of stop prices for existing positions and positions to open.", ) @@ -592,7 +592,6 @@ def derive_side_from_action( class StopPrice(BaseModel): - symbol: str = Field(..., description="Exchange symbol, e.g., BTC/USDT") stop_gain_price: Optional[float] = Field( ..., description="Stop gain price for this position.", @@ -657,9 +656,9 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) - stop_prices: List[StopPrice] = Field( - default_factory=list, - description="List of stop prices for existing positions and positions to open.", + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", ) @@ -931,6 +930,10 @@ class StrategySummary(BaseModel): default=None, description="Total portfolio value (equity) including cash and positions", ) + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", + ) last_updated_ts: Optional[int] = Field(default=None) @@ -954,7 +957,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None - stop_prices: List[StopPrice] = [] + stop_prices: Dict[str, StopPrice] = {} class FeaturesPipelineResult(BaseModel): @@ -977,4 +980,3 @@ class DecisionCycleResult: history_records: List[HistoryRecord] digest: TradeDigest portfolio_view: PortfolioView - stop_prices: List[StopPrice] diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 7b8bfa9f7..4d4cac4c2 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -14,7 +14,6 @@ TradingMode, ) from valuecell.agents.common.trading.utils import extract_price_map -from valuecell.server.db.models import StrategyStopPrices from .interfaces import BasePortfolioService @@ -43,7 +42,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, - stop_prices: Dict[str, StrategyStopPrices], + stop_prices: Dict[str, StopPrice], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -93,21 +92,15 @@ def get_view(self) -> PortfolioView: pass return self._view - def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: - for stop_price in stop_prices: - if stop_price.symbol in self._view.stop_prices: - self._view.stop_prices[stop_price.symbol].stop_gain_price = ( - stop_price.stop_gain_price - if stop_price.stop_gain_price is not None - else self._view.stop_prices[stop_price.symbol].stop_gain_price - ) - self._view.stop_prices[stop_price.symbol].stop_loss_price = ( - stop_price.stop_loss_price - if stop_price.stop_loss_price is not None - else self._view.stop_prices[stop_price.symbol].stop_loss_price - ) + def update_stop_prices(self, stop_prices: Dict[str, StopPrice]) -> None: + for symbol, new_stop in stop_prices.items(): + existing = self._view.stop_prices.get(symbol) + if existing: + update_data = new_stop.model_dump(exclude_unset=True, exclude_none=True) + for key, value in update_data.items(): + setattr(existing, key, value) else: - self._view.stop_prices[stop_price.symbol] = stop_price + self._view.stop_prices[symbol] = new_stop def apply_trades( self, trades: List[TradeHistoryEntry], market_features: List[FeatureVector] diff --git a/python/valuecell/agents/common/trading/portfolio/interfaces.py b/python/valuecell/agents/common/trading/portfolio/interfaces.py index 669a5d0bb..544944a87 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Dict, List, Optional from valuecell.agents.common.trading.models import ( FeatureVector, @@ -35,7 +35,7 @@ def apply_trades( """ raise NotImplementedError - def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: + def update_stop_prices(self, stop_prices: Dict[str, StopPrice]) -> None: """Update the stop prices to the portfolio view. Implementations that support state changes (paper trading, backtests) diff --git a/python/valuecell/server/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index c9885b864..e8d8688e9 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -17,7 +17,6 @@ from .strategy_holding import StrategyHolding from .strategy_instruction import StrategyInstruction from .strategy_portfolio import StrategyPortfolioView -from .strategy_stop_price import StrategyStopPrices from .user_profile import ProfileCategory, UserProfile from .watchlist import Watchlist, WatchlistItem @@ -36,5 +35,4 @@ "StrategyPortfolioView", "StrategyComposeCycle", "StrategyInstruction", - "StrategyStopPrices", ] diff --git a/python/valuecell/server/db/models/strategy_stop_price.py b/python/valuecell/server/db/models/strategy_stop_price.py deleted file mode 100644 index 022cafafb..000000000 --- a/python/valuecell/server/db/models/strategy_stop_price.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -ValueCell Server - Strategy Stop Prices Model - -This module defines the database model for strategy stop price records. -Each row represents a stop gain & loss info associated with a strategy and symbol. -""" - -from typing import Any, Dict - -from sqlalchemy import ( - Column, - DateTime, - ForeignKey, - Integer, - Numeric, - String, - Text, - UniqueConstraint, -) -from sqlalchemy.sql import func - -from .base import Base - - -class StrategyStopPrices(Base): - """Strategy detail record for trades/positions associated with a strategy.""" - - __tablename__ = "strategy_stop_prices" - - # Primary key - id = Column(Integer, primary_key=True, index=True) - - # Foreign key to strategies (uses unique strategy_id) - strategy_id = Column( - String(100), - ForeignKey("strategies.strategy_id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="Runtime strategy identifier", - ) - - # Instrument and trade info - symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") - stop_gain_price = Column( - Numeric(20, 8), nullable=True, comment="Price to stop gain" - ) - stop_loss_price = Column( - Numeric(20, 8), nullable=True, comment="Price to stop loss" - ) - - # Notes - note = Column(Text, nullable=True, comment="Optional note") - - # Timestamps - 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, - ) - - # Uniqueness: strategy_id + trade_id must be unique - __table_args__ = ( - UniqueConstraint("strategy_id", "symbol", name="uq_strategy_id_symbol"), - ) - - def __repr__(self) -> str: - return ( - f"" - ) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "strategy_id": self.strategy_id, - "symbol": self.symbol, - "stop_gain_price": ( - float(self.stop_gain_price) - if self.stop_gain_price is not None - else None - ), - "stop_loss_price": ( - float(self.stop_loss_price) - if self.stop_loss_price is not None - 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/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 9b54865a4..861913ade 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -11,7 +11,6 @@ from sqlalchemy import asc, desc, func from sqlalchemy.orm import Session -from ....agents.common.trading.models import StopPrice from ..connection import get_database_manager from ..models.strategy import Strategy from ..models.strategy_compose_cycle import StrategyComposeCycle @@ -20,7 +19,6 @@ from ..models.strategy_instruction import StrategyInstruction from ..models.strategy_portfolio import StrategyPortfolioView from ..models.strategy_prompt import StrategyPrompt -from ..models.strategy_stop_price import StrategyStopPrices class StrategyRepository: @@ -447,59 +445,6 @@ def add_instruction( if not self.db_session: session.close() - # Stop price operations - def upsert_stop_price( - self, - strategy_id: str, - stop_prices: List[StopPrice], - note: Optional[str] = None, - ) -> List[StrategyStopPrices]: - """Insert one strategy detail record.""" - session = self._get_session() - upserted_items = [] - try: - for stop_price in stop_prices: - existing_item = ( - session.query(StrategyStopPrices) - .filter( - StrategyStopPrices.strategy_id == strategy_id, - StrategyStopPrices.symbol == stop_price.symbol, - ) - .one_or_none() - ) - - if existing_item: - existing_item.stop_gain_price = stop_price.stop_gain_price - existing_item.stop_loss_price = stop_price.stop_loss_price - existing_item.note = note - item_to_return = existing_item - else: - new_item = StrategyStopPrices( - strategy_id=strategy_id, - symbol=stop_price.symbol, - stop_gain_price=stop_price.stop_gain_price, - stop_loss_price=stop_price.stop_loss_price, - note=note, - ) - session.add(new_item) - item_to_return = new_item - - session.add(item_to_return) - upserted_items.append(item_to_return) - - session.commit() - for item in upserted_items: - session.refresh(item) - session.expunge(item) - except Exception: - session.rollback() - return [] - finally: - if not self.db_session: - session.close() - - return upserted_items - def get_cycles( self, strategy_id: str, limit: Optional[int] = None ) -> List[StrategyComposeCycle]: @@ -585,21 +530,6 @@ def get_details( if not self.db_session: session.close() - def get_stop_prices(self, strategy_id: str) -> List[StrategyStopPrices]: - """Get detail records for a strategy ordered by event_time desc.""" - session = self._get_session() - try: - query = session.query(StrategyStopPrices).filter( - StrategyDetail.strategy_id == strategy_id - ) - items = query.all() - for item in items: - session.expunge(item) - return items - finally: - if not self.db_session: - session.close() - # Prompts operations (kept under strategy namespace) def list_prompts(self) -> List[StrategyPrompt]: """Return all prompts ordered by updated_at desc.""" diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index d8fd096d2..0526bca90 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import List, Optional +from typing import Optional from loguru import logger @@ -225,22 +225,6 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: return False -def persist_stop_prices( - strategy_id: str, stop_prices: List[agent_models.StopPrice] -) -> bool: - """Persist a StrategySummary into the Strategy.strategy_metadata JSON. - - Returns True on success, False on failure. - """ - repo = get_strategy_repository() - try: - updated = repo.upsert_stop_price(strategy_id, stop_prices=stop_prices) - return updated is not None - except Exception as e: - logger.exception("persist_stop_prices failed for {}, error: {}", strategy_id, e) - return False - - def persist_strategy_summary(summary: agent_models.StrategySummary) -> bool: """Persist a StrategySummary into the Strategy.strategy_metadata JSON.