diff --git a/README.ja.md b/README.ja.md
index b8ffe0a93..64998c6d9 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -143,12 +143,34 @@ bash start.sh
アプリケーションが起動したら、WebインターフェースでValueCellの機能を操作して探索できます。
-## リアルタイム取引 (OKX/Binance)
-
-- 配AIモデルの設定: Webインターフェースから AI モデルの API キーを追加.
-- 取引所の設定: OKX / Binance の API 認証情報を設定
-- ストラテジー作成: AIモデルと取引所を組み合わせてカスタム戦略を作成
-- モニタリング&コントロール: 戦略の開始/停止を行い、パフォーマンスをリアルタイムで監視
+## リアルタイム取引
+
+- AIモデルの設定: Webインターフェースから AI モデルの API キーを追加します。
+- 取引所の設定: Binance/HyperLiquid/OKX/Coinbase... の API 認証情報を設定します。
+- ストラテジー作成: AIモデルと取引所を組み合わせてカスタム戦略を作成します。
+- モニタリング&コントロール: 戦略の開始/停止を行い、パフォーマンスをリアルタイムで監視します。
+
+### サポートされている取引所
+
+| 取引所 | 備考 | ステータス |
+| --- | --- | --- |
+| **Binance** | 国際サイト [binance.com](binance.com) のみサポート(米国サイトは非対応)。USDT-M 先物(USDT証拠金契約)を使用します。先物口座に十分な USDT 残高があることを確認してください。取引ペア形式: `BTC/USDT` | ✅ テスト済み |
+| **Hyperliquid** | 証拠金通貨として USDC のみサポートします。メインウォレットアドレス + API ウォレット秘密鍵認証を使用します([APIタブ](https://app.hyperliquid.xyz/API)から申請)。成行注文は自動的に IoC 指値注文に変換されます。取引ペア形式は手動で `SYMBOL/USDC` に調整する必要があります(例: `WIF/USDC`)。 | ✅ テスト済み |
+| **OKX** | 認証には API Key、Secret、Passphrase が必要です。USDT証拠金契約をサポートします。取引ペア形式: `BTC/USDT` | ✅ テスト済み |
+| Coinbase | USDT証拠金契約をサポートします。Coinbase International はまだサポートされていません。 | 🟡 部分的にテスト済み |
+| Gate.io | USDT証拠金契約をサポートします。API Key と Secret が必要です。 | 🟡 部分的にテスト済み |
+| MEXC | USDT証拠金契約をサポートします。API Key と Secret が必要です。 | 🟡 部分的にテスト済み |
+| Blockchain | USDT証拠金契約をサポートします。API Key と Secret が必要です。 | 🟡 部分的にテスト済み |
+
+**凡例**:
+- ✅ **テスト済み**: 本番環境で完全にテストおよび検証済み
+- 🟡 **部分的にテスト済み**: コードの実装は完了していますが、完全にはテストされておらず、デバッグが必要な場合があります
+- **推奨**: 完全にテストされた取引所(Binance, Hyperliquid, OKX)を優先的に使用してください
+
+### 注意事項
+- 現在はレバレッジ取引のみをサポートしているため、Perps(無期限先物)アカウントに十分な残高があることを確認する必要があります。
+- 資金の損失を防ぐため、API シークレットは安全に保管する必要があります。アプリはシークレットをデバイス上にローカルに保存し、インターネット経由で第三者に送信することはありません。
+- アカウントの安全を確保するために、API キーを定期的にリセットする必要があります。
---
**注意**: アプリケーションを実行する前に、すべての前提条件がインストールされ、環境変数が適切に設定されていることを確認してください。
diff --git a/README.md b/README.md
index 8a1473bde..536e0dcd6 100644
--- a/README.md
+++ b/README.md
@@ -151,15 +151,37 @@ bash start.sh
Once the application is running, you can explore the web interface to interact with ValueCell's features and capabilities.
-## Live Trading (OKX/Binance)
+## Live Trading
- Configure AI Models: Add your AI Model API Key through the web interface.
-- Configure Exchanges: Set up OKX/Binance API credentials
+- Configure Exchanges: Set up Binance/HyperLiquid/OKX/Coinbase... API credentials
- Create Strategies: Combine AI model with exchange to create custom strategies
- Monitor & Control: Start/stop traders and monitor performance in real-time
+### Supported Exchanges
+
+| Exchange | Notes | Status |
+| --- | --- | --- |
+| **Binance** | Only supports international site [binance.com](binance.com), not US site. Uses USDT-M futures (USDT-margined contracts). Ensure your futures account has sufficient USDT balance. Trading pair format: `BTC/USDT` | ✅ Tested |
+| **Hyperliquid** | Only supports USDC as margin currency. Uses your main wallet address + API wallet private key authentication (use [API tab](https://app.hyperliquid.xyz/API) to apply). Market orders are automatically converted to IoC limit orders. Trading pair format must be manually adjusted to `SYMBOL/USDC` (e.g., `WIF/USDC`) | ✅ Tested |
+| **OKX** | Requires API Key, Secret, and Passphrase for authentication. Supports USDT-margined contracts. Trading pair format: `BTC/USDT` | ✅ Tested |
+| Coinbase | Supports USDT-margined contracts. Coinbase International is not yet supported | 🟡 Partially Tested |
+| Gate.io | Supports USDT-margined contracts. Requires API Key and Secret | 🟡 Partially Tested |
+| MEXC | Supports USDT-margined contracts. Requires API Key and Secret | 🟡 Partially Tested |
+| Blockchain | Supports USDT-margined contracts. Requires API Key, Secret | 🟡 Partially Tested |
+
+**Legend**:
+- ✅ **Tested**: Fully tested and verified in production environment
+- 🟡 **Partially Tested**: Code implementation complete but not fully tested, may require debugging
+- **Recommended**: Prioritize using fully tested exchanges (Binance, Hyperliquid, OKX)
+
+### Notice
+- Currently supports leverage trading only, so you need to ensure your Perps account has sufficient balance.
+- You must keep your API secrets secure to avoid losing funds. The app stores secrets locally on your device and will not send them to any third party over the internet.
+- To ensure your account safety, you need to reset your API keys regularly.
+
---
-**Note**:Before running the application, ensure all prerequisites are installed and environment variables are properly configured. If it has been a long time since the last update, you can delete the database files in the project`lancedb/`,`valuecell.db`, `.knowledgebase/`and start again
+**Note**: Before running the application, ensure all prerequisites are installed and environment variables are properly configured. If it has been a long time since the last update, you can delete the database files in the project directories: `lancedb/`, `valuecell.db`, `.knowledgebase/` and start fresh
# Developers
diff --git a/README.zh.md b/README.zh.md
index ce05e76f8..46b35544f 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -150,12 +150,34 @@ bash start.sh
应用运行后,你可以通过网页界面探索并使用 ValueCell 的各项功能和能力。
-## 实盘交易 (OKX/Binance)
+## 实盘交易
-- 配置 AI 模型: 通过网页UI界面添加你的 AI模型 API Key.
-- 配置交易所: 设置 OKX / Binance 的 API 凭证
+- 配置 AI 模型: 通过网页UI界面添加你的 AI 模型 API Key。
+- 配置交易所: 设置 Binance/HyperLiquid/OKX/Coinbase... API 凭证
- 创建策略: 将 AI 模型与交易所组合,创建自定义交易策略
-- 监控与控制: 实时启动/停止策略,并监控交易表现
+- 监控与控制: 实时启动/停止策略,并实时监控交易表现
+
+### 支持的交易所
+
+| 交易所 | 说明 | 状态 |
+| --- | --- | --- |
+| **Binance** | 仅支持国际站 [binance.com](binance.com),不支持美国站。使用 USDT-M 合约(USDT 本位合约)。请确保您的合约账户有足够的 USDT 余额。交易对格式:`BTC/USDT` | ✅ 已测试 |
+| **Hyperliquid** | 仅支持 USDC 作为保证金货币。使用您的主钱包地址 + API 钱包私钥认证(使用 [API 页面](https://app.hyperliquid.xyz/API) 申请)。市价单会自动转换为 IoC 限价单。交易对格式必须手动调整为 `SYMBOL/USDC`(例如 `WIF/USDC`) | ✅ 已测试 |
+| **OKX** | 需要 API Key、Secret 和 Passphrase 进行认证。支持 USDT 本位合约。交易对格式:`BTC/USDT` | ✅ 已测试 |
+| Coinbase | 支持 USDT 本位合约。Coinbase International 尚未支持 | 🟡 部分测试 |
+| Gate.io | 支持 USDT 本位合约。需要 API Key 和 Secret | 🟡 部分测试 |
+| MEXC | 支持 USDT 本位合约。需要 API Key 和 Secret | 🟡 部分测试 |
+| Blockchain | 支持 USDT 本位合约。需要 API Key 和 Secret | 🟡 部分测试 |
+
+**图例**:
+- ✅ **已测试**: 在生产环境中经过充分测试和验证
+- 🟡 **部分测试**: 代码实现已完成但未完全测试,可能需要调试
+- **推荐**: 优先使用经过充分测试的交易所(Binance, Hyperliquid, OKX)
+
+### 注意事项
+- 目前仅支持杠杆/合约交易,因此您需要确保您的永续合约(Perps)账户有足够的余额。
+- 您必须妥善保管您的 API 密钥以避免资金损失。该应用程序将密钥本地存储在您的设备上,不会通过互联网发送给任何第三方。
+- 为了确保您的账户安全,您需要定期重置您的 API 密钥。
---
diff --git a/README.zh_Hant.md b/README.zh_Hant.md
index 5ff74e3d1..8bd4aa240 100644
--- a/README.zh_Hant.md
+++ b/README.zh_Hant.md
@@ -147,12 +147,34 @@ bash start.sh
應用程式啟動後,你可以透過網頁介面探索並使用 ValueCell 的各項功能與能力
-## 实盘交易 (OKX/Binance)
+## 實盤交易
-- 配置 AI 模型: 透過網頁介面新增你的 AI 模型 API Key
-- 配置交易所: 設定 OKX / Binance 的 API 憑證
+- 配置 AI 模型: 透過網頁介面新增你的 AI 模型 API Key。
+- 配置交易所: 設定 Binance/HyperLiquid/OKX/Coinbase... API 憑證
- 建立策略: 將 AI 模型與交易所組合,建立自訂交易策略
-- 監控與控制: 实时启动/停止策略,并监控交易表现
+- 監控與控制: 透過即時監控啟動/停止策略,並監控交易表現
+
+### 支援的交易所
+
+| 交易所 | 說明 | 狀態 |
+| --- | --- | --- |
+| **Binance** | 僅支援國際站 [binance.com](binance.com),不支援美國站。使用 USDT-M 合約(USDT 本位合約)。請確保您的合約帳戶有足夠的 USDT 餘額。交易對格式:`BTC/USDT` | ✅ 已測試 |
+| **Hyperliquid** | 僅支援 USDC 作為保證金貨幣。使用您的主錢包地址 + API 錢包私鑰認證(使用 [API 頁面](https://app.hyperliquid.xyz/API) 申請)。市價單會自動轉換為 IoC 限價單。交易對格式必須手動調整為 `SYMBOL/USDC`(例如 `WIF/USDC`) | ✅ 已測試 |
+| **OKX** | 需要 API Key、Secret 和 Passphrase 進行認證。支援 USDT 本位合約。交易對格式:`BTC/USDT` | ✅ 已測試 |
+| Coinbase | 支援 USDT 本位合約。Coinbase International 尚未支援 | 🟡 部分測試 |
+| Gate.io | 支援 USDT 本位合約。需要 API Key 和 Secret | 🟡 部分測試 |
+| MEXC | 支援 USDT 本位合約。需要 API Key 和 Secret | 🟡 部分測試 |
+| Blockchain | 支援 USDT 本位合約。需要 API Key 和 Secret | 🟡 部分測試 |
+
+**圖例**:
+- ✅ **已測試**: 在生產環境中經過充分測試和驗證
+- 🟡 **部分測試**: 程式碼實作已完成但未完全測試,可能需要除錯
+- **推薦**: 優先使用經過充分測試的交易所(Binance, Hyperliquid, OKX)
+
+### 注意事項
+- 目前僅支援槓桿/合約交易,因此您需要確保您的永續合約(Perps)帳戶有足夠的餘額。
+- 您必須妥善保管您的 API 密鑰以避免資金損失。該應用程式將密鑰本地儲存在您的裝置上,不會透過網際網路發送給任何第三方。
+- 為了確保您的帳戶安全,您需要定期重設您的 API 密鑰。
---
diff --git a/python/valuecell/agents/auto_trading_agent/__init__.py b/python/valuecell/agents/auto_trading_agent/__init__.py
deleted file mode 100644
index 6587d296b..000000000
--- a/python/valuecell/agents/auto_trading_agent/__init__.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""Auto Trading Agent - Modular architecture for automated crypto trading
-
-Modules:
-- agent: Main AutoTradingAgent orchestrator
-- models: Data models and enumerations
-- position_manager: Position and cash management
-- market_data: Technical analysis and indicator retrieval
-- trade_recorder: Trade history and statistics
-- trading_executor: High-level trade execution facade
-- technical_analysis: Backward-compatible technical analysis interface
-- portfolio_decision_manager: Portfolio-level decision making
-- formatters: Message formatting utilities
-- constants: Configuration constants
-"""
-
-from .agent import AutoTradingAgent
-from .market_data import MarketDataProvider, SignalGenerator
-from .models import (
- AutoTradingConfig,
- CashManagement,
- PortfolioValueSnapshot,
- Position,
- PositionHistorySnapshot,
- TechnicalIndicators,
- TradeAction,
- TradeHistoryRecord,
- TradeType,
- TradingRequest,
-)
-from .portfolio_decision_manager import (
- AssetAnalysis,
- PortfolioDecision,
- PortfolioDecisionManager,
-)
-from .position_manager import PositionManager
-from .technical_analysis import AISignalGenerator, TechnicalAnalyzer
-from .trade_recorder import TradeRecorder
-from .trading_executor import TradingExecutor
-
-__all__ = [
- # Main agent
- "AutoTradingAgent",
- # Core modules
- "TradingExecutor",
- "PositionManager",
- "TradeRecorder",
- "MarketDataProvider",
- "SignalGenerator",
- "PortfolioDecisionManager",
- # Models
- "AutoTradingConfig",
- "TradingRequest",
- "Position",
- "CashManagement",
- "TechnicalIndicators",
- "TradeHistoryRecord",
- "PositionHistorySnapshot",
- "PortfolioValueSnapshot",
- "TradeAction",
- "TradeType",
- "AssetAnalysis",
- "PortfolioDecision",
- # Utilities
- "TechnicalAnalyzer",
- "AISignalGenerator",
-]
diff --git a/python/valuecell/agents/auto_trading_agent/__main__.py b/python/valuecell/agents/auto_trading_agent/__main__.py
deleted file mode 100644
index d919d266a..000000000
--- a/python/valuecell/agents/auto_trading_agent/__main__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Main entry point for auto trading agent"""
-
-import asyncio
-
-from valuecell.core.agent.decorator import create_wrapped_agent
-
-from .agent import AutoTradingAgent
-
-if __name__ == "__main__":
- agent = create_wrapped_agent(AutoTradingAgent)
- asyncio.run(agent.serve())
diff --git a/python/valuecell/agents/auto_trading_agent/agent.py b/python/valuecell/agents/auto_trading_agent/agent.py
deleted file mode 100644
index 03ed7136e..000000000
--- a/python/valuecell/agents/auto_trading_agent/agent.py
+++ /dev/null
@@ -1,1249 +0,0 @@
-"""Main auto trading agent implementation with multi-instance support"""
-
-import asyncio
-import json
-import logging
-import os
-from collections import deque
-from datetime import datetime, timezone
-from typing import Any, AsyncGenerator, Deque, Dict, List, Optional
-
-from agno.agent import Agent
-
-from valuecell.core.agent.responses import streaming
-from valuecell.core.types import (
- BaseAgent,
- ComponentType,
- FilteredCardPushNotificationComponentData,
- FilteredLineChartComponentData,
- StreamResponse,
-)
-
-from .constants import (
- DEFAULT_AGENT_MODEL,
- DEFAULT_CHECK_INTERVAL,
- ENV_PARSER_MODEL_ID,
- ENV_SIGNAL_MODEL_ID,
-)
-from .exchanges import (
- ExchangeBase,
- ExchangeType,
- OKXExchange,
- OKXExchangeError,
- PaperTrading,
-)
-from .formatters import MessageFormatter
-from .market_data import MarketDataProvider, OkxMarketDataProvider
-from .models import (
- SUPPORTED_EXCHANGES,
- SUPPORTED_NETWORKS,
- AutoTradingConfig,
- TradingRequest,
-)
-from .portfolio_decision_manager import (
- AssetAnalysis,
- PortfolioDecisionManager,
-)
-from .technical_analysis import AISignalGenerator, TechnicalAnalyzer
-from .trading_executor import TradingExecutor
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-# Maximum cached notifications per session
-MAX_NOTIFICATION_CACHE_SIZE = 5000
-
-
-class AutoTradingAgent(BaseAgent):
- """
- Automated crypto trading agent with technical analysis and position management.
- Supports multiple trading instances per session with independent configurations.
- """
-
- def __init__(self):
- super().__init__()
-
- # Configuration
- self.parser_model_id = os.getenv("TRADING_PARSER_MODEL_ID", DEFAULT_AGENT_MODEL)
- self.default_exchange = self._sanitize_exchange(
- os.getenv("AUTO_TRADING_EXCHANGE", ExchangeType.PAPER.value)
- )
- self.okx_api_key = os.getenv("OKX_API_KEY")
- self.okx_api_secret = os.getenv("OKX_API_SECRET")
- self.okx_api_passphrase = os.getenv("OKX_API_PASSPHRASE")
- self.okx_network = self._sanitize_network(os.getenv("OKX_NETWORK", "paper"))
- self.okx_allow_live_trading_flag = self._parse_bool(
- os.getenv("OKX_ALLOW_LIVE_TRADING", "false")
- )
- self.okx_margin_mode = os.getenv("OKX_MARGIN_MODE", "cash")
- self.okx_use_server_time = self._parse_bool(
- os.getenv("OKX_USE_SERVER_TIME", "false")
- )
- self.default_exchange_network = self.okx_network
-
- # Select price data provider based on default exchange configuration
- try:
- if self.default_exchange == ExchangeType.OKX.value:
- TechnicalAnalyzer.set_provider(OkxMarketDataProvider())
- else:
- TechnicalAnalyzer.set_provider(MarketDataProvider())
- except Exception:
- pass
-
- # Multi-instance state management
- # Structure: {session_id: {instance_id: TradingInstanceData}}
- self.trading_instances: Dict[str, Dict[str, Dict[str, Any]]] = {}
-
- # Notification cache for batch sending
- # Structure: {session_id: deque[FilteredCardPushNotificationComponentData]}
- # Using deque with maxlen for automatic FIFO eviction
- self.notification_cache: Dict[
- str, Deque[FilteredCardPushNotificationComponentData]
- ] = {}
-
- try:
- # Parser agent for natural language query parsing
- # Uses centralized configuration system with automatic provider detection
- from valuecell.utils.model import get_model
-
- parser_model = get_model(
- env_key=ENV_PARSER_MODEL_ID,
- )
-
- self.parser_agent = Agent(
- model=parser_model,
- output_schema=TradingRequest,
- markdown=True,
- )
- logger.info("Auto Trading Agent initialized successfully")
- except Exception as e:
- logger.error(f"Failed to initialize Auto Trading Agent: {e}")
- raise
-
- @staticmethod
- def _parse_bool(value: Optional[str], default: bool = False) -> bool:
- if value is None:
- return default
- return value.strip().lower() in {"1", "true", "yes", "on"}
-
- @staticmethod
- def _parse_float(value: Optional[str], fallback: float) -> float:
- try:
- parsed = float(value) if value is not None else fallback
- except (TypeError, ValueError):
- return fallback
- return max(0.0, min(parsed, 0.5))
-
- def _sanitize_exchange(self, value: Optional[str]) -> str:
- lowered = (value or ExchangeType.PAPER.value).lower()
- return lowered if lowered in SUPPORTED_EXCHANGES else ExchangeType.PAPER.value
-
- def _sanitize_network(self, value: Optional[str]) -> str:
- lowered = (value or "testnet").lower()
- return lowered if lowered in SUPPORTED_NETWORKS else "testnet"
-
- async def _process_trading_instance(
- self,
- session_id: str,
- instance_id: str,
- semaphore: asyncio.Semaphore,
- unified_timestamp: Optional[datetime] = None,
- ) -> None:
- """
- Process a single trading instance with semaphore control for concurrency limiting.
-
- Args:
- session_id: Session identifier
- instance_id: Trading instance identifier
- semaphore: Asyncio semaphore to limit concurrent processing
- unified_timestamp: Optional unified timestamp for snapshot alignment across instances
- """
- async with semaphore:
- try:
- # Check if instance still exists and is active
- if instance_id not in self.trading_instances.get(session_id, {}):
- return
-
- instance = self.trading_instances[session_id][instance_id]
- if not instance["active"]:
- return
-
- # Get instance components
- executor = instance["executor"]
- config = instance["config"]
- ai_signal_generator = instance["ai_signal_generator"]
-
- # Update check info
- instance["check_count"] += 1
- instance["last_check"] = datetime.now()
- check_count = instance["check_count"]
-
- logger.info(
- f"Trading check #{check_count} for instance {instance_id} (model: {config.agent_model})"
- )
-
- logger.info(
- f"\n{'=' * 50}\n"
- f"🔄 **Check #{check_count}** - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
- f"Instance: `{instance_id}`\n"
- f"Model: `{config.agent_model}`\n"
- f"{'=' * 50}\n\n"
- )
-
- # Phase 1: Collect analysis for all symbols
- logger.info("📊 **Phase 1: Analyzing all assets...**\n\n")
-
- # Initialize portfolio manager with LLM client for AI-powered decisions
- llm_client = None
- if ai_signal_generator and ai_signal_generator.llm_client:
- llm_client = ai_signal_generator.llm_client
-
- portfolio_manager = PortfolioDecisionManager(config, llm_client)
-
- for symbol in config.crypto_symbols:
- # Calculate indicators
- indicators = TechnicalAnalyzer.calculate_indicators(symbol)
-
- if indicators is None:
- logger.warning(f"Skipping {symbol} - insufficient data")
- continue
-
- # Generate technical signal
- technical_action, technical_trade_type = (
- TechnicalAnalyzer.generate_signal(indicators)
- )
-
- # Generate AI signal if enabled
- ai_action, ai_trade_type, ai_reasoning, ai_confidence = (
- None,
- None,
- None,
- None,
- )
-
- if ai_signal_generator:
- ai_signal = await ai_signal_generator.get_signal(indicators)
- if ai_signal:
- (
- ai_action,
- ai_trade_type,
- ai_reasoning,
- ai_confidence,
- ) = ai_signal
- logger.info(
- f"AI signal for {symbol}: {ai_action.value} {ai_trade_type.value} "
- f"(confidence: {ai_confidence}%)"
- )
-
- # Create asset analysis
- asset_analysis = AssetAnalysis(
- symbol=symbol,
- indicators=indicators,
- technical_action=technical_action,
- technical_trade_type=technical_trade_type,
- ai_action=ai_action,
- ai_trade_type=ai_trade_type,
- ai_reasoning=ai_reasoning,
- ai_confidence=ai_confidence,
- )
-
- # Add to portfolio manager
- portfolio_manager.add_asset_analysis(asset_analysis)
-
- # Display individual asset analysis
- logger.info(
- MessageFormatter.format_market_analysis_notification(
- symbol,
- indicators,
- asset_analysis.recommended_action,
- asset_analysis.recommended_trade_type,
- executor.positions,
- ai_reasoning,
- )
- )
-
- # Phase 2: Make portfolio-level decision
- logger.info(
- "\n" + "=" * 50 + "\n"
- "🎯 **Phase 2: Portfolio Decision Making...**\n" + "=" * 50 + "\n\n"
- )
-
- # Get portfolio summary
- portfolio_summary = portfolio_manager.get_portfolio_summary()
- logger.info(portfolio_summary + "\n")
-
- # Make coordinated decision (async call for AI analysis)
- portfolio_decision = await portfolio_manager.make_portfolio_decision(
- current_positions=executor.positions,
- available_cash=executor.get_current_capital(),
- total_portfolio_value=executor.get_portfolio_value(),
- )
-
- # Display decision reasoning - cache it
- portfolio_decision_msg = FilteredCardPushNotificationComponentData(
- title=f"{config.agent_model} Analysis",
- data=f"💰 **Portfolio Decision Reasoning**\n{portfolio_decision.reasoning}\n",
- filters=[config.agent_model],
- table_title="Market Analysis",
- create_time=datetime.now(timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- ),
- )
- # Cache the decision notification
- self._cache_notification(session_id, portfolio_decision_msg)
-
- # Phase 3: Execute approved trades
- if portfolio_decision.trades_to_execute:
- logger.info(
- "\n" + "=" * 50 + "\n"
- f"⚡ **Phase 3: Executing {len(portfolio_decision.trades_to_execute)} trade(s)...**\n"
- + "=" * 50
- + "\n\n"
- )
-
- for (
- symbol,
- action,
- trade_type,
- ) in portfolio_decision.trades_to_execute:
- # Get indicators for this symbol
- asset_analysis = portfolio_manager.asset_analyses.get(symbol)
- if not asset_analysis:
- continue
-
- # Execute trade
- trade_details = await executor.execute_trade(
- symbol, action, trade_type, asset_analysis.indicators
- )
-
- if trade_details:
- # Cache trade notification
- trade_message_text = (
- MessageFormatter.format_trade_notification(
- trade_details, config.agent_model
- )
- )
- trade_message = FilteredCardPushNotificationComponentData(
- title=f"{config.agent_model} Trade",
- data=f"💰 **Trade Executed**\n\n{trade_message_text}\n",
- filters=[config.agent_model],
- table_title="Trade Detail",
- create_time=datetime.now(timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- ),
- )
- # Cache the trade notification
- self._cache_notification(session_id, trade_message)
- else:
- trade_message = FilteredCardPushNotificationComponentData(
- title=f"{config.agent_model} Trade",
- data=f"💰 **Trade Failed:** Could not execute {action.value} "
- f"{trade_type.value} on {symbol}\n",
- filters=[config.agent_model],
- table_title="Trade Detail",
- create_time=datetime.now(timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- ),
- )
- # Cache the failed trade notification
- self._cache_notification(session_id, trade_message)
-
- # Take snapshots with unified timestamp if provided
- timestamp = unified_timestamp if unified_timestamp else datetime.now()
- executor.snapshot_positions(timestamp)
- executor.snapshot_portfolio(timestamp)
-
- # Send portfolio update
- portfolio_value = executor.get_portfolio_value()
- total_pnl = portfolio_value - config.initial_capital
-
- portfolio_msg = (
- f"💰 **Portfolio Update**\n"
- f"Model: {config.agent_model}\n"
- f"Time: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n"
- f"Total Value: ${portfolio_value:,.2f}\n"
- f"P&L: ${total_pnl:,.2f}\n"
- f"Open Positions: {len(executor.positions)}\n"
- f"Available Capital: ${executor.current_capital:,.2f}\n"
- )
-
- if executor.positions:
- portfolio_msg += "\n**Open Positions:**\n"
- provider = TechnicalAnalyzer._market_data_provider
- for symbol, pos in executor.positions.items():
- try:
- current_price = (
- provider.get_current_price(symbol) or pos.entry_price
- )
- if pos.trade_type.value == "long":
- current_pnl = (current_price - pos.entry_price) * abs(
- pos.quantity
- )
- else:
- current_pnl = (pos.entry_price - current_price) * abs(
- pos.quantity
- )
- pnl_emoji = "🟢" if current_pnl >= 0 else "🔴"
- portfolio_msg += f"- {symbol}: {pos.trade_type.value.upper()} @ ${pos.entry_price:,.2f} {pnl_emoji} P&L: ${current_pnl:,.2f}\n"
- except Exception as e:
- logger.warning(f"Failed to calculate P&L for {symbol}: {e}")
- portfolio_msg += f"- {symbol}: {pos.trade_type.value.upper()} @ ${pos.entry_price:,.2f}\n"
-
- logger.info(portfolio_msg + "\n")
-
- # Cache portfolio status notification
- component_data = self._get_instance_status_component_data(
- session_id, instance_id
- )
- if component_data:
- self._cache_notification(session_id, component_data)
-
- except Exception as e:
- logger.error(f"Error processing trading instance {instance_id}: {e}")
- # Don't raise - let other instances continue
-
- def _generate_instance_id(self, task_id: str, model_id: str) -> str:
- """
- Generate unique instance ID for a specific model
-
- Args:
- task_id: Task ID from the request
- model_id: Model identifier (e.g., 'deepseek/deepseek-v3.1-terminus')
-
- Returns:
- Unique instance ID combining timestamp, task, and model
- """
- import hashlib
-
- timestamp = datetime.now().strftime(
- "%Y%m%d_%H%M%S_%f"
- ) # Include microseconds for uniqueness
- # Create a short hash from model_id for readability
- model_hash = hashlib.md5(model_id.encode()).hexdigest()[:6]
- # Extract model name (last part after /)
- model_name = model_id.split("/")[-1].replace("-", "_").replace(".", "_")[:15]
-
- return f"trade_{timestamp}_{model_name}_{model_hash}"
-
- def _init_notification_cache(self, session_id: str) -> None:
- """Initialize notification cache for a session if not exists"""
- if session_id not in self.notification_cache:
- self.notification_cache[session_id] = deque(
- maxlen=MAX_NOTIFICATION_CACHE_SIZE
- )
- logger.info(f"Initialized notification cache for session {session_id}")
-
- def _cache_notification(
- self, session_id: str, notification: FilteredCardPushNotificationComponentData
- ) -> None:
- """
- Cache a notification for later batch sending.
- Automatically evicts oldest notifications when cache exceeds MAX_NOTIFICATION_CACHE_SIZE.
-
- Args:
- session_id: Session ID
- notification: Notification to cache
- """
- self._init_notification_cache(session_id)
- self.notification_cache[session_id].append(notification)
- logger.debug(
- f"Cached notification for session {session_id}. "
- f"Cache size: {len(self.notification_cache[session_id])}"
- )
-
- def _get_cached_notifications(
- self, session_id: str
- ) -> List[FilteredCardPushNotificationComponentData]:
- """
- Get all cached notifications for a session.
-
- Args:
- session_id: Session ID
-
- Returns:
- List of cached notifications (oldest to newest)
- """
- if session_id not in self.notification_cache:
- return []
- return list(self.notification_cache[session_id])
-
- def _clear_notification_cache(self, session_id: str) -> None:
- """
- Clear notification cache for a session.
-
- Args:
- session_id: Session ID
- """
- if session_id in self.notification_cache:
- self.notification_cache[session_id].clear()
- logger.info(f"Cleared notification cache for session {session_id}")
-
- async def _parse_trading_request(self, query: str) -> TradingRequest:
- """
- Parse natural language query to extract trading parameters
-
- Args:
- query: User's natural language query
-
- Returns:
- TradingRequest object with parsed parameters
- """
- try:
- parse_prompt = f"""
- Parse the following user query and extract auto trading configuration parameters:
-
- User query: "{query}"
-
- Please identify:
- 1. crypto_symbols: List of cryptocurrency symbols to trade (e.g., BTC-USD, ETH-USD, SOL-USD)
- - If user mentions "Bitcoin", extract as "BTC-USD"
- - If user mentions "Ethereum", extract as "ETH-USD"
- - If user mentions "Solana", extract as "SOL-USD"
- - Always use format: SYMBOL-USD
- 2. initial_capital: Initial trading capital in USD (default: 100000 if not specified)
- 3. use_ai_signals: Whether to use AI-enhanced signals (default: true)
- 4. agent_model: Model ID for trading decisions (default: DEFAULT_AGENT_MODEL)
- 5. exchange (optional): Trading venue. Use "paper" (default) or "okx" if the user requests real execution.
- 6. exchange_network (optional): OKX network to use, default "paper". Accept "paper" or "testnet".
- 7. allow_live_trading (optional): Boolean that must be true to enable OKX live trading.
-
- Examples:
- - "Trade Bitcoin and Ethereum with $50000" -> {{"crypto_symbols": ["BTC-USD", "ETH-USD"], "initial_capital": 50000, "use_ai_signals": true}}
- - "Start auto trading BTC-USD" -> {{"crypto_symbols": ["BTC-USD"], "initial_capital": 100000, "use_ai_signals": true}}
- - "Trade BTC with AI signals" -> {{"crypto_symbols": ["BTC-USD"], "initial_capital": 100000, "use_ai_signals": true}}
- - "Trade BTC with AI signals using DeepSeek model" -> {{"crypto_symbols": ["BTC-USD"], "initial_capital": 100000, "use_ai_signals": true, "agent_models": ["deepseek/deepseek-v3.1-terminus"]}}
- - "Trade Bitcoin, SOL, Eth and DOGE with 100000 capital, using x-ai/grok-4, deepseek/deepseek-v3.1-terminus model" -> {{"crypto_symbols": ["BTC-USD", "SOL-USD", "ETH-USD", "DOGE-USD"], "initial_capital": 100000, "use_ai_signals": true, "agent_models": ["x-ai/grok-4", "deepseek/deepseek-v3.1-terminus"]}}
- """
-
- response = await self.parser_agent.arun(parse_prompt)
- trading_request = response.content
-
- logger.info(f"Parsed trading request: {trading_request}")
- return trading_request
-
- except Exception as e:
- logger.error(f"Failed to parse trading request: {e}")
- raise ValueError(
- f"Could not parse trading configuration from query: {query}"
- )
-
- def _initialize_ai_signal_generator(
- self, config: AutoTradingConfig
- ) -> Optional[AISignalGenerator]:
- """Initialize AI signal generator if configured.
-
- Uses the centralized configuration system with proper provider selection.
- Supports any provider configured in the config system.
-
- Args:
- config: AutoTradingConfig with use_ai_signals, agent_model, and agent_provider settings
-
- Returns:
- AISignalGenerator instance or None if AI signals are disabled or creation fails
- """
- if not config.use_ai_signals:
- return None
-
- try:
- # Use centralized configuration system for model creation
- # Supports automatic provider detection and fallback
- from valuecell.adapters.models.factory import create_model
-
- # Check for environment variable override
- model_id_override = os.getenv(ENV_SIGNAL_MODEL_ID)
- model_id = model_id_override or config.agent_model
-
- # Create model with provider auto-detection or explicit provider
- llm_client = create_model(
- model_id=model_id,
- provider=config.agent_provider, # None = auto-detect
- use_fallback=True, # Enable fallback to other providers
- )
-
- logger.info(
- f"Initialized AI signal generator: model_id={model_id}, "
- f"provider={config.agent_provider or 'auto-detect'}"
- )
- return AISignalGenerator(llm_client)
-
- except Exception as e:
- logger.error(f"Failed to initialize AI signal generator: {e}")
- logger.info(
- "Hint: Make sure provider API keys are configured in .env file. "
- "Check configs/providers/ for required environment variables. "
- "AI signals will be disabled for this trading instance."
- )
- return None
-
- async def _build_exchange(self, config: AutoTradingConfig) -> ExchangeBase:
- exchange_name = (config.exchange or ExchangeType.PAPER.value).lower()
-
- if exchange_name == ExchangeType.PAPER.value:
- adapter = PaperTrading(initial_balance=config.initial_capital)
- await adapter.connect()
- return adapter
-
- if exchange_name == ExchangeType.OKX.value:
- api_key = config.okx_api_key or self.okx_api_key
- api_secret = config.okx_api_secret or self.okx_api_secret
- passphrase = config.okx_api_passphrase or self.okx_api_passphrase
-
- if not api_key or not api_secret or not passphrase:
- raise OKXExchangeError(
- "OKX credentials missing. Set OKX_API_KEY/OKX_API_SECRET/OKX_API_PASSPHRASE."
- )
-
- if (
- config.exchange_network not in {"paper", "demo", "testnet"}
- and not config.allow_live_trading
- ):
- raise OKXExchangeError(
- "Live OKX trading disabled. Set OKX_ALLOW_LIVE_TRADING=true or request allow_live_trading in the query."
- )
-
- adapter = OKXExchange(
- api_key=api_key,
- api_secret=api_secret,
- passphrase=passphrase,
- network=config.exchange_network,
- margin_mode=config.okx_margin_mode,
- use_server_time=config.okx_use_server_time,
- )
- await adapter.connect()
- return adapter
-
- raise ValueError(f"Unsupported exchange '{exchange_name}'")
-
- def _get_instance_status_component_data(
- self, session_id: str, instance_id: str
- ) -> Optional[FilteredCardPushNotificationComponentData]:
- """
- Generate portfolio status report in rich text format
-
- Returns:
- FilteredCardPushNotificationComponentData object or None if instance not found
- """
- if session_id not in self.trading_instances:
- return None
-
- if instance_id not in self.trading_instances[session_id]:
- return None
-
- instance = self.trading_instances[session_id][instance_id]
- executor: TradingExecutor = instance["executor"]
- config: AutoTradingConfig = instance["config"]
-
- # Get comprehensive portfolio summary
- portfolio_summary = executor.get_portfolio_summary()
-
- # Calculate overall statistics
- total_pnl = portfolio_summary["portfolio"]["total_pnl"]
- pnl_pct = portfolio_summary["portfolio"]["pnl_percentage"]
- portfolio_value = portfolio_summary["portfolio"]["total_value"]
- available_cash = portfolio_summary["cash"]["available"]
-
- # Build rich text output
- output = []
-
- # Header
- output.append("📊 **Instance Configuration**\n")
- output.append(f"- Model: `{config.agent_model}`")
- output.append(f"- Symbols: {', '.join(config.crypto_symbols)}")
- output.append(
- f"- Status: {'🟢 Active' if instance['active'] else '🔴 Stopped'}\n"
- )
-
- # Portfolio Summary Section
- output.append("💰 **Portfolio Summary**\n")
- output.append("**Overall Performance**\n")
- output.append(f"- Current Value: `${portfolio_value:,.2f}`\n")
-
- pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
- pnl_sign = "+" if total_pnl >= 0 else ""
- output.append(
- f"- Total P&L: {pnl_emoji} **{pnl_sign}${total_pnl:,.2f}** ({pnl_sign}{pnl_pct:.2f}%)\n"
- )
- output.append(f"- Available Cash: `${available_cash:,.2f}`\n")
-
- # Current Positions Section
- output.append(f"📈 **Current Positions ({len(executor.positions)})**")
-
- if executor.positions:
- output.append(
- "\n| Symbol | Type | **Position**/Quantity | **Current**/Avg | **P&L** |"
- )
- output.append("|--------|------|---------|--------|--------|")
-
- for symbol, pos in executor.positions.items():
- try:
- provider = TechnicalAnalyzer._market_data_provider
- current_price = (
- provider.get_current_price(symbol) or pos.entry_price
- )
-
- # Calculate unrealized P&L
- if pos.trade_type.value == "long":
- unrealized_pnl = (current_price - pos.entry_price) * abs(
- pos.quantity
- )
- position_value = abs(pos.quantity) * current_price
- else:
- unrealized_pnl = (pos.entry_price - current_price) * abs(
- pos.quantity
- )
- position_value = pos.notional + unrealized_pnl
-
- # Format row with merged columns
- pnl_sign = "+" if unrealized_pnl >= 0 else ""
-
- output.append(
- f"| **{symbol}** | {pos.trade_type.value.upper()} | "
- f"**${position_value:,.2f}**
{abs(pos.quantity):.4f} | "
- f"**${current_price:,.2f}**
${pos.entry_price:,.2f} | "
- f"**{pnl_sign}${unrealized_pnl:,.2f}** |"
- )
-
- except Exception as e:
- logger.warning(f"Failed to get price for {symbol}: {e}")
- # Fallback display with entry price only
- output.append(
- f"| **{symbol}** | {pos.trade_type.value.upper()} | "
- f"**${pos.notional:,.2f}**
{abs(pos.quantity):.4f} | "
- f"**${pos.entry_price:,.2f}**
${pos.entry_price:,.2f} | "
- f"**N/A** |"
- )
- else:
- output.append("\n*No open positions*")
-
- component_data = FilteredCardPushNotificationComponentData(
- title=f"{config.agent_model} Portfolio Status",
- data="\n".join(output),
- filters=[config.agent_model],
- table_title="Portfolio Detail",
- create_time=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
- )
- return component_data
-
- def _get_session_portfolio_chart_data(self, session_id: str) -> str:
- """
- Generate FilteredLineChartComponentData for all instances in a session
- Uses forward-fill strategy to handle missing timestamps
-
- Data format:
- [
- ['Time', 'model1', 'model2', 'model3'],
- ['2025-10-21 10:00:00', 100000, 50000, 30000],
- ['2025-10-21 10:01:00', 100234, 50123, 30045],
- ...
- ]
-
- Returns:
- JSON string of FilteredLineChartComponentData
- """
- if session_id not in self.trading_instances:
- return ""
-
- # Collect portfolio value history from all instances
- # Store as {model_id: {'initial_capital': float, 'history': [(timestamp, value)]}}
- model_data = {}
-
- for instance_id, instance in self.trading_instances[session_id].items():
- executor: TradingExecutor = instance["executor"]
- config: AutoTradingConfig = instance["config"]
- model_id = config.agent_model
-
- if model_id not in model_data:
- model_data[model_id] = {
- "initial_capital": config.initial_capital,
- "history": [],
- }
-
- portfolio_history = executor.get_portfolio_history()
-
- for snapshot in portfolio_history:
- model_data[model_id]["history"].append(
- (snapshot.timestamp, snapshot.total_value)
- )
-
- if not model_data:
- return ""
-
- # Sort each model's history by timestamp
- for model_id in model_data:
- model_data[model_id]["history"].sort(key=lambda x: x[0])
-
- # Collect all unique timestamps across all models
- all_timestamps = set()
- for model_id, data in model_data.items():
- for timestamp, _ in data["history"]:
- all_timestamps.add(timestamp)
-
- if not all_timestamps:
- return ""
-
- sorted_timestamps = sorted(all_timestamps)
- model_ids = list(model_data.keys())
-
- # Build data array with forward-fill strategy
- # First row: ['Time', 'model1', 'model2', ...]
- data_array = [["Time"] + model_ids]
-
- # Track last known value for each model (for forward-fill)
- last_known_values = {
- model_id: data["initial_capital"] for model_id, data in model_data.items()
- }
-
- # Data rows: ['timestamp', value1, value2, ...]
- for timestamp in sorted_timestamps:
- timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S")
- row = [timestamp_str]
-
- for model_id in model_ids:
- # Find value at this timestamp for this model
- value_at_timestamp = None
- for ts, val in model_data[model_id]["history"]:
- if ts == timestamp:
- value_at_timestamp = val
- break
-
- # Update logic: use new value if found, otherwise forward-fill
- if value_at_timestamp is not None:
- last_known_values[model_id] = value_at_timestamp
- row.append(value_at_timestamp)
- else:
- # Use last known value (forward-fill)
- row.append(last_known_values[model_id])
-
- data_array.append(row)
-
- component_data = FilteredLineChartComponentData(
- title=f"Portfolio Value History - Session {session_id[:8]}",
- data=json.dumps(data_array),
- create_time=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
- )
-
- return component_data.model_dump_json()
-
- async def _handle_stop_command(
- self, session_id: str, query: str
- ) -> AsyncGenerator[StreamResponse, None]:
- """Handle stop command for trading instances"""
- query_lower = query.lower().strip()
-
- # Check if specific instance_id is provided
- instance_id = None
- if "instance_id:" in query_lower or "instance:" in query_lower:
- # Extract instance_id
- parts = query.split(":")
- if len(parts) >= 2:
- instance_id = parts[1].strip()
-
- if session_id not in self.trading_instances:
- yield streaming.message_chunk(
- "⚠️ No active trading instances found in this session.\n"
- )
- return
-
- if instance_id:
- # Stop specific instance
- if instance_id in self.trading_instances[session_id]:
- self.trading_instances[session_id][instance_id]["active"] = False
- executor = self.trading_instances[session_id][instance_id]["executor"]
- portfolio_value = executor.get_portfolio_value()
-
- yield streaming.message_chunk(
- f"🛑 **Trading Instance Stopped**\n\n"
- f"Instance ID: `{instance_id}`\n"
- f"Final Portfolio Value: ${portfolio_value:,.2f}\n"
- f"Open Positions: {len(executor.positions)}\n\n"
- )
- else:
- yield streaming.message_chunk(
- f"⚠️ Instance ID '{instance_id}' not found.\n"
- )
- else:
- # Stop all instances in this session
- count = 0
- for inst_id in self.trading_instances[session_id]:
- self.trading_instances[session_id][inst_id]["active"] = False
- count += 1
-
- yield streaming.message_chunk(
- f"🛑 **All Trading Instances Stopped**\n\n"
- f"Stopped {count} instance(s) in session: {session_id[:8]}\n\n"
- )
-
- async def _handle_status_command(
- self, session_id: str
- ) -> AsyncGenerator[StreamResponse, None]:
- """Handle status query command"""
- if (
- session_id not in self.trading_instances
- or not self.trading_instances[session_id]
- ):
- yield streaming.message_chunk(
- "⚠️ No trading instances found in this session.\n"
- )
- return
-
- status_message = f"📊 **Session Status** - {session_id[:8]}\n\n"
- status_message += (
- f"**Total Instances:** {len(self.trading_instances[session_id])}\n\n"
- )
-
- for instance_id, instance in self.trading_instances[session_id].items():
- executor: TradingExecutor = instance["executor"]
- config: AutoTradingConfig = instance["config"]
-
- status = "🟢 Active" if instance["active"] else "🔴 Stopped"
- portfolio_value = executor.get_portfolio_value()
- total_pnl = portfolio_value - config.initial_capital
-
- status_message += (
- f"**Instance:** `{instance_id}` {status}\n"
- f"- Model: {config.agent_model}\n"
- f"- Symbols: {', '.join(config.crypto_symbols)}\n"
- f"- Exchange: {config.exchange} ({config.exchange_network})\n"
- f"- Live Trading: {'✅' if config.allow_live_trading else '❌'}\n"
- f"- Portfolio Value: ${portfolio_value:,.2f}\n"
- f"- P&L: ${total_pnl:,.2f}\n"
- f"- Open Positions: {len(executor.positions)}\n"
- f"- Total Trades: {len(executor.get_trade_history())}\n"
- f"- Checks: {instance['check_count']}\n\n"
- )
-
- logger.info(f"Status message: {status_message}")
-
- async def stream(
- self,
- query: str,
- session_id: str,
- task_id: str,
- dependencies: Optional[Dict] = None,
- ) -> AsyncGenerator[StreamResponse, None]:
- """
- Process trading requests and manage multiple trading instances per session.
-
- Args:
- query: User's natural language query
- session_id: Session ID
- task_id: Task ID
- dependencies: Optional dependencies
-
- Yields:
- StreamResponse: Trading setup, execution updates, and data visualizations
- """
- # Track created instances for cleanup
- created_instances = []
-
- try:
- logger.info(
- f"Processing auto trading request - session: {session_id}, task: {task_id}"
- )
-
- query_lower = query.lower().strip()
-
- # Handle stop commands
- if any(
- cmd in query_lower.split()
- for cmd in ["stop", "pause", "halt", "停止", "暂停"]
- ):
- async for response in self._handle_stop_command(session_id, query):
- yield response
- return
-
- # Handle status query commands
- if any(
- cmd in query_lower.split()
- for cmd in ["status", "summary", "状态", "摘要"]
- ):
- async for response in self._handle_status_command(session_id):
- yield response
- return
-
- # Parse natural language query to extract trading configuration
- yield streaming.message_chunk("🔍 **Parsing trading request...**\n\n")
-
- try:
- trading_request = await self._parse_trading_request(query)
- logger.info(f"Parsed request: {trading_request}")
- except Exception as e:
- logger.error(f"Failed to parse trading request: {e}")
- yield streaming.failed(
- "**Parse Error**: Could not parse trading configuration from your query. "
- "Please specify cryptocurrency symbols (e.g., 'Trade Bitcoin and Ethereum')."
- )
- return
-
- # Initialize session structure if needed
- if session_id not in self.trading_instances:
- self.trading_instances[session_id] = {}
-
- # Initialize notification cache for this session
- self._init_notification_cache(session_id)
-
- # Get list of models to create instances for
- agent_models = trading_request.agent_models or [DEFAULT_AGENT_MODEL]
-
- # Create one trading instance per model
- yield streaming.message_chunk(
- f"🚀 **Creating {len(agent_models)} trading instance(s)...**\n\n"
- )
-
- for model_id in agent_models:
- # Generate unique instance ID for this model
- instance_id = self._generate_instance_id(task_id, model_id)
-
- # Create configuration for this specific model
- exchange_choice = self._sanitize_exchange(
- trading_request.exchange or self.default_exchange
- )
- # If parser filled default 'paper' but environment prefers a real exchange,
- # and the user did not explicitly mention paper/demo/testnet in the query,
- # honor environment default to avoid surprising fallback to paper.
- if (
- exchange_choice == ExchangeType.PAPER.value
- and self.default_exchange != ExchangeType.PAPER.value
- ):
- hints = ["paper", "demo", "testnet", "模拟", "仿真", "纸面", "演示"]
- if not any(hint in query_lower for hint in hints):
- exchange_choice = self.default_exchange
-
- exchange_network_input = trading_request.exchange_network
- if not exchange_network_input:
- if exchange_choice == ExchangeType.OKX.value:
- exchange_network_input = self.okx_network
- else:
- exchange_network_input = self.default_exchange_network
- exchange_network = self._sanitize_network(exchange_network_input)
-
- if trading_request.allow_live_trading is not None:
- allow_live_trading = trading_request.allow_live_trading
- elif exchange_choice == ExchangeType.OKX.value:
- allow_live_trading = self.okx_allow_live_trading_flag
- else:
- allow_live_trading = False
-
- config = AutoTradingConfig(
- initial_capital=trading_request.initial_capital or 100000,
- crypto_symbols=trading_request.crypto_symbols,
- use_ai_signals=trading_request.use_ai_signals or False,
- agent_model=model_id,
- exchange=exchange_choice,
- exchange_network=exchange_network,
- allow_live_trading=allow_live_trading,
- okx_api_key=self.okx_api_key
- if exchange_choice == ExchangeType.OKX.value
- else None,
- okx_api_secret=self.okx_api_secret
- if exchange_choice == ExchangeType.OKX.value
- else None,
- okx_api_passphrase=self.okx_api_passphrase
- if exchange_choice == ExchangeType.OKX.value
- else None,
- okx_margin_mode=self.okx_margin_mode,
- okx_use_server_time=self.okx_use_server_time,
- )
-
- # Initialize exchange adapter
- try:
- exchange_adapter = await self._build_exchange(config)
- except (OKXExchangeError,) as exc:
- logger.error("Failed to create exchange adapter: %s", exc)
- if exchange_choice == ExchangeType.OKX.value:
- remediation = "Please verify your OKX API credentials and live-trading flag."
- else:
- remediation = "Please verify exchange credentials."
- yield streaming.failed(
- f"❌ **Instance {instance_id}**: {str(exc)}\n" + remediation
- )
- continue
- except Exception as exc: # noqa: BLE001
- logger.error("Unexpected exchange initialization error: %s", exc)
- yield streaming.failed(
- f"❌ **Instance {instance_id}**: Unexpected exchange initialization failure."
- )
- continue
-
- # Initialize executor with exchange adapter
- executor = TradingExecutor(config, exchange=exchange_adapter)
-
- # Initialize AI signal generator if enabled
- ai_signal_generator = self._initialize_ai_signal_generator(config)
-
- # Store instance
- self.trading_instances[session_id][instance_id] = {
- "instance_id": instance_id,
- "config": config,
- "executor": executor,
- "exchange": exchange_adapter,
- "ai_signal_generator": ai_signal_generator,
- "active": True,
- "created_at": datetime.now(),
- "check_count": 0,
- "last_check": None,
- }
-
- created_instances.append(instance_id)
-
- # Display configuration for this instance
- ai_status = "✅ Enabled" if config.use_ai_signals else "❌ Disabled"
- exchange_label = config.exchange.capitalize()
- exchange_details = (
- f"{exchange_label} ({config.exchange_network})"
- if config.exchange in {ExchangeType.OKX.value}
- else exchange_label
- )
- live_flag = "✅ Enabled" if config.allow_live_trading else "❌ Disabled"
- config_message = (
- f"✅ **Trading Instance Created**\n\n"
- f"**Instance ID:** `{instance_id}`\n"
- f"**Model:** `{model_id}`\n\n"
- f"**Configuration:**\n"
- f"- Trading Symbols: {', '.join(config.crypto_symbols)}\n"
- f"- Initial Capital: ${config.initial_capital:,.2f}\n"
- f"- Check Interval: {config.check_interval}s (1 minute)\n"
- f"- Risk Per Trade: {config.risk_per_trade * 100:.1f}%\n"
- f"- Max Positions: {config.max_positions}\n"
- f"- Exchange: {exchange_details}\n"
- f"- Live Trading: {live_flag}\n"
- f"- AI Signals: {ai_status}\n\n"
- )
-
- yield streaming.message_chunk(config_message)
-
- # Summary message
- yield streaming.message_chunk(
- f"**Session ID:** `{session_id[:8]}`\n"
- f"**Total Active Instances in Session:** {len(self.trading_instances[session_id])}\n\n"
- f"🚀 **Starting continuous trading for all instances...**\n"
- f"All instances will run continuously until stopped.\n\n"
- )
-
- # Initialize all instances with portfolio snapshots
- # Use unified timestamp for initial snapshots to align chart data
- unified_initial_timestamp = datetime.now()
- for instance_id in created_instances:
- instance = self.trading_instances[session_id][instance_id]
- executor = instance["executor"]
- config = instance["config"]
-
- # Send initial portfolio snapshot - cache it
- portfolio_value = executor.get_portfolio_value()
- executor.snapshot_portfolio(unified_initial_timestamp)
-
- initial_portfolio_msg = FilteredCardPushNotificationComponentData(
- title=f"{config.agent_model} Portfolio",
- data=f"💰 **Initial Portfolio**\nTotal Value: ${portfolio_value:,.2f}\nAvailable Capital: ${executor.current_capital:,.2f}\n",
- filters=[config.agent_model],
- table_title="Portfolio Detail",
- create_time=datetime.now(timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- ),
- )
- # Cache the initial notification
- self._cache_notification(session_id, initial_portfolio_msg)
-
- # Set check interval
- check_interval = DEFAULT_CHECK_INTERVAL
-
- # Create semaphore to limit concurrent instance processing (max 10)
- semaphore = asyncio.Semaphore(10)
-
- # Main trading loop - monitor all instances in parallel
- yield streaming.message_chunk(
- "📈 **Starting monitoring loop for all instances...**\n\n"
- )
-
- # Check if any instance is still active
- while any(
- self.trading_instances[session_id][inst_id]["active"]
- for inst_id in created_instances
- if inst_id in self.trading_instances[session_id]
- ):
- try:
- # Create unified timestamp for this iteration to align snapshots
- unified_timestamp = datetime.now()
-
- # Process all active instances concurrently using task pool
- tasks = []
- for instance_id in created_instances:
- # Skip if instance was removed or is inactive
- if instance_id not in self.trading_instances[session_id]:
- continue
-
- instance = self.trading_instances[session_id][instance_id]
- if not instance["active"]:
- continue
-
- # Create task for this instance with semaphore control and unified timestamp
- task = asyncio.create_task(
- self._process_trading_instance(
- session_id, instance_id, semaphore, unified_timestamp
- )
- )
- tasks.append(task)
-
- # Wait for all instance tasks to complete (process concurrently)
- if tasks:
- # Gather all tasks and handle any exceptions
- results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Log any exceptions that occurred
- for i, result in enumerate(results):
- if isinstance(result, Exception):
- logger.error(
- f"Task {i} failed with exception: {result}"
- )
-
- # After processing all instances, send batched notifications
- cached_notifications = self._get_cached_notifications(session_id)
- if cached_notifications:
- logger.info(
- f"Sending {len(cached_notifications)} cached notifications for session {session_id}"
- )
- # Convert all cached notifications to a list of dicts for batch sending
- batch_data = [
- notif.model_dump() for notif in cached_notifications
- ]
- # Send as a single batch component - frontend will receive all historical data
- yield streaming.component_generator(
- json.dumps(batch_data),
- ComponentType.FILTERED_CARD_PUSH_NOTIFICATION,
- component_id=f"trading_status_{session_id}",
- )
-
- # Send chart data (not cached, sent separately)
- chart_data = self._get_session_portfolio_chart_data(session_id)
- if chart_data:
- yield streaming.component_generator(
- content=chart_data,
- component_type=ComponentType.FILTERED_LINE_CHART,
- component_id=f"portfolio_chart_{session_id}",
- )
-
- # Wait for next check interval - only sleep once after processing all instances
- logger.info(f"Waiting {check_interval}s until next check...")
- await asyncio.sleep(check_interval)
-
- except Exception as e:
- logger.error(f"Error during trading cycle: {e}")
- yield streaming.message_chunk(
- f"⚠️ **Error during trading cycle**: {str(e)}\n"
- f"Continuing with next check...\n\n"
- )
- await asyncio.sleep(check_interval)
-
- except Exception as e:
- logger.error(f"Critical error in stream method: {e}")
- yield streaming.failed(f"Critical error: {str(e)}")
- finally:
- # Mark all created instances as inactive but keep data for history
- if session_id in self.trading_instances:
- for instance_id in created_instances:
- if instance_id in self.trading_instances[session_id]:
- self.trading_instances[session_id][instance_id]["active"] = (
- False
- )
- logger.info(f"Stopped instance: {instance_id}")
diff --git a/python/valuecell/agents/auto_trading_agent/constants.py b/python/valuecell/agents/auto_trading_agent/constants.py
deleted file mode 100644
index d9f3dd2d0..000000000
--- a/python/valuecell/agents/auto_trading_agent/constants.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""Constants for auto trading agent"""
-
-# Limits
-MAX_SYMBOLS = 10
-DEFAULT_CHECK_INTERVAL = 60 # 1 minute in seconds
-
-# Default configuration values
-DEFAULT_INITIAL_CAPITAL = 100000
-DEFAULT_RISK_PER_TRADE = 0.02
-DEFAULT_MAX_POSITIONS = 3
-
-# Environment variable keys for model override
-# These allow users to override specific models via environment variables
-ENV_PARSER_MODEL_ID = "AUTO_TRADING_PARSER_MODEL_ID"
-ENV_SIGNAL_MODEL_ID = "AUTO_TRADING_SIGNAL_MODEL_ID"
-ENV_PRIMARY_MODEL_ID = "AUTO_TRADING_AGENT_MODEL_ID"
-
-# Deprecated (kept for backward compatibility)
-DEFAULT_AGENT_MODEL = "deepseek/deepseek-v3.1-terminus"
diff --git a/python/valuecell/agents/auto_trading_agent/exchanges/__init__.py b/python/valuecell/agents/auto_trading_agent/exchanges/__init__.py
deleted file mode 100644
index 8fd74f495..000000000
--- a/python/valuecell/agents/auto_trading_agent/exchanges/__init__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Exchange adapters for different trading platforms
-
-This module provides adapters for various cryptocurrency exchanges,
-allowing the AutoTradingAgent to trade on both paper (simulated) and live (real) exchanges.
-
-Adapters:
-- ExchangeBase: Abstract base class defining the exchange interface
-- PaperTrading: Simulated trading (default)
-- BinanceExchange: Live trading on Binance (requires API keys)
-"""
-
-from .base_exchange import ExchangeBase, ExchangeType, Order, OrderStatus
-from .okx_exchange import OKXExchange, OKXExchangeError
-from .paper_trading import PaperTrading
-
-__all__ = [
- "ExchangeBase",
- "ExchangeType",
- "Order",
- "OrderStatus",
- "OKXExchange",
- "OKXExchangeError",
- "PaperTrading",
-]
diff --git a/python/valuecell/agents/auto_trading_agent/exchanges/base_exchange.py b/python/valuecell/agents/auto_trading_agent/exchanges/base_exchange.py
deleted file mode 100644
index d79eeeee4..000000000
--- a/python/valuecell/agents/auto_trading_agent/exchanges/base_exchange.py
+++ /dev/null
@@ -1,419 +0,0 @@
-"""Abstract base class for exchange adapters"""
-
-import logging
-from abc import ABC, abstractmethod
-from datetime import datetime
-from enum import Enum
-from typing import Any, Dict, List, Optional
-
-from ..models import TradeType
-
-logger = logging.getLogger(__name__)
-
-
-class ExchangeType(str, Enum):
- """Supported exchange types"""
-
- PAPER = "paper" # Simulated trading
- BINANCE = "binance" # Binance exchange
- BYBIT = "bybit" # Bybit exchange (future support)
- COINBASE = "coinbase" # Coinbase (future support)
- OKX = "okx" # OKX spot/derivatives exchange
-
-
-class OrderStatus(str, Enum):
- """Order execution status"""
-
- PENDING = "pending"
- PARTIALLY_FILLED = "partially_filled"
- FILLED = "filled"
- CANCELLED = "cancelled"
- REJECTED = "rejected"
- EXPIRED = "expired"
-
-
-class Order:
- """Represents a single order"""
-
- def __init__(
- self,
- order_id: str,
- symbol: str,
- side: str, # "buy" or "sell"
- quantity: float,
- price: float,
- order_type: str = "limit", # "limit", "market", etc.
- trade_type: Optional[TradeType] = None,
- ):
- self.order_id = order_id
- self.symbol = symbol
- self.side = side
- self.quantity = quantity
- self.price = price
- self.order_type = order_type
- self.trade_type = trade_type
- self.status = OrderStatus.PENDING
- self.filled_quantity = 0.0
- self.filled_price = 0.0
- self.created_at = datetime.now()
- self.updated_at = datetime.now()
-
- def to_dict(self) -> Dict[str, Any]:
- """Convert order to dictionary"""
- return {
- "order_id": self.order_id,
- "symbol": self.symbol,
- "side": self.side,
- "quantity": self.quantity,
- "price": self.price,
- "order_type": self.order_type,
- "status": self.status.value,
- "filled_quantity": self.filled_quantity,
- "filled_price": self.filled_price,
- "created_at": self.created_at.isoformat(),
- }
-
-
-class ExchangeBase(ABC):
- """
- Abstract base class for exchange adapters.
-
- All exchange implementations (Binance, Bybit, etc.) must inherit from this
- class and implement all abstract methods.
- """
-
- def __init__(self, exchange_type: ExchangeType):
- """
- Initialize exchange adapter.
-
- Args:
- exchange_type: Type of exchange (PAPER, BINANCE, etc.)
- """
- self.exchange_type = exchange_type
- self.is_connected = False
- self.orders: Dict[str, Order] = {}
- self.order_history: List[Order] = []
-
- # ============ Connection Management ============
-
- @abstractmethod
- async def connect(self) -> bool:
- """
- Connect to exchange (authenticate, validate credentials).
-
- Returns:
- True if connection successful
- """
- pass
-
- @abstractmethod
- async def disconnect(self) -> bool:
- """
- Disconnect from exchange gracefully.
-
- Returns:
- True if disconnection successful
- """
- pass
-
- @abstractmethod
- async def validate_connection(self) -> bool:
- """
- Validate that connection is still active and valid.
-
- Returns:
- True if connection is valid
- """
- pass
-
- # ============ Account Information ============
-
- @abstractmethod
- async def get_balance(self) -> Dict[str, float]:
- """
- Get account balances across all assets.
-
- Returns:
- Dictionary mapping asset symbols to balances
- Example: {"USDT": 100000, "BTC": 1.5}
- """
- pass
-
- @abstractmethod
- async def get_asset_balance(self, asset: str) -> float:
- """
- Get balance for a specific asset.
-
- Args:
- asset: Asset symbol (e.g., "USDT", "BTC")
-
- Returns:
- Available balance
- """
- pass
-
- # ============ Market Data ============
-
- @abstractmethod
- async def get_current_price(self, symbol: str) -> float:
- """
- Get current market price for a symbol.
-
- Args:
- symbol: Trading symbol (e.g., "BTCUSDT")
-
- Returns:
- Current price
- """
- pass
-
- @abstractmethod
- async def get_24h_ticker(self, symbol: str) -> Dict[str, Any]:
- """
- Get 24-hour ticker data.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Dictionary with price, volume, change data
- """
- pass
-
- # ============ Order Management ============
-
- @abstractmethod
- async def place_order(
- self,
- symbol: str,
- side: str,
- quantity: float,
- price: Optional[float] = None,
- order_type: str = "limit",
- **kwargs,
- ) -> Order:
- """
- Place a new order.
-
- Args:
- symbol: Trading symbol (e.g., "BTCUSDT")
- side: "buy" or "sell"
- quantity: Order quantity
- price: Order price (None for market orders)
- order_type: "limit" or "market"
- **kwargs: Exchange-specific parameters
-
- Returns:
- Order object with order_id
- """
- pass
-
- @abstractmethod
- async def cancel_order(self, symbol: str, order_id: str) -> bool:
- """
- Cancel an open order.
-
- Args:
- symbol: Trading symbol
- order_id: Order ID to cancel
-
- Returns:
- True if cancellation successful
- """
- pass
-
- @abstractmethod
- async def get_order_status(self, symbol: str, order_id: str) -> OrderStatus:
- """
- Get status of a specific order.
-
- Args:
- symbol: Trading symbol
- order_id: Order ID
-
- Returns:
- Order status
- """
- pass
-
- @abstractmethod
- async def get_open_orders(self, symbol: Optional[str] = None) -> List[Order]:
- """
- Get all open orders.
-
- Args:
- symbol: Optional symbol to filter by
-
- Returns:
- List of open Order objects
- """
- pass
-
- @abstractmethod
- async def get_order_history(
- self, symbol: Optional[str] = None, limit: int = 100
- ) -> List[Order]:
- """
- Get order history.
-
- Args:
- symbol: Optional symbol to filter by
- limit: Maximum number of orders to return
-
- Returns:
- List of Order objects
- """
- pass
-
- # ============ Position Management ============
-
- @abstractmethod
- async def get_open_positions(
- self, symbol: Optional[str] = None
- ) -> Dict[str, Dict[str, Any]]:
- """
- Get all open positions.
-
- Args:
- symbol: Optional symbol to filter by
-
- Returns:
- Dictionary with position details
- Example: {
- "BTC": {
- "quantity": 1.5,
- "entry_price": 45000,
- "current_price": 46000,
- "unrealized_pnl": 1500
- }
- }
- """
- pass
-
- @abstractmethod
- async def get_position_details(self, symbol: str) -> Optional[Dict[str, Any]]:
- """
- Get details for a specific position.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Position details or None if no position
- """
- pass
-
- # ============ Trade Execution ============
-
- @abstractmethod
- async def execute_buy(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a buy order.
-
- Args:
- symbol: Trading symbol
- quantity: Amount to buy
- price: Price (None for market order)
- **kwargs: Exchange-specific parameters
-
- Returns:
- Order object or None if execution failed
- """
- pass
-
- @abstractmethod
- async def execute_sell(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a sell order.
-
- Args:
- symbol: Trading symbol
- quantity: Amount to sell
- price: Price (None for market order)
- **kwargs: Exchange-specific parameters
-
- Returns:
- Order object or None if execution failed
- """
- pass
-
- # ============ Utilities ============
-
- @abstractmethod
- def normalize_symbol(self, symbol: str) -> str:
- """
- Normalize symbol to exchange format.
-
- Args:
- symbol: Symbol to normalize (e.g., "BTC-USD")
-
- Returns:
- Exchange-formatted symbol (e.g., "BTCUSDT" for Binance)
- """
- pass
-
- @abstractmethod
- async def get_fee_tier(self) -> Dict[str, float]:
- """
- Get current trading fee tier.
-
- Returns:
- Dictionary with maker/taker fees
- """
- pass
-
- @abstractmethod
- async def get_trading_limits(self, symbol: str) -> Dict[str, float]:
- """
- Get trading limits for a symbol.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Dictionary with min/max quantities, precision, etc.
- """
- pass
-
- # ============ Error Handling ============
-
- async def handle_order_rejection(self, order: Order, reason: str) -> bool:
- """
- Handle order rejection (cleanup, logging, etc.).
-
- Args:
- order: Rejected order
- reason: Rejection reason
-
- Returns:
- True if handled successfully
- """
- logger.warning(f"Order {order.order_id} rejected: {reason}")
- order.status = OrderStatus.REJECTED
- return True
-
- async def handle_connection_error(self, error: Exception) -> bool:
- """
- Handle connection errors.
-
- Args:
- error: Connection error
-
- Returns:
- True if handled, False if critical
- """
- logger.error(f"Connection error: {error}")
- self.is_connected = False
- return False
diff --git a/python/valuecell/agents/auto_trading_agent/exchanges/binance_exchange.py b/python/valuecell/agents/auto_trading_agent/exchanges/binance_exchange.py
deleted file mode 100644
index 548899a95..000000000
--- a/python/valuecell/agents/auto_trading_agent/exchanges/binance_exchange.py
+++ /dev/null
@@ -1,533 +0,0 @@
-"""Binance exchange adapter for live trading
-
-This adapter connects to Binance API for real trading on live accounts.
-Requires: API key and secret from Binance account settings.
-
-WARNING: Real money trading - handle with care!
-"""
-
-import logging
-from typing import Any, Dict, List, Optional
-
-from .base_exchange import ExchangeBase, ExchangeType, Order, OrderStatus
-
-logger = logging.getLogger(__name__)
-
-
-class BinanceExchange(ExchangeBase):
- """
- Binance exchange adapter for live trading.
-
- Features (TODO - Future Implementation):
- - Connect to Binance API
- - Execute real trades
- - Monitor real-time positions
- - Handle Binance-specific errors
- - Support spot and margin trading
-
- WARNING: This implementation is for architecture design only.
- Real implementation requires proper error handling, rate limiting, and security measures.
- """
-
- def __init__(self, api_key: str, api_secret: str, testnet: bool = False):
- """
- Initialize Binance exchange adapter.
-
- Args:
- api_key: Binance API key
- api_secret: Binance API secret
- testnet: Use testnet for testing (default: False)
-
- Note:
- - testnet=True connects to https://testnet.binance.vision (for testing)
- - testnet=False connects to https://api.binance.com (real trading!)
- """
- super().__init__(ExchangeType.BINANCE)
- self.api_key = api_key
- self.api_secret = api_secret
- self.testnet = testnet
-
- # TODO: Initialize Binance client
- # self.client = BinanceClientAsync(api_key, api_secret)
- # if testnet:
- # self.client.API_URL = "https://testnet.binance.vision"
-
- logger.warning(
- f"BinanceExchange initialized in {'TESTNET' if testnet else 'LIVE'} mode. "
- "TODO: Implement real API connections."
- )
-
- # ============ Connection Management ============
-
- async def connect(self) -> bool:
- """
- Connect to Binance API.
-
- TODO: Implementation
- - Validate API credentials
- - Check API rate limits
- - Verify account status
-
- Returns:
- True if connection successful
- """
- logger.info("[TODO] Connecting to Binance API...")
- # self.is_connected = await self.client.ping()
- self.is_connected = True
- return self.is_connected
-
- async def disconnect(self) -> bool:
- """
- Disconnect from Binance API gracefully.
-
- TODO: Implementation
- - Close websocket connections
- - Clean up resources
-
- Returns:
- True if disconnection successful
- """
- logger.info("[TODO] Disconnecting from Binance API...")
- self.is_connected = False
- return True
-
- async def validate_connection(self) -> bool:
- """
- Validate that connection is still active.
-
- TODO: Implementation
- - Ping Binance API
- - Check if credentials are still valid
-
- Returns:
- True if connection is valid
- """
- logger.info("[TODO] Validating Binance connection...")
- return self.is_connected
-
- # ============ Account Information ============
-
- async def get_balance(self) -> Dict[str, float]:
- """
- Get account balances from Binance.
-
- TODO: Implementation
- - Fetch account info from Binance
- - Parse balances for each asset
- - Filter out zero balances
-
- Returns:
- Dictionary mapping asset -> balance
- Example: {"USDT": 100000.0, "BTC": 1.5}
- """
- logger.info("[TODO] Fetching balances from Binance...")
- return {"USDT": 100000.0} # Placeholder
-
- async def get_asset_balance(self, asset: str) -> float:
- """
- Get balance for a specific asset.
-
- TODO: Implementation
- - Query Binance for specific asset
- - Return available balance
-
- Args:
- asset: Asset symbol (e.g., "USDT", "BTC")
-
- Returns:
- Available balance
- """
- logger.info(f"[TODO] Fetching {asset} balance from Binance...")
- return 0.0 # Placeholder
-
- # ============ Market Data ============
-
- async def get_current_price(self, symbol: str) -> float:
- """
- Get current market price from Binance.
-
- TODO: Implementation
- - Query latest price from Binance
- - Handle rate limits
- - Cache results if needed
-
- Args:
- symbol: Trading symbol (e.g., "BTCUSDT")
-
- Returns:
- Current price
- """
- logger.info(f"[TODO] Fetching price for {symbol} from Binance...")
- return 0.0 # Placeholder
-
- async def get_24h_ticker(self, symbol: str) -> Dict[str, Any]:
- """
- Get 24-hour ticker data from Binance.
-
- TODO: Implementation
- - Query Binance 24h ticker
- - Parse response
- - Calculate changes
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Ticker data dictionary
- """
- logger.info(f"[TODO] Fetching 24h ticker for {symbol} from Binance...")
- return {} # Placeholder
-
- # ============ Order Management ============
-
- async def place_order(
- self,
- symbol: str,
- side: str,
- quantity: float,
- price: Optional[float] = None,
- order_type: str = "limit",
- **kwargs,
- ) -> Order:
- """
- Place an order on Binance.
-
- TODO: Implementation
- - Validate parameters
- - Send order to Binance
- - Handle order confirmation
- - Return Order object with order_id from Binance
-
- Args:
- symbol: Trading symbol (e.g., "BTCUSDT")
- side: "buy" or "sell"
- quantity: Order quantity
- price: Limit price (None for market orders)
- order_type: "limit" or "market"
- **kwargs: Binance-specific parameters
-
- Returns:
- Order object with Binance order_id
- """
- logger.info(
- f"[TODO] Placing {order_type} order on Binance: "
- f"{side} {quantity} {symbol} @ ${price or 'market'}"
- )
-
- # This is a placeholder - real implementation would:
- # response = await self.client.create_order(
- # symbol=symbol,
- # side=side.upper(),
- # type=order_type.upper(),
- # quantity=quantity,
- # price=price,
- # )
- # return Order(order_id=response['orderId'], ...)
-
- return Order(
- order_id="binance_placeholder",
- symbol=symbol,
- side=side,
- quantity=quantity,
- price=price or 0.0,
- order_type=order_type,
- )
-
- async def cancel_order(self, symbol: str, order_id: str) -> bool:
- """
- Cancel an order on Binance.
-
- TODO: Implementation
- - Send cancel request to Binance
- - Verify cancellation
- - Handle errors
-
- Args:
- symbol: Trading symbol
- order_id: Binance order ID
-
- Returns:
- True if cancellation successful
- """
- logger.info(f"[TODO] Cancelling order {order_id} on Binance...")
- return False # Placeholder
-
- async def get_order_status(self, symbol: str, order_id: str) -> OrderStatus:
- """
- Get order status from Binance.
-
- TODO: Implementation
- - Query Binance for order status
- - Map Binance status to OrderStatus enum
-
- Args:
- symbol: Trading symbol
- order_id: Binance order ID
-
- Returns:
- Order status
- """
- logger.info(f"[TODO] Fetching status for order {order_id} from Binance...")
- return OrderStatus.PENDING # Placeholder
-
- async def get_open_orders(self, symbol: Optional[str] = None) -> List[Order]:
- """
- Get open orders from Binance.
-
- TODO: Implementation
- - Query Binance for open orders
- - Parse each order into Order objects
- - Filter by symbol if provided
-
- Args:
- symbol: Optional symbol filter
-
- Returns:
- List of open Order objects
- """
- logger.info(f"[TODO] Fetching open orders from Binance (symbol={symbol})...")
- return [] # Placeholder
-
- async def get_order_history(
- self, symbol: Optional[str] = None, limit: int = 100
- ) -> List[Order]:
- """
- Get order history from Binance.
-
- TODO: Implementation
- - Query Binance for closed orders
- - Parse into Order objects
- - Respect limit parameter
-
- Args:
- symbol: Optional symbol filter
- limit: Maximum orders to return
-
- Returns:
- List of Order objects
- """
- logger.info(
- f"[TODO] Fetching order history from Binance "
- f"(symbol={symbol}, limit={limit})..."
- )
- return [] # Placeholder
-
- # ============ Position Management ============
-
- async def get_open_positions(
- self, symbol: Optional[str] = None
- ) -> Dict[str, Dict[str, Any]]:
- """
- Get open positions from Binance account.
-
- TODO: Implementation
- - Query account balances
- - Filter non-zero balances (excluding USDT)
- - Calculate current price for each
- - Calculate unrealized P&L
-
- Args:
- symbol: Optional symbol filter
-
- Returns:
- Dictionary of positions with details
- """
- logger.info(f"[TODO] Fetching open positions from Binance (symbol={symbol})...")
- return {} # Placeholder
-
- async def get_position_details(self, symbol: str) -> Optional[Dict[str, Any]]:
- """
- Get details for a specific position.
-
- TODO: Implementation
- - Query position data
- - Calculate current value
- - Calculate unrealized P&L
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Position details or None
- """
- logger.info(f"[TODO] Fetching position details for {symbol} from Binance...")
- return None # Placeholder
-
- # ============ Trade Execution ============
-
- async def execute_buy(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a buy order on Binance.
-
- TODO: Implementation
- - Check balance
- - Place market or limit order
- - Monitor fill status
- - Return filled Order
-
- Args:
- symbol: Trading symbol
- quantity: Amount to buy
- price: Price (None for market order)
- **kwargs: Additional parameters
-
- Returns:
- Filled Order or None if failed
- """
- logger.info(
- f"[TODO] Executing BUY on Binance: {quantity} {symbol} @ ${price or 'market'}"
- )
- return None # Placeholder
-
- async def execute_sell(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a sell order on Binance.
-
- TODO: Implementation
- - Check position exists
- - Place market or limit order
- - Monitor fill status
- - Calculate P&L
- - Return filled Order
-
- Args:
- symbol: Trading symbol
- quantity: Amount to sell
- price: Price (None for market order)
- **kwargs: Additional parameters
-
- Returns:
- Filled Order or None if failed
- """
- logger.info(
- f"[TODO] Executing SELL on Binance: {quantity} {symbol} @ ${price or 'market'}"
- )
- return None # Placeholder
-
- # ============ Utilities ============
-
- def normalize_symbol(self, symbol: str) -> str:
- """
- Normalize symbol to Binance format.
-
- Args:
- symbol: Original symbol (e.g., "BTC-USD")
-
- Returns:
- Binance format (e.g., "BTCUSDT")
- """
- return symbol.replace("-USD", "USDT").replace("-USDT", "USDT")
-
- async def get_fee_tier(self) -> Dict[str, float]:
- """
- Get current trading fee tier from Binance.
-
- TODO: Implementation
- - Query user trading fees
- - Handle VIP tiers
- - Return maker/taker fees
-
- Returns:
- Fee dictionary with maker/taker fees
- """
- logger.info("[TODO] Fetching fee tier from Binance...")
- # Default Binance fees
- return {"maker": 0.001, "taker": 0.001}
-
- async def get_trading_limits(self, symbol: str) -> Dict[str, float]:
- """
- Get trading limits for a symbol on Binance.
-
- TODO: Implementation
- - Query symbol filters
- - Parse lot size filter
- - Parse min notional filter
- - Return all limits
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Dictionary with trading limits
- """
- logger.info(f"[TODO] Fetching trading limits for {symbol} from Binance...")
- return {
- "min_quantity": 0.0001,
- "max_quantity": 1000000,
- "quantity_precision": 8,
- "min_notional": 10.0,
- }
-
- # ============ WebSocket Subscriptions (Future) ============
-
- async def subscribe_to_ticker(self, symbol: str, callback) -> bool:
- """
- Subscribe to real-time ticker updates via WebSocket.
-
- TODO: Future implementation
- - Connect to Binance WebSocket
- - Subscribe to ticker stream
- - Call callback on each update
- - Handle reconnection
-
- Args:
- symbol: Trading symbol
- callback: Callback function for updates
-
- Returns:
- True if subscription successful
- """
- logger.info(f"[TODO] Subscribing to ticker updates for {symbol}...")
- return False
-
- async def subscribe_to_trades(self, symbol: str, callback) -> bool:
- """
- Subscribe to real-time trade updates via WebSocket.
-
- TODO: Future implementation
- - Connect to Binance WebSocket
- - Subscribe to trades stream
- - Call callback on each trade
-
- Args:
- symbol: Trading symbol
- callback: Callback function for updates
-
- Returns:
- True if subscription successful
- """
- logger.info(f"[TODO] Subscribing to trade updates for {symbol}...")
- return False
-
- # ============ Error Handling ============
-
- async def handle_api_error(self, error: Dict[str, Any]) -> bool:
- """
- Handle API errors from Binance.
-
- TODO: Implementation
- - Parse Binance error codes
- - Determine severity (warning vs critical)
- - Log appropriately
- - Take corrective action if needed
-
- Args:
- error: Error response from Binance
-
- Returns:
- True if error was handled, False if critical
- """
- logger.error(f"[TODO] Handling Binance API error: {error}")
- return False
diff --git a/python/valuecell/agents/auto_trading_agent/exchanges/okx_exchange.py b/python/valuecell/agents/auto_trading_agent/exchanges/okx_exchange.py
deleted file mode 100644
index 4cc7fb783..000000000
--- a/python/valuecell/agents/auto_trading_agent/exchanges/okx_exchange.py
+++ /dev/null
@@ -1,519 +0,0 @@
-"""OKX exchange adapter for live trading (spot and contracts)."""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import uuid
-from typing import Any, Dict, List, Optional
-
-from okx.Account import AccountAPI
-from okx.MarketData import MarketAPI
-from okx.PublicData import PublicAPI
-from okx.Trade import TradeAPI
-
-from .base_exchange import ExchangeBase, ExchangeType, Order, OrderStatus
-
-logger = logging.getLogger(__name__)
-
-
-class OKXExchangeError(RuntimeError):
- """Raised for recoverable OKX-related failures."""
-
-
-class OKXExchange(ExchangeBase):
- """Exchange adapter for OKX via the official SDK.
-
- Supports both SPOT and CONTRACTS (e.g., SWAP). Default is contracts mode.
- """
-
- def __init__(
- self,
- api_key: str,
- api_secret: str,
- passphrase: str,
- *,
- network: str = "paper",
- # Default to contracts trading mode (cross) instead of spot cash
- margin_mode: str = "cross",
- inst_type: str = "SWAP",
- use_server_time: bool = False,
- domain: str = "https://www.okx.com",
- ) -> None:
- super().__init__(ExchangeType.OKX)
-
- if not (api_key and api_secret and passphrase):
- raise OKXExchangeError("OKX API key/secret/passphrase must be provided")
-
- normalized_network = (network or "paper").lower()
- # OKX SDK flag semantics: "1" => demo/paper, "0" => live/mainnet
- self._flag = "1" if normalized_network in {"paper", "demo", "testnet"} else "0"
- self.margin_mode = (
- margin_mode or ("cash" if (inst_type or "").upper() == "SPOT" else "cross")
- ).lower()
- self.inst_type = (inst_type or "SWAP").upper()
- self.use_server_time = use_server_time
- self.domain = domain
-
- self._trade_client = TradeAPI(
- api_key,
- api_secret,
- passphrase,
- use_server_time=self.use_server_time,
- flag=self._flag,
- domain=self.domain,
- )
- self._account_client = AccountAPI(
- api_key,
- api_secret,
- passphrase,
- use_server_time=self.use_server_time,
- flag=self._flag,
- domain=self.domain,
- )
- self._market_client = MarketAPI(
- api_key,
- api_secret,
- passphrase,
- use_server_time=self.use_server_time,
- flag=self._flag,
- domain=self.domain,
- )
- self._public_client = PublicAPI(
- api_key,
- api_secret,
- passphrase,
- use_server_time=self.use_server_time,
- flag=self._flag,
- domain=self.domain,
- )
-
- # ------------------------------------------------------------------
- # Connection lifecycle
- # ------------------------------------------------------------------
-
- async def connect(self) -> bool:
- try:
- await asyncio.to_thread(self._account_client.get_account_balance)
- self.is_connected = True
- logger.info(
- "Connected to OKX (%s mode)", "paper" if self._flag == "1" else "live"
- )
- return True
- except Exception as exc: # noqa: BLE001
- logger.error("Failed to connect to OKX: %s", exc)
- raise OKXExchangeError("Unable to connect to OKX") from exc
-
- async def disconnect(self) -> bool:
- self.is_connected = False
- return True
-
- async def validate_connection(self) -> bool:
- try:
- await asyncio.to_thread(self._account_client.get_account_config)
- return True
- except Exception as exc: # noqa: BLE001
- logger.warning("OKX connection validation failed: %s", exc)
- self.is_connected = False
- return False
-
- # ------------------------------------------------------------------
- # Account information
- # ------------------------------------------------------------------
-
- async def get_balance(self) -> Dict[str, float]:
- response = await asyncio.to_thread(self._account_client.get_account_balance)
- return self._parse_balance(response)
-
- async def get_asset_balance(self, asset: str) -> float:
- balances = await self.get_balance()
- return balances.get(asset.upper(), 0.0)
-
- # ------------------------------------------------------------------
- # Market data
- # ------------------------------------------------------------------
-
- async def get_current_price(self, symbol: str) -> float:
- inst_id = self.normalize_symbol(symbol)
- response = await asyncio.to_thread(self._market_client.get_ticker, inst_id)
- data = self._extract_first(response)
- if not data:
- raise OKXExchangeError(f"No ticker data returned for {inst_id}")
- return float(data.get("last", data.get("lastPx", 0.0)))
-
- async def get_24h_ticker(self, symbol: str) -> Dict[str, Any]:
- inst_id = self.normalize_symbol(symbol)
- response = await asyncio.to_thread(self._market_client.get_ticker, inst_id)
- data = self._extract_first(response) or {}
- return {
- "symbol": inst_id,
- "last": self._safe_float(data.get("last", data.get("lastPx"))),
- "high_24h": self._safe_float(data.get("high24h")),
- "low_24h": self._safe_float(data.get("low24h")),
- "volume_24h": self._safe_float(data.get("vol24h")),
- "best_bid": self._safe_float(data.get("bidPx")),
- "best_ask": self._safe_float(data.get("askPx")),
- }
-
- # ------------------------------------------------------------------
- # Order management
- # ------------------------------------------------------------------
-
- async def place_order(
- self,
- symbol: str,
- side: str,
- quantity: float,
- price: Optional[float] = None,
- order_type: str = "limit",
- **kwargs: Any,
- ) -> Order:
- inst_id = self.normalize_symbol(symbol)
- cloid = kwargs.get("client_order_id") or uuid.uuid4().hex
- ord_type = "market" if order_type == "market" or price is None else "limit"
- px_value = "" if ord_type == "market" else f"{price:.8f}"
-
- payload = {
- "instId": inst_id,
- "tdMode": self.margin_mode,
- "side": side.lower(),
- "ordType": ord_type,
- "sz": f"{quantity:.8f}",
- "clOrdId": cloid,
- }
- # tgtCcy is only applicable for SPOT orders
- if self.inst_type == "SPOT":
- payload["tgtCcy"] = "base_ccy"
- if px_value:
- payload["px"] = px_value
-
- try:
- response = await asyncio.to_thread(
- self._trade_client.place_order, **payload
- )
- except Exception as exc: # noqa: BLE001
- logger.error("OKX order submission failed: %s", exc)
- raise OKXExchangeError("Order submission failed") from exc
-
- order_data = self._extract_first(response)
- if not order_data:
- logger.error("Invalid response from OKX place_order: %s", response)
- raise OKXExchangeError("Order submission returned no data")
-
- status = self._map_order_state(order_data.get("state"))
- filled_px = self._safe_float(order_data.get("avgPx")) or self._safe_float(
- px_value
- )
-
- order = Order(
- order_id=order_data.get("ordId", cloid),
- symbol=symbol,
- side=side.lower(),
- quantity=quantity,
- price=filled_px or 0.0,
- order_type=ord_type,
- trade_type=kwargs.get("trade_type"),
- )
- order.status = status
- if filled_px:
- order.filled_price = filled_px
- order.filled_quantity = quantity if status == OrderStatus.FILLED else 0.0
- self.orders[order.order_id] = order
- self.order_history.append(order)
- return order
-
- async def cancel_order(self, symbol: str, order_id: str) -> bool:
- inst_id = self.normalize_symbol(symbol)
- try:
- await asyncio.to_thread(
- self._trade_client.cancel_order, inst_id, ordId=order_id
- )
- if order_id in self.orders:
- self.orders[order_id].status = OrderStatus.CANCELLED
- return True
- except Exception as exc: # noqa: BLE001
- logger.warning("Failed to cancel OKX order %s: %s", order_id, exc)
- return False
-
- async def get_order_status(self, symbol: str, order_id: str) -> OrderStatus:
- inst_id = self.normalize_symbol(symbol)
- try:
- response = await asyncio.to_thread(
- self._trade_client.get_order, inst_id, ordId=order_id
- )
- except Exception as exc: # noqa: BLE001
- logger.warning("Failed to query OKX order %s: %s", order_id, exc)
- return self.orders.get(
- order_id, Order(order_id, symbol, "buy", 0, 0)
- ).status
-
- order_data = self._extract_first(response)
- status = (
- self._map_order_state(order_data.get("state"))
- if order_data
- else OrderStatus.PENDING
- )
- if order_id in self.orders:
- self.orders[order_id].status = status
- return status
-
- async def get_open_orders(self, symbol: Optional[str] = None) -> List[Order]:
- inst_id = self.normalize_symbol(symbol) if symbol else ""
- response = await asyncio.to_thread(
- self._trade_client.get_order_list, self.inst_type, instId=inst_id
- )
- orders: List[Order] = []
- for item in response.get("data", []):
- inst = item.get("instId")
- client_symbol = self._from_okx_symbol(inst)
- order = Order(
- order_id=item.get("ordId") or item.get("clOrdId", uuid.uuid4().hex),
- symbol=client_symbol,
- side=item.get("side", "buy"),
- quantity=self._safe_float(item.get("sz"), default=0.0),
- price=self._safe_float(item.get("px"), default=0.0),
- )
- order.status = self._map_order_state(item.get("state"))
- orders.append(order)
- return orders
-
- async def get_order_history(
- self, symbol: Optional[str] = None, limit: int = 100
- ) -> List[Order]:
- inst_id = self.normalize_symbol(symbol) if symbol else ""
- response = await asyncio.to_thread(
- self._trade_client.get_orders_history,
- self.inst_type,
- instId=inst_id,
- limit=str(limit),
- )
- orders: List[Order] = []
- for item in response.get("data", []):
- inst = item.get("instId")
- client_symbol = self._from_okx_symbol(inst)
- order = Order(
- order_id=item.get("ordId") or item.get("clOrdId", uuid.uuid4().hex),
- symbol=client_symbol,
- side=item.get("side", "buy"),
- quantity=self._safe_float(item.get("sz"), default=0.0),
- price=self._safe_float(
- item.get("fillPx") or item.get("avgPx"), default=0.0
- ),
- )
- order.status = self._map_order_state(item.get("state"))
- order.filled_quantity = self._safe_float(item.get("accFillSz"), default=0.0)
- order.filled_price = self._safe_float(
- item.get("avgPx"), default=order.price
- )
- orders.append(order)
- return orders
-
- # ------------------------------------------------------------------
- # Position helpers
- # ------------------------------------------------------------------
-
- async def get_open_positions(
- self, symbol: Optional[str] = None
- ) -> Dict[str, Dict[str, Any]]:
- # Contracts mode: query positions; Spot mode: derive from balances
- if self.inst_type != "SPOT":
- try:
- response = await asyncio.to_thread(
- self._account_client.get_positions, self.inst_type
- )
- positions: Dict[str, Dict[str, Any]] = {}
- for item in response.get("data", []) or []:
- inst = item.get("instId")
- client_symbol = self._from_okx_symbol(inst)
- if symbol and client_symbol != symbol:
- continue
- qty = (
- self._safe_float(
- item.get("pos")
- or item.get("posSz")
- or item.get("availPos"),
- default=0.0,
- )
- or 0.0
- )
- entry_px = self._safe_float(item.get("avgPx"), default=0.0) or 0.0
- unreal_pnl = self._safe_float(item.get("upl"), default=0.0) or 0.0
- positions[client_symbol] = {
- "quantity": qty,
- "entry_price": entry_px,
- "unrealized_pnl": unreal_pnl,
- }
- return positions
- except Exception: # noqa: BLE001 - fallback to spot-like behavior
- pass
-
- balances = await self.get_balance()
- # Spot balances act as positions for cash mode; return filtered view
- positions: Dict[str, Dict[str, Any]] = {}
- for asset, balance in balances.items():
- if asset in {"USD", "USDT", "USDC", "withdrawable_usd"}:
- continue
- client_symbol = f"{asset}-USDT"
- if symbol and client_symbol != symbol:
- continue
- positions[client_symbol] = {
- "quantity": balance,
- "entry_price": 0.0,
- "unrealized_pnl": 0.0,
- }
- return positions
-
- async def get_position_details(self, symbol: str) -> Optional[Dict[str, Any]]:
- positions = await self.get_open_positions(symbol)
- return positions.get(symbol)
-
- async def execute_buy(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs: Any,
- ) -> Optional[Order]:
- order = await self.place_order(
- symbol=symbol,
- side="buy",
- quantity=quantity,
- price=price,
- order_type="market" if price is None else "limit",
- **kwargs,
- )
- return order
-
- async def execute_sell(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs: Any,
- ) -> Optional[Order]:
- order = await self.place_order(
- symbol=symbol,
- side="sell",
- quantity=quantity,
- price=price,
- order_type="market" if price is None else "limit",
- **kwargs,
- )
- return order
-
- # ------------------------------------------------------------------
- # Utilities
- # ------------------------------------------------------------------
-
- def normalize_symbol(self, symbol: str) -> str:
- clean = symbol.replace("/", "-").upper()
- base, _, quote = clean.partition("-")
- if self.inst_type == "SPOT":
- if not quote:
- return clean
- if quote == "USD":
- quote = "USDT"
- return f"{base}-{quote}"
- # Contracts (default): map to USDT-margined perpetual swap by default
- return f"{base}-USDT-SWAP"
-
- def _from_okx_symbol(self, inst_id: Optional[str]) -> str:
- if not inst_id:
- return ""
- parts = inst_id.upper().split("-")
- if not parts:
- return ""
- base = parts[0]
- quote = parts[1] if len(parts) > 1 else "USD"
- # Strip contract suffix like SWAP/QUARTER/NEXT_QUARTER if present
- if len(parts) > 2:
- quote = parts[1]
- if quote == "USDT":
- quote = "USD"
- return f"{base}-{quote}"
-
- async def get_fee_tier(self) -> Dict[str, float]:
- try:
- response = await asyncio.to_thread(
- self._account_client.get_fee_rates, self.inst_type
- )
- except Exception as exc: # noqa: BLE001
- logger.debug("Failed to fetch OKX fee rates: %s", exc)
- return {"maker": 0.0, "taker": 0.0}
-
- data = self._extract_first(response) or {}
- return {
- "maker": self._safe_float(data.get("maker"), default=0.0) or 0.0,
- "taker": self._safe_float(data.get("taker"), default=0.0) or 0.0,
- }
-
- async def get_trading_limits(self, symbol: str) -> Dict[str, float]:
- inst_id = self.normalize_symbol(symbol)
- try:
- response = await asyncio.to_thread(
- self._public_client.get_instruments, self.inst_type, instId=inst_id
- )
- except Exception as exc: # noqa: BLE001
- logger.debug("Failed to fetch OKX instrument metadata: %s", exc)
- return {}
-
- data = self._extract_first(response) or {}
- min_sz = self._safe_float(data.get("minSz"))
- lot_sz = self._safe_float(data.get("lotSz"))
- return {
- "min_quantity": min_sz or 0.0,
- "lot_size": lot_sz or 0.0,
- "tick_size": self._safe_float(data.get("tickSz"), default=0.0) or 0.0,
- }
-
- # ------------------------------------------------------------------
- # Helper methods
- # ------------------------------------------------------------------
-
- @staticmethod
- def _extract_first(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- data = response.get("data") if isinstance(response, dict) else None
- if isinstance(data, list) and data:
- return data[0]
- return None
-
- @staticmethod
- def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
- if value in (None, ""):
- return default
- try:
- return float(value)
- except (TypeError, ValueError):
- return default
-
- @staticmethod
- def _map_order_state(state: Optional[str]) -> OrderStatus:
- if not state:
- return OrderStatus.PENDING
- state = state.lower()
- if state in {"filled", "canceledfilled", "partially_filled"}:
- return OrderStatus.FILLED
- if state in {"live", "partially_filled_not_canceled"}:
- return OrderStatus.PENDING
- if state in {"canceled", "cancelled"}:
- return OrderStatus.CANCELLED
- if state in {"rejected"}:
- return OrderStatus.REJECTED
- return OrderStatus.PENDING
-
- def _parse_balance(self, response: Dict[str, Any]) -> Dict[str, float]:
- balances: Dict[str, float] = {}
- for account in response.get("data", []):
- total_eq = account.get("totalEq")
- if total_eq is not None:
- balances["USD"] = float(total_eq)
- for detail in account.get("details", []):
- currency = detail.get("ccy")
- if not currency:
- continue
- total = detail.get("eq") or detail.get("cashBal") or "0"
- try:
- balances[currency.upper()] = float(total)
- except (TypeError, ValueError):
- continue
- return balances
diff --git a/python/valuecell/agents/auto_trading_agent/exchanges/paper_trading.py b/python/valuecell/agents/auto_trading_agent/exchanges/paper_trading.py
deleted file mode 100644
index cab1d31d6..000000000
--- a/python/valuecell/agents/auto_trading_agent/exchanges/paper_trading.py
+++ /dev/null
@@ -1,502 +0,0 @@
-"""Paper trading (simulated) exchange adapter"""
-
-import logging
-import uuid
-from typing import Any, Dict, List, Optional
-
-import yfinance as yf
-
-from .base_exchange import ExchangeBase, ExchangeType, Order, OrderStatus
-
-logger = logging.getLogger(__name__)
-
-
-class PaperTrading(ExchangeBase):
- """
- Simulated trading on paper (no real money, no real orders).
-
- Used for backtesting and strategy development without risking real capital.
- """
-
- def __init__(self, initial_balance: float = 100000.0):
- """
- Initialize paper trading exchange.
-
- Args:
- initial_balance: Starting capital for simulated trading
- """
- super().__init__(ExchangeType.PAPER)
- self.initial_balance = initial_balance
- self.balance = initial_balance
- self.positions: Dict[str, Dict[str, Any]] = {} # {symbol: position_data}
- self.is_connected = True
-
- # ============ Connection Management ============
-
- async def connect(self) -> bool:
- """Paper trading is always connected"""
- self.is_connected = True
- logger.info("Paper trading connected (simulated)")
- return True
-
- async def disconnect(self) -> bool:
- """Disconnect paper trading"""
- self.is_connected = False
- logger.info("Paper trading disconnected")
- return True
-
- async def validate_connection(self) -> bool:
- """Paper trading is always valid"""
- return self.is_connected
-
- # ============ Account Information ============
-
- async def get_balance(self) -> Dict[str, float]:
- """
- Get simulated account balances.
-
- Returns:
- Dictionary with USDT and other assets
- """
- balances = {"USDT": self.balance}
- # Add positions as assets
- for symbol, pos_data in self.positions.items():
- asset = symbol.replace("USDT", "")
- balances[asset] = pos_data["quantity"]
- return balances
-
- async def get_asset_balance(self, asset: str) -> float:
- """
- Get balance for a specific asset.
-
- Args:
- asset: Asset symbol
-
- Returns:
- Available balance
- """
- if asset == "USDT":
- return self.balance
-
- # Check if we have a position
- for symbol, pos_data in self.positions.items():
- if symbol.startswith(asset):
- return pos_data["quantity"]
-
- return 0.0
-
- # ============ Market Data ============
-
- async def get_current_price(self, symbol: str) -> float:
- """
- Get current simulated price from yfinance.
-
- Args:
- symbol: Trading symbol in exchange format
-
- Returns:
- Current price
- """
- try:
- # Convert exchange format back to ticker format
- ticker_symbol = self._denormalize_symbol(symbol)
- ticker = yf.Ticker(ticker_symbol)
- data = ticker.history(period="1d", interval="1m")
- if data.empty:
- logger.warning(f"No price data for {symbol}")
- return 0.0
- return float(data["Close"].iloc[-1])
- except Exception as e:
- logger.error(f"Failed to get price for {symbol}: {e}")
- return 0.0
-
- async def get_24h_ticker(self, symbol: str) -> Dict[str, Any]:
- """
- Get 24-hour ticker data.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Ticker data dictionary
- """
- try:
- ticker_symbol = self._denormalize_symbol(symbol)
- ticker = yf.Ticker(ticker_symbol)
- data = ticker.history(period="1d", interval="1h")
-
- if data.empty:
- return {}
-
- return {
- "symbol": symbol,
- "current_price": float(data["Close"].iloc[-1]),
- "24h_high": float(data["High"].iloc[-24:].max()),
- "24h_low": float(data["Low"].iloc[-24:].min()),
- "24h_volume": float(data["Volume"].iloc[-24:].sum()),
- "24h_change": float(
- (data["Close"].iloc[-1] - data["Close"].iloc[0])
- / data["Close"].iloc[0]
- * 100
- ),
- }
- except Exception as e:
- logger.error(f"Failed to get 24h ticker for {symbol}: {e}")
- return {}
-
- # ============ Order Management ============
-
- async def place_order(
- self,
- symbol: str,
- side: str,
- quantity: float,
- price: Optional[float] = None,
- order_type: str = "limit",
- **kwargs,
- ) -> Order:
- """
- Place a simulated order.
-
- Args:
- symbol: Trading symbol
- side: "buy" or "sell"
- quantity: Order quantity
- price: Order price (None for market)
- order_type: "limit" or "market"
- **kwargs: Additional parameters
-
- Returns:
- Order object
- """
- order_id = str(uuid.uuid4())[:8]
-
- # Get current price if market order
- if price is None or order_type == "market":
- price = await self.get_current_price(symbol)
-
- order = Order(
- order_id=order_id,
- symbol=symbol,
- side=side.lower(),
- quantity=quantity,
- price=price,
- order_type=order_type,
- trade_type=kwargs.get("trade_type"),
- )
-
- # Immediately fill market orders
- if order_type == "market":
- await self._fill_order(order)
-
- self.orders[order_id] = order
- logger.info(
- f"Order placed: {order_id} - {side} {quantity} {symbol} @ ${price:.2f}"
- )
- return order
-
- async def cancel_order(self, symbol: str, order_id: str) -> bool:
- """
- Cancel an order (for paper trading, just mark as cancelled).
-
- Args:
- symbol: Trading symbol
- order_id: Order ID to cancel
-
- Returns:
- True if successful
- """
- if order_id in self.orders:
- self.orders[order_id].status = OrderStatus.CANCELLED
- logger.info(f"Order cancelled: {order_id}")
- return True
- return False
-
- async def get_order_status(self, symbol: str, order_id: str) -> OrderStatus:
- """
- Get order status.
-
- Args:
- symbol: Trading symbol
- order_id: Order ID
-
- Returns:
- Order status
- """
- if order_id in self.orders:
- return self.orders[order_id].status
- return OrderStatus.EXPIRED
-
- async def get_open_orders(self, symbol: Optional[str] = None) -> List[Order]:
- """
- Get open orders.
-
- Args:
- symbol: Optional symbol filter
-
- Returns:
- List of open orders
- """
- open_orders = [
- o
- for o in self.orders.values()
- if o.status in [OrderStatus.PENDING, OrderStatus.PARTIALLY_FILLED]
- ]
- if symbol:
- open_orders = [o for o in open_orders if o.symbol == symbol]
- return open_orders
-
- async def get_order_history(
- self, symbol: Optional[str] = None, limit: int = 100
- ) -> List[Order]:
- """
- Get order history.
-
- Args:
- symbol: Optional symbol filter
- limit: Max orders to return
-
- Returns:
- Order history
- """
- history = self.order_history
- if symbol:
- history = [o for o in history if o.symbol == symbol]
- return history[-limit:]
-
- # ============ Position Management ============
-
- async def get_open_positions(
- self, symbol: Optional[str] = None
- ) -> Dict[str, Dict[str, Any]]:
- """
- Get open positions.
-
- Args:
- symbol: Optional symbol filter
-
- Returns:
- Dictionary of positions
- """
- if symbol:
- if symbol in self.positions:
- return {symbol: self.positions[symbol]}
- return {}
- return self.positions.copy()
-
- async def get_position_details(self, symbol: str) -> Optional[Dict[str, Any]]:
- """
- Get details for a specific position.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Position details or None
- """
- return self.positions.get(symbol)
-
- # ============ Trade Execution ============
-
- async def execute_buy(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a buy order.
-
- Args:
- symbol: Trading symbol
- quantity: Amount to buy
- price: Price (None for market)
- **kwargs: Additional parameters
-
- Returns:
- Order or None
- """
- # Get price
- if price is None:
- price = await self.get_current_price(symbol)
-
- notional = quantity * price
-
- # Check balance
- if notional > self.balance:
- logger.warning(
- f"Insufficient balance for buy: need ${notional:.2f}, have ${self.balance:.2f}"
- )
- return None
-
- # Place and fill order
- order = await self.place_order(symbol, "buy", quantity, price, "market")
- return order
-
- async def execute_sell(
- self,
- symbol: str,
- quantity: float,
- price: Optional[float] = None,
- **kwargs,
- ) -> Optional[Order]:
- """
- Execute a sell order.
-
- Args:
- symbol: Trading symbol
- quantity: Amount to sell
- price: Price (None for market)
- **kwargs: Additional parameters
-
- Returns:
- Order or None
- """
- # Check if we have the position
- if symbol not in self.positions:
- logger.warning(f"No position to sell for {symbol}")
- return None
-
- if self.positions[symbol]["quantity"] < quantity:
- logger.warning(
- f"Insufficient position: have {self.positions[symbol]['quantity']}, "
- f"trying to sell {quantity}"
- )
- return None
-
- # Get price
- if price is None:
- price = await self.get_current_price(symbol)
-
- # Place and fill order
- order = await self.place_order(symbol, "sell", quantity, price, "market")
- return order
-
- # ============ Utilities ============
-
- def normalize_symbol(self, symbol: str) -> str:
- """
- Normalize symbol to paper trading format.
-
- Args:
- symbol: Original symbol (e.g., "BTC-USD")
-
- Returns:
- Normalized symbol (e.g., "BTCUSDT")
- """
- return symbol.replace("-USD", "USDT").replace("-USDT", "USDT")
-
- def _denormalize_symbol(self, symbol: str) -> str:
- """
- Convert from exchange format back to yfinance format.
-
- Args:
- symbol: Exchange format (e.g., "BTCUSDT")
-
- Returns:
- yfinance format (e.g., "BTC-USD")
- """
- return symbol.replace("USDT", "-USD")
-
- async def get_fee_tier(self) -> Dict[str, float]:
- """
- Paper trading has no fees.
-
- Returns:
- Fee dictionary
- """
- return {"maker": 0.0, "taker": 0.0}
-
- async def get_trading_limits(self, symbol: str) -> Dict[str, float]:
- """
- Get trading limits (paper trading has no limits).
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Limits dictionary
- """
- return {
- "min_quantity": 0.0001,
- "max_quantity": 1000000,
- "quantity_precision": 8,
- "min_notional": 1.0,
- }
-
- # ============ Private Methods ============
-
- async def _fill_order(self, order: Order) -> bool:
- """
- Fill an order (update balance, positions).
-
- Args:
- order: Order to fill
-
- Returns:
- True if filled successfully
- """
- try:
- if order.side == "buy":
- notional = order.quantity * order.price
- self.balance -= notional
-
- # Update position
- if order.symbol in self.positions:
- self.positions[order.symbol]["quantity"] += order.quantity
- # Update entry price (average)
- old_notional = self.positions[order.symbol]["entry_price"] * (
- self.positions[order.symbol]["quantity"] - order.quantity
- )
- total_notional = old_notional + notional
- total_quantity = self.positions[order.symbol]["quantity"]
- self.positions[order.symbol]["entry_price"] = (
- total_notional / total_quantity
- )
- else:
- self.positions[order.symbol] = {
- "quantity": order.quantity,
- "entry_price": order.price,
- "entry_time": order.created_at,
- }
-
- order.filled_quantity = order.quantity
- order.filled_price = order.price
- order.status = OrderStatus.FILLED
-
- elif order.side == "sell":
- notional = order.quantity * order.price
- self.balance += notional
-
- # Update position
- if order.symbol in self.positions:
- self.positions[order.symbol]["quantity"] -= order.quantity
- if self.positions[order.symbol]["quantity"] <= 0:
- del self.positions[order.symbol]
-
- order.filled_quantity = order.quantity
- order.filled_price = order.price
- order.status = OrderStatus.FILLED
-
- self.order_history.append(order)
- logger.info(f"Order filled: {order.order_id} - {order.status.value}")
- return True
-
- except Exception as e:
- logger.error(f"Failed to fill order: {e}")
- return False
-
- async def reset(self, initial_balance: float):
- """
- Reset paper trading to initial state.
-
- Args:
- initial_balance: New starting balance
- """
- self.initial_balance = initial_balance
- self.balance = initial_balance
- self.positions.clear()
- self.orders.clear()
- self.order_history.clear()
- logger.info(f"Paper trading reset with balance: ${initial_balance:,.2f}")
diff --git a/python/valuecell/agents/auto_trading_agent/formatters.py b/python/valuecell/agents/auto_trading_agent/formatters.py
deleted file mode 100644
index 12cbed843..000000000
--- a/python/valuecell/agents/auto_trading_agent/formatters.py
+++ /dev/null
@@ -1,309 +0,0 @@
-"""Formatting utilities for notifications and messages"""
-
-import json
-import logging
-from datetime import datetime, timezone
-from typing import Any, Dict, Optional
-
-from ...utils.i18n_utils import convert_timezone, get_current_timezone
-from .models import Position, TechnicalIndicators, TradeAction, TradeType
-
-logger = logging.getLogger(__name__)
-
-
-class MessageFormatter:
- """Formats various messages and notifications"""
-
- @staticmethod
- def _convert_and_format_timestamp(
- dt: datetime, format_str: str = "%m/%d, %I:%M %p", include_tz: bool = False
- ) -> str:
- """
- Convert timestamp to user's timezone and format it.
-
- Args:
- dt: DateTime to convert and format
- format_str: Format string for strftime
- include_tz: Whether to include timezone abbreviation
-
- Returns:
- Formatted timestamp string
- """
- try:
- # Get user's configured timezone
- user_tz = get_current_timezone()
-
- # Convert from UTC to user's timezone
- # Assume input is UTC if no timezone info
- if dt.tzinfo is None:
- dt = datetime(
- dt.year,
- dt.month,
- dt.day,
- dt.hour,
- dt.minute,
- dt.second,
- tzinfo=timezone.utc,
- )
-
- converted_dt = convert_timezone(dt, "UTC", user_tz)
-
- # Format the datetime
- formatted = converted_dt.strftime(format_str)
-
- # Optionally append timezone info
- if include_tz:
- formatted += f" ({user_tz})"
-
- return formatted
- except Exception as e:
- logger.warning(f"Failed to convert timezone: {e}, using UTC")
- # Fallback to UTC format
- if dt.tzinfo is None:
- dt = datetime(
- dt.year,
- dt.month,
- dt.day,
- dt.hour,
- dt.minute,
- dt.second,
- tzinfo=timezone.utc,
- )
- return dt.strftime(format_str)
-
- @staticmethod
- def format_trade_notification(
- trade_details: Dict[str, Any], agent_name: str = "AutoTrading"
- ) -> str:
- """
- Format trade details into a notification message
-
- Args:
- trade_details: Trade execution details
- agent_name: Name of the agent
-
- Returns:
- Formatted notification message
- """
- try:
- symbol = trade_details["symbol"]
- action = trade_details["action"]
- trade_type = trade_details["trade_type"]
- timestamp = trade_details["timestamp"]
-
- # Convert timestamp to user's timezone
- formatted_time = MessageFormatter._convert_and_format_timestamp(timestamp)
-
- if action == "opened":
- message = (
- f"**{agent_name}** opened a **{trade_type}** position on **{symbol}**!\n\n"
- f"📅 {formatted_time}\n\n"
- f"**Price:** `${trade_details['entry_price']:,.2f}`\n\n"
- f"**Quantity:** `{trade_details['quantity']:.4f}`\n\n"
- f"**Notional:** `${trade_details['notional']:,.2f}`"
- )
- else: # closed
- hours = int(trade_details["holding_time"].total_seconds() // 3600)
- minutes = int(
- (trade_details["holding_time"].total_seconds() % 3600) // 60
- )
- pnl = trade_details["pnl"]
- pnl_sign = "+" if pnl >= 0 else ""
- pnl_emoji = "🟢" if pnl >= 0 else "🔴"
-
- message = (
- f"**{agent_name}** completed a **{trade_type}** trade on **{symbol}**!\n\n"
- f"📅 {formatted_time}\n\n"
- f"**Price:** `${trade_details['entry_price']:,.2f}` → `${trade_details['exit_price']:,.2f}`\n\n"
- f"**Quantity:** `{trade_details['quantity']:.4f}`\n\n"
- f"**Notional:** `${trade_details['entry_notional']:,.2f}` → `${trade_details['exit_notional']:,.2f}`\n\n"
- f"**Holding time:** `{hours}H {minutes}M`\n\n"
- f"**Net P&L:** {pnl_emoji} **{pnl_sign}${pnl:,.2f}**"
- )
-
- return message
-
- except Exception as e:
- logger.error(f"Failed to format trade notification: {e}")
- return "Trade executed"
-
- @staticmethod
- def format_portfolio_notification(
- portfolio_value: float,
- positions_count: int,
- current_capital: float,
- agent_model: str,
- session_id: str,
- portfolio_history: list,
- ) -> tuple[str, Optional[str]]:
- """
- Format portfolio value notification for chart rendering
-
- Args:
- portfolio_value: Current portfolio value
- positions_count: Number of open positions
- current_capital: Available capital
- agent_model: Agent model name
- session_id: Current session ID
- portfolio_history: Historical portfolio data
-
- Returns:
- Tuple of (display message, chart data JSON)
- """
- try:
- timestamp = datetime.now(timezone.utc)
-
- # Append to history
- portfolio_history.append(
- {"timestamp": timestamp.isoformat(), "value": portfolio_value}
- )
-
- # Create chart data payload
- chart_data = {
- "id": f"AutoTradingAgent-{agent_model}",
- "filters": [
- {"dimension": "Time", "gte": timestamp.isoformat()},
- {"dimension": "Model", "=": agent_model},
- ],
- "data": {"Portfolio": portfolio_value},
- }
-
- # Convert timestamp to user's timezone for display
- formatted_time = MessageFormatter._convert_and_format_timestamp(
- timestamp, format_str="%m/%d, %I:%M %p", include_tz=True
- )
-
- display_message = (
- f"💰 Portfolio Update\n"
- f"Time: {formatted_time}\n"
- f"Total Value: ${portfolio_value:,.2f}\n"
- f"Open Positions: {positions_count}\n"
- f"Available Capital: ${current_capital:,.2f}"
- )
-
- return display_message, json.dumps(chart_data)
-
- except Exception as e:
- logger.error(f"Failed to format portfolio notification: {e}")
- return "Portfolio update failed", None
-
- @staticmethod
- def format_market_analysis_notification(
- symbol: str,
- indicators: TechnicalIndicators,
- action: TradeAction,
- trade_type: TradeType,
- positions: Dict[str, Position],
- ai_reasoning: Optional[str] = None,
- ) -> str:
- """
- Format market analysis notification including HOLD decisions
-
- Args:
- symbol: Trading symbol
- indicators: Technical indicators
- action: Recommended action
- trade_type: Trade type
- positions: Current positions dictionary
- ai_reasoning: AI reasoning if available
-
- Returns:
- Formatted analysis message
- """
- try:
- timestamp = datetime.now(timezone.utc)
-
- # Convert timestamp to user's timezone for display
- formatted_time = MessageFormatter._convert_and_format_timestamp(
- timestamp, format_str="%m/%d, %I:%M %p", include_tz=True
- )
-
- # Format action with emoji
- action_emoji = {
- TradeAction.BUY: "🟢",
- TradeAction.SELL: "🔴",
- TradeAction.HOLD: "⏸️",
- }
-
- message = (
- f"📊 **Market Analysis - {symbol}**\n"
- f"Time: {formatted_time}\n\n"
- f"**Current Price:** ${indicators.close_price:,.2f}\n"
- f"**Decision:** {action_emoji.get(action, '')} {action.value.upper()}"
- )
-
- if action != TradeAction.HOLD:
- message += f" ({trade_type.value.upper()})"
-
- message += "\n\n**Technical Indicators:**\n"
-
- # Add MACD
- if indicators.macd is not None and indicators.macd_signal is not None:
- macd_signal = (
- "🟢 Bullish"
- if indicators.macd > indicators.macd_signal
- else "🔴 Bearish"
- )
- message += f"- MACD: {indicators.macd:.4f} / Signal: {indicators.macd_signal:.4f} ({macd_signal})\n"
-
- # Add RSI
- if indicators.rsi is not None:
- rsi_signal = (
- "🟢 Oversold"
- if indicators.rsi < 30
- else ("🔴 Overbought" if indicators.rsi > 70 else "⚪ Neutral")
- )
- message += f"- RSI: {indicators.rsi:.2f} ({rsi_signal})\n"
-
- # Add EMAs
- if indicators.ema_12 is not None and indicators.ema_26 is not None:
- ema_signal = (
- "🟢 Bullish"
- if indicators.ema_12 > indicators.ema_26
- else "🔴 Bearish"
- )
- message += f"- EMA 12/26: ${indicators.ema_12:,.2f} / ${indicators.ema_26:,.2f} ({ema_signal})\n"
-
- # Add Bollinger Bands
- if indicators.bb_upper is not None and indicators.bb_lower is not None:
- if indicators.close_price > indicators.bb_upper:
- bb_signal = "🔴 Above Upper Band"
- elif indicators.close_price < indicators.bb_lower:
- bb_signal = "🟢 Below Lower Band"
- else:
- bb_signal = "⚪ Within Bands"
- message += f"- Bollinger Bands: ${indicators.bb_lower:,.2f} - ${indicators.bb_upper:,.2f} ({bb_signal})\n"
-
- # Add AI reasoning if available
- if ai_reasoning:
- message += f"\n**AI Analysis:**\n{ai_reasoning}\n"
-
- # Add current position info if exists
- if symbol in positions:
- pos = positions[symbol]
- current_pnl = 0
- if pos.trade_type == TradeType.LONG:
- current_pnl = (indicators.close_price - pos.entry_price) * abs(
- pos.quantity
- )
- else:
- current_pnl = (pos.entry_price - indicators.close_price) * abs(
- pos.quantity
- )
-
- pnl_emoji = "🟢" if current_pnl >= 0 else "🔴"
- message += (
- f"\n**Current Position:**\n"
- f"- Type: {pos.trade_type.value.upper()}\n"
- f"- Entry: ${pos.entry_price:,.2f}\n"
- f"- Quantity: {abs(pos.quantity):.4f}\n"
- f"- Unrealized P&L: {pnl_emoji} ${current_pnl:,.2f}\n"
- )
- else:
- message += f"\n**Current Position:** No open position for {symbol}\n\n"
-
- return message
-
- except Exception as e:
- logger.error(f"Failed to format market analysis notification: {e}")
- return f"Market analysis for {symbol}"
diff --git a/python/valuecell/agents/auto_trading_agent/market_data.py b/python/valuecell/agents/auto_trading_agent/market_data.py
deleted file mode 100644
index 3d30fec4a..000000000
--- a/python/valuecell/agents/auto_trading_agent/market_data.py
+++ /dev/null
@@ -1,340 +0,0 @@
-"""Market data and technical indicator retrieval - from a trader's perspective"""
-
-import logging
-import os
-from datetime import datetime, timezone
-from typing import Dict, Optional
-
-import pandas as pd
-import yfinance as yf
-
-from .exchanges.okx_exchange import OKXExchange
-from .models import TechnicalIndicators
-
-logger = logging.getLogger(__name__)
-
-
-class MarketDataProvider:
- """
- Fetches and caches market data.
-
- A trader typically thinks about:
- 1. "What's the current price?"
- 2. "What are the technical indicators telling me?"
- 3. "Is there enough volume for good execution?"
- """
-
- def __init__(self, cache_ttl_seconds: int = 60):
- """
- Initialize market data provider with optional caching.
-
- Args:
- cache_ttl_seconds: Time to live for cached data
- """
- self.cache_ttl_seconds = cache_ttl_seconds
- self._cache: Dict[str, tuple] = {} # {symbol: (data, timestamp)}
-
- def get_current_price(self, symbol: str) -> Optional[float]:
- """
- Get current market price for a symbol.
-
- Args:
- symbol: Trading symbol (e.g., BTC-USD)
-
- Returns:
- Current price or None if fetch fails
- """
- try:
- ticker = yf.Ticker(symbol)
- data = ticker.history(period="1d", interval="1m")
- if data.empty:
- logger.warning(f"No data available for {symbol}")
- return None
- return float(data["Close"].iloc[-1])
- except Exception as e:
- logger.error(f"Failed to get current price for {symbol}: {e}")
- return None
-
- def calculate_indicators(
- self, symbol: str, period: str = "5d", interval: str = "1m"
- ) -> Optional[TechnicalIndicators]:
- """
- Calculate all technical indicators for a symbol.
-
- Args:
- symbol: Trading symbol
- period: Data period (default: 5 days for intraday trading)
- interval: Data interval (default: 1 minute)
-
- Returns:
- TechnicalIndicators object or None if calculation fails
- """
- try:
- # Fetch data from yfinance
- ticker = yf.Ticker(symbol)
- df = ticker.history(period=period, interval=interval)
-
- if df.empty or len(df) < 50:
- logger.warning(f"Insufficient data for {symbol}: {len(df)} bars")
- return None
-
- # Calculate all indicators
- self._calculate_moving_averages(df)
- self._calculate_macd(df)
- self._calculate_rsi(df)
- self._calculate_bollinger_bands(df)
-
- # Get latest values
- return self._extract_latest_indicators(df, symbol)
-
- except Exception as e:
- logger.error(f"Failed to calculate indicators for {symbol}: {e}")
- return None
-
- @staticmethod
- def _calculate_moving_averages(df: pd.DataFrame):
- """Calculate exponential moving averages"""
- df["ema_12"] = df["Close"].ewm(span=12, adjust=False).mean()
- df["ema_26"] = df["Close"].ewm(span=26, adjust=False).mean()
- df["ema_50"] = df["Close"].ewm(span=50, adjust=False).mean()
-
- @staticmethod
- def _calculate_macd(df: pd.DataFrame):
- """Calculate MACD and signal line"""
- df["ema_12"] = df["Close"].ewm(span=12, adjust=False).mean()
- df["ema_26"] = df["Close"].ewm(span=26, adjust=False).mean()
- df["macd"] = df["ema_12"] - df["ema_26"]
- df["macd_signal"] = df["macd"].ewm(span=9, adjust=False).mean()
- df["macd_histogram"] = df["macd"] - df["macd_signal"]
-
- @staticmethod
- def _calculate_rsi(df: pd.DataFrame, period: int = 14):
- """Calculate Relative Strength Index"""
- delta = df["Close"].diff()
- gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
- loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
- # Avoid division by zero: if loss is 0, RSI = 100 (maximum strength)
- rs = gain / loss.replace(0, float("inf"))
- df["rsi"] = 100 - (100 / (1 + rs))
-
- @staticmethod
- def _calculate_bollinger_bands(
- df: pd.DataFrame, period: int = 20, std_dev: float = 2
- ):
- """Calculate Bollinger Bands"""
- df["bb_middle"] = df["Close"].rolling(window=period).mean()
- bb_std = df["Close"].rolling(window=period).std()
- df["bb_upper"] = df["bb_middle"] + (bb_std * std_dev)
- df["bb_lower"] = df["bb_middle"] - (bb_std * std_dev)
-
- @staticmethod
- def _extract_latest_indicators(
- df: pd.DataFrame, symbol: str
- ) -> TechnicalIndicators:
- """Extract latest indicator values from dataframe"""
- latest = df.iloc[-1]
-
- def safe_float(value):
- """Safely convert to float, handling NaN"""
- return float(value) if pd.notna(value) else None
-
- return TechnicalIndicators(
- symbol=symbol,
- timestamp=datetime.now(timezone.utc),
- close_price=float(latest["Close"]),
- volume=float(latest["Volume"]),
- macd=safe_float(latest.get("macd")),
- macd_signal=safe_float(latest.get("macd_signal")),
- macd_histogram=safe_float(latest.get("macd_histogram")),
- rsi=safe_float(latest.get("rsi")),
- ema_12=safe_float(latest.get("ema_12")),
- ema_26=safe_float(latest.get("ema_26")),
- ema_50=safe_float(latest.get("ema_50")),
- bb_upper=safe_float(latest.get("bb_upper")),
- bb_middle=safe_float(latest.get("bb_middle")),
- bb_lower=safe_float(latest.get("bb_lower")),
- )
-
-
-class OkxMarketDataProvider(MarketDataProvider):
- """Market data provider that uses OKX APIs for spot/contract prices.
-
- Falls back to parent implementation for indicators (yfinance) to keep
- indicator availability without extra OKX Kline wiring for now.
- """
-
- def __init__(self, cache_ttl_seconds: int = 60):
- super().__init__(cache_ttl_seconds=cache_ttl_seconds)
- self._okx = None
-
- def _ensure_okx(self) -> OKXExchange:
- if self._okx is None:
- api_key = os.getenv("OKX_API_KEY", "")
- api_secret = os.getenv("OKX_API_SECRET", "")
- passphrase = os.getenv("OKX_API_PASSPHRASE", "")
- network = os.getenv("OKX_NETWORK", "paper")
- self._okx = OKXExchange(
- api_key=api_key,
- api_secret=api_secret,
- passphrase=passphrase,
- network=network,
- )
- return self._okx
-
- def get_current_price(self, symbol: str) -> Optional[float]:
- try:
- okx = self._ensure_okx()
- # Connect lazily on first price call
- if not getattr(okx, "is_connected", False):
- import asyncio
-
- try:
- asyncio.get_running_loop()
- # we're in an async context; run sync via to_thread later
- # Fallback to parent if connection cannot be ensured here
- except RuntimeError:
- # No running loop; safe to run connect synchronously
- asyncio.run(okx.connect())
-
- # Get price from OKX adapter
- price = None
- try:
- # Try synchronous bridge via asyncio for adapter's async method
- import asyncio
-
- async def _get():
- if not okx.is_connected:
- await okx.connect()
- return await okx.get_current_price(symbol)
-
- try:
- loop = asyncio.get_running_loop()
- # If running loop exists, schedule and block until done
- price = loop.run_until_complete(_get()) # type: ignore
- except RuntimeError:
- # No running loop; we can create one
- price = asyncio.run(_get())
- except Exception:
- # As a fallback, use parent (yfinance)
- return super().get_current_price(symbol)
-
- return float(price) if price is not None else None
- except Exception as e:
- logger.error(f"Failed to get OKX price for {symbol}: {e}")
- return super().get_current_price(symbol)
-
-
-class SignalGenerator:
- """
- Generates trading signals from technical indicators.
-
- A trader's signal logic:
- 1. When to buy? (Entry signals)
- 2. When to sell? (Exit signals)
- 3. How confident am I?
- """
-
- from .models import TradeAction, TradeType
-
- @staticmethod
- def generate_signal(
- indicators: TechnicalIndicators,
- ) -> tuple["SignalGenerator.TradeAction", "SignalGenerator.TradeType"]:
- """
- Generate trading signal based on technical indicators.
-
- Uses a combination of:
- - MACD for trend direction
- - RSI for momentum/exhaustion
- - Bollinger Bands for volatility and support/resistance
-
- Args:
- indicators: Technical indicators for analysis
-
- Returns:
- Tuple of (TradeAction, TradeType)
- """
- from .models import TradeAction, TradeType
-
- try:
- # Check if we have all required indicators
- if (
- indicators.macd is None
- or indicators.macd_signal is None
- or indicators.rsi is None
- ):
- return (TradeAction.HOLD, TradeType.LONG)
-
- # Analyze trend direction
- macd_bullish = indicators.macd > indicators.macd_signal
- macd_bearish = indicators.macd < indicators.macd_signal
-
- # Analyze momentum
- rsi_oversold = indicators.rsi < 30
- rsi_overbought = indicators.rsi > 70
-
- # Entry signals: Look for mean-reversion opportunities with trend confirmation
- # Long signal: MACD bullish + RSI showing oversold
- if macd_bullish and rsi_oversold:
- return (TradeAction.BUY, TradeType.LONG)
-
- # Short signal: MACD bearish + RSI showing overbought
- if macd_bearish and rsi_overbought:
- return (TradeAction.BUY, TradeType.SHORT)
-
- # Exit signals: Close positions when momentum reverses
- # Exit long: MACD turns bearish or RSI gets overbought
- if macd_bearish or rsi_overbought:
- return (TradeAction.SELL, TradeType.LONG)
-
- # Exit short: MACD turns bullish or RSI gets oversold
- if macd_bullish or rsi_oversold:
- return (TradeAction.SELL, TradeType.SHORT)
-
- return (TradeAction.HOLD, TradeType.LONG)
-
- except Exception as e:
- logger.error(f"Failed to generate signal: {e}")
- return (TradeAction.HOLD, TradeType.LONG)
-
- @staticmethod
- def get_signal_strength(indicators: TechnicalIndicators) -> Dict[str, float]:
- """
- Get quantitative strength of signals.
-
- Returns:
- Dictionary with various signal strength indicators (0-100)
- """
- strength = {}
-
- # MACD strength (0-100)
- if indicators.macd is not None and indicators.macd_signal is not None:
- macd_diff = indicators.macd - indicators.macd_signal
- # Normalize to 0-100 scale (assuming typical range)
- strength["macd"] = min(100, max(0, 50 + (macd_diff * 100)))
- else:
- strength["macd"] = 50 # Neutral
-
- # RSI strength (already 0-100)
- if indicators.rsi is not None:
- strength["rsi"] = indicators.rsi
- else:
- strength["rsi"] = 50 # Neutral
-
- # Distance from Bollinger Bands (0-100)
- if (
- indicators.bb_lower is not None
- and indicators.bb_upper is not None
- and indicators.bb_middle is not None
- ):
- band_range = indicators.bb_upper - indicators.bb_lower
- if band_range > 0:
- # Distance from middle: 0 = at lower band, 100 = at upper band
- distance = (indicators.close_price - indicators.bb_lower) / band_range
- strength["bollinger"] = min(100, max(0, distance * 100))
- else:
- strength["bollinger"] = 50
- else:
- strength["bollinger"] = 50 # Neutral
-
- return strength
diff --git a/python/valuecell/agents/auto_trading_agent/models.py b/python/valuecell/agents/auto_trading_agent/models.py
deleted file mode 100644
index ee4b250f8..000000000
--- a/python/valuecell/agents/auto_trading_agent/models.py
+++ /dev/null
@@ -1,337 +0,0 @@
-"""Data models and enumerations for auto trading agent"""
-
-from datetime import datetime
-from enum import Enum
-from typing import List, Optional
-
-from pydantic import BaseModel, Field, field_validator
-
-from .constants import (
- DEFAULT_AGENT_MODEL,
- DEFAULT_INITIAL_CAPITAL,
- DEFAULT_MAX_POSITIONS,
- DEFAULT_RISK_PER_TRADE,
- MAX_SYMBOLS,
-)
-
-SUPPORTED_EXCHANGES = {"paper", "okx"}
-SUPPORTED_NETWORKS = {"testnet", "mainnet", "demo", "beta", "local", "paper"}
-
-
-class TradeAction(str, Enum):
- """Trade action enumeration"""
-
- BUY = "buy"
- SELL = "sell"
- HOLD = "hold"
-
-
-class TradeType(str, Enum):
- """Trade type enumeration"""
-
- LONG = "long"
- SHORT = "short"
-
-
-class TradingRequest(BaseModel):
- """Auto trading request model for parsing natural language queries"""
-
- crypto_symbols: List[str] = Field(
- ...,
- description="List of crypto symbols to trade (e.g., ['BTC-USD', 'ETH-USD'])",
- )
- initial_capital: Optional[float] = Field(
- default=DEFAULT_INITIAL_CAPITAL,
- description="Initial capital for trading in USD",
- gt=0,
- )
- use_ai_signals: Optional[bool] = Field(
- default=False,
- description="Whether to use AI-enhanced trading signals",
- )
- agent_models: Optional[List[str]] = Field(
- default=[DEFAULT_AGENT_MODEL],
- description="List of model IDs for trading decisions - one instance per model",
- )
- exchange: Optional[str] = Field(
- default=None,
- description="Exchange adapter to use (paper or okx)",
- )
- exchange_network: Optional[str] = Field(
- default=None,
- description="Target network for the exchange (testnet, mainnet, etc.)",
- )
- allow_live_trading: Optional[bool] = Field(
- default=None,
- description="Explicit confirmation toggle for mainnet trading",
- )
- okx_td_mode: Optional[str] = Field(
- default=None,
- description="OKX trading mode (cash, cross, isolated); defaults to cash",
- )
-
- @field_validator("crypto_symbols")
- @classmethod
- def validate_symbols(cls, v):
- if not v or len(v) == 0:
- raise ValueError("At least one crypto symbol is required")
- if len(v) > MAX_SYMBOLS:
- raise ValueError(f"Maximum {MAX_SYMBOLS} symbols allowed")
- # Normalize symbols to uppercase
- return [s.upper() for s in v]
-
- @field_validator("exchange")
- @classmethod
- def validate_exchange(cls, value):
- if value is None:
- return value
- lowered = value.lower()
- if lowered not in SUPPORTED_EXCHANGES:
- raise ValueError(f"Unsupported exchange '{value}'")
- return lowered
-
- @field_validator("exchange_network")
- @classmethod
- def validate_exchange_network(cls, value):
- if value is None:
- return value
- lowered = value.lower()
- if lowered not in SUPPORTED_NETWORKS:
- raise ValueError(f"Unsupported exchange network '{value}'")
- return lowered
-
-
-class AutoTradingConfig(BaseModel):
- """Configuration for auto trading agent"""
-
- initial_capital: float = Field(..., description="Initial capital for trading", gt=0)
- crypto_symbols: List[str] = Field(
- ...,
- description="List of crypto symbols to trade (max 10)",
- max_length=MAX_SYMBOLS,
- )
- check_interval: int = Field(
- default=60,
- description="Check interval in seconds",
- gt=0,
- )
- risk_per_trade: float = Field(
- default=DEFAULT_RISK_PER_TRADE,
- description="Risk per trade as percentage of capital",
- gt=0,
- lt=1,
- )
- max_positions: int = Field(
- default=DEFAULT_MAX_POSITIONS,
- description="Maximum number of concurrent positions",
- gt=0,
- )
- agent_model: str = Field(
- default=DEFAULT_AGENT_MODEL,
- description="Model ID for AI-enhanced trading decisions (single model per instance)",
- )
- agent_provider: Optional[str] = Field(
- default=None,
- description="Provider name (null = auto-detect from config system)",
- )
- use_ai_signals: bool = Field(
- default=False,
- description="Whether to use AI model for enhanced signal generation",
- )
- openrouter_api_key: Optional[str] = Field(
- default=None,
- description="OpenRouter API key for AI model access",
- )
- exchange: str = Field(
- default="paper",
- description="Exchange adapter to use (paper or okx)",
- )
- exchange_network: str = Field(
- default="testnet",
- description="Exchange network identifier (testnet, mainnet, etc.)",
- )
- allow_live_trading: bool = Field(
- default=False,
- description="Must be true to enable mainnet order placement",
- )
- okx_api_key: Optional[str] = Field(
- default=None,
- description="OKX API key for REST access",
- repr=False,
- )
- okx_api_secret: Optional[str] = Field(
- default=None,
- description="OKX API secret",
- repr=False,
- )
- okx_api_passphrase: Optional[str] = Field(
- default=None,
- description="OKX API passphrase",
- repr=False,
- )
- okx_margin_mode: str = Field(
- default="cross",
- description="OKX trading mode (contracts cross by default; use 'cash' for SPOT)",
- )
- okx_use_server_time: bool = Field(
- default=False,
- description="Sync with OKX server time when placing orders",
- )
-
- @field_validator("crypto_symbols")
- @classmethod
- def validate_symbols(cls, v):
- if not v or len(v) == 0:
- raise ValueError("At least one crypto symbol is required")
- if len(v) > MAX_SYMBOLS:
- raise ValueError(f"Maximum {MAX_SYMBOLS} symbols allowed")
- # Normalize symbols to uppercase
- return [s.upper() for s in v]
-
- @field_validator("exchange")
- @classmethod
- def validate_exchange(cls, value: str) -> str:
- lowered = value.lower()
- if lowered not in SUPPORTED_EXCHANGES:
- raise ValueError(f"Unsupported exchange '{value}'")
- return lowered
-
- @field_validator("exchange_network")
- @classmethod
- def validate_network(cls, value: str) -> str:
- lowered = value.lower()
- if lowered not in SUPPORTED_NETWORKS:
- raise ValueError(f"Unsupported exchange network '{value}'")
- return lowered
-
-
-class Position(BaseModel):
- """Trading position model"""
-
- symbol: str
- entry_price: float
- quantity: float
- entry_time: datetime
- trade_type: TradeType
- notional: float
-
-
-class CashManagement(BaseModel):
- """Cash management tracking"""
-
- total_cash: float = Field(..., description="Total available cash for trading")
- initial_cash: float = Field(..., description="Initial cash allocated")
- reserved_cash: float = Field(
- default=0, description="Cash reserved for pending positions"
- )
- available_cash: float = Field(
- ..., description="Available cash for new trades (total_cash - reserved_cash)"
- )
- cash_in_trades: float = Field(
- default=0, description="Cash currently deployed in open positions"
- )
-
- class Config:
- """Pydantic config"""
-
- frozen = False
-
-
-class TechnicalIndicators(BaseModel):
- """Technical indicators for a symbol"""
-
- symbol: str
- timestamp: datetime
- close_price: float
- volume: float
- macd: Optional[float] = None
- macd_signal: Optional[float] = None
- macd_histogram: Optional[float] = None
- rsi: Optional[float] = None
- ema_12: Optional[float] = None
- ema_26: Optional[float] = None
- ema_50: Optional[float] = None
- bb_upper: Optional[float] = None
- bb_middle: Optional[float] = None
- bb_lower: Optional[float] = None
-
-
-class TradeHistoryRecord(BaseModel):
- """Single trade execution history record"""
-
- timestamp: datetime = Field(..., description="Trade execution timestamp")
- symbol: str = Field(..., description="Trading symbol")
- action: str = Field(..., description="Trade action: opened or closed")
- trade_type: str = Field(..., description="Trade type: long or short")
- price: float = Field(..., description="Execution price")
- quantity: float = Field(..., description="Trade quantity")
- notional: float = Field(..., description="Trade notional value")
- pnl: Optional[float] = Field(None, description="P&L for closed positions")
- portfolio_value_after: float = Field(
- ..., description="Portfolio value after this trade"
- )
- cash_after: float = Field(..., description="Available cash after this trade")
-
-
-class PositionHistorySnapshot(BaseModel):
- """Position snapshot at a point in time"""
-
- timestamp: datetime = Field(..., description="Snapshot timestamp")
- symbol: str = Field(..., description="Trading symbol")
- quantity: float = Field(..., description="Position quantity")
- entry_price: float = Field(..., description="Entry price")
- current_price: float = Field(..., description="Current market price")
- trade_type: str = Field(..., description="Trade type: long or short")
- unrealized_pnl: float = Field(..., description="Unrealized P&L")
- notional: float = Field(..., description="Position notional value")
-
-
-class PortfolioValueSnapshot(BaseModel):
- """Portfolio value snapshot at a point in time"""
-
- timestamp: datetime = Field(..., description="Snapshot timestamp")
- total_value: float = Field(..., description="Total portfolio value")
- cash: float = Field(..., description="Available cash")
- cash_in_trades: float = Field(
- ..., description="Cash currently deployed in positions"
- )
- positions_value: float = Field(..., description="Value of open positions")
- positions_count: int = Field(..., description="Number of open positions")
- total_pnl: float = Field(..., description="Total unrealized P&L")
-
-
-class TradingInstanceData(BaseModel):
- """Complete data for a trading instance"""
-
- instance_id: str = Field(..., description="Unique instance ID")
- session_id: str = Field(..., description="Session ID")
- config: AutoTradingConfig = Field(..., description="Trading configuration")
- created_at: datetime = Field(..., description="Instance creation time")
- active: bool = Field(..., description="Whether instance is active")
-
- # Historical data
- trade_history: List[TradeHistoryRecord] = Field(
- default_factory=list, description="All trade executions"
- )
- position_history: List[PositionHistorySnapshot] = Field(
- default_factory=list, description="Position snapshots over time"
- )
- portfolio_history: List[PortfolioValueSnapshot] = Field(
- default_factory=list, description="Portfolio value over time"
- )
-
- # Current state
- current_positions: List[Position] = Field(
- default_factory=list, description="Current open positions"
- )
- current_capital: float = Field(..., description="Current available capital")
- current_portfolio_value: float = Field(
- ..., description="Current total portfolio value"
- )
-
- # Statistics
- check_count: int = Field(default=0, description="Number of market checks performed")
- last_check_time: Optional[datetime] = Field(
- None, description="Last market check time"
- )
- total_trades: int = Field(default=0, description="Total number of trades executed")
diff --git a/python/valuecell/agents/auto_trading_agent/portfolio_decision_manager.py b/python/valuecell/agents/auto_trading_agent/portfolio_decision_manager.py
deleted file mode 100644
index 337cc8094..000000000
--- a/python/valuecell/agents/auto_trading_agent/portfolio_decision_manager.py
+++ /dev/null
@@ -1,686 +0,0 @@
-"""Portfolio-level decision manager using AI for coordinated multi-asset trading decisions"""
-
-import logging
-from datetime import datetime
-from typing import Dict, List, Optional, Tuple
-
-from agno.agent import Agent
-from pydantic import BaseModel, Field
-
-from .models import (
- AutoTradingConfig,
- Position,
- TechnicalIndicators,
- TradeAction,
- TradeType,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class AssetAnalysis:
- """Analysis result for a single asset"""
-
- def __init__(
- self,
- symbol: str,
- indicators: TechnicalIndicators,
- technical_action: TradeAction,
- technical_trade_type: TradeType,
- ai_action: Optional[TradeAction] = None,
- ai_trade_type: Optional[TradeType] = None,
- ai_reasoning: Optional[str] = None,
- ai_confidence: Optional[float] = None,
- ):
- self.symbol = symbol
- self.indicators = indicators
- self.technical_action = technical_action
- self.technical_trade_type = technical_trade_type
- self.ai_action = ai_action
- self.ai_trade_type = ai_trade_type
- self.ai_reasoning = ai_reasoning
- self.ai_confidence = ai_confidence
-
- # Final recommendation (AI takes precedence if available)
- self.recommended_action = ai_action or technical_action
- self.recommended_trade_type = ai_trade_type or technical_trade_type
-
- @property
- def current_price(self) -> float:
- """Get current price from indicators"""
- return self.indicators.close_price
-
- def to_dict(self) -> Dict:
- """Convert analysis to dictionary for prompt construction"""
- return {
- "symbol": self.symbol,
- "current_price": self.current_price,
- "volume": self.indicators.volume,
- "technical_indicators": {
- "macd": self.indicators.macd,
- "macd_signal": self.indicators.macd_signal,
- "macd_histogram": self.indicators.macd_histogram,
- "rsi": self.indicators.rsi,
- "ema_12": self.indicators.ema_12,
- "ema_26": self.indicators.ema_26,
- "ema_50": self.indicators.ema_50,
- "bb_upper": self.indicators.bb_upper,
- "bb_middle": self.indicators.bb_middle,
- "bb_lower": self.indicators.bb_lower,
- },
- "technical_signal": {
- "action": self.technical_action.value,
- "trade_type": self.technical_trade_type.value,
- },
- "ai_signal": {
- "action": self.ai_action.value if self.ai_action else None,
- "trade_type": self.ai_trade_type.value if self.ai_trade_type else None,
- "reasoning": self.ai_reasoning,
- "confidence": self.ai_confidence,
- }
- if self.ai_action
- else None,
- }
-
-
-class TradeDecision(BaseModel):
- """Single trade decision"""
-
- symbol: str = Field(..., description="Trading symbol")
- action: str = Field(..., description="BUY, SELL, or HOLD")
- trade_type: str = Field(..., description="LONG or SHORT")
- priority: int = Field(..., description="Priority score 1-100")
- reasoning: str = Field(..., description="Reasoning for this trade decision")
-
-
-class PortfolioDecisionSchema(BaseModel):
- """AI-generated portfolio decision schema"""
-
- overall_market_sentiment: str = Field(
- ..., description="Overall market sentiment: BULLISH, BEARISH, or NEUTRAL"
- )
- portfolio_risk_assessment: str = Field(
- ..., description="Assessment of current portfolio risk: LOW, MEDIUM, or HIGH"
- )
- recommended_trades: List[TradeDecision] = Field(
- default_factory=list,
- description="List of recommended trades in priority order (max 3)",
- )
- portfolio_strategy: str = Field(
- ...,
- description="Overall portfolio strategy: AGGRESSIVE_GROWTH, BALANCED, DEFENSIVE, or HOLD",
- )
- risk_warnings: List[str] = Field(
- default_factory=list, description="Any risk warnings or concerns"
- )
- reasoning: str = Field(
- ..., description="Comprehensive reasoning for the portfolio decision"
- )
-
-
-class PortfolioDecision:
- """Portfolio-level trading decision"""
-
- def __init__(self):
- self.trades_to_execute: List[Tuple[str, TradeAction, TradeType]] = []
- self.reasoning: str = ""
- self.risk_level: float = 0.0 # 0-1 scale
- self.market_sentiment: str = "neutral"
- self.portfolio_strategy: str = "balanced"
- self.risk_warnings: List[str] = []
-
-
-class PortfolioDecisionManager:
- """
- AI-powered portfolio-level decision manager that considers all assets holistically.
-
- This manager:
- 1. Collects analysis for all assets in the portfolio
- 2. Uses LLM to analyze portfolio state, risk, and market conditions
- 3. Makes coordinated trading decisions based on AI reasoning
- 4. Provides transparent reasoning for all decisions
- """
-
- def __init__(self, config: AutoTradingConfig, llm_client=None):
- """
- Initialize portfolio decision manager.
-
- Args:
- config: Trading configuration
- llm_client: OpenRouter LLM client for portfolio analysis
- """
- self.config = config
- self.llm_client = llm_client
- self.asset_analyses: Dict[str, AssetAnalysis] = {}
-
- def add_asset_analysis(self, analysis: AssetAnalysis):
- """
- Add analysis for a single asset.
-
- Args:
- analysis: Asset analysis result
- """
- self.asset_analyses[analysis.symbol] = analysis
- logger.info(
- f"Added analysis for {analysis.symbol}: "
- f"{analysis.recommended_action.value} {analysis.recommended_trade_type.value}"
- )
-
- def clear_analyses(self):
- """Clear all asset analyses for a new decision cycle"""
- self.asset_analyses.clear()
-
- async def make_portfolio_decision(
- self,
- current_positions: Dict[str, Position],
- available_cash: float,
- total_portfolio_value: float,
- ) -> PortfolioDecision:
- """
- Make AI-powered portfolio-level trading decision.
-
- Args:
- current_positions: Current open positions
- available_cash: Available cash for trading
- total_portfolio_value: Total portfolio value
-
- Returns:
- PortfolioDecision with AI-coordinated trading actions
- """
- decision = PortfolioDecision()
-
- if not self.asset_analyses:
- decision.reasoning = "No asset analyses available"
- return decision
-
- # Calculate basic portfolio metrics
- portfolio_metrics = self._calculate_portfolio_metrics(
- current_positions, available_cash, total_portfolio_value
- )
-
- # Use AI to make portfolio decision if available
- if self.llm_client:
- try:
- ai_decision = await self._get_ai_portfolio_decision(
- current_positions,
- portfolio_metrics,
- available_cash,
- total_portfolio_value,
- )
- decision = self._convert_ai_decision(ai_decision, current_positions)
- except Exception as e:
- logger.error(f"Failed to get AI portfolio decision: {e}")
- # Fallback to rule-based decision
- decision = self._make_rule_based_decision(
- current_positions,
- portfolio_metrics,
- available_cash,
- total_portfolio_value,
- )
- else:
- # Fallback to rule-based decision
- decision = self._make_rule_based_decision(
- current_positions,
- portfolio_metrics,
- available_cash,
- total_portfolio_value,
- )
-
- return decision
-
- async def _get_ai_portfolio_decision(
- self,
- current_positions: Dict[str, Position],
- portfolio_metrics: Dict,
- available_cash: float,
- total_portfolio_value: float,
- ) -> PortfolioDecisionSchema:
- """
- Use LLM to analyze portfolio and make trading decisions.
-
- Returns:
- AI-generated portfolio decision
- """
- # Construct comprehensive prompt
- prompt = self._build_portfolio_analysis_prompt(
- current_positions, portfolio_metrics, available_cash, total_portfolio_value
- )
-
- # Create agent with structured output
- agent = Agent(
- model=self.llm_client,
- output_schema=PortfolioDecisionSchema,
- markdown=False,
- )
-
- # Get AI decision
- response = await agent.arun(prompt)
- ai_decision = response.content
-
- logger.info(
- f"AI Portfolio Decision: {ai_decision.portfolio_strategy}, "
- f"Sentiment: {ai_decision.overall_market_sentiment}, "
- f"Trades: {len(ai_decision.recommended_trades)}"
- )
-
- return ai_decision
-
- def _build_portfolio_analysis_prompt(
- self,
- current_positions: Dict[str, Position],
- portfolio_metrics: Dict,
- available_cash: float,
- total_portfolio_value: float,
- ) -> str:
- """Build comprehensive prompt for portfolio analysis"""
-
- # Current time
- current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- # Portfolio state section
- prompt_parts = [
- "You are an expert portfolio manager for cryptocurrency trading. Analyze the following portfolio state and asset analyses to make coordinated trading decisions.",
- "",
- f"=== ANALYSIS TIME: {current_time} ===",
- "",
- "=== PORTFOLIO STATE ===",
- f"Total Portfolio Value: ${total_portfolio_value:,.2f}",
- f"Available Cash: ${available_cash:,.2f} ({portfolio_metrics['cash_ratio'] * 100:.1f}%)",
- f"Cash in Positions: ${total_portfolio_value - available_cash:,.2f}",
- f"Open Positions: {portfolio_metrics['position_count']}/{self.config.max_positions}",
- f"Risk Per Trade: {self.config.risk_per_trade * 100:.1f}%",
- f"Max Positions Allowed: {self.config.max_positions}",
- "",
- ]
-
- # Current positions section
- if current_positions:
- prompt_parts.append("=== CURRENT POSITIONS ===")
- for symbol, position in current_positions.items():
- if symbol in self.asset_analyses:
- current_price = self.asset_analyses[symbol].current_price
- position_value = abs(position.quantity) * current_price
-
- if position.trade_type == TradeType.LONG:
- unrealized_pnl = (current_price - position.entry_price) * abs(
- position.quantity
- )
- else:
- unrealized_pnl = (position.entry_price - current_price) * abs(
- position.quantity
- )
-
- pnl_pct = (
- (unrealized_pnl / position.notional * 100)
- if position.notional > 0
- else 0
- )
- concentration = (
- (position_value / total_portfolio_value * 100)
- if total_portfolio_value > 0
- else 0
- )
-
- prompt_parts.extend(
- [
- f"\n{symbol}:",
- f" Type: {position.trade_type.value.upper()}",
- f" Entry Price: ${position.entry_price:,.2f}",
- f" Current Price: ${current_price:,.2f}",
- f" Quantity: {abs(position.quantity):.4f}",
- f" Position Value: ${position_value:,.2f}",
- f" Unrealized P&L: ${unrealized_pnl:,.2f} ({pnl_pct:+.2f}%)",
- f" Portfolio Concentration: {concentration:.1f}%",
- ]
- )
- prompt_parts.append("")
- else:
- prompt_parts.extend(
- [
- "=== CURRENT POSITIONS ===",
- "No open positions",
- "",
- ]
- )
-
- # Asset analyses section
- prompt_parts.append("=== ASSET ANALYSES ===")
- for symbol, analysis in self.asset_analyses.items():
- indicators = analysis.indicators
- prompt_parts.extend(
- [
- f"\n{symbol}:",
- f" Current Price: ${analysis.current_price:,.2f}",
- f" Volume: {indicators.volume:,.0f}",
- "",
- " Technical Indicators:",
- ]
- )
-
- # MACD
- if indicators.macd is not None and indicators.macd_signal is not None:
- macd_trend = (
- "BULLISH" if indicators.macd > indicators.macd_signal else "BEARISH"
- )
- prompt_parts.append(
- f" - MACD: {indicators.macd:.4f} / Signal: {indicators.macd_signal:.4f} ({macd_trend})"
- )
-
- # RSI
- if indicators.rsi is not None:
- if indicators.rsi < 30:
- rsi_status = "OVERSOLD (Potential Buy)"
- elif indicators.rsi > 70:
- rsi_status = "OVERBOUGHT (Potential Sell)"
- else:
- rsi_status = "NEUTRAL"
- prompt_parts.append(f" - RSI: {indicators.rsi:.2f} ({rsi_status})")
-
- # EMAs
- if indicators.ema_12 is not None and indicators.ema_26 is not None:
- ema_trend = (
- "BULLISH" if indicators.ema_12 > indicators.ema_26 else "BEARISH"
- )
- prompt_parts.append(
- f" - EMA 12/26: ${indicators.ema_12:,.2f} / ${indicators.ema_26:,.2f} ({ema_trend})"
- )
-
- # Bollinger Bands
- if indicators.bb_upper is not None and indicators.bb_lower is not None:
- if analysis.current_price > indicators.bb_upper:
- bb_status = "ABOVE UPPER BAND (Overbought)"
- elif analysis.current_price < indicators.bb_lower:
- bb_status = "BELOW LOWER BAND (Oversold)"
- else:
- bb_status = "WITHIN BANDS"
- prompt_parts.append(
- f" - Bollinger Bands: ${indicators.bb_lower:,.2f} - ${indicators.bb_upper:,.2f} ({bb_status})"
- )
-
- prompt_parts.append("")
- prompt_parts.append(" Technical Analysis Signal:")
- prompt_parts.append(
- f" - Action: {analysis.technical_action.value.upper()}"
- )
- if analysis.technical_action != TradeAction.HOLD:
- prompt_parts.append(
- f" - Type: {analysis.technical_trade_type.value.upper()}"
- )
-
- # AI signal if available
- if analysis.ai_action:
- prompt_parts.append("")
- prompt_parts.append(" AI-Enhanced Signal:")
- prompt_parts.append(f" - Action: {analysis.ai_action.value.upper()}")
- if analysis.ai_action != TradeAction.HOLD:
- prompt_parts.append(
- f" - Type: {analysis.ai_trade_type.value.upper()}"
- )
- if analysis.ai_confidence:
- prompt_parts.append(
- f" - Confidence: {analysis.ai_confidence:.0f}%"
- )
- if analysis.ai_reasoning:
- prompt_parts.append(f" - Reasoning: {analysis.ai_reasoning}")
-
- # Current position status
- if symbol in current_positions:
- prompt_parts.append(
- f" ⚠️ CURRENTLY HOLDING: {current_positions[symbol].trade_type.value.upper()} position"
- )
- else:
- prompt_parts.append(" ℹ️ No current position")
-
- prompt_parts.append("")
-
- # Risk management constraints
- prompt_parts.extend(
- [
- "",
- "=== RISK MANAGEMENT CONSTRAINTS ===",
- f"- Maximum {self.config.max_positions} concurrent positions allowed",
- "- Maximum 3 trades per decision cycle",
- f"- Risk per trade: {self.config.risk_per_trade * 100:.1f}% of available cash",
- "- Avoid single asset concentration >40% of portfolio",
- "- Prioritize closing losing positions if risk is high",
- "- Maintain minimum 10% cash reserve",
- "",
- ]
- )
-
- # Decision instructions
- prompt_parts.extend(
- [
- "=== YOUR TASK ===",
- "As a professional portfolio manager, analyze:",
- "1. Overall market sentiment across all assets",
- "2. Current portfolio risk level and concentration",
- "3. Individual asset signals (both technical and AI)",
- "4. Correlation and diversification opportunities",
- "5. Risk/reward of each potential trade",
- "",
- "Then provide:",
- "- overall_market_sentiment: BULLISH, BEARISH, or NEUTRAL",
- "- portfolio_risk_assessment: LOW, MEDIUM, or HIGH",
- "- recommended_trades: Up to 3 trades in priority order",
- " * For each trade: symbol, action (BUY/SELL/HOLD), trade_type (LONG/SHORT), priority (1-100), reasoning",
- " * Prioritize closing positions (SELL) over opening new ones if risk is high",
- " * Only recommend BUY if we have room and cash available",
- "- portfolio_strategy: AGGRESSIVE_GROWTH, BALANCED, DEFENSIVE, or HOLD",
- "- risk_warnings: List any concerns (concentration, volatility, etc.)",
- "- reasoning: Comprehensive explanation of your portfolio-level decision",
- "",
- "Important considerations:",
- "- Consider the portfolio as a whole, not just individual assets",
- "- Balance risk and opportunity across all positions",
- "- Prioritize capital preservation when risk is high",
- "- Consider taking profits on winning positions",
- "- Cut losses on losing positions if trend has reversed",
- "- Ensure diversification and avoid over-concentration",
- "",
- ]
- )
-
- return "\n".join(prompt_parts)
-
- def _convert_ai_decision(
- self,
- ai_decision: PortfolioDecisionSchema,
- current_positions: Dict[str, Position],
- ) -> PortfolioDecision:
- """Convert AI decision schema to PortfolioDecision"""
- decision = PortfolioDecision()
-
- # Set metadata
- decision.market_sentiment = ai_decision.overall_market_sentiment.lower()
- decision.portfolio_strategy = ai_decision.portfolio_strategy.lower()
- decision.risk_warnings = ai_decision.risk_warnings
- decision.reasoning = ai_decision.reasoning
-
- # Map risk assessment to risk level
- risk_map = {"LOW": 0.3, "MEDIUM": 0.6, "HIGH": 0.9}
- decision.risk_level = risk_map.get(
- ai_decision.portfolio_risk_assessment.upper(), 0.6
- )
-
- # Convert trades
- for trade in ai_decision.recommended_trades:
- try:
- action = TradeAction(trade.action.lower())
- trade_type = TradeType(trade.trade_type.lower())
-
- # Validate trade
- if action == TradeAction.BUY and trade.symbol in current_positions:
- logger.warning(
- f"Skipping BUY for {trade.symbol} - position already exists"
- )
- continue
-
- if action == TradeAction.SELL and trade.symbol not in current_positions:
- logger.warning(
- f"Skipping SELL for {trade.symbol} - no position to close"
- )
- continue
-
- if action == TradeAction.SELL and trade.symbol in current_positions:
- # Verify trade type matches
- if current_positions[trade.symbol].trade_type != trade_type:
- logger.warning(
- f"Skipping SELL for {trade.symbol} - trade type mismatch "
- f"(have {current_positions[trade.symbol].trade_type.value}, "
- f"trying to close {trade_type.value})"
- )
- continue
-
- if action != TradeAction.HOLD:
- decision.trades_to_execute.append(
- (trade.symbol, action, trade_type)
- )
-
- except Exception as e:
- logger.error(
- f"Failed to convert trade decision for {trade.symbol}: {e}"
- )
-
- return decision
-
- def _make_rule_based_decision(
- self,
- current_positions: Dict[str, Position],
- portfolio_metrics: Dict,
- available_cash: float,
- total_portfolio_value: float,
- ) -> PortfolioDecision:
- """Fallback rule-based decision making"""
- decision = PortfolioDecision()
-
- # Simple rule-based logic
- max_trades = 3
- trades_added = 0
-
- # Prioritize selling losing positions
- for symbol, position in current_positions.items():
- if trades_added >= max_trades:
- break
-
- if symbol in self.asset_analyses:
- analysis = self.asset_analyses[symbol]
- current_price = analysis.current_price
-
- # Calculate P&L
- if position.trade_type == TradeType.LONG:
- pnl = (current_price - position.entry_price) * abs(
- position.quantity
- )
- else:
- pnl = (position.entry_price - current_price) * abs(
- position.quantity
- )
-
- # Close losing positions if analysis suggests exit
- if pnl < 0 and analysis.recommended_action == TradeAction.SELL:
- decision.trades_to_execute.append(
- (symbol, TradeAction.SELL, position.trade_type)
- )
- trades_added += 1
-
- # Add new positions if we have room and strong signals
- for symbol, analysis in self.asset_analyses.items():
- if trades_added >= max_trades:
- break
-
- if (
- symbol not in current_positions
- and analysis.recommended_action == TradeAction.BUY
- ):
- if portfolio_metrics["position_count"] < self.config.max_positions:
- decision.trades_to_execute.append(
- (symbol, TradeAction.BUY, analysis.recommended_trade_type)
- )
- trades_added += 1
-
- decision.reasoning = (
- f"Rule-based decision: {len(decision.trades_to_execute)} trades selected"
- )
- decision.risk_level = portfolio_metrics.get("risk_level", 0.5)
-
- return decision
-
- def _calculate_portfolio_metrics(
- self,
- current_positions: Dict[str, Position],
- available_cash: float,
- total_portfolio_value: float,
- ) -> Dict:
- """Calculate basic portfolio metrics"""
- metrics = {
- "position_count": len(current_positions),
- "cash_ratio": (
- available_cash / total_portfolio_value
- if total_portfolio_value > 0
- else 0
- ),
- "risk_level": 0.0,
- "concentration": {},
- "max_concentration": 0.0,
- }
-
- # Calculate concentration
- for symbol, position in current_positions.items():
- if symbol in self.asset_analyses:
- current_value = (
- abs(position.quantity) * self.asset_analyses[symbol].current_price
- )
- concentration = (
- current_value / total_portfolio_value
- if total_portfolio_value > 0
- else 0
- )
- metrics["concentration"][symbol] = concentration
- metrics["max_concentration"] = max(
- metrics["max_concentration"], concentration
- )
-
- # Calculate risk level
- concentration_risk = metrics["max_concentration"] * 0.4
- cash_risk = (1 - metrics["cash_ratio"]) * 0.3
- position_count_risk = (
- min(metrics["position_count"] / self.config.max_positions, 1.0) * 0.3
- )
- metrics["risk_level"] = concentration_risk + cash_risk + position_count_risk
-
- return metrics
-
- def get_portfolio_summary(self) -> str:
- """Get summary of current portfolio analysis"""
- if not self.asset_analyses:
- return "No asset analyses available"
-
- summary = (
- f"**Portfolio Analysis Summary** ({len(self.asset_analyses)} assets)\n\n"
- )
-
- for symbol, analysis in self.asset_analyses.items():
- summary += (
- f"**{symbol}:**\n"
- f"- Price: ${analysis.current_price:,.2f}\n"
- f"- Technical Signal: {analysis.technical_action.value.upper()}"
- )
- if analysis.technical_action != TradeAction.HOLD:
- summary += f" ({analysis.technical_trade_type.value.upper()})"
- summary += "\n"
-
- if analysis.ai_action:
- summary += f"- AI Signal: {analysis.ai_action.value.upper()}"
- if analysis.ai_action != TradeAction.HOLD:
- summary += f" ({analysis.ai_trade_type.value.upper()})"
- if analysis.ai_confidence:
- summary += f" - Confidence: {analysis.ai_confidence:.0f}%"
- summary += "\n"
-
- if analysis.ai_reasoning:
- summary += f"- AI Reasoning: {analysis.ai_reasoning}\n"
-
- summary += "\n"
-
- return summary
diff --git a/python/valuecell/agents/auto_trading_agent/position_manager.py b/python/valuecell/agents/auto_trading_agent/position_manager.py
deleted file mode 100644
index f40252e11..000000000
--- a/python/valuecell/agents/auto_trading_agent/position_manager.py
+++ /dev/null
@@ -1,315 +0,0 @@
-"""Position and cash management module - from a trader's perspective"""
-
-import logging
-from datetime import datetime
-from typing import Dict, Optional, Tuple
-
-import yfinance as yf
-
-from .models import (
- CashManagement,
- PortfolioValueSnapshot,
- Position,
- PositionHistorySnapshot,
- TradeType,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class PositionManager:
- """
- Manages all trading positions and cash from a trader's perspective.
-
- A trader typically thinks about:
- 1. "How much cash do I have available?"
- 2. "What positions am I currently holding?"
- 3. "What's my P&L on each position?"
- 4. "How much total capital is deployed?"
- """
-
- def __init__(self, initial_capital: float):
- """
- Initialize position manager with initial capital.
-
- Args:
- initial_capital: Total capital available for trading
- """
- self.initial_capital = initial_capital
-
- # Current state
- self._positions: Dict[str, Position] = {} # symbol -> Position
- self._cash_management = CashManagement(
- total_cash=initial_capital,
- initial_cash=initial_capital,
- available_cash=initial_capital,
- cash_in_trades=0.0,
- )
-
- # Historical snapshots for analysis
- self._position_history: list[PositionHistorySnapshot] = []
- self._portfolio_history: list[PortfolioValueSnapshot] = []
-
- # ============ Cash Management Section ============
-
- def get_cash_status(self) -> CashManagement:
- """Get current cash management status"""
- return self._cash_management.model_copy()
-
- def get_available_cash(self) -> float:
- """Get available cash for new trades"""
- return self._cash_management.available_cash
-
- def get_total_cash_deployed(self) -> float:
- """Get total cash currently deployed in positions"""
- return self._cash_management.cash_in_trades
-
- def allocate_cash(self, amount: float) -> bool:
- """
- Allocate cash for a new position.
-
- Args:
- amount: Amount to allocate
-
- Returns:
- True if allocation successful, False if insufficient cash
- """
- if amount > self._cash_management.available_cash:
- logger.warning(
- f"Insufficient cash: requested {amount}, "
- f"available {self._cash_management.available_cash}"
- )
- return False
-
- self._cash_management.available_cash -= amount
- self._cash_management.cash_in_trades += amount
- return True
-
- def release_cash(self, amount: float, pnl: float = 0.0):
- """
- Release cash from a closed position (including P&L).
-
- Args:
- amount: Initial position notional
- pnl: Profit/loss from the position
- """
- self._cash_management.cash_in_trades -= amount
- self._cash_management.total_cash += pnl
- self._cash_management.available_cash = (
- self._cash_management.total_cash - self._cash_management.cash_in_trades
- )
-
- # ============ Position Management Section ============
-
- def open_position(self, symbol: str, position: Position) -> bool:
- """
- Open a new position.
-
- Args:
- symbol: Trading symbol
- position: Position object
-
- Returns:
- True if position opened successfully
- """
- if symbol in self._positions:
- logger.warning(f"Position already exists for {symbol}")
- return False
-
- # Allocate cash for this position
- if not self.allocate_cash(position.notional):
- return False
-
- self._positions[symbol] = position
- logger.info(f"Opened {position.trade_type.value} position on {symbol}")
- return True
-
- def close_position(self, symbol: str) -> Optional[Position]:
- """
- Close an existing position.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Closed position or None if not found
- """
- if symbol not in self._positions:
- logger.warning(f"No position found for {symbol}")
- return None
-
- position = self._positions.pop(symbol)
- logger.info(f"Closed {position.trade_type.value} position on {symbol}")
- return position
-
- def get_position(self, symbol: str) -> Optional[Position]:
- """Get position for a specific symbol"""
- return self._positions.get(symbol)
-
- def get_all_positions(self) -> Dict[str, Position]:
- """Get all current positions"""
- return self._positions.copy()
-
- def get_positions_count(self) -> int:
- """Get number of current open positions"""
- return len(self._positions)
-
- # ============ Portfolio Valuation Section ============
-
- def calculate_position_pnl(self, position: Position, current_price: float) -> float:
- """
- Calculate unrealized P&L for a position.
-
- Args:
- position: Position object
- current_price: Current market price
-
- Returns:
- Unrealized P&L amount
- """
- if position.trade_type == TradeType.LONG:
- # Long: profit when price goes up
- return (current_price - position.entry_price) * abs(position.quantity)
- else:
- # Short: profit when price goes down
- return (position.entry_price - current_price) * abs(position.quantity)
-
- def calculate_portfolio_value(self) -> Tuple[float, float, float]:
- """
- Calculate total portfolio value with breakdown.
-
- Returns:
- Tuple of (total_value, positions_value, total_pnl)
- """
- total_value = self._cash_management.total_cash
- positions_value = 0.0
- total_pnl = 0.0
-
- for symbol, position in self._positions.items():
- try:
- ticker = yf.Ticker(symbol)
- current_price = ticker.history(period="1d", interval="1m")[
- "Close"
- ].iloc[-1]
-
- # Calculate unrealized P&L
- pnl = self.calculate_position_pnl(position, current_price)
- total_pnl += pnl
-
- # Calculate position value
- if position.trade_type == TradeType.LONG:
- pos_value = abs(position.quantity) * current_price
- else:
- pos_value = position.notional + pnl
-
- positions_value += pos_value
- total_value += pnl
-
- except Exception as e:
- logger.warning(f"Failed to get price for {symbol}: {e}")
- # Fallback to notional
- positions_value += position.notional
-
- return total_value, positions_value, total_pnl
-
- def get_portfolio_summary(self) -> Dict:
- """
- Get complete portfolio summary from trader's perspective.
-
- Returns:
- Dictionary with all portfolio information
- """
- total_value, positions_value, total_pnl = self.calculate_portfolio_value()
-
- return {
- "cash": {
- "available": self._cash_management.available_cash,
- "deployed": self._cash_management.cash_in_trades,
- "total": self._cash_management.total_cash,
- },
- "positions": {
- "count": self.get_positions_count(),
- "total_value": positions_value,
- },
- "portfolio": {
- "total_value": total_value,
- "total_pnl": total_pnl,
- "pnl_percentage": (total_pnl / self.initial_capital * 100)
- if self.initial_capital > 0
- else 0,
- },
- }
-
- # ============ History Tracking Section ============
-
- def snapshot_positions(self, timestamp: datetime):
- """
- Take a snapshot of all positions at a point in time.
-
- Args:
- timestamp: Snapshot timestamp
- """
- for symbol, position in self._positions.items():
- try:
- ticker = yf.Ticker(symbol)
- current_price = ticker.history(period="1d", interval="1m")[
- "Close"
- ].iloc[-1]
-
- unrealized_pnl = self.calculate_position_pnl(position, current_price)
-
- snapshot = PositionHistorySnapshot(
- timestamp=timestamp,
- symbol=symbol,
- quantity=position.quantity,
- entry_price=position.entry_price,
- current_price=current_price,
- trade_type=position.trade_type.value,
- unrealized_pnl=unrealized_pnl,
- notional=position.notional,
- )
- self._position_history.append(snapshot)
-
- except Exception as e:
- logger.warning(f"Failed to snapshot position for {symbol}: {e}")
-
- def snapshot_portfolio(self, timestamp: datetime):
- """
- Take a snapshot of the entire portfolio.
-
- Args:
- timestamp: Snapshot timestamp
- """
- total_value, positions_value, total_pnl = self.calculate_portfolio_value()
-
- snapshot = PortfolioValueSnapshot(
- timestamp=timestamp,
- total_value=total_value,
- cash=self._cash_management.available_cash,
- cash_in_trades=self._cash_management.cash_in_trades,
- positions_value=positions_value,
- positions_count=self.get_positions_count(),
- total_pnl=total_pnl,
- )
- self._portfolio_history.append(snapshot)
-
- def get_position_history(self) -> list[PositionHistorySnapshot]:
- """Get all position history snapshots"""
- return self._position_history.copy()
-
- def get_portfolio_history(self) -> list[PortfolioValueSnapshot]:
- """Get all portfolio history snapshots"""
- return self._portfolio_history.copy()
-
- def reset(self, initial_capital: float):
- """Reset to initial state"""
- self.initial_capital = initial_capital
- self._positions.clear()
- self._cash_management = CashManagement(
- total_cash=initial_capital,
- initial_cash=initial_capital,
- available_cash=initial_capital,
- cash_in_trades=0.0,
- )
- self._position_history.clear()
- self._portfolio_history.clear()
diff --git a/python/valuecell/agents/auto_trading_agent/technical_analysis.py b/python/valuecell/agents/auto_trading_agent/technical_analysis.py
deleted file mode 100644
index eda3b28f9..000000000
--- a/python/valuecell/agents/auto_trading_agent/technical_analysis.py
+++ /dev/null
@@ -1,189 +0,0 @@
-"""Technical analysis and signal generation (refactored)"""
-
-import json
-import logging
-from typing import Optional
-
-from agno.agent import Agent
-
-from .market_data import MarketDataProvider, SignalGenerator
-from .models import TechnicalIndicators, TradeAction, TradeType
-
-logger = logging.getLogger(__name__)
-
-
-class TechnicalAnalyzer:
- """
- Static interface for technical analysis (backward compatible).
-
- Now delegates to MarketDataProvider internally.
- """
-
- _market_data_provider = MarketDataProvider()
-
- @staticmethod
- def set_provider(provider: MarketDataProvider) -> None:
- """Override the default market data provider."""
- TechnicalAnalyzer._market_data_provider = provider
-
- @staticmethod
- def calculate_indicators(
- symbol: str, period: str = "5d", interval: str = "1m"
- ) -> Optional[TechnicalIndicators]:
- """
- Calculate technical indicators using yfinance data.
-
- Args:
- symbol: Trading symbol (e.g., BTC-USD)
- period: Data period
- interval: Data interval
-
- Returns:
- TechnicalIndicators object or None if calculation fails
- """
- return TechnicalAnalyzer._market_data_provider.calculate_indicators(
- symbol, period, interval
- )
-
- @staticmethod
- def generate_signal(
- indicators: TechnicalIndicators,
- ) -> tuple[TradeAction, TradeType]:
- """
- Generate trading signal based on technical indicators.
-
- Args:
- indicators: Technical indicators for analysis
-
- Returns:
- Tuple of (TradeAction, TradeType)
- """
- return SignalGenerator.generate_signal(indicators)
-
-
-class AISignalGenerator:
- """AI-enhanced signal generation using LLM"""
-
- def __init__(self, llm_client):
- """
- Initialize AI signal generator
-
- Args:
- llm_client: OpenRouter client instance
- """
- self.llm_client = llm_client
-
- async def get_signal(
- self, indicators: TechnicalIndicators
- ) -> Optional[tuple[TradeAction, TradeType, str, float]]:
- """
- Get AI-enhanced trading signal using OpenRouter model
-
- Args:
- indicators: Technical indicators for analysis
-
- Returns:
- Tuple of (TradeAction, TradeType, reasoning, confidence) or None if AI not available
- """
- if not self.llm_client:
- return None
-
- try:
- # Create analysis prompt with proper formatting
- macd_str = (
- f"{indicators.macd:.4f}" if indicators.macd is not None else "N/A"
- )
- macd_signal_str = (
- f"{indicators.macd_signal:.4f}"
- if indicators.macd_signal is not None
- else "N/A"
- )
- macd_histogram_str = (
- f"{indicators.macd_histogram:.4f}"
- if indicators.macd_histogram is not None
- else "N/A"
- )
- rsi_str = f"{indicators.rsi:.2f}" if indicators.rsi is not None else "N/A"
- ema_12_str = (
- f"${indicators.ema_12:,.2f}" if indicators.ema_12 is not None else "N/A"
- )
- ema_26_str = (
- f"${indicators.ema_26:,.2f}" if indicators.ema_26 is not None else "N/A"
- )
- ema_50_str = (
- f"${indicators.ema_50:,.2f}" if indicators.ema_50 is not None else "N/A"
- )
- bb_upper_str = (
- f"${indicators.bb_upper:,.2f}"
- if indicators.bb_upper is not None
- else "N/A"
- )
- bb_middle_str = (
- f"${indicators.bb_middle:,.2f}"
- if indicators.bb_middle is not None
- else "N/A"
- )
- bb_lower_str = (
- f"${indicators.bb_lower:,.2f}"
- if indicators.bb_lower is not None
- else "N/A"
- )
-
- prompt = f"""You are an expert crypto trading analyst. Analyze the following technical indicators for {indicators.symbol} and provide a trading recommendation.
-
-Current Market Data:
-- Symbol: {indicators.symbol}
-- Price: ${indicators.close_price:,.2f}
-- Volume: {indicators.volume:,.0f}
-
-Technical Indicators:
-- MACD: {macd_str}
-- MACD Signal: {macd_signal_str}
-- MACD Histogram: {macd_histogram_str}
-- RSI: {rsi_str}
-- EMA 12: {ema_12_str}
-- EMA 26: {ema_26_str}
-- EMA 50: {ema_50_str}
-- BB Upper: {bb_upper_str}
-- BB Middle: {bb_middle_str}
-- BB Lower: {bb_lower_str}
-
-Based on these indicators, provide:
-1. Action: BUY, SELL, or HOLD
-2. Type: LONG or SHORT (if BUY)
-3. Confidence: 0-100%
-4. Reasoning: Brief explanation (1-2 sentences)
-
-Format your response as JSON:
-{{"action": "BUY|SELL|HOLD", "type": "LONG|SHORT", "confidence": 0-100, "reasoning": "explanation"}}"""
-
- agent = Agent(model=self.llm_client, markdown=False)
- response = await agent.arun(prompt)
-
- # Parse response
- content = response.content.strip()
- # Extract JSON from markdown code blocks if present
- if "```json" in content:
- content = content.split("```json")[1].split("```")[0].strip()
- elif "```" in content:
- content = content.split("```")[1].split("```")[0].strip()
-
- result = json.loads(content)
-
- action = TradeAction(result["action"].lower())
- trade_type = (
- TradeType(result["type"].lower()) if result["type"] else TradeType.LONG
- )
- reasoning = result["reasoning"]
- confidence = float(result.get("confidence", 75.0))
-
- logger.info(
- f"AI Signal for {indicators.symbol}: {action.value} {trade_type.value} "
- f"(confidence: {confidence}%) - {reasoning}"
- )
-
- return (action, trade_type, reasoning, confidence)
-
- except Exception as e:
- logger.error(f"Failed to get AI trading signal: {e}")
- return None
diff --git a/python/valuecell/agents/auto_trading_agent/trade_recorder.py b/python/valuecell/agents/auto_trading_agent/trade_recorder.py
deleted file mode 100644
index 1443e2bab..000000000
--- a/python/valuecell/agents/auto_trading_agent/trade_recorder.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""Trade recording and history management - from a trader's perspective"""
-
-import logging
-from datetime import datetime, timedelta
-from typing import Dict, List
-
-from .models import TradeHistoryRecord
-
-logger = logging.getLogger(__name__)
-
-
-class TradeRecorder:
- """
- Records and analyzes all trading activity.
-
- A trader typically wants to know:
- 1. "What have I traded?"
- 2. "What's my win rate?"
- 3. "What's my average win/loss?"
- 4. "Which symbols are most profitable?"
- """
-
- def __init__(self):
- """Initialize trade recorder"""
- self._trades: List[TradeHistoryRecord] = []
-
- def record_trade(self, trade_record: TradeHistoryRecord):
- """
- Record a new trade.
-
- Args:
- trade_record: TradeHistoryRecord to record
- """
- self._trades.append(trade_record)
- logger.info(
- f"Recorded {trade_record.action} {trade_record.trade_type} on "
- f"{trade_record.symbol} at ${trade_record.price:.2f}"
- )
-
- def get_all_trades(self) -> List[TradeHistoryRecord]:
- """Get all recorded trades"""
- return self._trades.copy()
-
- def get_recent_trades(self, limit: int = 10) -> List[TradeHistoryRecord]:
- """Get most recent N trades"""
- return self._trades[-limit:] if self._trades else []
-
- def get_trades_by_symbol(self, symbol: str) -> List[TradeHistoryRecord]:
- """Get all trades for a specific symbol"""
- return [t for t in self._trades if t.symbol == symbol]
-
- def get_trades_by_action(self, action: str) -> List[TradeHistoryRecord]:
- """Get all trades of a specific action (opened/closed)"""
- return [t for t in self._trades if t.action == action]
-
- def get_trades_in_period(
- self, start_time: datetime, end_time: datetime
- ) -> List[TradeHistoryRecord]:
- """Get trades executed in a time period"""
- return [t for t in self._trades if start_time <= t.timestamp <= end_time]
-
- # ============ Trade Statistics Section ============
-
- def get_trade_statistics(self) -> Dict:
- """
- Get comprehensive trade statistics.
-
- Returns:
- Dictionary with various statistics
- """
- if not self._trades:
- return {
- "total_trades": 0,
- "win_trades": 0,
- "loss_trades": 0,
- "win_rate": 0,
- "total_pnl": 0,
- "average_win": 0,
- "average_loss": 0,
- "largest_win": 0,
- "largest_loss": 0,
- "profit_factor": 0,
- }
-
- # Calculate closed trades (those with P&L)
- closed_trades = [t for t in self._trades if t.pnl is not None]
-
- if not closed_trades:
- return {
- "total_trades": len(self._trades),
- "win_trades": 0,
- "loss_trades": 0,
- "win_rate": 0,
- "total_pnl": 0,
- "average_win": 0,
- "average_loss": 0,
- "largest_win": 0,
- "largest_loss": 0,
- "profit_factor": 0,
- }
-
- winning_trades = [t for t in closed_trades if t.pnl > 0]
- losing_trades = [t for t in closed_trades if t.pnl < 0]
-
- total_pnl = sum(t.pnl for t in closed_trades)
- total_wins = sum(t.pnl for t in winning_trades) if winning_trades else 0
- total_losses = sum(t.pnl for t in losing_trades) if losing_trades else 0
-
- return {
- "total_trades": len(closed_trades),
- "win_trades": len(winning_trades),
- "loss_trades": len(losing_trades),
- "win_rate": (len(winning_trades) / len(closed_trades) * 100)
- if closed_trades
- else 0,
- "total_pnl": total_pnl,
- "average_win": (total_wins / len(winning_trades)) if winning_trades else 0,
- "average_loss": (total_losses / len(losing_trades)) if losing_trades else 0,
- "largest_win": max(t.pnl for t in winning_trades) if winning_trades else 0,
- "largest_loss": min(t.pnl for t in losing_trades) if losing_trades else 0,
- "profit_factor": (total_wins / abs(total_losses))
- if total_losses != 0
- else (1.0 if total_wins > 0 else 0),
- }
-
- def get_symbol_statistics(self, symbol: str) -> Dict:
- """
- Get trading statistics for a specific symbol.
-
- Args:
- symbol: Trading symbol
-
- Returns:
- Statistics dictionary for that symbol
- """
- symbol_trades = self.get_trades_by_symbol(symbol)
- if not symbol_trades:
- return {"symbol": symbol, "trades": 0}
-
- closed_trades = [t for t in symbol_trades if t.pnl is not None]
- if not closed_trades:
- return {"symbol": symbol, "trades": len(symbol_trades), "closed": 0}
-
- winning_trades = [t for t in closed_trades if t.pnl > 0]
- losing_trades = [t for t in closed_trades if t.pnl < 0]
-
- total_pnl = sum(t.pnl for t in closed_trades)
-
- return {
- "symbol": symbol,
- "total_trades": len(closed_trades),
- "win_trades": len(winning_trades),
- "loss_trades": len(losing_trades),
- "win_rate": (len(winning_trades) / len(closed_trades) * 100),
- "total_pnl": total_pnl,
- "average_pnl_per_trade": total_pnl / len(closed_trades),
- "largest_win": max(t.pnl for t in winning_trades) if winning_trades else 0,
- "largest_loss": min(t.pnl for t in losing_trades) if losing_trades else 0,
- }
-
- def get_daily_statistics(self) -> Dict[str, Dict]:
- """
- Get daily P&L breakdown.
-
- Returns:
- Dictionary mapping dates to daily statistics
- """
- daily_stats = {}
-
- for trade in self._trades:
- date_key = trade.timestamp.strftime("%Y-%m-%d")
- if date_key not in daily_stats:
- daily_stats[date_key] = {
- "trades": 0,
- "pnl": 0,
- "wins": 0,
- "losses": 0,
- }
-
- daily_stats[date_key]["trades"] += 1
- if trade.pnl is not None:
- daily_stats[date_key]["pnl"] += trade.pnl
- if trade.pnl > 0:
- daily_stats[date_key]["wins"] += 1
- else:
- daily_stats[date_key]["losses"] += 1
-
- return daily_stats
-
- def get_holding_time_statistics(self) -> Dict:
- """
- Get statistics about holding times.
-
- Returns:
- Statistics about position holding duration
- """
- # Match opens and closes for each symbol
- holding_times = []
-
- for symbol in set(t.symbol for t in self._trades):
- symbol_trades = sorted(
- self.get_trades_by_symbol(symbol), key=lambda t: t.timestamp
- )
-
- for i in range(0, len(symbol_trades) - 1, 2):
- if (
- i + 1 < len(symbol_trades)
- and symbol_trades[i].action == "opened"
- and symbol_trades[i + 1].action == "closed"
- ):
- holding_time = (
- symbol_trades[i + 1].timestamp - symbol_trades[i].timestamp
- )
- holding_times.append(holding_time)
-
- if not holding_times:
- return {
- "avg_holding_time": timedelta(0),
- "min_holding_time": timedelta(0),
- "max_holding_time": timedelta(0),
- }
-
- total_holding = sum(holding_times, timedelta())
-
- return {
- "total_positions": len(holding_times),
- "avg_holding_time": total_holding / len(holding_times),
- "min_holding_time": min(holding_times),
- "max_holding_time": max(holding_times),
- }
-
- # ============ Trade Analysis Section ============
-
- def get_best_trades(self, limit: int = 5) -> List[TradeHistoryRecord]:
- """Get the most profitable trades"""
- closed_trades = [t for t in self._trades if t.pnl is not None]
- closed_trades.sort(key=lambda t: t.pnl, reverse=True)
- return closed_trades[:limit]
-
- def get_worst_trades(self, limit: int = 5) -> List[TradeHistoryRecord]:
- """Get the least profitable trades"""
- closed_trades = [t for t in self._trades if t.pnl is not None]
- closed_trades.sort(key=lambda t: t.pnl)
- return closed_trades[:limit]
-
- def get_trade_breakdown_by_type(self) -> Dict[str, Dict]:
- """
- Get performance breakdown by trade type (LONG vs SHORT).
-
- Returns:
- Statistics for each trade type
- """
- closed_trades = [t for t in self._trades if t.pnl is not None]
-
- breakdown = {"LONG": {}, "SHORT": {}}
-
- for trade_type in ["LONG", "SHORT"]:
- type_trades = [
- t for t in closed_trades if t.trade_type.upper() == trade_type
- ]
-
- if not type_trades:
- breakdown[trade_type] = {
- "trades": 0,
- "wins": 0,
- "losses": 0,
- "total_pnl": 0,
- }
- else:
- winning = [t for t in type_trades if t.pnl > 0]
- losing = [t for t in type_trades if t.pnl < 0]
-
- breakdown[trade_type] = {
- "trades": len(type_trades),
- "wins": len(winning),
- "losses": len(losing),
- "win_rate": (len(winning) / len(type_trades) * 100),
- "total_pnl": sum(t.pnl for t in type_trades),
- "average_pnl": sum(t.pnl for t in type_trades) / len(type_trades),
- }
-
- return breakdown
-
- def reset(self):
- """Clear all trade history"""
- self._trades.clear()
diff --git a/python/valuecell/agents/auto_trading_agent/trading_executor.py b/python/valuecell/agents/auto_trading_agent/trading_executor.py
deleted file mode 100644
index ab0f584f5..000000000
--- a/python/valuecell/agents/auto_trading_agent/trading_executor.py
+++ /dev/null
@@ -1,355 +0,0 @@
-"""Trading execution and position management (refactored)"""
-
-import logging
-from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
-
-from .exchanges import ExchangeBase, Order, OrderStatus, PaperTrading
-from .models import (
- AutoTradingConfig,
- PortfolioValueSnapshot,
- Position,
- PositionHistorySnapshot,
- TechnicalIndicators,
- TradeAction,
- TradeHistoryRecord,
- TradeType,
-)
-from .position_manager import PositionManager
-from .trade_recorder import TradeRecorder
-
-logger = logging.getLogger(__name__)
-
-
-class TradingExecutor:
- """
- Orchestrates trade execution using specialized modules.
-
- This is the main facade that coordinates:
- - Position management (via PositionManager)
- - Trade recording (via TradeRecorder)
- - Cash management (via PositionManager)
- """
-
- def __init__(
- self,
- config: AutoTradingConfig,
- exchange: Optional[ExchangeBase] = None,
- ):
- """
- Initialize trading executor.
-
- Args:
- config: Auto trading configuration
- """
- self.config = config
- self.initial_capital = config.initial_capital
-
- # Exchange adapter (defaults to in-memory paper trading)
- self.exchange: ExchangeBase = exchange or PaperTrading(
- initial_balance=config.initial_capital
- )
- self.exchange_type = self.exchange.exchange_type
-
- # Use specialized modules
- self._position_manager = PositionManager(config.initial_capital)
- self._trade_recorder = TradeRecorder()
-
- async def execute_trade(
- self,
- symbol: str,
- action: TradeAction,
- trade_type: TradeType,
- indicators: TechnicalIndicators,
- ) -> Optional[Dict[str, Any]]:
- """
- Execute a trade (open or close position).
-
- Args:
- symbol: Trading symbol
- action: Trade action (buy/sell)
- trade_type: Trade type (long/short)
- indicators: Current technical indicators
-
- Returns:
- Trade execution details or None if execution failed
- """
- try:
- current_price = indicators.close_price
- timestamp = datetime.now(timezone.utc)
-
- if action == TradeAction.BUY:
- return await self._execute_buy(
- symbol, trade_type, current_price, timestamp
- )
- if action == TradeAction.SELL:
- return await self._execute_sell(
- symbol, trade_type, current_price, timestamp
- )
-
- return None
-
- except Exception as e:
- logger.error(f"Failed to execute trade for {symbol}: {e}")
- return None
-
- async def _execute_buy(
- self,
- symbol: str,
- trade_type: TradeType,
- current_price: float,
- timestamp: datetime,
- ) -> Optional[Dict[str, Any]]:
- """Open a new position"""
- # Check if we already have a position
- if self._position_manager.get_position(symbol) is not None:
- logger.info(f"Position already exists for {symbol}, skipping")
- return None
-
- # Check max positions limit
- if self._position_manager.get_positions_count() >= self.config.max_positions:
- logger.info(f"Max positions reached ({self.config.max_positions})")
- return None
-
- # Calculate position size
- available_cash = self._position_manager.get_available_cash()
- risk_amount = available_cash * self.config.risk_per_trade
- quantity = risk_amount / current_price if current_price > 0 else 0.0
- if quantity <= 0:
- logger.warning("Calculated quantity is non-positive; skipping trade")
- return None
- notional = quantity * current_price
-
- # Check if we have enough cash
- if notional > available_cash:
- logger.warning(
- f"Insufficient cash: need ${notional:.2f}, have ${available_cash:.2f}"
- )
- return None
-
- side = "buy" if trade_type == TradeType.LONG else "sell"
- order = await self._submit_order(
- symbol=symbol,
- side=side,
- quantity=abs(quantity),
- trade_type=trade_type,
- )
-
- if order is None or order.status in {
- OrderStatus.REJECTED,
- OrderStatus.CANCELLED,
- }:
- logger.warning("Exchange rejected open order for %s", symbol)
- return None
-
- fill_price = order.price or current_price
- notional = abs(quantity) * fill_price
-
- # Create and open position
- position = Position(
- symbol=symbol,
- entry_price=fill_price,
- quantity=abs(quantity) if trade_type == TradeType.LONG else -abs(quantity),
- entry_time=timestamp,
- trade_type=trade_type,
- notional=notional,
- )
-
- if not self._position_manager.open_position(symbol, position):
- return None
-
- # Record trade
- portfolio_value = self.get_portfolio_value()
- trade_record = TradeHistoryRecord(
- timestamp=timestamp,
- symbol=symbol,
- action="opened",
- trade_type=trade_type.value,
- price=fill_price,
- quantity=abs(position.quantity),
- notional=notional,
- pnl=None,
- portfolio_value_after=portfolio_value,
- cash_after=self._position_manager.get_available_cash(),
- )
- self._trade_recorder.record_trade(trade_record)
-
- return {
- "action": "opened",
- "trade_type": trade_type.value,
- "symbol": symbol,
- "entry_price": fill_price,
- "quantity": position.quantity,
- "notional": notional,
- "timestamp": timestamp,
- "order_id": order.order_id,
- "exchange": self.exchange_type.value,
- }
-
- async def _execute_sell(
- self,
- symbol: str,
- trade_type: TradeType,
- current_price: float,
- timestamp: datetime,
- ) -> Optional[Dict[str, Any]]:
- """Close an existing position"""
- # Get position
- position = self._position_manager.get_position(symbol)
- if position is None:
- return None
-
- # Check if trade type matches
- if position.trade_type != trade_type:
- return None
-
- side = "sell" if trade_type == TradeType.LONG else "buy"
- order = await self._submit_order(
- symbol=symbol,
- side=side,
- quantity=abs(position.quantity),
- trade_type=trade_type,
- )
-
- if order is None:
- logger.warning("Failed to close position on %s via exchange", symbol)
- return None
-
- exit_price = order.price or current_price
- pnl = self._position_manager.calculate_position_pnl(position, exit_price)
- exit_notional = abs(position.quantity) * exit_price
-
- # Close position locally
- self._position_manager.close_position(symbol)
- self._position_manager.release_cash(position.notional, pnl)
-
- # Record trade
- holding_time = timestamp - position.entry_time
- portfolio_value = self.get_portfolio_value()
- trade_record = TradeHistoryRecord(
- timestamp=timestamp,
- symbol=symbol,
- action="closed",
- trade_type=trade_type.value,
- price=exit_price,
- quantity=abs(position.quantity),
- notional=exit_notional,
- pnl=pnl,
- portfolio_value_after=portfolio_value,
- cash_after=self._position_manager.get_available_cash(),
- )
- self._trade_recorder.record_trade(trade_record)
-
- return {
- "action": "closed",
- "trade_type": trade_type.value,
- "symbol": symbol,
- "entry_price": position.entry_price,
- "exit_price": exit_price,
- "quantity": position.quantity,
- "entry_notional": position.notional,
- "exit_notional": exit_notional,
- "pnl": pnl,
- "holding_time": holding_time,
- "timestamp": timestamp,
- "order_id": order.order_id,
- "exchange": self.exchange_type.value,
- }
-
- async def _submit_order(
- self,
- *,
- symbol: str,
- side: str,
- quantity: float,
- trade_type: TradeType,
- order_type: str = "market",
- ) -> Optional[Order]:
- try:
- if not self.exchange.is_connected:
- await self.exchange.connect()
- return await self.exchange.place_order(
- symbol=symbol,
- side=side,
- quantity=quantity,
- price=None,
- order_type=order_type,
- trade_type=trade_type,
- )
- except Exception as exc: # noqa: BLE001
- logger.error(
- "Order submission failed (%s %s %s): %s",
- side,
- quantity,
- symbol,
- exc,
- )
- return None
-
- # ============ Portfolio Queries ============
-
- def get_portfolio_value(self) -> float:
- """Get total portfolio value"""
- total_value, _, _ = self._position_manager.calculate_portfolio_value()
- return total_value
-
- def get_portfolio_summary(self) -> Dict:
- """Get complete portfolio summary"""
- return self._position_manager.get_portfolio_summary()
-
- def get_current_capital(self) -> float:
- """Get available cash"""
- return self._position_manager.get_available_cash()
-
- @property
- def current_capital(self) -> float:
- """Property for backward compatibility"""
- return self._position_manager.get_available_cash()
-
- @property
- def positions(self) -> Dict[str, Position]:
- """Property for backward compatibility"""
- return self._position_manager.get_all_positions()
-
- # ============ History Management ============
-
- def snapshot_positions(self, timestamp: datetime):
- """Take a snapshot of all positions"""
- self._position_manager.snapshot_positions(timestamp)
-
- def snapshot_portfolio(self, timestamp: datetime):
- """Take a snapshot of portfolio value"""
- self._position_manager.snapshot_portfolio(timestamp)
-
- def get_trade_history(self) -> List[TradeHistoryRecord]:
- """Get all trade history"""
- return self._trade_recorder.get_all_trades()
-
- def get_position_history(self) -> List[PositionHistorySnapshot]:
- """Get all position snapshots"""
- return self._position_manager.get_position_history()
-
- def get_portfolio_history(self) -> List[PortfolioValueSnapshot]:
- """Get all portfolio snapshots"""
- return self._position_manager.get_portfolio_history()
-
- # ============ Statistics ============
-
- def get_trade_statistics(self) -> Dict:
- """Get trading statistics"""
- return self._trade_recorder.get_trade_statistics()
-
- def get_symbol_statistics(self, symbol: str) -> Dict:
- """Get statistics for a symbol"""
- return self._trade_recorder.get_symbol_statistics(symbol)
-
- def get_daily_statistics(self) -> Dict[str, Dict]:
- """Get daily P&L breakdown"""
- return self._trade_recorder.get_daily_statistics()
-
- # ============ Management ============
-
- def reset(self, initial_capital: float):
- """Reset executor state"""
- self._position_manager.reset(initial_capital)
- self._trade_recorder.reset()
diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py
index c621f45aa..fba7aeef2 100644
--- a/python/valuecell/agents/common/trading/_internal/coordinator.py
+++ b/python/valuecell/agents/common/trading/_internal/coordinator.py
@@ -20,8 +20,10 @@
FeatureVector,
HistoryRecord,
MarketType,
+ PriceMode,
StrategyStatus,
StrategySummary,
+ TradeDecisionAction,
TradeHistoryEntry,
TradeInstruction,
TradeSide,
@@ -60,6 +62,11 @@ async def run_once(self) -> DecisionCycleResult:
"""Execute one decision cycle and return the result."""
raise NotImplementedError
+ @abstractmethod
+ async def close_all_positions(self) -> None:
+ """Close all open positions for this strategy."""
+ raise NotImplementedError
+
@abstractmethod
async def close(self) -> None:
"""Release any held resources."""
@@ -531,6 +538,99 @@ def _create_history_records(
),
]
+ async def execute_instructions(
+ self, instructions: List[TradeInstruction]
+ ) -> List[TxResult]:
+ """Execute a list of instructions directly via the gateway."""
+ if not instructions:
+ return []
+ return await self._execution_gateway.execute(instructions)
+
+ async def close_all_positions(self) -> List[TradeHistoryEntry]:
+ """Close all open positions for the strategy.
+
+ Generates and executes market orders to close all existing positions found
+ in the current portfolio view. Returns the list of executed trades.
+ """
+ try:
+ logger.info("Closing all positions for strategy {}", self.strategy_id)
+
+ # Get current positions
+ portfolio = self.portfolio_service.get_view()
+
+ if not portfolio.positions:
+ logger.info(
+ "No open positions to close for strategy {}", self.strategy_id
+ )
+ return []
+
+ instructions = []
+ compose_id = generate_uuid("close_all")
+ timestamp_ms = get_current_timestamp_ms()
+
+ for symbol, pos in portfolio.positions.items():
+ quantity = float(pos.quantity)
+ if quantity == 0:
+ continue
+
+ # Determine side and action
+ side = TradeSide.SELL if quantity > 0 else TradeSide.BUY
+ action = (
+ TradeDecisionAction.CLOSE_LONG
+ if quantity > 0
+ else TradeDecisionAction.CLOSE_SHORT
+ )
+
+ # Create instruction with reduceOnly flag for closing
+ inst = TradeInstruction(
+ instruction_id=generate_uuid("inst"),
+ compose_id=compose_id,
+ instrument=pos.instrument,
+ action=action,
+ side=side,
+ quantity=abs(quantity),
+ price_mode=PriceMode.MARKET,
+ meta={
+ "rationale": "Strategy stopped: closing all positions",
+ "reduceOnly": True,
+ },
+ )
+ instructions.append(inst)
+
+ if not instructions:
+ return []
+
+ logger.info("Executing {} close instructions", len(instructions))
+
+ # Execute instructions
+ tx_results = await self.execute_instructions(instructions)
+
+ # Create trades and apply to portfolio
+ trades = self._create_trades(tx_results, compose_id, timestamp_ms)
+ self.portfolio_service.apply_trades(trades, market_features=[])
+
+ # Record to in-memory history
+ for trade in trades:
+ self._history_recorder.record(
+ HistoryRecord(
+ ts=timestamp_ms,
+ kind="execution",
+ reference_id=compose_id,
+ payload={"trades": [trade.model_dump(mode="json")]},
+ )
+ )
+
+ logger.info(
+ "Successfully closed all positions, generated {} trades", len(trades)
+ )
+ return trades
+
+ except Exception:
+ logger.exception(
+ "Failed to close all positions for strategy {}", self.strategy_id
+ )
+ return []
+
async def close(self) -> None:
"""Release resources for the execution gateway if it supports closing."""
try:
diff --git a/python/valuecell/agents/common/trading/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py
index ff24f267c..0fed8b5ef 100644
--- a/python/valuecell/agents/common/trading/_internal/stream_controller.py
+++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py
@@ -14,6 +14,7 @@
from loguru import logger
+from valuecell.agents.common.trading import models as agent_models
from valuecell.agents.common.trading.utils import get_current_timestamp_ms
from valuecell.server.services import strategy_persistence
@@ -206,15 +207,25 @@ async def finalize(
"Failed to close runtime resources for strategy {}", self.strategy_id
)
- # Mark strategy as stopped in persistence
+ if reason == "error_closing_positions":
+ # Special case: we failed to close positions, so mark as ERROR to alert user
+ final_status = agent_models.StrategyStatus.ERROR.value
+ else:
+ final_status = agent_models.StrategyStatus.STOPPED.value
+
+ # Mark strategy as stopped/error in persistence
try:
- strategy_persistence.mark_strategy_stopped(self.strategy_id)
+ strategy_persistence.set_strategy_status(self.strategy_id, final_status)
logger.info(
- "Marked strategy {} as stopped (reason: {})", self.strategy_id, reason
+ "Marked strategy {} as {} (reason: {})",
+ self.strategy_id,
+ final_status,
+ reason,
)
except Exception:
logger.exception(
- "Failed to mark strategy stopped for {} (reason: {})",
+ "Failed to mark strategy {} for {} (reason: {})",
+ final_status,
self.strategy_id,
reason,
)
@@ -228,3 +239,23 @@ def is_running(self) -> bool:
"Error checking running status for strategy {}", self.strategy_id
)
return False
+
+ def persist_trades(self, trades: list) -> None:
+ """Persist a list of ad-hoc trades (e.g., from forced closure)."""
+ if not trades:
+ return
+ try:
+ for trade in trades:
+ item = strategy_persistence.persist_trade_history(
+ self.strategy_id, trade
+ )
+ if item:
+ logger.info(
+ "Persisted ad-hoc trade {} for strategy={}",
+ trade.trade_id,
+ self.strategy_id,
+ )
+ except Exception:
+ logger.exception(
+ "Error persisting ad-hoc trades for strategy {}", self.strategy_id
+ )
diff --git a/python/valuecell/agents/common/trading/base_agent.py b/python/valuecell/agents/common/trading/base_agent.py
index 84e22c42b..1df081787 100644
--- a/python/valuecell/agents/common/trading/base_agent.py
+++ b/python/valuecell/agents/common/trading/base_agent.py
@@ -222,6 +222,23 @@ async def stream(
logger.exception("StrategyAgent stream failed: {}", err)
yield streaming.message_chunk(f"StrategyAgent error: {err}")
finally:
+ # Enforce position closure on normal stop (e.g., user clicked stop)
+ if stop_reason == "normal_exit":
+ try:
+ trades = await runtime.coordinator.close_all_positions()
+ if trades:
+ controller.persist_trades(trades)
+ except Exception:
+ logger.exception(
+ "Error closing positions on stop for strategy {}", strategy_id
+ )
+ # If closing positions fails, we should consider this an error state
+ # to prevent the strategy from being marked as cleanly stopped if it still has positions.
+ # However, the user intent was to stop.
+ # Let's log it and proceed, but maybe mark status as ERROR instead of STOPPED?
+ # For now, we stick to STOPPED but log the error clearly.
+ stop_reason = "error_closing_positions"
+
# Call user hook before finalization
try:
self._on_stop(runtime, request, stop_reason)
diff --git a/python/valuecell/agents/common/trading/decision/interfaces.py b/python/valuecell/agents/common/trading/decision/interfaces.py
index 024e427d2..c3dcc18fb 100644
--- a/python/valuecell/agents/common/trading/decision/interfaces.py
+++ b/python/valuecell/agents/common/trading/decision/interfaces.py
@@ -445,15 +445,11 @@ def _create_instruction(
meta["rationale"] = item.rationale
# For derivatives/perpetual markets, mark reduceOnly when instruction reduces absolute exposure to avoid accidental reverse opens
+ # Note: Exchange-specific parameter name normalization (e.g., reduceOnly vs reduce_only) is handled by the execution gateway
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
diff --git a/python/valuecell/agents/common/trading/execution/ccxt_trading.py b/python/valuecell/agents/common/trading/execution/ccxt_trading.py
index fdf33f04b..0e904f80b 100644
--- a/python/valuecell/agents/common/trading/execution/ccxt_trading.py
+++ b/python/valuecell/agents/common/trading/execution/ccxt_trading.py
@@ -254,16 +254,102 @@ async def _setup_margin_mode(self, symbol: str, exchange: ccxt.Exchange) -> None
def _sanitize_client_order_id(self, raw_id: str) -> str:
"""Sanitize client order id to satisfy exchange constraints.
- - Remove non-alphanumeric characters (safe for OKX 'clOrdId')
- - Truncate to 32 characters (common OKX limit)
- - If empty after sanitization, derive a short hash
+ Constraints:
+ - Gate.io: max 28 chars, alphanumeric + '.-_'
+ - OKX: max 32 chars, alphanumeric only
+ - Binance: max 36 chars (typically), alphanumeric + '.-_:'
+ - Others: default to 32 chars (MD5 length) for safety
+
+ Strategy:
+ 1. Filter allowed characters based on exchange rules.
+ 2. Check length limit.
+ 3. If too long, use MD5 hash (32 chars) and truncate if necessary.
"""
- safe = "".join(ch for ch in (raw_id or "") if ch.isalnum())
- if not safe:
- import hashlib
+ if not raw_id:
+ return ""
- safe = hashlib.sha1((raw_id or "").encode()).hexdigest()[:16]
- return safe[:32]
+ # 1. Determine allowed characters and max length
+ # Default: alphanumeric + basic separators
+ allowed_chars = set(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_:"
+ )
+ max_len = 32
+
+ if self.exchange_id == "gate":
+ # Gate.io: max 28 chars, alphanumeric + .-_ (no colon)
+ max_len = 28
+ allowed_chars = set(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
+ )
+ elif self.exchange_id == "okx":
+ # OKX: max 32 chars, alphanumeric only
+ max_len = 32
+ allowed_chars = set(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ )
+ elif self.exchange_id == "binance":
+ # Binance: max 36 chars
+ max_len = 36
+ elif self.exchange_id == "bybit":
+ # Bybit: max 36 chars
+ max_len = 36
+
+ # Filter characters
+ safe = "".join(ch for ch in raw_id if ch in allowed_chars)
+
+ # 2. Check length
+ if safe and len(safe) <= max_len:
+ return safe
+
+ # 3. Fallback: MD5 hash (32 chars)
+ import hashlib
+
+ hashed = hashlib.md5(raw_id.encode()).hexdigest()
+
+ # If hash is still too long (e.g. Gate.io 28), truncate it
+ return hashed[:max_len]
+
+ def _normalize_reduce_only_meta(self, meta: Dict) -> Dict:
+ """Normalize and apply reduceOnly parameter for exchange compatibility.
+
+ Different exchanges use different parameter names and formats:
+ - Gate.io, Bybit: use 'reduce_only' (snake_case)
+ - Binance, OKX, Hyperliquid, MEXC, Coinbase, Blockchain: use 'reduceOnly' (camelCase)
+
+ This function:
+ 1. Extracts reduceOnly value from input (supports both camelCase and snake_case)
+ 2. Sets appropriate default (False) for the exchange if not explicitly set
+ 3. Applies exchange-specific parameter name
+ 4. Ensures consistent boolean format across all exchanges
+
+ Args:
+ meta: Dictionary potentially containing reduceOnly parameters
+
+ Returns:
+ Dictionary with exchange-specific reduceOnly parameter name and value
+ """
+ result = dict(meta or {})
+ exid = self.exchange_id.lower() if self.exchange_id else ""
+
+ # Extract any existing reduceOnly value (supports both formats for flexibility)
+ reduce_only_value = result.pop("reduceOnly", None)
+ if reduce_only_value is None:
+ reduce_only_value = result.pop("reduce_only", None)
+
+ # Determine which parameter name to use based on exchange
+ if exid in ("gate", "bybit"):
+ param_name = "reduce_only"
+ else:
+ # All other exchanges (binance, okx, hyperliquid, mexc, coinbaseexchange, blockchaincom, etc.)
+ param_name = "reduceOnly"
+
+ # Set default to False if not explicitly provided by caller
+ if reduce_only_value is None:
+ result[param_name] = False
+ else:
+ result[param_name] = bool(reduce_only_value)
+
+ return result
def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict:
"""Build exchange-specific order params with safe defaults.
@@ -273,20 +359,16 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict:
- Provide reduceOnly defaults for derivatives
- Provide tdMode for OKX if not specified
"""
- params: Dict = dict(inst.meta or {})
+ params: Dict = self._normalize_reduce_only_meta(inst.meta or {})
exid = self.exchange_id
- # Idempotency / client order id (sanitize for OKX)
+ # Idempotency / client order id
# Hyperliquid doesn't support clientOrderId
if exid != "hyperliquid":
raw_client_id = params.get("clientOrderId", inst.instruction_id)
if raw_client_id:
- client_id = (
- self._sanitize_client_order_id(raw_client_id)
- if exid == "okx"
- else raw_client_id
- )
+ client_id = self._sanitize_client_order_id(raw_client_id)
params["clientOrderId"] = client_id
# Default tdMode for OKX on all orders
@@ -302,15 +384,6 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict:
elif exid == "bybit":
params.setdefault("time_in_force", "GoodTillCancel")
- # reduceOnly default for derivatives (oneway mode defaults to False)
- if exid in ("binance", "okx"):
- params.setdefault("reduceOnly", False)
- elif exid == "bybit":
- params.setdefault("reduce_only", False)
- elif exid == "hyperliquid":
- # Hyperliquid only uses 'reduceOnly' (not 'reduce_only')
- params.setdefault("reduceOnly", False)
-
# Enforce single-sided mode: strip positionSide/posSide if present
try:
mode = (self.position_mode or "oneway").lower()
@@ -520,6 +593,139 @@ async def _get_free_usdt_binance(self, exchange: ccxt.Exchange) -> Optional[floa
logger.warning(f"Could not fetch Binance futures balance: {e}")
return None
+ def _extract_fee_from_order(
+ self, order: Dict, symbol: str, filled_qty: float, avg_price: float
+ ) -> float:
+ """Extract fee cost from order response with exchange-specific fallbacks.
+
+ Supports multiple exchange fee structures:
+ - Standard CCXT unified 'fee' field
+ - Binance 'fills' array with commission details
+ - OKX 'info.fee' field
+ - Bybit 'info.cumExecFee' field
+ - Other exchange-specific formats
+
+ Args:
+ order: Order response from exchange
+ symbol: Trading symbol
+ filled_qty: Filled quantity
+ avg_price: Average execution price
+
+ Returns:
+ Fee cost in quote currency (USDT/USD), or 0.0 if not available
+ """
+ fee_cost = 0.0
+
+ try:
+ # Method 1: Standard CCXT unified fee field
+ if "fee" in order and order["fee"]:
+ fee_info = order["fee"]
+ cost = fee_info.get("cost")
+ if cost is not None and cost > 0:
+ fee_cost = float(cost)
+ logger.debug(f" 💰 Fee from CCXT unified field: {fee_cost}")
+ return fee_cost
+
+ # Method 2: Exchange-specific extraction from 'info' field
+ info = order.get("info", {})
+
+ if self.exchange_id == "binance":
+ # Binance: Extract from 'fills' array
+ fills = info.get("fills", [])
+ if fills:
+ for fill in fills:
+ commission = float(fill.get("commission", 0.0))
+ commission_asset = fill.get("commissionAsset", "")
+
+ # If fee is in quote currency (USDT/BUSD/USD), add directly
+ if commission_asset in ("USDT", "BUSD", "USD", "USDC"):
+ fee_cost += commission
+ # If fee is in BNB or other asset, log it but don't convert
+ elif commission > 0:
+ logger.info(
+ f" 💰 Fee paid in {commission_asset}: {commission}"
+ )
+ # Could implement conversion logic here if needed
+
+ if fee_cost > 0:
+ logger.debug(f" 💰 Fee from Binance fills: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "okx":
+ # OKX: fee is in 'info.fee' or 'info.fillFee'
+ fee_str = info.get("fee") or info.get("fillFee")
+ if fee_str:
+ fee_cost = abs(float(fee_str)) # OKX returns negative fee
+ logger.debug(f" 💰 Fee from OKX info: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "bybit":
+ # Bybit: cumExecFee or execFee
+ cum_fee = info.get("cumExecFee") or info.get("execFee")
+ if cum_fee:
+ fee_cost = float(cum_fee)
+ logger.debug(f" 💰 Fee from Bybit info: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id in ("gate", "gateio"):
+ # Gate.io: fee field in info
+ fee_str = info.get("fee")
+ if fee_str:
+ fee_cost = float(fee_str)
+ logger.debug(f" 💰 Fee from Gate.io info: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "kucoin":
+ # KuCoin: fee in info
+ fee_str = info.get("fee")
+ if fee_str:
+ fee_cost = float(fee_str)
+ logger.debug(f" 💰 Fee from KuCoin info: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "mexc":
+ # MEXC: commission in fills
+ fills = info.get("fills", [])
+ if fills:
+ for fill in fills:
+ commission = float(fill.get("commission", 0.0))
+ fee_cost += commission
+
+ if fee_cost > 0:
+ logger.debug(f" 💰 Fee from MEXC fills: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "bitget":
+ # Bitget: fee in info.feeDetail
+ fee_detail = info.get("feeDetail", {})
+ if fee_detail:
+ total_fee = float(fee_detail.get("totalFee", 0.0))
+ if total_fee > 0:
+ fee_cost = total_fee
+ logger.debug(f" 💰 Fee from Bitget info: {fee_cost}")
+ return fee_cost
+
+ elif self.exchange_id == "hyperliquid":
+ # Hyperliquid: typically no fee field, might need special handling
+ # Check if there's a fee in info
+ fee_str = info.get("fee")
+ if fee_str:
+ fee_cost = float(fee_str)
+ logger.debug(f" 💰 Fee from Hyperliquid info: {fee_cost}")
+ return fee_cost
+
+ # Method 3: Estimate from trading fee rate if available (last resort)
+ if fee_cost == 0.0 and filled_qty > 0 and avg_price > 0:
+ # Don't estimate, just log that fee wasn't found
+ logger.debug(
+ f" 💰 No fee information found for {symbol} on {self.exchange_id}"
+ )
+
+ except Exception as e:
+ logger.warning(f" ⚠️ Error extracting fee for {symbol}: {e}")
+
+ return fee_cost
+
async def execute(
self,
instructions: List[TradeInstruction],
@@ -603,6 +809,51 @@ async def _execute_single(
# Fallback to generic submission
return await self._submit_order(inst, exchange)
+ def _apply_exchange_specific_precision(
+ self, symbol: str, amount: float, price: float | None, exchange: ccxt.Exchange
+ ) -> tuple[float, float | None]:
+ """Apply exchange-specific precision rules.
+
+ Especially important for Hyperliquid (integers for some, decimals for others)
+ and handling min/max constraints robustly.
+ """
+ try:
+ # 1. Standard CCXT precision
+ # Some exchanges raise errors if amount < precision (e.g. Binance)
+ # We catch this and return 0.0 to signal invalid amount
+ try:
+ amount = float(exchange.amount_to_precision(symbol, amount))
+ except Exception as e:
+ # Catch generic errors from amount_to_precision, including precision violations
+ # Log warning but return 0.0 to allow clean skipping downstream
+ logger.warning(
+ f" ⚠️ Amount {amount} failed precision check for {symbol}: {e}"
+ )
+ amount = 0.0
+
+ if price is not None:
+ price = float(exchange.price_to_precision(symbol, price))
+
+ # 2. Hyperliquid specific handling
+ if self.exchange_id == "hyperliquid":
+ market = (getattr(exchange, "markets", {}) or {}).get(symbol) or {}
+ price_precision = market.get("precision", {}).get("price")
+
+ # If precision is 1.0 (integer only), force integer price
+ if price is not None and price_precision == 1.0:
+ price = float(int(price))
+ logger.debug(
+ f" 🔢 Hyperliquid: Rounded price to integer {price} for {symbol}"
+ )
+
+ return amount, price
+
+ except Exception as e:
+ logger.warning(f" ⚠️ Precision application failed for {symbol}: {e}")
+ # Return original values on error, but for 'amount too small' cases this might
+ # just lead to downstream rejection. If we couldn't fix it here, we let it flow.
+ return amount, price
+
async def _submit_order(
self,
inst: TradeInstruction,
@@ -638,9 +889,16 @@ async def _submit_order(
elif alt3 in markets:
symbol = alt3
- # Setup leverage and margin mode
- await self._setup_leverage(symbol, inst.leverage, exchange)
- await self._setup_margin_mode(symbol, exchange)
+ # Setup leverage and margin mode only for opening positions
+ # For closing positions (reduceOnly), skip these as they are not needed
+ action = (inst.action.value if getattr(inst, "action", None) else None) or str(
+ (inst.meta or {}).get("action") or ""
+ ).lower()
+ is_opening = action in ("open_long", "open_short")
+
+ if is_opening:
+ await self._setup_leverage(symbol, inst.leverage, exchange)
+ await self._setup_margin_mode(symbol, exchange)
# Map instruction to CCXT parameters
local_side = (
@@ -654,6 +912,7 @@ async def _submit_order(
price = float(inst.limit_price) if inst.limit_price else None
# For OKX derivatives, amount must be in contracts; convert from base units if needed
+ ct_val = None
try:
market = (getattr(exchange, "markets", {}) or {}).get(symbol) or {}
if self.exchange_id == "okx" and market.get("contract"):
@@ -672,16 +931,23 @@ async def _submit_order(
except Exception:
pass
- # Align precision if supported
- try:
- amount = float(exchange.amount_to_precision(symbol, amount))
- except Exception:
- pass
- if price is not None:
- try:
- price = float(exchange.price_to_precision(symbol, price))
- except Exception:
- pass
+ # Apply precision
+ amount, price = self._apply_exchange_specific_precision(
+ symbol, amount, price, exchange
+ )
+
+ # If amount became zero after precision/rounding (e.g. < min precision), skip order
+ if amount <= 0:
+ return TxResult(
+ instruction_id=inst.instruction_id,
+ instrument=inst.instrument,
+ side=local_side,
+ requested_qty=float(inst.quantity),
+ filled_qty=0.0,
+ status=TxStatus.REJECTED,
+ reason="amount_too_small_for_precision",
+ meta=inst.meta,
+ )
# Reject orders below exchange minimums (do not lift to min)
try:
@@ -831,26 +1097,10 @@ async def _submit_order(
# For sell orders, set price lower to ensure execution
price = price * (1 - slippage_pct)
- # Apply CCXT price precision (critical for Hyperliquid)
- # This handles both integer prices (BTC) and decimal prices (WIF, ENA, etc.)
- try:
- price = float(exchange.price_to_precision(symbol, price))
- logger.debug(f" 🔢 Price after precision: {price}")
- except Exception as e:
- logger.warning(f" ⚠️ Could not apply price precision: {e}")
- # Fallback: try integer conversion for high-value assets
- try:
- market = (getattr(exchange, "markets", {}) or {}).get(
- symbol
- ) or {}
- price_precision = market.get("precision", {}).get("price")
- if price_precision is not None and price_precision == 0:
- price = float(int(price))
- logger.debug(
- f" 🔢 Fallback: rounded to integer {price}"
- )
- except Exception:
- pass
+ # Apply precision again on the simulated price
+ _, price = self._apply_exchange_specific_precision(
+ symbol, amount, price, exchange
+ )
# Use IoC (Immediate or Cancel) to simulate market execution
params["timeInForce"] = "Ioc"
@@ -927,6 +1177,11 @@ async def _submit_order(
# Parse order response
filled_qty = float(order.get("filled", 0.0))
+
+ # For OKX derivatives, filled quantity is in contracts; convert back to base units
+ if self.exchange_id == "okx" and ct_val and ct_val > 0 and filled_qty > 0:
+ filled_qty = filled_qty * ct_val
+
avg_price = float(order.get("average") or 0.0)
fee_cost = 0.0
@@ -934,10 +1189,7 @@ async def _submit_order(
f" 📊 Final parsed: filled_qty={filled_qty}, avg_price={avg_price}"
)
- # Extract fee information
- if "fee" in order and order["fee"]:
- fee_info = order["fee"]
- fee_cost = float(fee_info.get("cost", 0.0))
+ fee_cost = self._extract_fee_from_order(order, symbol, filled_qty, avg_price)
# Calculate slippage if applicable
slippage_bps = None