diff --git a/python/valuecell/agents/common/trading/data/market.py b/python/valuecell/agents/common/trading/data/market.py index 68484892c..2ecf1a87f 100644 --- a/python/valuecell/agents/common/trading/data/market.py +++ b/python/valuecell/agents/common/trading/data/market.py @@ -9,7 +9,6 @@ MarketSnapShotType, ) from valuecell.agents.common.trading.utils import get_exchange_cls, normalize_symbol -from valuecell.utils.i18n_utils import detect_user_region from .interfaces import BaseMarketDataSource @@ -26,18 +25,7 @@ class SimpleMarketDataSource(BaseMarketDataSource): def __init__(self, exchange_id: Optional[str] = None) -> None: if not exchange_id: - # Auto-detect region and select appropriate exchange - region = detect_user_region() - if region == "us": - # Use OKX for United States users (best support for USDT perpetuals) - self._exchange_id = "okx" - logger.info( - "Detected US region, using okx exchange (USDT perpetuals supported)" - ) - else: - # Use regular Binance for other regions - self._exchange_id = "binance" - logger.info("Detected non-US region, using binance exchange") + self._exchange_id = "okx" else: self._exchange_id = exchange_id diff --git a/python/valuecell/agents/common/trading/decision/grid_composer/grid_composer.py b/python/valuecell/agents/common/trading/decision/grid_composer/grid_composer.py index eaab8e8d0..fe9578bad 100644 --- a/python/valuecell/agents/common/trading/decision/grid_composer/grid_composer.py +++ b/python/valuecell/agents/common/trading/decision/grid_composer/grid_composer.py @@ -1,10 +1,15 @@ from __future__ import annotations import math -from typing import List, Optional +from typing import List, Optional, Tuple from loguru import logger +from valuecell.agents.common.trading.constants import ( + FEATURE_GROUP_BY_KEY, + FEATURE_GROUP_BY_MARKET_SNAPSHOT, +) + from ...models import ( ComposeContext, ComposeResult, @@ -16,6 +21,7 @@ UserRequest, ) from ..interfaces import BaseComposer +from .llm_param_advisor import GridParamAdvisor class GridComposer(BaseComposer): @@ -42,6 +48,7 @@ def __init__( step_pct: float = 0.005, max_steps: int = 3, base_fraction: float = 0.08, + use_llm_params: bool = False, default_slippage_bps: int = 25, quantity_precision: float = 1e-9, ) -> None: @@ -53,29 +60,273 @@ def __init__( self._step_pct = float(step_pct) self._max_steps = int(max_steps) self._base_fraction = float(base_fraction) + self._use_llm_params = bool(use_llm_params) + self._llm_params_applied = False + # Optional grid zone and discretization + self._grid_lower_pct: Optional[float] = None + self._grid_upper_pct: Optional[float] = None + self._grid_count: Optional[int] = None + # Dynamic LLM advice refresh control + self._last_llm_advice_ts: Optional[int] = None + self._llm_advice_refresh_sec: int = 300 + self._llm_advice_rationale: Optional[str] = None + # Apply stability: do not change params frequently unless market clearly shifts + self._market_change_threshold_pct: float = ( + 0.01 # 1% absolute change triggers update + ) + # Minimum grid zone bounds (relative to avg price) to ensure clear trading window + self._min_grid_zone_pct: float = 0.10 # at least ±10% + # Limit per-update grid_count change to avoid oscillation + self._max_grid_count_delta: int = 2 + + def _max_abs_change_pct(self, context: ComposeContext) -> Optional[float]: + symbols = list(self._request.trading_config.symbols or []) + max_abs: Optional[float] = None + for fv in context.features or []: + try: + sym = str(getattr(fv.instrument, "symbol", "")) + if sym not in symbols: + continue + change = fv.values.get("change_pct") + if change is None: + change = fv.values.get("price.change_pct") + if change is None: + last_px = fv.values.get("price.last") or fv.values.get( + "price.close" + ) + open_px = fv.values.get("price.open") + if last_px is not None and open_px is not None: + try: + change = (float(last_px) - float(open_px)) / float(open_px) + except Exception: + change = None + if change is None: + continue + val = abs(float(change)) + if (max_abs is None) or (val > max_abs): + max_abs = val + except Exception: + continue + return max_abs + + def _has_clear_market_change(self, context: ComposeContext) -> bool: + try: + max_abs = self._max_abs_change_pct(context) + if max_abs is None: + return False + return max_abs >= float(self._market_change_threshold_pct) + except Exception: + return False + + def _zone_suffix(self, context: ComposeContext) -> str: + """Return a concise zone description suffix for rationales. + Prefer price ranges based on positions' avg_price; fall back to pct. + """ + if (self._grid_lower_pct is None) and (self._grid_upper_pct is None): + return "" + try: + zone_entries = [] + positions = getattr(context.portfolio, "positions", None) or {} + for sym, pos in positions.items(): + avg_px = getattr(pos, "avg_price", None) + if avg_px is None or float(avg_px) <= 0.0: + continue + lower_bound = float(avg_px) * (1.0 - float(self._grid_lower_pct or 0.0)) + upper_bound = float(avg_px) * (1.0 + float(self._grid_upper_pct or 0.0)) + zone_entries.append(f"{sym}=[{lower_bound:.4f}, {upper_bound:.4f}]") + if zone_entries: + return " — zone_prices(" + "; ".join(zone_entries) + ")" + except Exception: + pass + return f" — zone_pct=[-{float(self._grid_lower_pct or 0.0):.4f}, +{float(self._grid_upper_pct or 0.0):.4f}]" async def compose(self, context: ComposeContext) -> ComposeResult: + ts = int(context.ts) + # 0) Refresh interval is internal (no user-configurable grid_* fields) + + # 1) User grid overrides removed — parameters decided by the model only + + # 2) Refresh LLM advice periodically (always enabled) + try: + source_is_llm = True + should_refresh = ( + (self._last_llm_advice_ts is None) + or ( + (ts - int(self._last_llm_advice_ts)) + >= int(self._llm_advice_refresh_sec) + ) + or (not self._llm_params_applied) + ) + if source_is_llm and should_refresh: + prev_params = { + "grid_step_pct": self._step_pct, + "grid_max_steps": self._max_steps, + "grid_base_fraction": self._base_fraction, + "grid_lower_pct": self._grid_lower_pct, + "grid_upper_pct": self._grid_upper_pct, + "grid_count": self._grid_count, + } + advisor = GridParamAdvisor(self._request, prev_params=prev_params) + advice = await advisor.advise(context) + if advice: + # Decide whether to apply new params based on market change + apply_new = ( + not self._llm_params_applied + ) or self._has_clear_market_change(context) + if apply_new: + # Apply advised params with sanity clamps — model decides dynamically + self._step_pct = max(1e-6, float(advice.grid_step_pct)) + self._max_steps = max(1, int(advice.grid_max_steps)) + self._base_fraction = max( + 1e-6, float(advice.grid_base_fraction) + ) + # Optional zone and grid discretization with minimum ±10% bounds + if getattr(advice, "grid_lower_pct", None) is not None: + proposed_lower = max(0.0, float(advice.grid_lower_pct)) + else: + proposed_lower = self._min_grid_zone_pct + if getattr(advice, "grid_upper_pct", None) is not None: + proposed_upper = max(0.0, float(advice.grid_upper_pct)) + else: + proposed_upper = self._min_grid_zone_pct + # Enforce minimum zone widths + self._grid_lower_pct = max( + self._min_grid_zone_pct, proposed_lower + ) + self._grid_upper_pct = max( + self._min_grid_zone_pct, proposed_upper + ) + if getattr(advice, "grid_count", None) is not None: + proposed_count = max(1, int(advice.grid_count)) + if self._grid_count is not None: + # Clamp change to avoid abrupt jumps (±self._max_grid_count_delta) + lower_bound = max( + 1, + int(self._grid_count) + - int(self._max_grid_count_delta), + ) + upper_bound = int(self._grid_count) + int( + self._max_grid_count_delta + ) + self._grid_count = max( + lower_bound, min(upper_bound, proposed_count) + ) + else: + self._grid_count = proposed_count + total_span = (self._grid_lower_pct or 0.0) + ( + self._grid_upper_pct or 0.0 + ) + if total_span > 0.0: + self._step_pct = max( + 1e-6, total_span / float(self._grid_count) + ) + self._max_steps = max(1, int(self._grid_count)) + self._llm_params_applied = True + logger.info( + "Applied dynamic LLM grid params: step_pct={}, max_steps={}, base_fraction={}, lower={}, upper={}, count={}", + self._step_pct, + self._max_steps, + self._base_fraction, + self._grid_lower_pct, + self._grid_upper_pct, + self._grid_count, + ) + else: + logger.info( + "Suppressed grid param update due to stable market (threshold={}): keeping step_pct={}, max_steps={}, base_fraction={}", + self._market_change_threshold_pct, + self._step_pct, + self._max_steps, + self._base_fraction, + ) + # Capture advisor rationale when available + try: + self._llm_advice_rationale = getattr( + advice, "advisor_rationale", None + ) + except Exception: + self._llm_advice_rationale = None + self._last_llm_advice_ts = ts + except Exception: + # Non-fatal; continue with configured defaults + pass + # Prepare buying power/constraints/price map, then generate plan and reuse parent normalization equity, allowed_lev, constraints, _projected_gross, price_map = ( self._init_buying_power_context(context) ) items: List[TradeDecisionItem] = [] - ts = int(context.ts) # Pre-fetch micro change percentage from features (prefer 1s, fallback 1m) - def latest_change_pct(symbol: str) -> Optional[float]: + def latest_change_pct( + symbol: str, *, allow_market_snapshot: bool = True + ) -> Optional[float]: best: Optional[float] = None best_rank = 999 for fv in context.features or []: try: if str(getattr(fv.instrument, "symbol", "")) != symbol: continue - interval = (fv.meta or {}).get("interval") + + meta = fv.meta or {} + interval = meta.get("interval") + group_key = meta.get(FEATURE_GROUP_BY_KEY) + + # 1) Primary: candle features provide bare `change_pct` with interval change = fv.values.get("change_pct") + used_market_snapshot = False + + # 2) Fallback: market snapshot provides `price.change_pct` + if change is None: + if not allow_market_snapshot: + # Skip market snapshot-based percent change when disallowed + pass + else: + change = fv.values.get("price.change_pct") + used_market_snapshot = change is not None + + # 3) Last resort: infer from price.last/close vs price.open + if change is None: + # Only allow price-based inference for candle intervals when snapshot disallowed + if allow_market_snapshot or (interval in ("1s", "1m")): + last_px = fv.values.get("price.last") or fv.values.get( + "price.close" + ) + open_px = fv.values.get("price.open") + if last_px is not None and open_px is not None: + try: + o = float(open_px) + last_price = float(last_px) + if o > 0: + change = last_price / o - 1.0 + used_market_snapshot = ( + group_key + == FEATURE_GROUP_BY_MARKET_SNAPSHOT + ) + except Exception: + # ignore parse errors + pass + if change is None: continue - rank = 0 if interval == "1s" else (1 if interval == "1m" else 2) + + # Ranking preference: + # - 1s candle features are best + # - Market snapshot next (often closest to real-time) + # - 1m candle features then + # - Anything else last + if interval == "1s": + rank = 0 + elif ( + group_key == FEATURE_GROUP_BY_MARKET_SNAPSHOT + ) or used_market_snapshot: + rank = 1 + elif interval == "1m": + rank = 2 + else: + rank = 3 + if rank < best_rank: best = float(change) best_rank = rank @@ -83,22 +334,100 @@ def latest_change_pct(symbol: str) -> Optional[float]: continue return best + def snapshot_price_debug(symbol: str) -> str: + keys = ( + "price.last", + "price.close", + "price.open", + "price.bid", + "price.ask", + "price.mark", + "funding.mark_price", + ) + found: List[str] = [] + for fv in context.features or []: + try: + if str(getattr(fv.instrument, "symbol", "")) != symbol: + continue + meta = fv.meta or {} + group_key = meta.get(FEATURE_GROUP_BY_KEY) + if group_key != FEATURE_GROUP_BY_MARKET_SNAPSHOT: + continue + for k in keys: + val = fv.values.get(k) + if val is not None: + try: + num = float(val) + found.append(f"{k}={num:.4f}") + except Exception: + found.append(f"{k}=") + except Exception: + continue + return ", ".join(found) if found else "no snapshot price keys present" + + # Resolve previous and current price pair for the symbol using best available feature + def resolve_prev_curr_prices(symbol: str) -> Optional[Tuple[float, float]]: + best_pair: Optional[Tuple[float, float]] = None + best_rank = 999 + for fv in context.features or []: + try: + if str(getattr(fv.instrument, "symbol", "")) != symbol: + continue + meta = fv.meta or {} + interval = meta.get("interval") + group_key = meta.get(FEATURE_GROUP_BY_KEY) + open_px = fv.values.get("price.open") + last_px = fv.values.get("price.last") or fv.values.get( + "price.close" + ) + if open_px is None or last_px is None: + continue + try: + o = float(open_px) + last_price = float(last_px) + if o <= 0 or last_price <= 0: + continue + except Exception: + continue + if interval == "1s": + rank = 0 + elif group_key == FEATURE_GROUP_BY_MARKET_SNAPSHOT: + rank = 1 + elif interval == "1m": + rank = 2 + else: + rank = 3 + if rank < best_rank: + best_pair = (o, last_price) + best_rank = rank + except Exception: + continue + return best_pair + symbols = list(dict.fromkeys(self._request.trading_config.symbols)) is_spot = self._request.exchange_config.market_type == MarketType.SPOT + noop_reasons: List[str] = [] for symbol in symbols: price = float(price_map.get(symbol) or 0.0) if price <= 0: logger.debug("Skip {} due to missing/invalid price", symbol) + debug_info = snapshot_price_debug(symbol) + noop_reasons.append( + f"{symbol}: missing or invalid price ({debug_info})" + ) continue pos = context.portfolio.positions.get(symbol) qty = float(getattr(pos, "quantity", 0.0) or 0.0) avg_px = float(getattr(pos, "avg_price", 0.0) or 0.0) - # Base order size: equity fraction converted to quantity; parent applies risk controls + # Base order size per grid: equity fraction converted to quantity; parent applies risk controls base_qty = max(0.0, (equity * self._base_fraction) / price) if base_qty <= 0: + noop_reasons.append( + f"{symbol}: base_qty=0 (equity={equity:.4f}, base_fraction={self._base_fraction:.4f}, price={price:.4f})" + ) continue # Compute steps from average price when holding; without average, trigger one step @@ -109,14 +438,20 @@ def steps_from_avg(px: float, avg: float) -> int: k = int(math.floor(move_pct / max(self._step_pct, 1e-9))) return max(0, min(k, self._max_steps)) - # No position: use latest change to trigger direction (spot long-only) + # No position: open when current price crosses a grid step from previous price if abs(qty) <= self._quantity_precision: - chg = latest_change_pct(symbol) - if chg is None: - # If no change feature available, skip conservatively + pair = resolve_prev_curr_prices(symbol) + if pair is None: + noop_reasons.append( + f"{symbol}: prev/curr price unavailable; prefer NOOP" + ) continue - if chg <= -self._step_pct: - # Short-term drop → open long + prev_px, curr_px = pair + # Compute grid indices around a reference (use curr_px as temporary anchor) + # For initial opens, direction follows price movement across a step + moved_down = curr_px <= prev_px * (1.0 - self._step_pct) + moved_up = curr_px >= prev_px * (1.0 + self._step_pct) + if moved_down: items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -139,12 +474,11 @@ def steps_from_avg(px: float, avg: float) -> int: ), ) ), - confidence=min(1.0, abs(chg) / (2 * self._step_pct)), - rationale=f"Grid open-long: change_pct={chg:.4f} ≤ -step={self._step_pct:.4f}", + confidence=1.0, + rationale=f"Grid open-long: crossed down ≥1 step from prev {prev_px:.4f} to {curr_px:.4f}{self._zone_suffix(context)}", ) ) - elif (not is_spot) and chg >= self._step_pct: - # Short-term rise → open short (perpetual only) + elif (not is_spot) and moved_up: items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -161,24 +495,57 @@ def steps_from_avg(px: float, avg: float) -> int: or 1.0 ), ), - confidence=min(1.0, abs(chg) / (2 * self._step_pct)), - rationale=f"Grid open-short: change_pct={chg:.4f} ≥ step={self._step_pct:.4f}", + confidence=1.0, + rationale=f"Grid open-short: crossed up ≥1 step from prev {prev_px:.4f} to {curr_px:.4f}{self._zone_suffix(context)}", ) ) - # Otherwise NOOP + else: + noop_reasons.append( + f"{symbol}: no position — no grid step crossed (prev={prev_px:.4f}, curr={curr_px:.4f})" + ) continue - # With position: adjust around average using grid - k = steps_from_avg(price, avg_px) - if k <= 0: - # No grid step triggered → NOOP + # With position: adjust strictly when crossing grid lines from previous to current price + pair = resolve_prev_curr_prices(symbol) + if pair is None or avg_px <= 0: + noop_reasons.append( + f"{symbol}: missing prev/curr or avg price; cannot evaluate grid crossing" + ) continue + prev_px, curr_px = pair + + # Compute integer grid indices relative to avg price + def grid_index(px: float) -> int: + return int(math.floor((px / avg_px - 1.0) / max(self._step_pct, 1e-9))) + + gi_prev = grid_index(prev_px) + gi_curr = grid_index(curr_px) + delta_idx = gi_curr - gi_prev + if delta_idx == 0: + lower = avg_px * (1.0 - self._step_pct) + upper = avg_px * (1.0 + self._step_pct) + noop_reasons.append( + f"{symbol}: position — no grid index change (prev={prev_px:.4f}, curr={curr_px:.4f}) within [{lower:.4f}, {upper:.4f}]" + ) + continue + + # Optional: enforce configured grid zone around average + if (avg_px > 0) and ( + (self._grid_lower_pct is not None) or (self._grid_upper_pct is not None) + ): + lower_bound = avg_px * (1.0 - float(self._grid_lower_pct or 0.0)) + upper_bound = avg_px * (1.0 + float(self._grid_upper_pct or 0.0)) + if (price < lower_bound) or (price > upper_bound): + noop_reasons.append( + f"{symbol}: price {price:.4f} outside grid zone [{lower_bound:.4f}, {upper_bound:.4f}]" + ) + continue # Long: add on down, reduce on up if qty > 0: - down = (avg_px > 0) and (price <= avg_px * (1.0 - self._step_pct)) - up = (avg_px > 0) and (price >= avg_px * (1.0 + self._step_pct)) - if down: + # Cap per-cycle applied steps by max_steps to avoid oversized reactions + applied_steps = min(abs(delta_idx), int(self._max_steps)) + if delta_idx < 0: items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -186,7 +553,8 @@ def steps_from_avg(px: float, avg: float) -> int: exchange_id=self._request.exchange_config.exchange_id, ), action=TradeDecisionAction.OPEN_LONG, - target_qty=base_qty * k, + # per-crossing sizing: one base per grid crossed + target_qty=base_qty * applied_steps, leverage=1.0 if is_spot else min( @@ -197,11 +565,11 @@ def steps_from_avg(px: float, avg: float) -> int: or 1.0 ), ), - confidence=min(1.0, k / float(self._max_steps)), - rationale=f"Grid long add: price {price:.4f} ≤ avg {avg_px:.4f} by {k} steps", + confidence=min(1.0, applied_steps / float(self._max_steps)), + rationale=f"Grid long add: crossed {abs(delta_idx)} grid(s) down, applying {applied_steps} (prev={prev_px:.4f} → curr={curr_px:.4f}) around avg {avg_px:.4f}{self._zone_suffix(context)}", ) ) - elif up: + elif delta_idx > 0: items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -209,19 +577,18 @@ def steps_from_avg(px: float, avg: float) -> int: exchange_id=self._request.exchange_config.exchange_id, ), action=TradeDecisionAction.CLOSE_LONG, - target_qty=min(abs(qty), base_qty * k), + target_qty=min(abs(qty), base_qty * applied_steps), leverage=1.0, - confidence=min(1.0, k / float(self._max_steps)), - rationale=f"Grid long reduce: price {price:.4f} ≥ avg {avg_px:.4f} by {k} steps", + confidence=min(1.0, applied_steps / float(self._max_steps)), + rationale=f"Grid long reduce: crossed {abs(delta_idx)} grid(s) up, applying {applied_steps} (prev={prev_px:.4f} → curr={curr_px:.4f}) around avg {avg_px:.4f}{self._zone_suffix(context)}", ) ) continue # Short: add on up, cover on down if qty < 0: - up = (avg_px > 0) and (price >= avg_px * (1.0 + self._step_pct)) - down = (avg_px > 0) and (price <= avg_px * (1.0 - self._step_pct)) - if up and (not is_spot): + applied_steps = min(abs(delta_idx), int(self._max_steps)) + if delta_idx > 0 and (not is_spot): items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -229,7 +596,7 @@ def steps_from_avg(px: float, avg: float) -> int: exchange_id=self._request.exchange_config.exchange_id, ), action=TradeDecisionAction.OPEN_SHORT, - target_qty=base_qty * k, + target_qty=base_qty * applied_steps, leverage=min( float(self._request.trading_config.max_leverage or 1.0), float( @@ -238,11 +605,11 @@ def steps_from_avg(px: float, avg: float) -> int: or 1.0 ), ), - confidence=min(1.0, k / float(self._max_steps)), - rationale=f"Grid short add: price {price:.4f} ≥ avg {avg_px:.4f} by {k} steps", + confidence=min(1.0, applied_steps / float(self._max_steps)), + rationale=f"Grid short add: crossed {abs(delta_idx)} grid(s) up, applying {applied_steps} (prev={prev_px:.4f} → curr={curr_px:.4f}) around avg {avg_px:.4f}{self._zone_suffix(context)}", ) ) - elif down: + elif delta_idx < 0: items.append( TradeDecisionItem( instrument=InstrumentRef( @@ -250,24 +617,77 @@ def steps_from_avg(px: float, avg: float) -> int: exchange_id=self._request.exchange_config.exchange_id, ), action=TradeDecisionAction.CLOSE_SHORT, - target_qty=min(abs(qty), base_qty * k), + target_qty=min(abs(qty), base_qty * applied_steps), leverage=1.0, - confidence=min(1.0, k / float(self._max_steps)), - rationale=f"Grid short cover: price {price:.4f} ≤ avg {avg_px:.4f} by {k} steps", + confidence=min(1.0, applied_steps / float(self._max_steps)), + rationale=f"Grid short cover: crossed {abs(delta_idx)} grid(s) down, applying {applied_steps} (prev={prev_px:.4f} → curr={curr_px:.4f}) around avg {avg_px:.4f}{self._zone_suffix(context)}", ) ) + else: + if avg_px > 0: + lower = avg_px * (1.0 - self._step_pct) + upper = avg_px * (1.0 + self._step_pct) + noop_reasons.append( + f"{symbol}: short position — no grid index change (prev={prev_px:.4f}, curr={curr_px:.4f}) within [{lower:.4f}, {upper:.4f}]" + ) + else: + noop_reasons.append( + f"{symbol}: short position — missing avg_price" + ) continue + # Build common rationale fragments for transparency + # Grid parameters always come from the model now + src = "LLM" + zone_desc = None + if (self._grid_lower_pct is not None) or (self._grid_upper_pct is not None): + # Prefer price-based zone display using current positions' avg_price + try: + zone_entries = [] + for sym, pos in (context.portfolio.positions or {}).items(): + avg_px = getattr(pos, "avg_price", None) + if avg_px is None or float(avg_px) <= 0.0: + continue + lower_bound = float(avg_px) * ( + 1.0 - float(self._grid_lower_pct or 0.0) + ) + upper_bound = float(avg_px) * ( + 1.0 + float(self._grid_upper_pct or 0.0) + ) + zone_entries.append(f"{sym}=[{lower_bound:.4f}, {upper_bound:.4f}]") + if zone_entries: + zone_desc = "zone_prices(" + "; ".join(zone_entries) + ")" + else: + # Fallback to percent display when no avg_price available + zone_desc = f"zone_pct=[-{float(self._grid_lower_pct or 0.0):.4f}, +{float(self._grid_upper_pct or 0.0):.4f}]" + except Exception: + zone_desc = f"zone_pct=[-{float(self._grid_lower_pct or 0.0):.4f}, +{float(self._grid_upper_pct or 0.0):.4f}]" + count_desc = ( + f", count={int(self._grid_count)}" if self._grid_count is not None else "" + ) + params_desc = f"params(source={src}, step_pct={self._step_pct:.4f}, max_steps={self._max_steps}, base_fraction={self._base_fraction:.4f}" + if zone_desc: + params_desc += f", {zone_desc}" + params_desc += f"{count_desc})" + advisor_desc = ( + f"; advisor_rationale={self._llm_advice_rationale}" + if self._llm_advice_rationale + else "" + ) + if not items: logger.debug( "GridComposer produced NOOP plan for compose_id={}", context.compose_id ) - return ComposeResult(instructions=[], rationale="Grid NOOP") + # Compose a concise rationale summarizing why no actions were emitted + summary = "; ".join(noop_reasons) if noop_reasons else "no triggers hit" + rationale = f"Grid NOOP — reasons: {summary}. {params_desc}{advisor_desc}" + return ComposeResult(instructions=[], rationale=rationale) plan = TradePlanProposal( ts=ts, items=items, - rationale=f"Grid step={self._step_pct:.4f}, base_fraction={self._base_fraction:.3f}", + rationale=f"Grid plan — {params_desc}{advisor_desc}", ) # Reuse parent normalization: quantity filters, buying power, cap_factor, reduceOnly, etc. normalized = self._normalize_plan(context, plan) diff --git a/python/valuecell/agents/common/trading/decision/grid_composer/llm_param_advisor.py b/python/valuecell/agents/common/trading/decision/grid_composer/llm_param_advisor.py new file mode 100644 index 000000000..9426b7190 --- /dev/null +++ b/python/valuecell/agents/common/trading/decision/grid_composer/llm_param_advisor.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +from typing import Optional + +from agno.agent import Agent as AgnoAgent +from loguru import logger + +from valuecell.agents.common.trading.constants import ( + FEATURE_GROUP_BY_KEY, + FEATURE_GROUP_BY_MARKET_SNAPSHOT, +) +from valuecell.utils import model as model_utils + +from ...models import ComposeContext, GridParamAdvice, UserRequest + +SYSTEM_PROMPT = ( + "You are a grid parameter advisor. " + "Given the current market snapshot metrics and runtime settings, propose grid parameters dynamically. " + "Use higher sensitivity (smaller step_pct, larger max_steps) for high-liquidity, high-volatility pairs; lower sensitivity otherwise. " + "Respect typical ranges: step_pct 0.0005~0.01, max_steps 1~5, base_fraction 0.03~0.10. " + "Optionally include grid zone bounds (grid_lower_pct, grid_upper_pct) and grid_count when appropriate. " + "Calibrate base_fraction and optional grid_count using portfolio context: equity, buying_power, free_cash, and constraints.max_leverage. " + "Align parameter sensitivity with available capital and risk limits (cap_factor). Prefer smaller base_fraction and fewer steps when capital is tight. " + "Output pure JSON with fields: grid_step_pct, grid_max_steps, grid_base_fraction, and optionally grid_lower_pct, grid_upper_pct, grid_count, advisor_rationale. " + "advisor_rationale should briefly explain your thinking and operational basis (e.g., volatility, liquidity, funding, OI, buying_power) for parameter selection." +) + + +class GridParamAdvisor: + def __init__( + self, request: UserRequest, prev_params: Optional[dict] = None + ) -> None: + self._request = request + # Previous applied grid params from composer (optional), used to anchor suggestions + self._prev_params = prev_params or {} + + async def advise(self, context: ComposeContext) -> Optional[GridParamAdvice]: + cfg = self._request.llm_model_config + try: + model = model_utils.create_model_with_provider( + provider=cfg.provider, + model_id=cfg.model_id, + api_key=cfg.api_key, + ) + + # Extract a compact per-symbol snapshot of key metrics + keys = ( + "price.last", + "price.change_pct", + "price.volume", + "open_interest", + "funding.rate", + ) + metrics: dict[str, dict[str, float]] = {} + for fv in context.features or []: + try: + symbol = str(getattr(fv.instrument, "symbol", "")) + meta = fv.meta or {} + if ( + meta.get(FEATURE_GROUP_BY_KEY) + != FEATURE_GROUP_BY_MARKET_SNAPSHOT + ): + continue + if symbol not in (self._request.trading_config.symbols or []): + continue + snap = metrics.setdefault(symbol, {}) + for k in keys: + val = fv.values.get(k) + if val is not None: + try: + snap[k] = float(val) # type: ignore + except Exception: + pass + except Exception: + continue + + payload = { + "market_type": self._request.exchange_config.market_type, + "decide_interval": self._request.trading_config.decide_interval, + "symbols": self._request.trading_config.symbols, + "snapshot_metrics": metrics, + } + + # Include previous applied parameters to promote continuity and gradual changes + try: + prev = {} + for k in ( + "grid_step_pct", + "grid_max_steps", + "grid_base_fraction", + "grid_lower_pct", + "grid_upper_pct", + "grid_count", + ): + v = self._prev_params.get(k) + if v is not None: + prev[k] = float(v) if isinstance(v, (int, float)) else v + if prev: + payload["previous_params"] = prev + except Exception: + # Ignore if previous params cannot be assembled + pass + + # Include portfolio/buying power context so the model scales params realistically + try: + pv = context.portfolio + # Derive equity with safe fallbacks + equity: Optional[float] = None + try: + if getattr(pv, "total_value", None) is not None: + equity = float(pv.total_value) # type: ignore + else: + bal = float(pv.account_balance) # type: ignore + upnl = float(getattr(pv, "total_unrealized_pnl", 0.0) or 0.0) # type: ignore + equity = bal + upnl + except Exception: + equity = None + + constraints = getattr(pv, "constraints", None) + max_lev = None + try: + max_lev = ( + float(getattr(constraints, "max_leverage", None)) + if constraints is not None + and getattr(constraints, "max_leverage", None) is not None + else float(self._request.trading_config.max_leverage) + ) + except Exception: + max_lev = None + + portfolio_ctx = { + "equity": equity, + "buying_power": getattr(pv, "buying_power", None), + "free_cash": getattr(pv, "free_cash", None), + "constraints": { + "max_leverage": max_lev, + "quantity_step": getattr(constraints, "quantity_step", None) + if constraints + else None, + "min_trade_qty": getattr(constraints, "min_trade_qty", None) + if constraints + else None, + "max_order_qty": getattr(constraints, "max_order_qty", None) + if constraints + else None, + "max_position_qty": getattr( + constraints, "max_position_qty", None + ) + if constraints + else None, + }, + "cap_factor": float(self._request.trading_config.cap_factor), + } + payload["portfolio"] = portfolio_ctx + except Exception: + # Portfolio context is optional; proceed without if assembly fails + pass + + instructions = ( + "Return JSON only. Include advisor_rationale summarizing your thought process and operational basis. " + "Keep within ranges; favor smaller step_pct for high-liquidity and high-volatility pairs. " + "If funding.rate is high or open_interest large, prefer tighter grid and smaller base_fraction; otherwise be conservative. " + "Consider portfolio.equity, buying_power, free_cash, constraints.max_leverage, and cap_factor to scale base_fraction and optional grid_count. " + "Avoid suggesting parameter combinations that imply excessive total size under available buying_power. " + "Anchor suggestions to previous_params when provided; prefer gradual adjustments (e.g., limit grid_count delta within ±2 and keep step_pct changes small) unless metrics indicate a clear regime shift." + ) + prompt = ( + f"{instructions}\n\nContext:\n{json.dumps(payload, ensure_ascii=False)}" + ) + + agent = AgnoAgent( + model=model, + output_schema=GridParamAdvice, + markdown=False, + instructions=[SYSTEM_PROMPT], + use_json_mode=model_utils.model_should_use_json_mode(model), + ) + + response = await agent.arun(prompt) + content = getattr(response, "content", None) or response + if isinstance(content, GridParamAdvice): + logger.info( + "LLM grid advice: step_pct={}, max_steps={}, base_fraction={}, lower={}, upper={}, count={}, rationale={}", + content.grid_step_pct, + content.grid_max_steps, + content.grid_base_fraction, + content.grid_lower_pct, + content.grid_upper_pct, + content.grid_count, + getattr(content, "advisor_rationale", None), + ) + return content + logger.warning("LLM advice failed validation: {}", content) + except Exception as exc: + logger.error("LLM param advisor error: {}", exc) + return None 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 bb087b12e..6b1cd3536 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py @@ -170,6 +170,8 @@ def _build_llm_prompt(self, context: ComposeContext) -> str: "features.1m = structural trends (240 periods), features.1s = realtime signals (180 periods). " "market.funding_rate: positive = longs pay shorts. " "Respect constraints and risk_flags. Prefer NOOP when edge unclear. " + "Always include a concise top-level 'rationale'. " + "If you choose NOOP (items is empty), set 'rationale' to explain why: reference current prices and 'price.change_pct' vs thresholds, and any constraints or risk flags that led to NOOP. " "Output JSON with items array." ) 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 e5f01db64..bd0532924 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 @@ -33,6 +33,11 @@ - 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. +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. + 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 1d378c0ee..d2d8f88af 100644 --- a/python/valuecell/agents/common/trading/models.py +++ b/python/valuecell/agents/common/trading/models.py @@ -248,6 +248,7 @@ class TradingConfig(BaseModel): description="Notional cap factor used by the composer to limit per-symbol exposure (e.g., 1.5)", gt=0, ) + # Grid parameters are model-decided at runtime; no user-configurable grid_* fields. @field_validator("symbols") @classmethod @@ -339,6 +340,29 @@ class Candle(BaseModel): interval: str = Field(..., description='Interval string, e.g., "1m", "5m"') +class GridParamAdvice(BaseModel): + """LLM-advised grid parameter set. + + Advisor should return sensible values within typical ranges: + - grid_step_pct: 0.0005 ~ 0.01 + - grid_max_steps: 1 ~ 5 + - grid_base_fraction: 0.03 ~ 0.10 + """ + + ts: int = Field(..., description="Advice timestamp in ms") + grid_step_pct: float = Field(..., gt=0) + grid_max_steps: int = Field(..., gt=0) + grid_base_fraction: float = Field(..., gt=0) + # Optional zone and discretization + grid_lower_pct: Optional[float] = Field(default=None, gt=0) + grid_upper_pct: Optional[float] = Field(default=None, gt=0) + grid_count: Optional[int] = Field(default=None, gt=0) + advisor_rationale: Optional[str] = Field( + default=None, + description="Model-provided reasoning explaining how grid parameters were chosen", + ) + + CommonKeyType = str CommonValueType = float | str | int diff --git a/python/valuecell/agents/grid_agent/grid_agent.py b/python/valuecell/agents/grid_agent/grid_agent.py index d8d0ad665..5d47e656c 100644 --- a/python/valuecell/agents/grid_agent/grid_agent.py +++ b/python/valuecell/agents/grid_agent/grid_agent.py @@ -41,7 +41,8 @@ async def _create_decision_composer( # Adjust step_pct / max_steps / base_fraction as needed return GridComposer( request=request, - step_pct=0.005, # ~0.5% per step - max_steps=3, # up to 3 steps per cycle - base_fraction=0.08, # base order size = equity * 8% + step_pct=0.001, # 0.1% per step (more sensitive) + max_steps=3, + base_fraction=0.08, + use_llm_params=True, )