Skip to content

Commit b60ae3d

Browse files
committed
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.
1 parent c5224d7 commit b60ae3d

File tree

12 files changed

+293
-7
lines changed

12 files changed

+293
-7
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ async def run_once(self) -> DecisionCycleResult:
179179
compose_result = await self._composer.compose(context)
180180
instructions = compose_result.instructions
181181
rationale = compose_result.rationale
182+
stop_prices = compose_result.stop_prices
182183
logger.info(f"🔍 Composer returned {len(instructions)} instructions")
183184
for idx, inst in enumerate(instructions):
184185
logger.info(
@@ -229,6 +230,7 @@ async def run_once(self) -> DecisionCycleResult:
229230

230231
trades = self._create_trades(tx_results, compose_id, timestamp_ms)
231232
self.portfolio_service.apply_trades(trades, market_features)
233+
self.portfolio_service.update_stop_prices(stop_prices)
232234
summary = self.build_summary(timestamp_ms, trades)
233235

234236
history_records = self._create_history_records(
@@ -253,6 +255,7 @@ async def run_once(self) -> DecisionCycleResult:
253255
history_records=history_records,
254256
digest=digest,
255257
portfolio_view=portfolio,
258+
stop_prices=stop_prices,
256259
)
257260

258261
def _create_trades(

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515
InMemoryHistoryRecorder,
1616
RollingDigestBuilder,
1717
)
18-
from ..models import Constraints, DecisionCycleResult, TradingMode, UserRequest
18+
from ..models import (
19+
Constraints,
20+
DecisionCycleResult,
21+
StopPrice,
22+
TradingMode,
23+
UserRequest,
24+
)
1925
from ..portfolio.in_memory import InMemoryPortfolioService
2026
from ..utils import fetch_free_cash_from_gateway, fetch_positions_from_gateway
2127
from .coordinator import DefaultDecisionCoordinator
@@ -122,6 +128,7 @@ async def create_strategy_runtime(
122128
# so the in-memory portfolio starts with the previously recorded equity.
123129
free_cash_override = None
124130
total_cash_override = None
131+
stop_prices = {}
125132
if strategy_id_override:
126133
try:
127134
repo = get_strategy_repository()
@@ -140,6 +147,19 @@ async def create_strategy_runtime(
140147
"Initialized runtime initial capital from persisted snapshot for strategy_id=%s",
141148
strategy_id_override,
142149
)
150+
stop_prices = {
151+
stop_price.symbol: StopPrice(
152+
symbol=stop_price.symbol,
153+
stop_gain_price=stop_price.stop_gain_price,
154+
stop_loss_price=stop_price.stop_loss_price,
155+
)
156+
for stop_price in repo.get_stop_prices(strategy_id_override)
157+
}
158+
logger.info(
159+
"Initialized runtime stop prices {} from persisted snapshot for strategy_id {}",
160+
stop_prices,
161+
strategy_id_override,
162+
)
143163
except Exception:
144164
logger.exception(
145165
"Failed to initialize initial capital from persisted snapshot for strategy_id=%s",
@@ -160,6 +180,7 @@ async def create_strategy_runtime(
160180
market_type=request.exchange_config.market_type,
161181
constraints=constraints,
162182
strategy_id=strategy_id,
183+
stop_prices=stop_prices,
163184
)
164185

165186
# Use custom composer if provided, otherwise default to LlmComposer

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,12 @@ def persist_cycle_results(self, result: DecisionCycleResult) -> None:
271271
"Persisted portfolio view for strategy={}", self.strategy_id
272272
)
273273

274+
ok = strategy_persistence.persist_stop_prices(
275+
self.strategy_id, result.stop_prices
276+
)
277+
if ok:
278+
logger.info("Persisted stop prices for strategy={}", self.strategy_id)
279+
274280
ok = strategy_persistence.persist_strategy_summary(result.strategy_summary)
275281
if ok:
276282
logger.info(

python/valuecell/agents/common/trading/decision/prompt_based/composer.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ async def compose(self, context: ComposeContext) -> ComposeResult:
111111
logger.error("Failed sending plan to Discord: {}", exc)
112112

113113
normalized = self._normalize_plan(context, plan)
114-
return ComposeResult(instructions=normalized, rationale=plan.rationale)
114+
return ComposeResult(
115+
instructions=normalized,
116+
rationale=plan.rationale,
117+
stop_prices=plan.stop_prices,
118+
)
115119

116120
# ------------------------------------------------------------------
117121

@@ -150,16 +154,21 @@ def _build_llm_prompt(self, context: ComposeContext) -> str:
150154
market = extract_market_section(features.get("market_snapshot", []))
151155

152156
# Portfolio positions
153-
positions = [
154-
{
155-
"symbol": sym,
157+
positions = {
158+
sym: {
159+
"avg_price": snap.avg_price,
156160
"qty": float(snap.quantity),
157161
"unrealized_pnl": snap.unrealized_pnl,
158162
"entry_ts": snap.entry_ts,
159163
}
160164
for sym, snap in pv.positions.items()
161165
if abs(float(snap.quantity)) > 0
162-
]
166+
}
167+
for symbol, stop_price in pv.stop_prices.items():
168+
if symbol not in positions:
169+
continue
170+
positions[symbol]["stop_gain_price"] = stop_price.stop_gain_price
171+
positions[symbol]["stop_loss_price"] = stop_price.stop_loss_price
163172

164173
# Constraints
165174
constraints = (
@@ -200,6 +209,7 @@ async def _call_llm(self, prompt: str) -> TradePlanProposal:
200209
agent's `response.content` is returned (or validated) as a
201210
`LlmPlanProposal`.
202211
"""
212+
logger.debug("LLM prompt {}", prompt)
203213
response = await self.agent.arun(prompt)
204214
# Agent may return a raw object or a wrapper with `.content`.
205215
content = getattr(response, "content", None) or response
@@ -240,6 +250,13 @@ async def _send_plan_to_discord(self, plan: TradePlanProposal) -> None:
240250
if top_r:
241251
parts.append("**Overall rationale:**\n")
242252
parts.append(f"{top_r}\n")
253+
if len(plan.stop_prices) > 0:
254+
parts.append("**Updated stop prices:**")
255+
for stop_price in plan.stop_prices:
256+
parts.append(
257+
f"{stop_price.symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}"
258+
)
259+
parts.append("")
243260

244261
parts.append("**Items:**\n")
245262
for it in actionable:

python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- 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.
1919
- For spot: only open_long/close_long are valid; open_short/close_short will be treated as reducing toward 0 or ignored.
2020
- One item per symbol at most. No hedging (never propose both long and short exposure on the same symbol).
21+
- 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.
2122
2223
CONSTRAINTS & VALIDATION
2324
- 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 @@
3233
- Prefer fewer, higher-quality actions; choose noop when edge is weak.
3334
- 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.
3435
- 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.
36+
- Respect the stop prices - do not close position if stop prices are not hit
3537
3638
OUTPUT & EXPLANATION
3739
- Always include a brief top-level rationale summarizing your decision basis.
3840
- 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).
3941
- 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.
42+
- For open_long and open_short actions, always include stop loss and stop gain prices for the symbol.
4043
4144
MARKET FEATURES
4245
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:

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,10 @@ class PortfolioView(BaseModel):
550550
" effective leverage if available, otherwise falls back to constraints.max_leverage."
551551
),
552552
)
553+
stop_prices: Dict[str, "StopPrice"] = Field(
554+
default_factory=list,
555+
description="List of stop prices for existing positions and positions to open.",
556+
)
553557

554558

555559
class TradeDecisionAction(str, Enum):
@@ -587,6 +591,18 @@ def derive_side_from_action(
587591
return None
588592

589593

594+
class StopPrice(BaseModel):
595+
symbol: str = Field(..., description="Exchange symbol, e.g., BTC/USDT")
596+
stop_gain_price: Optional[float] = Field(
597+
...,
598+
description="Stop gain price for this position.",
599+
)
600+
stop_loss_price: Optional[float] = Field(
601+
...,
602+
description="Stop loss price for this position.",
603+
)
604+
605+
590606
class TradeDecisionItem(BaseModel):
591607
"""Trade plan item. Interprets target_qty as operation size (magnitude).
592608
@@ -641,6 +657,10 @@ class TradePlanProposal(BaseModel):
641657
rationale: Optional[str] = Field(
642658
default=None, description="Optional natural language rationale"
643659
)
660+
stop_prices: List[StopPrice] = Field(
661+
default_factory=list,
662+
description="List of stop prices for existing positions and positions to open.",
663+
)
644664

645665

646666
class PriceMode(str, Enum):
@@ -934,6 +954,7 @@ class ComposeResult(BaseModel):
934954

935955
instructions: List[TradeInstruction]
936956
rationale: Optional[str] = None
957+
stop_prices: List[StopPrice] = []
937958

938959

939960
class FeaturesPipelineResult(BaseModel):
@@ -956,3 +977,4 @@ class DecisionCycleResult:
956977
history_records: List[HistoryRecord]
957978
digest: TradeDigest
958979
portfolio_view: PortfolioView
980+
stop_prices: List[StopPrice]

python/valuecell/agents/common/trading/portfolio/in_memory.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
MarketType,
88
PortfolioView,
99
PositionSnapshot,
10+
StopPrice,
1011
TradeHistoryEntry,
1112
TradeSide,
1213
TradeType,
1314
TradingMode,
1415
)
1516
from valuecell.agents.common.trading.utils import extract_price_map
17+
from valuecell.server.db.models import StrategyStopPrices
1618

1719
from .interfaces import BasePortfolioService
1820

@@ -41,6 +43,7 @@ def __init__(
4143
initial_positions: Dict[str, PositionSnapshot],
4244
trading_mode: TradingMode,
4345
market_type: MarketType,
46+
stop_prices: Dict[str, StrategyStopPrices],
4447
constraints: Optional[Constraints] = None,
4548
strategy_id: Optional[str] = None,
4649
) -> None:
@@ -75,6 +78,7 @@ def __init__(
7578
total_realized_pnl=0.0,
7679
buying_power=free_cash,
7780
free_cash=free_cash,
81+
stop_prices=stop_prices,
7882
)
7983
self._trading_mode = trading_mode
8084
self._market_type = market_type
@@ -89,6 +93,22 @@ def get_view(self) -> PortfolioView:
8993
pass
9094
return self._view
9195

96+
def update_stop_prices(self, stop_prices: List[StopPrice]) -> None:
97+
for stop_price in stop_prices:
98+
if stop_price.symbol in self._view.stop_prices:
99+
self._view.stop_prices[stop_price.symbol].stop_gain_price = (
100+
stop_price.stop_gain_price
101+
if stop_price.stop_gain_price is not None
102+
else self._view.stop_prices[stop_price.symbol].stop_gain_price
103+
)
104+
self._view.stop_prices[stop_price.symbol].stop_loss_price = (
105+
stop_price.stop_loss_price
106+
if stop_price.stop_loss_price is not None
107+
else self._view.stop_prices[stop_price.symbol].stop_loss_price
108+
)
109+
else:
110+
self._view.stop_prices[stop_price.symbol] = stop_price
111+
92112
def apply_trades(
93113
self, trades: List[TradeHistoryEntry], market_features: List[FeatureVector]
94114
) -> None:

python/valuecell/agents/common/trading/portfolio/interfaces.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from valuecell.agents.common.trading.models import (
77
FeatureVector,
88
PortfolioView,
9+
StopPrice,
910
TradeHistoryEntry,
1011
)
1112

@@ -34,6 +35,17 @@ def apply_trades(
3435
"""
3536
raise NotImplementedError
3637

38+
def update_stop_prices(self, stop_prices: List[StopPrice]) -> None:
39+
"""Update the stop prices to the portfolio view.
40+
41+
Implementations that support state changes (paper trading, backtests)
42+
should update their internal view accordingly. `stop_prices`
43+
a vector of stop (gain/loss) prices for each symbol. This method
44+
is optional for read-only portfolio services, but providing it here
45+
makes the contract explicit to callers.
46+
"""
47+
raise NotImplementedError
48+
3749

3850
class BasePortfolioSnapshotStore(ABC):
3951
"""Persist/load portfolio snapshots (optional for paper/backtest modes)."""

python/valuecell/server/db/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .strategy_holding import StrategyHolding
1818
from .strategy_instruction import StrategyInstruction
1919
from .strategy_portfolio import StrategyPortfolioView
20+
from .strategy_stop_price import StrategyStopPrices
2021
from .user_profile import ProfileCategory, UserProfile
2122
from .watchlist import Watchlist, WatchlistItem
2223

@@ -35,4 +36,5 @@
3536
"StrategyPortfolioView",
3637
"StrategyComposeCycle",
3738
"StrategyInstruction",
39+
"StrategyStopPrices",
3840
]

0 commit comments

Comments
 (0)