diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py index 40f04ccec..c934077fa 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( @@ -480,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) @@ -489,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) @@ -513,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 515fb1ab0..e9574a605 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 = {} + 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, + 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/decision/prompt_based/composer.py b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py index 0a9d9fb42..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( @@ -111,7 +115,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 +158,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 +213,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 +254,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 symbol, stop_price in plan.stop_prices.items(): + parts.append( + f"{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..f10a5ea8f 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=dict, + description="Dictionary of stop prices for existing positions and positions to open.", + ) class TradeDecisionAction(str, Enum): @@ -587,6 +591,17 @@ def derive_side_from_action( return None +class StopPrice(BaseModel): + 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 +656,10 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", + ) class PriceMode(str, Enum): @@ -911,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) @@ -934,6 +957,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None + stop_prices: Dict[str, StopPrice] = {} class FeaturesPipelineResult(BaseModel): diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 857bb8d18..4d4cac4c2 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -7,6 +7,7 @@ MarketType, PortfolioView, PositionSnapshot, + StopPrice, TradeHistoryEntry, TradeSide, TradeType, @@ -41,6 +42,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, + stop_prices: Dict[str, StopPrice], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -75,6 +77,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 +92,16 @@ def get_view(self) -> PortfolioView: pass return self._view + 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[symbol] = new_stop + 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..544944a87 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -1,11 +1,12 @@ 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, PortfolioView, + StopPrice, TradeHistoryEntry, ) @@ -34,6 +35,17 @@ def apply_trades( """ raise NotImplementedError + 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) + 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)."""