diff --git a/python/valuecell/agents/strategy_agent/README.md b/python/valuecell/agents/strategy_agent/README.md new file mode 100644 index 000000000..7b2c8c58d --- /dev/null +++ b/python/valuecell/agents/strategy_agent/README.md @@ -0,0 +1,210 @@ +# Strategy Agent (Design Overview) + +This document describes the design for the Strategy Agent: a lightweight, LLM-driven trading decision pipeline with a short, testable chain from market data to executable instructions, plus history and digest for feedback. + +- Assumptions (current stage): + - Real-time data (no explicit handling of late/out-of-order data yet) + - No complex live-trading order/fill/cancel processing (kept out of scope for now) + - Decisions are generated by an LLM inside the composer; guardrails normalize the output into executable instructions. + +## Goals + +- Keep the dependency flow one-way and simple: data → features → composer(LLM + guardrails) → execution → history/digest +- Clearly defined DTOs and interfaces so each module can be developed and tested in isolation +- Minimal surface area for configuration: the strategy prompt is a plain string (prompt_text) +- Idempotent and auditable: each composition run has a compose_id; any optional + auditing metadata (prompt hash, model name, token usage, latency, filters) + is recorded as a HistoryRecord payload (no separate report object). + +## Module Layout + +- `data/` + - `market_data.py` — Market data source (candles) abstraction(s) +- `features/` + - `technical_indicators.py`, `multimodal_analysis.py`, etc. — Feature computation from raw data +- `decision/` + - `composer.py` — LLM decision + normalization + guardrails (core) + - `system_prompt.py` (optional) — prompt templates, or store in config/constants +- `execution/` + - `exchanges.py`, `paper_trading.py` — Gateways to real or paper execution (only instructions input for now) +- `trading_history/` + - `recorder.py` — Persist key checkpoints + - `digest.py` — Build `TradeDigest` for historical guidance +- Root files + - `models.py` — DTOs only (interfaces live in module-level files) + - `core.py` — DecisionCoordinator (wires the full decision cycle) + - `constants.py` — Basic configuration/limits; can hold prompt_text initially + +## Data Flow (one decision cycle) + +1. DecisionCoordinator pulls `PortfolioView` (positions, cash, optional constraints) +1. DecisionCoordinator gets recent `Candle` from `MarketDataSource` +1. `FeatureComputer` produces `FeatureVector[]` +1. DecisionCoordinator assembles `ComposeContext`: features, portfolio, digest, prompt_text (string), optional market_snapshot and extra constraints + +1. `Composer.compose(context)`: calls LLM with `ComposeContext` → `LlmPlanProposal`; normalizes plan (target position logic, limits, step size, min notional, cool-down, etc.); returns `TradeInstruction[]` + +1. `ExecutionGateway.execute(instructions)` (no detailed order/fill handling at this stage) +1. `HistoryRecorder.record(...)` checkpoints (including optional auditing metadata); + + DigestBuilder updates `TradeDigest` + +ASCII overview: + +```text +Data → Features → Composer(LLM+Guardrails) → Execution → History → Digest + ↑ ↓ ↑ + PortfolioView ----------------------------- | + prompt_text ----------------------------------------→ +``` + +## DTOs (Pydantic models) + +Defined in `models.py`: + +- Identification and raw data + - `InstrumentRef { symbol, exchange_id?, quote_ccy? }` + - `Candle { ts, instrument, open, high, low, close, volume, interval }` + +- User request / configuration + - `UserRequest { model_config: ModelConfig, exchange_config: ExchangeConfig, trading_config: TradingConfig }` + - `ModelConfig { provider, model_id, api_key }` + - `ExchangeConfig { exchange_id?, trading_mode, api_key?, secret_key? }` + - `TradingConfig { strategy_name?, initial_capital?, max_leverage?, max_positions?, symbols, decide_interval?, template_id?, custom_prompt? }` + +- Features and portfolio + - `FeatureVector { ts, instrument, values: Dict[str, float], meta? }` + - `PositionSnapshot { instrument, quantity, avg_price?, mark_price?, unrealized_pnl?, notional?, leverage?, entry_ts?, pnl_pct?, trade_type? }` + - `PortfolioView { strategy_id?, ts, cash, positions: Dict[symbol, PositionSnapshot], gross_exposure?, net_exposure?, constraints?, total_value?, total_unrealized_pnl?, available_cash? }` + +- LLM decision and normalization + - `LlmDecisionItem { instrument, action: (buy|sell|flat|noop), target_qty, confidence?, rationale? }` + - `LlmPlanProposal { ts, items: List[LlmDecisionItem], notes?, model_meta? }` + - `TradeInstruction { instruction_id, compose_id, instrument, side: (buy|sell), quantity, price_mode, limit_price?, max_slippage_bps?, meta? }` + - `ComposeContext { ts, compose_id, strategy_id?, features, portfolio, digest, prompt_text, market_snapshot?, constraints? }` + +- History and digest + - `HistoryRecord { ts, kind, reference_id, payload }` + - `TradeDigestEntry { instrument, trade_count, realized_pnl, win_rate?, avg_holding_ms?, last_trade_ts?, avg_entry_price?, max_drawdown?, recent_performance_score? }` + - `TradeDigest { ts, by_instrument: Dict[symbol, TradeDigestEntry] }` + +- UI/summary and series (optional; for leaderboard and charts) + - `TradingMode = (live|virtual)` + - `StrategyStatus = (running|paused|stopped|error)` + - `StrategySummary { strategy_id?, name?, model_provider?, model_id?, exchange_id?, mode?, status?, pnl_abs?, pnl_pct?, last_updated_ts? }` + - `StrategySummary { strategy_id?, name?, model_provider?, model_id?, exchange_id?, mode?, status?, realized_pnl?, unrealized_pnl?, pnl_pct?, last_updated_ts? }` + - `MetricPoint { ts, value }` + - `PortfolioValueSeries { strategy_id?, points: List[MetricPoint] }` + +`TradeHistoryEntry { trade_id?, compose_id?, instruction_id?, strategy_id?, trade_ts?, entry_ts?, exit_ts?, instrument, side, type, quantity, entry_price?, exit_price?, realized_pnl?, realized_pnl_pct?, holding_ms?, leverage?, note? }` + +Notes: + +- Only `target_qty` is used (no `delta_qty`). Composer computes `order_qty = target_qty − current_qty` and turns it into a `TradeInstruction` (side + quantity). +- Initial versions can set `price_mode = "market"` for simplicity. +Action semantics: + +- `flat`: target position is zero (may emit close-out instructions) +- `noop`: target equals current (delta == 0), emit no instruction + +Additional notes: + +- `mark_price` in `PositionSnapshot` allows consistent P&L visualization without coupling to feed-specific last trade logic. +- The UI-oriented DTOs (`StrategySummary`, `PortfolioValueSeries`, etc.) are additive and do not affect the core compose/execute pipeline. + +## ID and correlation model + +- `strategy_id`: identity of a running strategy; used by UI aggregation (`StrategySummary`, `PortfolioValueSeries`). +- `compose_id`: unique id generated per decision cycle by the coordinator. It is carried in `ComposeContext` and copied into each `TradeInstruction` for correlation. `HistoryRecord.reference_id` uses this id. +- `instruction_id`: deterministic id for idempotency, recommended format: `${compose_id}:${instrument.symbol}` (or include an ordinal if multiple instructions per instrument). +- `trade_id`: execution-layer id for a closed trade. `TradeHistoryEntry` can store `compose_id` and `instruction_id` optionally to link back to the decision that initiated it. + +## Abstract Interfaces (contracts) + +Interfaces live in their respective modules as ABCs (not Pydantic models): + +- `data/interfaces.py` + - `MarketDataSource.get_recent_candles(symbols, interval, lookback) -> List[Candle]` +- `features/interfaces.py` + - `FeatureComputer.compute_features(candles?: List[Candle]) -> List[FeatureVector]` +- `core.py` + - `DecisionCoordinator.run_once() -> None` +- `portfolio/interfaces.py` + - `PortfolioService.get_view() -> PortfolioView` + - `PortfolioSnapshotStore.load_latest() -> Optional[PortfolioView]` + - `PortfolioSnapshotStore.save(view: PortfolioView) -> None` +- `decision/interfaces.py` + - `Composer.compose(context: ComposeContext) -> List[TradeInstruction]` +- `execution/interfaces.py` + - `ExecutionGateway.execute(instructions: List[TradeInstruction]) -> None` +- `trading_history/interfaces.py` + - `HistoryRecorder.record(record: HistoryRecord) -> None` + - `DigestBuilder.build(records: List[HistoryRecord]) -> TradeDigest` + +## Guardrails (composer) + +- Position targeting: compute `order_qty` from `target_qty` vs current position +- Rounding: step size, minimum order quantity/nominal +- Limits: per-instrument cap, net exposure cap, optional shorting allowance +- Cool-down/recent performance: use `TradeDigest` to suppress or downweight +- Confidence threshold and invalid field filtering +- Audit: record optional metadata (prompt hash, model name, token usage, latency, rejection reasons) as a `HistoryRecord` payload at the "compose" checkpoint +- Fallback: if LLM output is invalid/empty, optionally use a simple deterministic rule from features or return no-op + +## History and Digest (clarified) + +We record a few compact checkpoints using `HistoryRecord { ts, kind, reference_id, payload }`: + +- kind = "features": + - reference_id: compose_id + - payload: a small summary (e.g., per-symbol feature keys and last values, or a hash) +- kind = "compose": + - reference_id: compose_id + - payload: optional auditing metadata (e.g., prompt_hash, model_name, token_usage, latency_ms, reasons filtered) +- kind = "instructions": + - reference_id: compose_id + - payload: the normalized `TradeInstruction[]` as a compact list or summary (symbol, side, qty) +- kind = "execution" (optional at this stage): + - reference_id: compose_id + - payload: ack/status if available from the gateway + +DigestBuilder consumes these records (recent N bars or N decisions) to build `TradeDigest`: + +- Per-instrument aggregates in `TradeDigestEntry`: + - trade_count, realized_pnl, win_rate, avg_holding_ms, last_trade_ts, + avg_entry_price, max_drawdown, recent_performance_score +- Update cadence: periodically (e.g., every M decisions or T minutes) or incrementally per instruction/execution +- Usage in composer: cool-down (skip recent losers), down-weight bad performers, + enforce simple risk heuristics (e.g., cap net additions if recent_performance_score < threshold) + +This keeps recording simple and purpose-driven for composer feedback without inventing a separate report object. + +## Runtime Modes + +- Paper trading: default mode (via `execution/paper_trading.py`) +- Live and backtest: future extensions; the same interfaces remain stable + +## Extensibility + +- Add new features by extending `FeatureComputer` +- Plug different LLM providers/parsers within `Composer` +- Add more execution backends by implementing `ExecutionGateway` +- Evolve digests: additional stats inside `TradeDigestEntry` without breaking composer + +## Out of Scope (current stage) + +- Order lifecycle (partial fills, cancels, rejections) +- Late/out-of-order data handling +- Complex portfolio accounting beyond `PortfolioView` + +## Minimal DecisionCoordinator Contract + +A typical `run_once()` should: + +1. `view = portfolio.get_view()` +2. Pull candles via `data` and compute `features = features.compute_features(candles=...)` +3. `context = ComposeContext(ts=..., features=features, portfolio=view, digest=..., prompt_text=..., market_snapshot=..., constraints=...)` +4. `instructions = composer.compose(context)` +5. `executor.execute(instructions)` +6. Record `HistoryRecord` for features, compose auditing metadata, and instructions +7. Update `TradeDigest` periodically or incrementally diff --git a/python/valuecell/agents/strategy_agent/__init__.py b/python/valuecell/agents/strategy_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/__main__.py b/python/valuecell/agents/strategy_agent/__main__.py new file mode 100644 index 000000000..0f6d27cf7 --- /dev/null +++ b/python/valuecell/agents/strategy_agent/__main__.py @@ -0,0 +1,9 @@ +import asyncio + +from valuecell.core.agent import create_wrapped_agent + +from .agent import StrategyAgent + +if __name__ == "__main__": + agent = create_wrapped_agent(StrategyAgent) + asyncio.run(agent.serve()) diff --git a/python/valuecell/agents/strategy_agent/agent.py b/python/valuecell/agents/strategy_agent/agent.py new file mode 100644 index 000000000..37d6382ec --- /dev/null +++ b/python/valuecell/agents/strategy_agent/agent.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import AsyncGenerator, Dict, Optional + +from valuecell.core.agent.responses import streaming +from valuecell.core.types import BaseAgent, StreamResponse + + +class StrategyAgent(BaseAgent): + """Minimal StrategyAgent entry for system integration. + + This is a placeholder agent that streams a short greeting and completes. + It can be extended to wire the Strategy Agent decision loop + (data -> features -> composer -> execution -> history/digest). + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + async def stream( + self, + query: str, + conversation_id: str, + task_id: str, + dependencies: Optional[Dict] = None, + ) -> AsyncGenerator[StreamResponse, None]: + # Minimal streaming lifecycle: one message and done + yield streaming.message_chunk( + "StrategyAgent is online. Decision pipeline will be wired here." + ) + yield streaming.done() diff --git a/python/valuecell/agents/strategy_agent/constants.py b/python/valuecell/agents/strategy_agent/constants.py new file mode 100644 index 000000000..5b9efdbb8 --- /dev/null +++ b/python/valuecell/agents/strategy_agent/constants.py @@ -0,0 +1,11 @@ +"""Default constants used across the strategy_agent package. + +Centralizes defaults so they can be imported from one place. +""" + +DEFAULT_INITIAL_CAPITAL = 100000.0 +DEFAULT_AGENT_MODEL = "deepseek-ai/DeepSeek-V3.1-Terminus" +DEFAULT_MODEL_PROVIDER = "siliconflow" +DEFAULT_MAX_POSITIONS = 5 +DEFAULT_MAX_SYMBOLS = 5 +DEFAULT_MAX_LEVERAGE = 10.0 diff --git a/python/valuecell/agents/strategy_agent/core.py b/python/valuecell/agents/strategy_agent/core.py new file mode 100644 index 000000000..6c9969f4d --- /dev/null +++ b/python/valuecell/agents/strategy_agent/core.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +# Core interfaces for orchestration and portfolio service. +# Plain ABCs to avoid runtime dependencies on pydantic. Concrete implementations +# wire the pipeline: data -> features -> composer -> execution -> history/digest. + + +class DecisionCoordinator(ABC): + """Coordinates a single decision cycle end-to-end. + + A typical run performs: + 1) fetch portfolio view + 2) pull data and compute features + 3) build compose context (prompt_text, digest, constraints) + 4) compose (LLM + guardrails) -> trade instructions + 5) execute instructions + 6) record checkpoints and update digest + """ + + @abstractmethod + def run_once(self) -> None: + """Execute one decision cycle.""" + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/data/__init__.py b/python/valuecell/agents/strategy_agent/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/data/interfaces.py b/python/valuecell/agents/strategy_agent/data/interfaces.py new file mode 100644 index 000000000..3899ab5ba --- /dev/null +++ b/python/valuecell/agents/strategy_agent/data/interfaces.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List + +from ..models import Candle + +# Contracts for market data sources (module-local abstract interfaces). +# These are plain ABCs (not Pydantic models) so implementations can be +# synchronous or asynchronous without runtime overhead. + + +class MarketDataSource(ABC): + """Abstract market data access used by feature computation. + + Implementations should fetch recent ticks or candles for the requested + symbols and intervals. Caching and batching policies are left to the + concrete classes. + """ + + @abstractmethod + def get_recent_candles( + self, symbols: List[str], interval: str, lookback: int + ) -> List[Candle]: + """Return recent candles (OHLCV) for the given symbols/interval. + + Args: + symbols: list of symbols (e.g., ["BTCUSDT", "ETHUSDT"]) + interval: candle interval string (e.g., "1m", "5m") + lookback: number of bars to retrieve + """ + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/data/market.py b/python/valuecell/agents/strategy_agent/data/market.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/data/news.py b/python/valuecell/agents/strategy_agent/data/news.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/decision/__init__.py b/python/valuecell/agents/strategy_agent/decision/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/decision/composer.py b/python/valuecell/agents/strategy_agent/decision/composer.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/decision/interfaces.py b/python/valuecell/agents/strategy_agent/decision/interfaces.py new file mode 100644 index 000000000..41568bd05 --- /dev/null +++ b/python/valuecell/agents/strategy_agent/decision/interfaces.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List + +from ..models import ComposeContext, TradeInstruction + +# Contracts for decision making (module-local abstract interfaces). +# Composer hosts the LLM call and guardrails, producing executable instructions. + + +class Composer(ABC): + """LLM-driven decision composer with guardrails. + + Input: ComposeContext + Output: TradeInstruction list + """ + + @abstractmethod + def compose(self, context: ComposeContext) -> List[TradeInstruction]: + """Produce normalized trade instructions given the current context. + Call the LLM, parse/validate output, apply guardrails (limits, step size, + min notional, cool-down), and return executable instructions. + Any optional auditing metadata should be recorded via HistoryRecorder. + """ + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/decision/system_prompt.py b/python/valuecell/agents/strategy_agent/decision/system_prompt.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/decision/validator.py b/python/valuecell/agents/strategy_agent/decision/validator.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/execution/__init__.py b/python/valuecell/agents/strategy_agent/execution/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/execution/exchanges.py b/python/valuecell/agents/strategy_agent/execution/exchanges.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/execution/interfaces.py b/python/valuecell/agents/strategy_agent/execution/interfaces.py new file mode 100644 index 000000000..c1e745bec --- /dev/null +++ b/python/valuecell/agents/strategy_agent/execution/interfaces.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List + +from ..models import TradeInstruction + +# Contracts for execution gateways (module-local abstract interfaces). +# An implementation may route to a real exchange or a paper broker. + + +class ExecutionGateway(ABC): + """Executes normalized trade instructions against an exchange/broker.""" + + @abstractmethod + def execute(self, instructions: List[TradeInstruction]) -> None: + """Submit the provided instructions for execution. + Implementors may be synchronous or asynchronous. At this stage we + do not model order/fill/cancel lifecycles. + """ + + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/execution/paper_trading.py b/python/valuecell/agents/strategy_agent/execution/paper_trading.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/features/__init__.py b/python/valuecell/agents/strategy_agent/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/features/interfaces.py b/python/valuecell/agents/strategy_agent/features/interfaces.py new file mode 100644 index 000000000..5ef4bc4bd --- /dev/null +++ b/python/valuecell/agents/strategy_agent/features/interfaces.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List, Optional + +from ..models import Candle, FeatureVector + +# Contracts for feature computation (module-local abstract interfaces). +# Plain ABCs (not Pydantic) to keep implementations lightweight. + + +class FeatureComputer(ABC): + """Computes feature vectors from raw market data (ticks/candles). + + Implementations may cache windows, offload CPU-heavy parts, or compose + multiple feature families. The output should be per-instrument features. + """ + + @abstractmethod + def compute_features( + self, + candles: Optional[List[Candle]] = None, + ) -> List[FeatureVector]: + """Build feature vectors from the given inputs. + + Args: + candles: optional window of candles + Returns: + A list of FeatureVector items, one or more per instrument. + """ + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/features/multimodal_analysis.py b/python/valuecell/agents/strategy_agent/features/multimodal_analysis.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/features/news_analysis.py b/python/valuecell/agents/strategy_agent/features/news_analysis.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/features/technical_indicators.py b/python/valuecell/agents/strategy_agent/features/technical_indicators.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/models.py b/python/valuecell/agents/strategy_agent/models.py new file mode 100644 index 000000000..42276f9bf --- /dev/null +++ b/python/valuecell/agents/strategy_agent/models.py @@ -0,0 +1,452 @@ +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field, field_validator + +from .constants import ( + DEFAULT_AGENT_MODEL, + DEFAULT_INITIAL_CAPITAL, + DEFAULT_MAX_LEVERAGE, + DEFAULT_MAX_POSITIONS, + DEFAULT_MAX_SYMBOLS, + DEFAULT_MODEL_PROVIDER, +) + + +class TradingMode(str, Enum): + """Trading mode for a strategy used by UI/leaderboard tags.""" + + LIVE = "live" + VIRTUAL = "virtual" + + +class TradeType(str, Enum): + """Semantic trade type for positions.""" + + LONG = "LONG" + SHORT = "SHORT" + + +class TradeSide(str, Enum): + """Side for executable trade instruction.""" + + BUY = "BUY" + SELL = "SELL" + + +class ModelConfig(BaseModel): + """AI model configuration for strategy.""" + + provider: str = Field( + default=DEFAULT_MODEL_PROVIDER, + description="Model provider (e.g., 'openrouter', 'google', 'openai')", + ) + model_id: str = Field( + default=DEFAULT_AGENT_MODEL, + description="Model identifier (e.g., 'deepseek-ai/deepseek-v3.1', 'gpt-4o')", + ) + api_key: str = Field(..., description="API key for the model provider") + + +class ExchangeConfig(BaseModel): + """Exchange configuration for trading.""" + + exchange_id: Optional[str] = Field( + default=None, description="Exchange identifier (e.g., 'okx', 'binance')" + ) + trading_mode: TradingMode = Field( + default=TradingMode.VIRTUAL, description="Trading mode for this strategy" + ) + api_key: Optional[str] = Field( + default=None, description="Exchange API key (required for live trading)" + ) + secret_key: Optional[str] = Field( + default=None, description="Exchange secret key (required for live trading)" + ) + + +class TradingConfig(BaseModel): + """Trading strategy configuration.""" + + strategy_name: Optional[str] = Field( + default=None, description="User-friendly name for this strategy" + ) + initial_capital: Optional[float] = Field( + default=DEFAULT_INITIAL_CAPITAL, + description="Initial capital for trading in USD", + gt=0, + ) + max_leverage: float = Field( + default=DEFAULT_MAX_LEVERAGE, + description="Maximum leverage", + gt=0, + ) + max_positions: int = Field( + default=DEFAULT_MAX_POSITIONS, + description="Maximum number of concurrent positions", + gt=0, + ) + symbols: List[str] = Field( + ..., + description="List of crypto symbols to trade (e.g., ['BTC-USD', 'ETH-USD'])", + ) + decide_interval: int = Field( + default=60, + description="Check interval in seconds", + gt=0, + ) + template_id: Optional[str] = Field( + default=None, description="Strategy template identifier to guide the agent" + ) + custom_prompt: Optional[str] = Field( + default=None, + description="Optional custom prompt to customize strategy behavior", + ) + + @field_validator("symbols") + @classmethod + def validate_symbols(cls, v): + if not v or len(v) == 0: + raise ValueError("At least one symbol is required") + if len(v) > DEFAULT_MAX_SYMBOLS: + raise ValueError(f"Maximum {DEFAULT_MAX_SYMBOLS} symbols allowed") + # Normalize symbols to uppercase + return [s.upper() for s in v] + + +class UserRequest(BaseModel): + """User-specified strategy request / configuration. + + This model captures the inputs a user (or frontend) sends to create or + update a strategy instance. It was previously named `Strategy`. + """ + + model_config: ModelConfig = Field( + default_factory=ModelConfig, description="AI model configuration" + ) + exchange_config: ExchangeConfig = Field( + default_factory=ExchangeConfig, description="Exchange configuration for trading" + ) + trading_config: TradingConfig = Field( + ..., description="Trading strategy configuration" + ) + + +# ========================= +# Minimal DTOs for Strategy Agent (LLM-driven composer, no StrategyHint) +# These DTOs define the data contract across modules following the +# simplified pipeline: data -> features -> composer(LLM+rules) -> execution -> history/digest. +# ========================= + + +class InstrumentRef(BaseModel): + """Identifies a tradable instrument. + + - symbol: exchange symbol, e.g., "BTCUSDT" + - exchange_id: optional exchange id, e.g., "binance", "virtual" + - quote_ccy: optional quote currency, e.g., "USDT" + """ + + symbol: str = Field(..., description="Exchange symbol, e.g., BTCUSDT") + exchange_id: Optional[str] = Field( + default=None, description="exchange identifier (e.g., binance)" + ) + quote_ccy: Optional[str] = Field( + default=None, description="Quote currency (e.g., USDT)" + ) + + +class Candle(BaseModel): + """Aggregated OHLCV candle for a fixed interval.""" + + ts: int = Field(..., description="Candle end timestamp in ms") + instrument: InstrumentRef + open: float + high: float + low: float + close: float + volume: float + interval: str = Field(..., description='Interval string, e.g., "1m", "5m"') + + +class FeatureVector(BaseModel): + """Computed features for a single instrument at a point in time.""" + + ts: int + instrument: InstrumentRef + values: Dict[str, float] = Field( + default_factory=dict, description="Feature name to numeric value" + ) + meta: Optional[Dict[str, float | int | str]] = Field( + default=None, description="Optional metadata (e.g., window lengths)" + ) + + +class StrategyStatus(str, Enum): + """High-level runtime status for strategies (for UI health dot).""" + + RUNNING = "running" + PAUSED = "paused" + STOPPED = "stopped" + ERROR = "error" + + +class PositionSnapshot(BaseModel): + """Current position snapshot for one instrument.""" + + instrument: InstrumentRef + quantity: float = Field(..., description="Position quantity (+long, -short)") + avg_price: Optional[float] = Field(default=None, description="Average entry price") + mark_price: Optional[float] = Field( + default=None, description="Current mark/reference price for P&L calc" + ) + unrealized_pnl: Optional[float] = Field(default=None, description="Unrealized PnL") + # Optional fields useful for UI and reporting + notional: Optional[float] = Field( + default=None, description="Position notional in quote currency" + ) + leverage: Optional[float] = Field( + default=None, description="Leverage applied to the position (if any)" + ) + entry_ts: Optional[int] = Field( + default=None, description="Entry timestamp (ms) for the current position" + ) + pnl_pct: Optional[float] = Field( + default=None, description="Unrealized P&L as a percent of position value" + ) + trade_type: Optional[TradeType] = Field( + default=None, description="Semantic trade type, e.g., 'long' or 'short'" + ) + + +class PortfolioView(BaseModel): + """Portfolio state used by the composer for decision making.""" + + strategy_id: Optional[str] = Field( + default=None, description="Owning strategy id for this portfolio snapshot" + ) + ts: int + cash: float + positions: Dict[str, PositionSnapshot] = Field( + default_factory=dict, description="Map symbol -> PositionSnapshot" + ) + gross_exposure: Optional[float] = Field( + default=None, description="Absolute exposure (optional)" + ) + net_exposure: Optional[float] = Field( + default=None, description="Net exposure (optional)" + ) + constraints: Optional[Dict[str, float | int]] = Field( + default=None, + description="Optional risk/limits snapshot (e.g., max position, step size)", + ) + # Optional aggregated fields convenient for UI + total_value: Optional[float] = Field( + default=None, description="Total portfolio value (cash + positions)" + ) + total_unrealized_pnl: Optional[float] = Field( + default=None, description="Sum of unrealized PnL across positions" + ) + available_cash: Optional[float] = Field( + default=None, description="Cash available for new positions" + ) + + +class LlmDecisionAction(str, Enum): + """Normalized high-level action from LLM plan item. + + Semantics: + - BUY/SELL: directional intent; final TradeSide is decided by delta (target - current) + - FLAT: target position is zero (may produce close-out instructions) + - NOOP: target equals current (delta == 0), no instruction should be emitted + """ + + BUY = "buy" + SELL = "sell" + FLAT = "flat" + NOOP = "noop" + + +class LlmDecisionItem(BaseModel): + """One LLM plan item. Uses target_qty only (no delta). + + The composer will compute order quantity as: target_qty - current_qty. + """ + + instrument: InstrumentRef + action: LlmDecisionAction + target_qty: float = Field( + ..., description="Desired position quantity after execution" + ) + confidence: Optional[float] = Field( + default=None, description="Optional confidence score [0,1]" + ) + rationale: Optional[str] = Field( + default=None, description="Optional natural language rationale" + ) + + +class LlmPlanProposal(BaseModel): + """Structured LLM output before rule normalization.""" + + ts: int + items: List[LlmDecisionItem] = Field(default_factory=list) + notes: Optional[List[str]] = Field(default=None) + model_meta: Optional[Dict[str, str]] = Field( + default=None, description="Optional model metadata (e.g., model_name)" + ) + + +class TradeInstruction(BaseModel): + """Executable instruction emitted by the composer after normalization.""" + + instruction_id: str = Field( + ..., description="Deterministic id for idempotency (e.g., compose_id+symbol)" + ) + compose_id: str = Field( + ..., description="Decision cycle id to correlate instructions and history" + ) + instrument: InstrumentRef + side: TradeSide + quantity: float = Field(..., description="Order quantity in instrument units") + price_mode: str = Field( + ..., description='"market" or "limit" (initial versions may use only "market")' + ) + limit_price: Optional[float] = Field(default=None) + max_slippage_bps: Optional[float] = Field(default=None) + meta: Optional[Dict[str, str | float]] = Field( + default=None, description="Optional metadata for auditing" + ) + + +class MetricPoint(BaseModel): + """Generic time-value point, used for value history charts.""" + + ts: int + value: float + + +class PortfolioValueSeries(BaseModel): + """Series for portfolio total value over time (for performance charts).""" + + strategy_id: Optional[str] = Field(default=None) + points: List[MetricPoint] = Field(default_factory=list) + + +class ComposeContext(BaseModel): + """Context assembled for the LLM-driven composer.""" + + ts: int + compose_id: str = Field( + ..., description="Decision cycle id generated by coordinator per strategy" + ) + strategy_id: Optional[str] = Field( + default=None, description="Owning strategy id for logging/aggregation" + ) + features: List[FeatureVector] = Field( + default_factory=list, description="Feature vectors across instruments" + ) + portfolio: PortfolioView + digest: "TradeDigest" + prompt_text: str = Field(..., description="Strategy/style prompt text") + market_snapshot: Optional[Dict[str, float]] = Field( + default=None, description="Optional map symbol -> current reference price" + ) + constraints: Optional[Dict[str, float | int]] = Field( + default=None, description="Optional extra constraints for guardrails" + ) + + +class HistoryRecord(BaseModel): + """Generic persisted record for post-hoc analysis and digest building.""" + + ts: int + kind: str = Field( + ..., description='"features" | "compose" | "instructions" | "execution"' + ) + reference_id: str = Field(..., description="Correlation id (e.g., compose_id)") + payload: Dict[str, object] = Field(default_factory=dict) + + +class TradeDigestEntry(BaseModel): + """Digest stats per instrument for historical guidance in composer.""" + + instrument: InstrumentRef + trade_count: int + realized_pnl: float + win_rate: Optional[float] = Field(default=None) + avg_holding_ms: Optional[int] = Field(default=None) + last_trade_ts: Optional[int] = Field(default=None) + avg_entry_price: Optional[float] = Field(default=None) + max_drawdown: Optional[float] = Field(default=None) + recent_performance_score: Optional[float] = Field(default=None) + + +class TradeHistoryEntry(BaseModel): + """Executed trade record for UI history and auditing. + + This model is intended to be a compact, display-friendly representation + of a completed trade (entry + exit). Fields are optional to allow + use for partially filled / in-progress records. + """ + + trade_id: Optional[str] = Field(default=None, description="Unique trade id") + compose_id: Optional[str] = Field( + default=None, description="Originating decision cycle id (if applicable)" + ) + instruction_id: Optional[str] = Field( + default=None, description="Instruction id that initiated this trade" + ) + strategy_id: Optional[str] = Field(default=None) + instrument: InstrumentRef + side: TradeSide + type: TradeType + quantity: float + entry_price: Optional[float] = Field(default=None) + exit_price: Optional[float] = Field(default=None) + 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") + realized_pnl: Optional[float] = Field(default=None) + realized_pnl_pct: Optional[float] = Field(default=None) + leverage: Optional[float] = Field(default=None) + note: Optional[str] = Field( + default=None, description="Optional free-form note or comment about the trade" + ) + + +class TradeDigest(BaseModel): + """Compact digest used by the composer as historical reference.""" + + ts: int + by_instrument: Dict[str, TradeDigestEntry] = Field(default_factory=dict) + + +class StrategySummary(BaseModel): + """Minimal summary for leaderboard and quick status views. + + Purely for UI aggregation; does not affect the compose pipeline. + All fields are optional to avoid breaking callers and allow + progressive enhancement by the backend. + """ + + strategy_id: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + model_provider: Optional[str] = Field(default=None) + model_id: Optional[str] = Field(default=None) + exchange_id: Optional[str] = Field(default=None) + mode: Optional[TradingMode] = Field(default=None) + status: Optional[StrategyStatus] = Field(default=None) + realized_pnl: Optional[float] = Field( + default=None, description="Realized P&L in quote CCY" + ) + unrealized_pnl: Optional[float] = Field( + default=None, description="Unrealized P&L in quote CCY" + ) + pnl_pct: Optional[float] = Field( + default=None, description="P&L as percent of equity or initial capital" + ) + last_updated_ts: Optional[int] = Field(default=None) diff --git a/python/valuecell/agents/strategy_agent/portfolio/__init__.py b/python/valuecell/agents/strategy_agent/portfolio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/portfolio/interfaces.py b/python/valuecell/agents/strategy_agent/portfolio/interfaces.py new file mode 100644 index 000000000..a81e366fa --- /dev/null +++ b/python/valuecell/agents/strategy_agent/portfolio/interfaces.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional + +from ..models import PortfolioView + + +class PortfolioService(ABC): + """Provides current portfolio state to decision modules. + + Keep this as a read-only service used by DecisionCoordinator and Composer. + """ + + @abstractmethod + def get_view(self) -> PortfolioView: + """Return the latest portfolio view (positions, cash, optional constraints).""" + raise NotImplementedError + + +class PortfolioSnapshotStore(ABC): + """Persist/load portfolio snapshots (optional for paper/backtest modes).""" + + @abstractmethod + def load_latest(self) -> Optional[PortfolioView]: + """Load the latest persisted portfolio snapshot, if any.""" + raise NotImplementedError + + @abstractmethod + def save(self, view: PortfolioView) -> None: + """Persist the provided portfolio view as a snapshot.""" + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/trading_history/__init__.py b/python/valuecell/agents/strategy_agent/trading_history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/trading_history/digest.py b/python/valuecell/agents/strategy_agent/trading_history/digest.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/valuecell/agents/strategy_agent/trading_history/interfaces.py b/python/valuecell/agents/strategy_agent/trading_history/interfaces.py new file mode 100644 index 000000000..831ca7385 --- /dev/null +++ b/python/valuecell/agents/strategy_agent/trading_history/interfaces.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List + +from ..models import HistoryRecord, TradeDigest + +# Contracts for history recording and digest building (module-local abstract interfaces). + + +class HistoryRecorder(ABC): + """Persists important checkpoints for later analysis and digest building.""" + + @abstractmethod + def record(self, record: HistoryRecord) -> None: + """Persist a single history record.""" + raise NotImplementedError + + +class DigestBuilder(ABC): + """Builds TradeDigest from historical records (incremental or batch).""" + + @abstractmethod + def build(self, records: List[HistoryRecord]) -> TradeDigest: + """Construct a digest object from given history records.""" + raise NotImplementedError diff --git a/python/valuecell/agents/strategy_agent/trading_history/recorder.py b/python/valuecell/agents/strategy_agent/trading_history/recorder.py new file mode 100644 index 000000000..e69de29bb