Skip to content
14 changes: 14 additions & 0 deletions python/valuecell/agents/strategy_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ async def stream(
logger.exception("StrategyAgent stream failed: {}", err)
yield streaming.message_chunk(f"StrategyAgent error: {err}")
finally:
# Close runtime resources (e.g., CCXT exchange) before marking stopped
try:
if hasattr(runtime, "coordinator") and hasattr(
runtime.coordinator, "close"
):
await runtime.coordinator.close()
logger.info(
"Closed runtime coordinator resources for strategy {}",
strategy_id,
)
except Exception:
logger.exception(
"Failed to close runtime resources for strategy {}", strategy_id
)
# Always mark strategy as stopped when stream ends for any reason
try:
strategy_persistence.mark_strategy_stopped(strategy_id)
Expand Down
16 changes: 13 additions & 3 deletions python/valuecell/agents/strategy_agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,16 @@ async def run_once(self) -> DecisionCycleResult:
else:
for q in ("USDT", "USD", "USDC"):
free_cash += float(free_map.get(q, 0.0) or 0.0)
portfolio.cash = float(free_cash)
portfolio.account_balance = float(free_cash)
if self._request.exchange_config.market_type == MarketType.SPOT:
portfolio.buying_power = max(0.0, float(portfolio.cash))
portfolio.buying_power = max(0.0, float(portfolio.account_balance))
except Exception:
# If syncing fails, continue with existing portfolio view
pass
# VIRTUAL mode: cash-only for spot; derivatives keep margin-based buying power
if self._request.exchange_config.trading_mode == TradingMode.VIRTUAL:
if self._request.exchange_config.market_type == MarketType.SPOT:
portfolio.buying_power = max(0.0, float(portfolio.cash))
portfolio.buying_power = max(0.0, float(portfolio.account_balance))

# Use fixed 1-second interval and lookback of 3 minutes (60 * 3 seconds)
candles_1s = await self._market_data_source.get_recent_candles(
Expand Down Expand Up @@ -559,3 +559,13 @@ def _create_history_records(
payload={"trades": trade_payload},
),
]

async def close(self) -> None:
"""Release resources for the execution gateway if it supports closing."""
try:
close_fn = getattr(self._execution_gateway, "close", None)
if callable(close_fn):
await close_fn()
except Exception:
# Avoid bubbling cleanup errors
pass
7 changes: 4 additions & 3 deletions python/valuecell/agents/strategy_agent/data/market.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ async def _fetch(symbol: str) -> List[List]:
interval=interval,
)
)
except Exception:
logger.exception(
"Failed to fetch candles for {} from {}, return empty candles",
except Exception as exc:
logger.error(
"Failed to fetch candles for {} from {}, return empty candles. Error: {}",
symbol,
self._exchange_id,
exc,
)
return candles

Expand Down
61 changes: 47 additions & 14 deletions python/valuecell/agents/strategy_agent/decision/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
LlmDecisionAction,
LlmPlanProposal,
MarketType,
PriceMode,
TradeInstruction,
TradeSide,
UserRequest,
Expand Down Expand Up @@ -164,10 +163,11 @@ def _build_summary(self, context: ComposeContext) -> Dict:
for snap in pv.positions.values()
if abs(float(getattr(snap, "quantity", 0.0) or 0.0)) > 0.0
),
"total_value": getattr(pv, "total_value", None),
"cash": pv.cash,
"unrealized_pnl": getattr(pv, "total_unrealized_pnl", None),
"sharpe_ratio": getattr(context.digest, "sharpe_ratio", None),
"total_value": pv.total_value,
"account_balance": pv.account_balance,
"free_cash": pv.free_cash,
"unrealized_pnl": pv.total_unrealized_pnl,
"sharpe_ratio": context.digest.sharpe_ratio,
}

def _build_llm_prompt(self, context: ComposeContext) -> str:
Expand Down Expand Up @@ -631,15 +631,28 @@ def _create_instruction(
if item.rationale:
meta["rationale"] = item.rationale

# For derivatives/perpetual markets, mark reduceOnly when instruction reduces absolute exposure to avoid accidental reverse opens
try:
if self._request.exchange_config.market_type != MarketType.SPOT:
if abs(final_target) < abs(current_qty):
meta["reduceOnly"] = True
# Bybit uses a different param key
if (
self._request.exchange_config.exchange_id or ""
).lower() == "bybit":
meta["reduce_only"] = True
except Exception:
# Ignore any exception; do not block instruction creation
pass

instruction = TradeInstruction(
instruction_id=f"{context.compose_id}:{symbol}:{idx}",
compose_id=context.compose_id,
instrument=item.instrument,
action=item.action,
side=side,
quantity=quantity,
leverage=final_leverage,
price_mode=PriceMode.MARKET,
limit_price=None,
max_slippage_bps=self._default_slippage_bps,
meta=meta,
)
Expand All @@ -659,18 +672,38 @@ def _resolve_target_quantity(
current_qty: float,
max_position_qty: Optional[float],
) -> float:
# If the composer requested NOOP, keep current quantity
# NOOP: keep current position
if item.action == LlmDecisionAction.NOOP:
return current_qty

# Interpret target_qty as a magnitude; apply action to determine sign
mag = float(item.target_qty)
if item.action == LlmDecisionAction.SELL:
target = -abs(mag)
# Interpret target_qty as operation magnitude (not final position), normalized to positive
mag = abs(float(item.target_qty))
target = current_qty

# Compute target position per open/close long/short action
if item.action == LlmDecisionAction.OPEN_LONG:
base = current_qty if current_qty > 0 else 0.0
target = base + mag
elif item.action == LlmDecisionAction.OPEN_SHORT:
base = current_qty if current_qty < 0 else 0.0
target = base - mag
elif item.action == LlmDecisionAction.CLOSE_LONG:
if current_qty > 0:
target = max(current_qty - mag, 0.0)
else:
# No long position, keep unchanged
target = current_qty
elif item.action == LlmDecisionAction.CLOSE_SHORT:
if current_qty < 0:
target = min(current_qty + mag, 0.0)
else:
# No short position, keep unchanged
target = current_qty
else:
# default to BUY semantics
target = abs(mag)
# Fallback: treat unknown action as NOOP
target = current_qty

# Clamp by max_position_qty (symmetric)
if max_position_qty is not None:
max_abs = abs(float(max_position_qty))
target = max(-max_abs, min(max_abs, target))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
You are an autonomous trading planner that outputs a structured plan for a crypto strategy executor. Your objective is to maximize risk-adjusted returns while preserving capital. You are stateless across cycles.

ACTION SEMANTICS
- target_qty is the desired FINAL signed position quantity: >0 long, <0 short, 0 flat (close). The executor computes delta = target_qty − current_qty to create orders.
- To close, set target_qty to 0. Do not invent other action names.
- action must be one of: open_long, open_short, close_long, close_short, noop.
- target_qty is the OPERATION SIZE (units) for this action, not the final position. It is a positive magnitude; the executor computes target position from the action and current_qty, then derives delta and orders.
- 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).

CONSTRAINTS & VALIDATION
- Respect max_positions, max_leverage, max_position_qty, quantity_step, min_trade_qty, max_order_qty, min_notional, and available buying power.
- Keep leverage positive if provided. Confidence must be in [0,1].
- If arrays appear in Context, they are ordered: OLDEST - NEWEST (last isthe most recent).
- If arrays appear in Context, they are ordered: OLDEST NEWEST (last is the most recent).
- If risk_flags contain low_buying_power or high_leverage_usage, prefer reducing size or choosing noop. If approaching_max_positions is set, prioritize managing existing positions over opening new ones.
- 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.

Expand Down
Loading