Skip to content
Merged
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
31 changes: 30 additions & 1 deletion python/valuecell/agents/strategy_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,41 @@ 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,
cycle_index=result.cycle_index,
rationale=result.rationale,
)
except Exception:
logger.warning(
"Failed to persist compose cycle for strategy={} compose_id={}",
strategy_id,
result.compose_id,
)

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,
result.compose_id,
)

for trade in result.trades:
item = strategy_persistence.persist_trade_history(strategy_id, trade)
if item:
logger.info(
"Persisted trade {} for strategy={}",
getattr(trade, "trade_id", None),
trade.trade_id,
strategy_id,
)

Expand Down
20 changes: 18 additions & 2 deletions python/valuecell/agents/strategy_agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -41,6 +41,8 @@ class DecisionCycleResult:

compose_id: str
timestamp_ms: int
cycle_index: int
rationale: Optional[str]
strategy_summary: StrategySummary
instructions: List[TradeInstruction]
trades: List[TradeHistoryEntry]
Expand Down Expand Up @@ -209,7 +211,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(
Expand Down Expand Up @@ -254,6 +258,8 @@ 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,
trades=trades,
Expand Down Expand Up @@ -363,6 +369,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,
Expand Down Expand Up @@ -395,6 +406,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,
Expand Down
89 changes: 60 additions & 29 deletions python/valuecell/agents/strategy_agent/decision/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +22,7 @@
UserRequest,
)
from ..utils import extract_price_map, send_discord_message
from .interfaces import Composer
from .interfaces import Composer, ComposeResult
from .system_prompt import SYSTEM_PROMPT


Expand Down Expand Up @@ -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)
Expand All @@ -63,21 +63,19 @@ async def compose(self, context: ComposeContext) -> List[TradeInstruction]:
context.compose_id,
plan.rationale,
)
return []
except ValidationError as exc:
logger.error("LLM output failed validation: {}", exc)
return []
except Exception: # noqa: BLE001
logger.exception("LLM invocation failed")
return []
return ComposeResult(instructions=[], rationale=plan.rationale)
except Exception as exc: # noqa: BLE001
logger.error("LLM invocation failed: {}", exc)
return ComposeResult(instructions=[], rationale="LLM invocation failed")

# Optionally forward non-NOOP plan rationale to Discord webhook (env-driven)
try:
await self._send_plan_to_discord(plan)
except Exception as exc: # do not fail compose on notification errors
logger.error("Failed sending plan to Discord: {}", exc)

return self._normalize_plan(context, plan)
normalized = self._normalize_plan(context, plan)
return ComposeResult(instructions=normalized, rationale=plan.rationale)

# ------------------------------------------------------------------
# Prompt + LLM helpers
Expand Down Expand Up @@ -198,8 +196,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
Expand Down Expand Up @@ -260,9 +258,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",
)

async def _send_plan_to_discord(self, plan: LlmPlanProposal) -> None:
"""Send plan rationale to Discord when there are actionable items.
Expand Down Expand Up @@ -331,16 +339,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:
Expand Down Expand Up @@ -409,9 +417,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)

Expand Down Expand Up @@ -449,11 +455,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
Expand Down Expand Up @@ -509,9 +532,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

Expand Down
14 changes: 11 additions & 3 deletions python/valuecell/agents/strategy_agent/decision/interfaces.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
25 changes: 20 additions & 5 deletions python/valuecell/agents/strategy_agent/decision/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):

Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions python/valuecell/agents/strategy_agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -732,12 +735,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)
Expand Down
Loading