Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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,
)

Expand Down
23 changes: 22 additions & 1 deletion python/valuecell/agents/common/trading/_internal/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)

# ------------------------------------------------------------------

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions python/valuecell/agents/common/trading/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand All @@ -934,6 +957,7 @@ class ComposeResult(BaseModel):

instructions: List[TradeInstruction]
rationale: Optional[str] = None
stop_prices: Dict[str, StopPrice] = {}


class FeaturesPipelineResult(BaseModel):
Expand Down
13 changes: 13 additions & 0 deletions python/valuecell/agents/common/trading/portfolio/in_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
MarketType,
PortfolioView,
PositionSnapshot,
StopPrice,
TradeHistoryEntry,
TradeSide,
TradeType,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion python/valuecell/agents/common/trading/portfolio/interfaces.py
Original file line number Diff line number Diff line change
@@ -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,
)

Expand Down Expand Up @@ -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)."""
Expand Down