@@ -0,0 +1,10 @@
|
||||
"""
|
||||
业务服务层
|
||||
"""
|
||||
from app.services.kline import KlineService
|
||||
from app.services.analysis import AnalysisService
|
||||
from app.services.backtest import BacktestService
|
||||
from app.services.strategy_compiler import StrategyCompiler
|
||||
|
||||
__all__ = ['KlineService', 'AnalysisService', 'BacktestService', 'StrategyCompiler']
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# 多智能体分析系统
|
||||
|
||||
基于 TradingAgents 架构优化的多智能体股票分析系统。
|
||||
|
||||
## 架构特点
|
||||
|
||||
### 1. 多智能体协作
|
||||
- **分析师团队**:市场分析师、基本面分析师、新闻分析师、情绪分析师、风险分析师
|
||||
- **研究团队**:看涨研究员、看跌研究员
|
||||
- **交易团队**:交易员、风险分析师(激进/中性/保守)
|
||||
|
||||
### 2. 工作流程
|
||||
```
|
||||
分析师团队分析 → 研究辩论 → 交易员决策 → 风险辩论 → 最终决策
|
||||
```
|
||||
|
||||
### 3. 记忆系统
|
||||
- 使用 SQLite 存储历史决策
|
||||
- 基于文本相似度检索历史经验
|
||||
- 支持从交易结果中学习
|
||||
|
||||
### 4. 工具调用
|
||||
- 智能体可以主动获取数据
|
||||
- 支持多数据源(yfinance, finnhub, ccxt, 腾讯接口)
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基本使用
|
||||
|
||||
```python
|
||||
from app.services.analysis import AnalysisService
|
||||
|
||||
# 使用多智能体架构(默认)
|
||||
service = AnalysisService(use_multi_agent=True)
|
||||
result = service.analyze("USStock", "AAPL", "zh-CN")
|
||||
|
||||
# 使用传统架构(向后兼容)
|
||||
service = AnalysisService(use_multi_agent=False)
|
||||
result = service.analyze("USStock", "AAPL", "zh-CN")
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 `.env` 文件中设置:
|
||||
|
||||
```bash
|
||||
# 是否启用多智能体架构(默认:True)
|
||||
USE_MULTI_AGENT=True
|
||||
|
||||
# 最大辩论轮数(默认:2)
|
||||
MAX_DEBATE_ROUNDS=2
|
||||
```
|
||||
|
||||
### 反思学习
|
||||
|
||||
```python
|
||||
from app.services.analysis import reflect_analysis
|
||||
|
||||
# 从交易结果中学习
|
||||
reflect_analysis(
|
||||
market="USStock",
|
||||
symbol="AAPL",
|
||||
decision="BUY",
|
||||
returns=1000.0, # 收益
|
||||
result="交易成功,收益 10%"
|
||||
)
|
||||
```
|
||||
|
||||
## 智能体说明
|
||||
|
||||
### 分析师智能体
|
||||
|
||||
- **MarketAnalyst**: 技术分析,计算技术指标
|
||||
- **FundamentalAnalyst**: 基本面分析,财务数据
|
||||
- **NewsAnalyst**: 新闻事件分析
|
||||
- **SentimentAnalyst**: 市场情绪分析
|
||||
- **RiskAnalyst**: 风险评估
|
||||
|
||||
### 研究员智能体
|
||||
|
||||
- **BullResearcher**: 构建看涨论据
|
||||
- **BearResearcher**: 构建看跌论据
|
||||
|
||||
### 交易员智能体
|
||||
|
||||
- **TraderAgent**: 综合所有分析,做出交易决策
|
||||
|
||||
### 风险分析师智能体
|
||||
|
||||
- **RiskyAnalyst**: 激进风险分析
|
||||
- **NeutralAnalyst**: 中性风险分析
|
||||
- **SafeAnalyst**: 保守风险分析
|
||||
|
||||
## 返回结果格式
|
||||
|
||||
多智能体模式返回的完整结果包括:
|
||||
|
||||
```json
|
||||
{
|
||||
"overview": {
|
||||
"overallScore": 75,
|
||||
"recommendation": "BUY",
|
||||
"confidence": 82,
|
||||
"dimensionScores": {...},
|
||||
"report": "..."
|
||||
},
|
||||
"fundamental": {...},
|
||||
"technical": {...},
|
||||
"news": {...},
|
||||
"sentiment": {...},
|
||||
"risk": {...},
|
||||
"debate": {
|
||||
"bull": {...},
|
||||
"bear": {...},
|
||||
"research_decision": "..."
|
||||
},
|
||||
"trader_decision": {
|
||||
"decision": "BUY",
|
||||
"confidence": 85,
|
||||
"trading_plan": {...}
|
||||
},
|
||||
"risk_debate": {
|
||||
"risky": {...},
|
||||
"neutral": {...},
|
||||
"safe": {...}
|
||||
},
|
||||
"final_decision": {
|
||||
"decision": "BUY",
|
||||
"confidence": 85,
|
||||
"reasoning": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **多角度分析**:多个智能体从不同角度分析,减少盲点
|
||||
2. **辩论机制**:看涨/看跌辩论,发现潜在问题
|
||||
3. **风险控制**:多维度风险分析,提高决策质量
|
||||
4. **持续学习**:记忆系统支持从历史经验中学习
|
||||
5. **向后兼容**:可以切换到传统模式
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 多智能体模式会产生更多的 API 调用,成本较高
|
||||
2. 分析时间会比传统模式长
|
||||
3. 记忆系统需要 SQLite 数据库支持
|
||||
4. 建议在生产环境中根据需求选择合适的模式
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
多智能体分析系统
|
||||
基于 TradingAgents 架构优化
|
||||
"""
|
||||
from .base_agent import BaseAgent
|
||||
from .analyst_agents import (
|
||||
MarketAnalyst,
|
||||
FundamentalAnalyst,
|
||||
NewsAnalyst,
|
||||
SentimentAnalyst,
|
||||
RiskAnalyst
|
||||
)
|
||||
from .researcher_agents import BullResearcher, BearResearcher
|
||||
from .trader_agent import TraderAgent
|
||||
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
|
||||
from .memory import AgentMemory
|
||||
|
||||
__all__ = [
|
||||
'BaseAgent',
|
||||
'MarketAnalyst',
|
||||
'FundamentalAnalyst',
|
||||
'NewsAnalyst',
|
||||
'SentimentAnalyst',
|
||||
'RiskAnalyst',
|
||||
'BullResearcher',
|
||||
'BearResearcher',
|
||||
'TraderAgent',
|
||||
'RiskyAnalyst',
|
||||
'NeutralAnalyst',
|
||||
'SafeAnalyst',
|
||||
'AgentMemory',
|
||||
]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Analyst agents.
|
||||
Includes: market/technical, fundamental, news, sentiment, risk analysts.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from .tools import AgentTools
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class MarketAnalyst(BaseAgent):
|
||||
"""Market / technical analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("MarketAnalyst", memory)
|
||||
self.tools = AgentTools()
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run technical analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
# Kline + current price
|
||||
kline_data = base_data.get('kline_data') or self.tools.get_stock_data(market, symbol, days=30)
|
||||
current_price = base_data.get('current_price') or self.tools.get_current_price(market, symbol)
|
||||
|
||||
# Technical indicators
|
||||
indicators = {}
|
||||
if kline_data:
|
||||
indicators = self.tools.calculate_technical_indicators(kline_data)
|
||||
|
||||
# Prompts
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a professional technical analyst. Please analyze the technical trends of the stock or cryptocurrency, including:
|
||||
{lang_instruction}
|
||||
1. Technical Indicator Signals (MACD signals, RSI range, trend strength, important MA directions)
|
||||
2. Technical Score (0-100). Be objective. Do not default to 75.
|
||||
- 0-40: Bearish/Weak
|
||||
- 41-60: Neutral
|
||||
- 61-100: Bullish/Strong
|
||||
3. Technical Analysis Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 75,
|
||||
"indicators": {{
|
||||
"MACD": "Golden Cross/Death Cross or Flat",
|
||||
"RSI(14)": "75 (Overbought)",
|
||||
"MA20": "Upward/Downward/Flat",
|
||||
"Support/Resistance": "Support: 150.00, Resistance: 165.50"
|
||||
}},
|
||||
"report": "Technical analysis report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform technical analysis for {symbol} in {market} market.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Last 30 days):**
|
||||
{json.dumps(kline_data[-10:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
**Calculated Technical Indicators:**
|
||||
{json.dumps(indicators, ensure_ascii=False, indent=2) if indicators else 'No Data'}
|
||||
|
||||
Please analyze the short-term and medium-term trends based on the above Kline data and price movements."""
|
||||
|
||||
# LLM call
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "indicators": {}, "report": "Failed to parse technical analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "technical",
|
||||
"data": result,
|
||||
"indicators": indicators
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
"""Return an English language instruction string for the LLM prompt."""
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class FundamentalAnalyst(BaseAgent):
|
||||
"""Fundamental analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("FundamentalAnalyst", memory)
|
||||
self.tools = AgentTools()
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run fundamental analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
# Fundamental data
|
||||
fundamental_data = base_data.get('fundamental_data') or self.tools.get_fundamental_data(market, symbol)
|
||||
company_data = base_data.get('company_data') or self.tools.get_company_data(market, symbol, language=language)
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} fundamental analysis"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a fundamental analyst. Please analyze the financial status and industry position of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Financial Indicators (Select key P/E, P/B, ROE, Revenue Growth)
|
||||
2. Fundamental Score (0-100). Be objective. Do not default to 80.
|
||||
- 0-40: Poor/Overvalued
|
||||
- 41-60: Fair/Neutral
|
||||
- 61-100: Good/Undervalued
|
||||
3. Fundamental Analysis Report (about 300 words)
|
||||
|
||||
{memory_prompt}
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 80,
|
||||
"financials": {{
|
||||
"P/E": "25.3",
|
||||
"P/B": "4.2",
|
||||
"ROE": "18.5%",
|
||||
"Market Cap": "1200.5 B"
|
||||
}},
|
||||
"report": "Fundamental analysis report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform fundamental analysis for {symbol} in {market} market.
|
||||
|
||||
**Basic Company Info:**
|
||||
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
|
||||
|
||||
**Raw Fundamental Indicators:**
|
||||
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
|
||||
|
||||
Please analyze based on the above data."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "financials": {}, "report": "Failed to parse fundamental analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "fundamental",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class NewsAnalyst(BaseAgent):
|
||||
"""News analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("NewsAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run news analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
company_data = base_data.get('company_data', {})
|
||||
news_data = base_data.get('news_data', [])
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a professional news intelligence analyst. Your task is to extract key intelligence that has a major impact on stock prices from massive market information.
|
||||
|
||||
Analysis Requirements:
|
||||
{lang_instruction}
|
||||
1. **Filter Noise**: News data may contain internet results from search engines. Please carefully discriminate and ignore ads, duplicate content, or irrelevant noise. Prioritize authoritative financial media and official announcements.
|
||||
2. **Timeliness**: Focus on breaking news within the last 48 hours. For old news, lower its weight unless there are new developments.
|
||||
3. **Deep Interpretation**: Do not just repeat news titles. Analyze the logic behind the event and its specific impact on company fundamentals or market sentiment (e.g., Earnings Beat -> Profit Improvement -> Valuation Increase).
|
||||
4. **Scoring**: News Score (0-100).
|
||||
- 0-40: Major Negative (e.g., financial fraud, regulatory crackdown, core business damage)
|
||||
- 41-59: Neutral or minor impact
|
||||
- 60-100: Positive (e.g., strong earnings, major partnership, policy support)
|
||||
- Higher scores indicate more positive news.
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 70,
|
||||
"events": [
|
||||
{{
|
||||
"title": "Event Title",
|
||||
"impact": "Positive/Negative/Neutral",
|
||||
"summary": "Event summary and deep impact analysis...",
|
||||
"date": "2023-10-27"
|
||||
}}
|
||||
],
|
||||
"report": "Comprehensive news analysis report, including overall judgment on recent market public opinion..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform in-depth news intelligence analysis for {symbol} in {market} market.
|
||||
|
||||
**Company Background:**
|
||||
{json.dumps(company_data, ensure_ascii=False, indent=2) if company_data else 'No Data'}
|
||||
|
||||
**Latest Intelligence Sources (Contains professional financial news and web search results, please discriminate):**
|
||||
{json.dumps(news_data, ensure_ascii=False, indent=2) if news_data else 'No directly related news'}
|
||||
|
||||
Please analyze based on the above intelligence. If the provided "Latest Intelligence Sources" contain no substantive content or only irrelevant noise, please explicitly state "No valid news available" and deduce logically based on the industry trends and macro market environment of the stock."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "events": [], "report": "Failed to parse news analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "news",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class SentimentAnalyst(BaseAgent):
|
||||
"""Sentiment analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("SentimentAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run sentiment analysis."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
current_price = base_data.get('current_price', {})
|
||||
kline_data = base_data.get('kline_data', [])
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a market sentiment analyst. Please analyze the market sentiment and popularity of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Sentiment Heat Indicators (e.g., analyst ratings, social media discussion volume, put/call ratio)
|
||||
2. Sentiment Score (0-100, higher means more optimistic). Be objective. Do not default to 65.
|
||||
- 0-40: Bearish/Fearful
|
||||
- 41-60: Neutral
|
||||
- 61-100: Bullish/Greedy
|
||||
3. Sentiment Analysis Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 65,
|
||||
"scores": {{
|
||||
"Analyst Rating": 90,
|
||||
"Social Media Heat": 85,
|
||||
"Market Sentiment Index": 70
|
||||
}},
|
||||
"report": "Sentiment analysis report content..."
|
||||
}}
|
||||
Note: All values in the 'scores' dictionary must be integers between 0-100 (pure numbers), representing optimism or heat, without any text or percentage signs."""
|
||||
|
||||
user_prompt = f"""Please perform sentiment analysis for {symbol} in {market} market.
|
||||
Based on the current Kline trends and price fluctuations, combined with your existing knowledge, evaluate whether the market sentiment for this stock is bullish, bearish, or neutral.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Recent Trends):**
|
||||
{json.dumps(kline_data[-5:] if kline_data else [], ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
Please evaluate market sentiment."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "scores": {"Analyst Rating": 50, "Social Media Heat": 50, "Market Sentiment Index": 50}, "report": "Failed to parse sentiment analysis"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "sentiment",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class RiskAnalyst(BaseAgent):
|
||||
"""Risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("RiskAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run risk assessment."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
base_data = context.get('base_data', {})
|
||||
|
||||
current_price = base_data.get('current_price', {})
|
||||
kline_data = base_data.get('kline_data', [])
|
||||
fundamental_data = base_data.get('fundamental_data', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a risk management analyst. Please evaluate the investment risks of the stock, including:
|
||||
{lang_instruction}
|
||||
1. Risk Indicators (Volatility, Liquidity, Concentration Risk, Systemic Risk Exposure)
|
||||
2. Risk Score (0-100, higher score means lower risk/safer). Be objective. Do not default to 60.
|
||||
- 0-40: High Risk (Dangerous)
|
||||
- 41-60: Moderate Risk
|
||||
- 61-100: Low Risk (Safe)
|
||||
3. Risk Assessment Report (about 300 words)
|
||||
|
||||
Please strictly return the result in JSON format as follows:
|
||||
{{
|
||||
"score": 60,
|
||||
"metrics": {{
|
||||
"Volatility (Beta)": "1.2 (Higher than market)",
|
||||
"Liquidity": "Good",
|
||||
"Concentration Risk": "Low (Diversified business)"
|
||||
}},
|
||||
"report": "Risk assessment report content..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Please perform risk assessment for {symbol} in {market} market.
|
||||
|
||||
**Current Price:**
|
||||
{json.dumps(current_price, ensure_ascii=False, indent=2) if current_price else 'No Data'}
|
||||
|
||||
**Kline Data (Volatility Analysis):**
|
||||
{json.dumps(kline_data, ensure_ascii=False, indent=2) if kline_data else 'No Data'}
|
||||
|
||||
**Fundamental Data (Debt Risk):**
|
||||
{json.dumps(fundamental_data, ensure_ascii=False, indent=2) if fundamental_data else 'No Data'}
|
||||
|
||||
Please evaluate investment risks based on price volatility, fundamental data, etc."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"score": 50, "metrics": {}, "report": "Failed to parse risk assessment"},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "risk",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
智能体基类
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""智能体基类,所有分析智能体都继承此类"""
|
||||
|
||||
def __init__(self, name: str, memory: Optional[Any] = None):
|
||||
"""
|
||||
初始化智能体
|
||||
|
||||
Args:
|
||||
name: 智能体名称
|
||||
memory: 记忆系统实例(可选)
|
||||
"""
|
||||
self.name = name
|
||||
self.memory = memory
|
||||
self.logger = get_logger(f"{__name__}.{name}")
|
||||
|
||||
@abstractmethod
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
执行分析任务
|
||||
|
||||
Args:
|
||||
context: 分析上下文,包含市场、代码、基础数据等
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_memories(self, situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从记忆中检索相似情况
|
||||
|
||||
Args:
|
||||
situation: 当前情况描述
|
||||
n_matches: 返回的匹配数量
|
||||
|
||||
Returns:
|
||||
匹配的历史记录列表
|
||||
"""
|
||||
if self.memory:
|
||||
return self.memory.get_memories(situation, n_matches=n_matches)
|
||||
return []
|
||||
|
||||
def format_memories_for_prompt(self, memories: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
格式化记忆为提示词
|
||||
|
||||
Args:
|
||||
memories: 记忆列表
|
||||
|
||||
Returns:
|
||||
格式化的字符串
|
||||
"""
|
||||
if not memories:
|
||||
return "无历史经验可参考。"
|
||||
|
||||
formatted = "历史经验参考:\n"
|
||||
for i, mem in enumerate(memories, 1):
|
||||
formatted += f"{i}. {mem.get('recommendation', 'N/A')}\n"
|
||||
|
||||
return formatted
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
Multi-agent coordinator.
|
||||
Orchestrates agents and the overall analysis workflow.
|
||||
"""
|
||||
from typing import Dict, Any, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from .analyst_agents import (
|
||||
MarketAnalyst, FundamentalAnalyst, NewsAnalyst,
|
||||
SentimentAnalyst, RiskAnalyst
|
||||
)
|
||||
from .researcher_agents import BullResearcher, BearResearcher
|
||||
from .trader_agent import TraderAgent
|
||||
from .risk_agents import RiskyAnalyst, NeutralAnalyst, SafeAnalyst
|
||||
from .memory import AgentMemory
|
||||
from .reflection import ReflectionService
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentCoordinator:
|
||||
"""Multi-agent coordinator."""
|
||||
|
||||
@staticmethod
|
||||
def _is_zh(language: str) -> bool:
|
||||
return str(language or "").lower().startswith("zh")
|
||||
|
||||
@classmethod
|
||||
def _t(cls, language: str, en: str, zh: str) -> str:
|
||||
"""Pick a localized string for user-facing fields (not logs)."""
|
||||
return zh if cls._is_zh(language) else en
|
||||
|
||||
def __init__(self, enable_memory: bool = True, max_debate_rounds: int = 2):
|
||||
"""
|
||||
Args:
|
||||
enable_memory: Enable memory/reflection
|
||||
max_debate_rounds: Max debate rounds
|
||||
"""
|
||||
self.enable_memory = enable_memory
|
||||
self.max_debate_rounds = max_debate_rounds
|
||||
|
||||
# Reflection service
|
||||
self.reflection_service = ReflectionService() if enable_memory else None
|
||||
|
||||
# Memory stores
|
||||
if enable_memory:
|
||||
self.memories = {
|
||||
'market': AgentMemory('market_analyst'),
|
||||
'fundamental': AgentMemory('fundamental_analyst'),
|
||||
'news': AgentMemory('news_analyst'),
|
||||
'sentiment': AgentMemory('sentiment_analyst'),
|
||||
'risk': AgentMemory('risk_analyst'),
|
||||
'bull': AgentMemory('bull_researcher'),
|
||||
'bear': AgentMemory('bear_researcher'),
|
||||
'trader': AgentMemory('trader_agent'),
|
||||
}
|
||||
else:
|
||||
self.memories = {}
|
||||
|
||||
# Initialize agents
|
||||
self._init_agents()
|
||||
|
||||
def _init_agents(self):
|
||||
"""Initialize all agents."""
|
||||
# Analyst agents
|
||||
self.market_analyst = MarketAnalyst(
|
||||
memory=self.memories.get('market')
|
||||
)
|
||||
self.fundamental_analyst = FundamentalAnalyst(
|
||||
memory=self.memories.get('fundamental')
|
||||
)
|
||||
self.news_analyst = NewsAnalyst(
|
||||
memory=self.memories.get('news')
|
||||
)
|
||||
self.sentiment_analyst = SentimentAnalyst(
|
||||
memory=self.memories.get('sentiment')
|
||||
)
|
||||
self.risk_analyst = RiskAnalyst(
|
||||
memory=self.memories.get('risk')
|
||||
)
|
||||
|
||||
# Researcher agents
|
||||
self.bull_researcher = BullResearcher(
|
||||
memory=self.memories.get('bull')
|
||||
)
|
||||
self.bear_researcher = BearResearcher(
|
||||
memory=self.memories.get('bear')
|
||||
)
|
||||
|
||||
# Trader agent
|
||||
self.trader_agent = TraderAgent(
|
||||
memory=self.memories.get('trader')
|
||||
)
|
||||
|
||||
# Risk debate agents
|
||||
self.risky_analyst = RiskyAnalyst()
|
||||
self.neutral_analyst = NeutralAnalyst()
|
||||
self.safe_analyst = SafeAnalyst()
|
||||
|
||||
def run_analysis(self, market: str, symbol: str, language: str = 'zh-CN', model: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the full multi-agent analysis workflow.
|
||||
"""
|
||||
logger.info(f"Multi-agent analysis start: {market}:{symbol}, model={model}, language={language}")
|
||||
|
||||
# Build base context
|
||||
from .tools import AgentTools
|
||||
tools = AgentTools()
|
||||
|
||||
# 1) Base data
|
||||
current_price = tools.get_current_price(market, symbol)
|
||||
company_data = tools.get_company_data(market, symbol, language=language)
|
||||
|
||||
# 2) Kline + fundamentals
|
||||
kline_data = tools.get_stock_data(market, symbol, days=30)
|
||||
fundamental_data = tools.get_fundamental_data(market, symbol)
|
||||
|
||||
# 3) News (Finnhub + web search)
|
||||
company_name = company_data.get('name', symbol) if company_data else symbol
|
||||
news_data = tools.get_news(market, symbol, days=7, company_name=company_name)
|
||||
|
||||
base_data = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"current_price": current_price,
|
||||
"kline_data": kline_data,
|
||||
"fundamental_data": fundamental_data,
|
||||
"company_data": company_data,
|
||||
"news_data": news_data,
|
||||
}
|
||||
|
||||
context = {
|
||||
"market": market,
|
||||
"symbol": symbol,
|
||||
"language": language,
|
||||
"model": model,
|
||||
"base_data": base_data
|
||||
}
|
||||
|
||||
# Phase 1: Analysts (parallel)
|
||||
logger.info("Phase 1: Analyst team (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
future_market = executor.submit(self.market_analyst.analyze, context)
|
||||
future_fundamental = executor.submit(self.fundamental_analyst.analyze, context)
|
||||
future_news = executor.submit(self.news_analyst.analyze, context)
|
||||
future_sentiment = executor.submit(self.sentiment_analyst.analyze, context)
|
||||
future_risk = executor.submit(self.risk_analyst.analyze, context)
|
||||
|
||||
market_report = future_market.result()
|
||||
fundamental_report = future_fundamental.result()
|
||||
news_report = future_news.result()
|
||||
sentiment_report = future_sentiment.result()
|
||||
risk_report = future_risk.result()
|
||||
|
||||
# Update context with analyst outputs
|
||||
context.update({
|
||||
"market_report": market_report,
|
||||
"fundamental_report": fundamental_report,
|
||||
"news_report": news_report,
|
||||
"sentiment_report": sentiment_report,
|
||||
"risk_report": risk_report,
|
||||
})
|
||||
|
||||
# Phase 2: Bull/Bear debate (parallel)
|
||||
logger.info("Phase 2: Research debate (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future_bull = executor.submit(self.bull_researcher.analyze, context)
|
||||
future_bear = executor.submit(self.bear_researcher.analyze, context)
|
||||
|
||||
bull_argument = future_bull.result()
|
||||
bear_argument = future_bear.result()
|
||||
|
||||
context["bull_argument"] = bull_argument
|
||||
context["bear_argument"] = bear_argument
|
||||
|
||||
# Research manager decision (lightweight, based on debate)
|
||||
research_decision = self._make_research_decision(
|
||||
bull_argument, bear_argument, context
|
||||
)
|
||||
context["research_decision"] = research_decision
|
||||
|
||||
# Phase 3: Trader decision
|
||||
logger.info("Phase 3: Trader decision")
|
||||
trader_result = self.trader_agent.analyze(context)
|
||||
trader_plan = trader_result.get('data', {}).get('trading_plan', {})
|
||||
context["trader_plan"] = trader_plan
|
||||
|
||||
# Phase 4: Risk debate (parallel)
|
||||
logger.info("Phase 4: Risk debate (parallel)")
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
future_risky = executor.submit(self.risky_analyst.analyze, context)
|
||||
future_neutral = executor.submit(self.neutral_analyst.analyze, context)
|
||||
future_safe = executor.submit(self.safe_analyst.analyze, context)
|
||||
|
||||
risky_result = future_risky.result()
|
||||
neutral_result = future_neutral.result()
|
||||
safe_result = future_safe.result()
|
||||
|
||||
# Risk manager final decision
|
||||
final_decision = self._make_risk_decision(
|
||||
risky_result, neutral_result, safe_result,
|
||||
trader_result, context
|
||||
)
|
||||
|
||||
# Record analysis result for later reflection/validation
|
||||
if self.reflection_service and final_decision.get('decision') in ['BUY', 'SELL', 'HOLD']:
|
||||
try:
|
||||
self.reflection_service.record_analysis(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
price=base_data.get('current_price', {}).get('price'),
|
||||
decision=final_decision.get('decision'),
|
||||
confidence=final_decision.get('confidence', 50),
|
||||
reasoning=final_decision.get('reasoning', ''),
|
||||
check_days=7 # Validate after 7 days by default
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Record reflection failed: {e}")
|
||||
|
||||
# Build final result (defensive defaults to keep frontend stable)
|
||||
debate_data = {
|
||||
"bull": bull_argument.get('data', {}) if bull_argument.get('data') else {},
|
||||
"bear": bear_argument.get('data', {}) if bear_argument.get('data') else {},
|
||||
"research_decision": research_decision if research_decision else "Analyzing..."
|
||||
}
|
||||
|
||||
trader_decision_data = trader_result.get('data', {}) if trader_result.get('data') else {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"trading_plan": {},
|
||||
"report": "Analyzing..."
|
||||
}
|
||||
|
||||
risk_debate_data = {
|
||||
"risky": risky_result.get('data', {}) if risky_result.get('data') else {},
|
||||
"neutral": neutral_result.get('data', {}) if neutral_result.get('data') else {},
|
||||
"safe": safe_result.get('data', {}) if safe_result.get('data') else {}
|
||||
}
|
||||
|
||||
# Ensure final_decision is present
|
||||
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
|
||||
final_decision = {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "Analyzing...",
|
||||
"risk_summary": {},
|
||||
"recommendation": "Analyzing..."
|
||||
}
|
||||
|
||||
result = {
|
||||
"overview": self._generate_overview(context, final_decision),
|
||||
"fundamental": fundamental_report.get('data', {}),
|
||||
"technical": market_report.get('data', {}),
|
||||
"news": news_report.get('data', {}),
|
||||
"sentiment": sentiment_report.get('data', {}),
|
||||
"risk": risk_report.get('data', {}),
|
||||
"debate": debate_data,
|
||||
"trader_decision": trader_decision_data,
|
||||
"risk_debate": risk_debate_data,
|
||||
"final_decision": final_decision,
|
||||
"error": None
|
||||
}
|
||||
|
||||
logger.info(f"Multi-agent analysis completed: {market}:{symbol}")
|
||||
logger.info(
|
||||
"Result fields - debate=%s, trader_decision=%s, risk_debate=%s, final_decision=%s",
|
||||
bool(result.get('debate')),
|
||||
bool(result.get('trader_decision')),
|
||||
bool(result.get('risk_debate')),
|
||||
bool(result.get('final_decision')),
|
||||
)
|
||||
return result
|
||||
|
||||
def _make_research_decision(self, bull: Dict, bear: Dict, context: Dict) -> str:
|
||||
"""Research manager decision (rule-based, lightweight)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
bull_confidence = bull.get('data', {}).get('confidence', 50)
|
||||
bear_confidence = bear.get('data', {}).get('confidence', 50)
|
||||
|
||||
# Tie-breaker when scores are close (<= 10)
|
||||
score_diff = bull_confidence - bear_confidence
|
||||
|
||||
if abs(score_diff) <= 10:
|
||||
# Use technical + sentiment as a bias signal
|
||||
market_score = context.get('market_report', {}).get('data', {}).get('score', 50)
|
||||
sentiment_score = context.get('sentiment_report', {}).get('data', {}).get('score', 50)
|
||||
|
||||
market_bias = (market_score + sentiment_score) / 2
|
||||
|
||||
if market_bias > 60:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
f"but technical/sentiment are optimistic (avg {market_bias:.1f}), slightly leaning bullish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
f"但鉴于技术面和市场情绪偏乐观(平均分 {market_bias:.1f}),稍微倾向于看涨。"
|
||||
),
|
||||
)
|
||||
elif market_bias < 40:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
f"but technical/sentiment are pessimistic (avg {market_bias:.1f}), slightly leaning bearish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
f"但鉴于技术面和市场情绪偏悲观(平均分 {market_bias:.1f}),稍微倾向于看跌。"
|
||||
),
|
||||
)
|
||||
else:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bull and bear cases are close (bull {bull_confidence}% vs bear {bear_confidence}%), "
|
||||
"and market bias is unclear. Prefer neutral / wait-and-see."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:多空论据势均力敌(看涨 {bull_confidence}% vs 看跌 {bear_confidence}%),"
|
||||
"且市场情绪不明朗,建议保持中立/观望。"
|
||||
),
|
||||
)
|
||||
|
||||
elif score_diff > 10:
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bullish case (confidence {bull_confidence}%) is clearly stronger than bearish "
|
||||
f"(confidence {bear_confidence}%). Lean bullish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:基于看涨论据(置信度 {bull_confidence}%)明显强于看跌论据(置信度 {bear_confidence}%),明确倾向于看涨。"
|
||||
),
|
||||
)
|
||||
else: # score_diff < -10
|
||||
return self._t(
|
||||
language,
|
||||
en=(
|
||||
f"Research decision: bearish case (confidence {bear_confidence}%) is clearly stronger than bullish "
|
||||
f"(confidence {bull_confidence}%). Lean bearish."
|
||||
),
|
||||
zh=(
|
||||
f"研究经理决策:基于看跌论据(置信度 {bear_confidence}%)明显强于看涨论据(置信度 {bull_confidence}%),明确倾向于看跌。"
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Research decision failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return self._t(language, en="Research decision: unable to reach a clear conclusion.", zh="研究经理决策:无法做出明确判断。")
|
||||
|
||||
def _make_risk_decision(self, risky: Dict, neutral: Dict, safe: Dict,
|
||||
trader: Dict, context: Dict) -> Dict[str, Any]:
|
||||
"""Risk manager final decision (lightweight)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
trader_decision = trader.get('data', {}).get('decision', 'HOLD')
|
||||
trader_confidence = trader.get('data', {}).get('confidence', 50)
|
||||
|
||||
# Risk debate summary
|
||||
risk_summary = {
|
||||
"risky_view": risky.get('data', {}).get('recommendation', ''),
|
||||
"neutral_view": neutral.get('data', {}).get('recommendation', ''),
|
||||
"safe_view": safe.get('data', {}).get('recommendation', ''),
|
||||
}
|
||||
|
||||
# Final decision (use trader decision + risk debate context)
|
||||
final_decision = {
|
||||
"decision": trader_decision,
|
||||
"confidence": trader_confidence,
|
||||
"reasoning": self._t(
|
||||
language,
|
||||
en=f"Final decision is based on trader analysis ({trader_decision}, confidence {trader_confidence}%) and the risk debate.",
|
||||
zh=f"基于交易员分析({trader_decision},置信度 {trader_confidence}%)和风险辩论,做出最终决策。",
|
||||
),
|
||||
"risk_summary": risk_summary,
|
||||
"recommendation": trader.get('data', {}).get('report', '')
|
||||
}
|
||||
|
||||
return final_decision
|
||||
except Exception as e:
|
||||
logger.error(f"Risk decision failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return {
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": self._t(language, en="Risk decision failed", zh="风险决策失败"),
|
||||
"risk_summary": {},
|
||||
"recommendation": ""
|
||||
}
|
||||
|
||||
def _generate_overview(self, context: Dict, final_decision: Dict) -> Dict[str, Any]:
|
||||
"""Generate overview section (lightweight, deterministic)."""
|
||||
try:
|
||||
language = context.get("language", "en-US")
|
||||
# Extract dimension scores
|
||||
technical_data = context.get('market_report', {}).get('data', {})
|
||||
fundamental_data = context.get('fundamental_report', {}).get('data', {})
|
||||
news_data = context.get('news_report', {}).get('data', {})
|
||||
sentiment_data = context.get('sentiment_report', {}).get('data', {})
|
||||
risk_data = context.get('risk_report', {}).get('data', {})
|
||||
|
||||
technical_score = technical_data.get('score', 50)
|
||||
fundamental_score = fundamental_data.get('score', 50)
|
||||
news_score = news_data.get('score', 50)
|
||||
sentiment_score = sentiment_data.get('score', 50)
|
||||
risk_score = risk_data.get('score', 50)
|
||||
|
||||
# Generate an overall score using weighted dimensions + decision/confidence adjustment
|
||||
decision = final_decision.get('decision', 'HOLD')
|
||||
confidence = final_decision.get('confidence', 50)
|
||||
|
||||
# 1) Base score: weighted average (tech 30%, fundamental 25%, news 15%, sentiment 15%, risk 15%)
|
||||
weighted_score = (
|
||||
technical_score * 0.3 +
|
||||
fundamental_score * 0.25 +
|
||||
news_score * 0.15 +
|
||||
sentiment_score * 0.15 +
|
||||
risk_score * 0.15
|
||||
)
|
||||
|
||||
# 2) Decision adjustment: BUY pushes toward 60-100, SELL toward 0-40, HOLD toward 50
|
||||
if decision == 'BUY':
|
||||
target_score = 60 + (confidence / 100 * 40) # Map to 60-100
|
||||
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
|
||||
elif decision == 'SELL':
|
||||
target_score = 40 - (confidence / 100 * 40) # Map to 0-40
|
||||
overall_score = (weighted_score * 0.4) + (target_score * 0.6)
|
||||
else:
|
||||
overall_score = (weighted_score * 0.6) + (50 * 0.4)
|
||||
|
||||
# Clamp to 0..100
|
||||
overall_score = max(0, min(100, int(overall_score)))
|
||||
|
||||
return {
|
||||
"overallScore": overall_score,
|
||||
"recommendation": decision,
|
||||
"confidence": confidence,
|
||||
"dimensionScores": {
|
||||
"fundamental": fundamental_score,
|
||||
"technical": technical_score,
|
||||
"news": news_score,
|
||||
"sentiment": sentiment_score,
|
||||
"risk": risk_score
|
||||
},
|
||||
"report": final_decision.get(
|
||||
'reasoning',
|
||||
self._t(language, en="Overview generated.", zh="综合分析完成"),
|
||||
)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Generate overview failed: {e}")
|
||||
language = context.get("language", "en-US") if isinstance(context, dict) else "en-US"
|
||||
return {
|
||||
"overallScore": 50,
|
||||
"recommendation": "HOLD",
|
||||
"confidence": 50,
|
||||
"dimensionScores": {
|
||||
"fundamental": 50,
|
||||
"technical": 50,
|
||||
"news": 50,
|
||||
"sentiment": 50,
|
||||
"risk": 50
|
||||
},
|
||||
"report": self._t(language, en="Failed to generate overview.", zh="综合分析生成失败")
|
||||
}
|
||||
|
||||
def reflect_and_learn(self, market: str, symbol: str, decision: str,
|
||||
returns: Optional[float] = None, result: Optional[str] = None):
|
||||
"""
|
||||
Reflection hook: store post-trade outcomes into memory (local-only).
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol
|
||||
decision: BUY/SELL/HOLD
|
||||
returns: Return percentage
|
||||
result: Free-text outcome
|
||||
"""
|
||||
if not self.enable_memory:
|
||||
return
|
||||
|
||||
try:
|
||||
situation = f"{market}:{symbol} trading decision"
|
||||
recommendation = f"Decision: {decision}, returns: {returns if returns is not None else 'N/A'}"
|
||||
|
||||
# Update trader memory
|
||||
if 'trader' in self.memories:
|
||||
self.memories['trader'].add_memory(
|
||||
situation, recommendation, result, returns
|
||||
)
|
||||
|
||||
logger.info(f"Reflection completed: {market}:{symbol}, decision={decision}, returns={returns}")
|
||||
except Exception as e:
|
||||
logger.error(f"Reflection failed: {e}")
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
智能体记忆系统
|
||||
使用 SQLite + 简单的文本相似度匹配
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import difflib
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""智能体记忆系统"""
|
||||
|
||||
def __init__(self, agent_name: str, db_path: Optional[str] = None):
|
||||
"""
|
||||
初始化记忆系统
|
||||
|
||||
Args:
|
||||
agent_name: 智能体名称
|
||||
db_path: 数据库路径(可选)
|
||||
"""
|
||||
self.agent_name = agent_name
|
||||
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, f'{agent_name}_memory.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
situation TEXT NOT NULL,
|
||||
recommendation TEXT NOT NULL,
|
||||
result TEXT,
|
||||
returns REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON memories(created_at)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化记忆数据库失败: {e}")
|
||||
|
||||
def add_memory(self, situation: str, recommendation: str, result: Optional[str] = None, returns: Optional[float] = None):
|
||||
"""
|
||||
添加记忆
|
||||
|
||||
Args:
|
||||
situation: 情况描述
|
||||
recommendation: 建议/决策
|
||||
result: 结果描述(可选)
|
||||
returns: 收益(可选)
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO memories (situation, recommendation, result, returns)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (situation, recommendation, result, returns))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 添加新记忆")
|
||||
except Exception as e:
|
||||
logger.error(f"添加记忆失败: {e}")
|
||||
|
||||
def get_memories(self, current_situation: str, n_matches: int = 2) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
检索相似记忆
|
||||
|
||||
Args:
|
||||
current_situation: 当前情况描述
|
||||
n_matches: 返回的匹配数量
|
||||
|
||||
Returns:
|
||||
匹配的记忆列表
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取所有记忆
|
||||
cursor.execute('''
|
||||
SELECT id, situation, recommendation, result, returns, created_at
|
||||
FROM memories
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
''')
|
||||
|
||||
all_memories = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not all_memories:
|
||||
return []
|
||||
|
||||
# 计算相似度
|
||||
scored_memories = []
|
||||
for mem in all_memories:
|
||||
mem_id, situation, recommendation, result, returns, created_at = mem
|
||||
|
||||
# 使用简单的文本相似度
|
||||
similarity = difflib.SequenceMatcher(
|
||||
None,
|
||||
current_situation.lower(),
|
||||
situation.lower()
|
||||
).ratio()
|
||||
|
||||
scored_memories.append({
|
||||
'id': mem_id,
|
||||
'matched_situation': situation,
|
||||
'recommendation': recommendation,
|
||||
'result': result,
|
||||
'returns': returns,
|
||||
'similarity_score': similarity,
|
||||
'created_at': created_at
|
||||
})
|
||||
|
||||
# 按相似度排序
|
||||
scored_memories.sort(key=lambda x: x['similarity_score'], reverse=True)
|
||||
|
||||
# 返回前 n_matches 个
|
||||
return scored_memories[:n_matches]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检索记忆失败: {e}")
|
||||
return []
|
||||
|
||||
def update_memory_result(self, memory_id: int, result: str, returns: Optional[float] = None):
|
||||
"""
|
||||
更新记忆的结果
|
||||
|
||||
Args:
|
||||
memory_id: 记忆ID
|
||||
result: 结果描述
|
||||
returns: 收益
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE memories
|
||||
SET result = ?, returns = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (result, returns, memory_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"{self.agent_name} 更新记忆 {memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新记忆失败: {e}")
|
||||
|
||||
def get_statistics(self) -> Dict[str, Any]:
|
||||
"""获取记忆统计信息"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT AVG(returns) FROM memories WHERE returns IS NOT NULL')
|
||||
avg_returns = cursor.fetchone()[0] or 0
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memories WHERE returns > 0')
|
||||
positive = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total_memories': total,
|
||||
'average_returns': round(avg_returns, 2),
|
||||
'positive_decisions': positive,
|
||||
'success_rate': round(positive / total * 100, 2) if total > 0 else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息失败: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
自动反思与验证服务
|
||||
用于记录分析预测,并在未来自动验证结果,实现闭环学习
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from .memory import AgentMemory
|
||||
from .tools import AgentTools
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ReflectionService:
|
||||
"""反思服务:管理分析记录的存储和验证"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
if db_path is None:
|
||||
# 默认数据库路径
|
||||
db_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'data', 'memory')
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db_path = os.path.join(db_dir, 'reflection_records.db')
|
||||
|
||||
self.db_path = db_path
|
||||
self.tools = AgentTools()
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建分析记录表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS analysis_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
market TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
initial_price REAL,
|
||||
decision TEXT,
|
||||
confidence INTEGER,
|
||||
reasoning TEXT,
|
||||
analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
target_check_date TIMESTAMP,
|
||||
status TEXT DEFAULT 'PENDING', -- PENDING, COMPLETED, FAILED
|
||||
final_price REAL,
|
||||
actual_return REAL,
|
||||
check_result TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_status_date ON analysis_records(status, target_check_date)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"初始化反思数据库失败: {e}")
|
||||
|
||||
def record_analysis(self, market: str, symbol: str, price: float,
|
||||
decision: str, confidence: int, reasoning: str,
|
||||
check_days: int = 7):
|
||||
"""
|
||||
记录一次分析,以便未来验证
|
||||
|
||||
Args:
|
||||
market: 市场
|
||||
symbol: 代码
|
||||
price: 当前价格
|
||||
decision: 决策 (BUY/SELL/HOLD)
|
||||
confidence: 置信度
|
||||
reasoning: 理由
|
||||
check_days: 几天后验证 (默认7天)
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
target_date = datetime.now() + timedelta(days=check_days)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO analysis_records
|
||||
(market, symbol, initial_price, decision, confidence, reasoning, target_check_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (market, symbol, price, decision, confidence, reasoning, target_date))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"已记录分析用于反思: {market}:{symbol}, 将在 {check_days} 天后验证")
|
||||
except Exception as e:
|
||||
logger.error(f"记录分析失败: {e}")
|
||||
|
||||
def run_verification_cycle(self):
|
||||
"""
|
||||
执行验证周期:检查到期的记录,验证结果,并写入记忆
|
||||
"""
|
||||
logger.info("开始执行自动反思验证周期...")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. 查找所有已到期且未处理的记录
|
||||
cursor.execute('''
|
||||
SELECT id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date
|
||||
FROM analysis_records
|
||||
WHERE status = 'PENDING' AND target_check_date <= CURRENT_TIMESTAMP
|
||||
''')
|
||||
|
||||
records = cursor.fetchall()
|
||||
|
||||
if not records:
|
||||
logger.info("没有需要验证的记录")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(f"发现 {len(records)} 条待验证记录")
|
||||
|
||||
# 初始化记忆系统(用于写入验证结果)
|
||||
trader_memory = AgentMemory('trader_agent')
|
||||
|
||||
for record in records:
|
||||
record_id, market, symbol, initial_price, decision, confidence, reasoning, analysis_date = record
|
||||
|
||||
try:
|
||||
# 2. 获取当前最新价格
|
||||
current_price_data = self.tools.get_current_price(market, symbol)
|
||||
current_price = current_price_data.get('price')
|
||||
|
||||
if not current_price:
|
||||
logger.warning(f"无法获取 {market}:{symbol} 的当前价格,跳过")
|
||||
continue
|
||||
|
||||
# 3. 计算收益和结果
|
||||
if not initial_price or initial_price == 0:
|
||||
actual_return = 0.0
|
||||
else:
|
||||
actual_return = (current_price - initial_price) / initial_price * 100
|
||||
|
||||
# 评估结果
|
||||
result_desc = ""
|
||||
is_good_prediction = False
|
||||
|
||||
if decision == "BUY":
|
||||
if actual_return > 2.0:
|
||||
result_desc = "准确:买入后价格上涨"
|
||||
is_good_prediction = True
|
||||
elif actual_return < -2.0:
|
||||
result_desc = "错误:买入后价格下跌"
|
||||
else:
|
||||
result_desc = "中性:价格波动不大"
|
||||
elif decision == "SELL":
|
||||
if actual_return < -2.0:
|
||||
result_desc = "准确:卖出后价格下跌"
|
||||
is_good_prediction = True
|
||||
elif actual_return > 2.0:
|
||||
result_desc = "错误:卖出后价格上涨"
|
||||
else:
|
||||
result_desc = "中性:价格波动不大"
|
||||
else: # HOLD
|
||||
if -2.0 <= actual_return <= 2.0:
|
||||
result_desc = "准确:持有期间波动不大"
|
||||
is_good_prediction = True
|
||||
else:
|
||||
result_desc = f"偏差:持有期间出现了较大波动 ({actual_return:.2f}%)"
|
||||
|
||||
# 4. 写入记忆系统 (Let the agent learn)
|
||||
memory_situation = f"{market}:{symbol} 自动验证 (预测日期: {analysis_date})"
|
||||
memory_recommendation = f"当时决策: {decision} (置信度 {confidence}), 理由: {reasoning[:50]}..."
|
||||
memory_result = f"验证结果: {result_desc}, 实际收益: {actual_return:.2f}% (初始 {initial_price} -> 最新 {current_price})"
|
||||
|
||||
trader_memory.add_memory(
|
||||
memory_situation,
|
||||
memory_recommendation,
|
||||
memory_result,
|
||||
actual_return
|
||||
)
|
||||
|
||||
# 5. 更新记录状态
|
||||
cursor.execute('''
|
||||
UPDATE analysis_records
|
||||
SET status = 'COMPLETED', final_price = ?, actual_return = ?, check_result = ?
|
||||
WHERE id = ?
|
||||
''', (current_price, actual_return, result_desc, record_id))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"验证完成 {market}:{symbol}: {result_desc}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"处理记录 {record_id} 失败: {inner_e}")
|
||||
# 标记为失败,避免重复处理
|
||||
# cursor.execute("UPDATE analysis_records SET status = 'FAILED' WHERE id = ?", (record_id,))
|
||||
# conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info("反思验证周期结束")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行验证周期失败: {e}")
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Researcher agents.
|
||||
Includes: bull researcher and bear researcher.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class BullResearcher(BaseAgent):
|
||||
"""Bullish researcher."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("BullResearcher", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construct the bull case."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} bull case"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Bullish Analyst, constructing a bullish argument for an investment decision. Your tasks are:
|
||||
{lang_instruction}
|
||||
1. Highlight growth potential, competitive advantages, and positive market indicators.
|
||||
2. Use the provided research and data to build a strong argument.
|
||||
3. Effectively address/counter bearish viewpoints.
|
||||
4. Learn from historical experience: {memory_prompt}
|
||||
5. **Confidence Score**: Evaluate your confidence in the bullish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Detailed bullish argument...",
|
||||
"key_points": ["Point 1", "Point 2", "Point 3"],
|
||||
"confidence": 75
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Based on the following analysis reports, construct a bullish argument for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
Please construct a strong bullish argument, emphasizing growth potential, competitive advantages, and positive indicators."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "key_points": [], "confidence": 50},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "bull",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class BearResearcher(BaseAgent):
|
||||
"""Bearish researcher."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("BearResearcher", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construct the bear case."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
# Bull argument (if present)
|
||||
bull_argument = context.get('bull_argument', '')
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} bear case"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Bearish Analyst, constructing a bearish argument for an investment decision. Your tasks are:
|
||||
{lang_instruction}
|
||||
1. Identify risks, challenges, and negative indicators.
|
||||
2. Use the provided research and data to build a strong argument.
|
||||
3. Effectively address/counter bullish viewpoints.
|
||||
4. Learn from historical experience: {memory_prompt}
|
||||
5. **Confidence Score**: Evaluate your confidence in the bearish case (0-100). Be realistic. If the data is mixed or weak, lower your confidence. Do NOT default to 75.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Detailed bearish argument...",
|
||||
"key_points": ["Point 1", "Point 2", "Point 3"],
|
||||
"confidence": 75
|
||||
}}"""
|
||||
|
||||
# Bull argument section (avoid backslashes in f-string expression)
|
||||
bull_argument_section = f"**Bullish Argument (Needs Rebuttal):**\n{bull_argument}" if bull_argument else ""
|
||||
|
||||
user_prompt = f"""Based on the following analysis reports, construct a bearish argument for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
**Risk Analysis:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
{bull_argument_section}
|
||||
|
||||
Please construct a strong bearish argument, emphasizing risks, challenges, and negative indicators."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "key_points": [], "confidence": 50},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "bear",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Risk debate agents.
|
||||
Includes: aggressive / neutral / conservative risk analysts.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class RiskyAnalyst(BaseAgent):
|
||||
"""Aggressive risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("RiskyAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from an aggressive perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are an Aggressive Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Emphasize high return potential, even with higher risks.
|
||||
2. Believe current risks are controllable and worth taking.
|
||||
3. Support aggressive trading strategies.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Aggressive risk analysis argument...",
|
||||
"risk_assessment": "Risk controllable, high return potential",
|
||||
"recommendation": "Support trading plan"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform aggressive risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
Please analyze risk from an aggressive perspective, emphasizing return potential."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "risky",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class NeutralAnalyst(BaseAgent):
|
||||
"""Neutral risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("NeutralAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from a neutral perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Neutral Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Balance risk and return.
|
||||
2. Objectively evaluate various possibilities.
|
||||
3. Provide neutral risk advice.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Neutral risk analysis argument...",
|
||||
"risk_assessment": "Balance between risk and return",
|
||||
"recommendation": "Cautiously execute trading plan"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform neutral risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
Please analyze risk from a neutral perspective, balancing risk and return."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "neutral",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
|
||||
|
||||
class SafeAnalyst(BaseAgent):
|
||||
"""Conservative risk analyst."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("SafeAnalyst", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Analyze risk from a conservative perspective."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
trader_plan = context.get('trader_plan', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Conservative Risk Analyst. You tend to:
|
||||
{lang_instruction}
|
||||
1. Emphasize risk control, prioritizing capital protection.
|
||||
2. Identify potential risk points.
|
||||
3. Suggest cautious or conservative trading strategies.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"argument": "Conservative risk analysis argument...",
|
||||
"risk_assessment": "High risk exists, suggest caution",
|
||||
"recommendation": "Suggest reducing position or suspending trading"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Perform conservative risk analysis for {symbol} in {market} market.
|
||||
|
||||
**Trading Plan:**
|
||||
{json.dumps(trader_plan, ensure_ascii=False, indent=2) if trader_plan else 'No Data'}
|
||||
|
||||
**Risk Analysis Report:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
Please analyze risk from a conservative perspective, emphasizing risk control."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{"argument": "", "risk_assessment": "", "recommendation": ""},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "safe",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Agent tools.
|
||||
|
||||
Provides data fetching helpers for the multi-agent analysis pipeline.
|
||||
All docstrings/log messages in this module are English. Output language of AI reports
|
||||
is controlled by the `language` value passed through the analysis context.
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import time
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
import finnhub
|
||||
import ccxt
|
||||
import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import APIKeys
|
||||
from app.services.search import SearchService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AgentTools:
|
||||
"""A thin wrapper around various public data sources used by agents."""
|
||||
|
||||
def __init__(self):
|
||||
self.search_service = SearchService()
|
||||
self.finnhub_client = None
|
||||
if APIKeys.is_configured('FINNHUB_API_KEY'):
|
||||
try:
|
||||
self.finnhub_client = finnhub.Client(api_key=APIKeys.FINNHUB_API_KEY)
|
||||
except Exception as e:
|
||||
# Safe logging to avoid cascading errors during exception handling
|
||||
try:
|
||||
logger.warning(f"Finnhub init failed: {e}")
|
||||
except Exception:
|
||||
# Fallback to print if logging fails
|
||||
print(f"Warning: Finnhub init failed: {e}")
|
||||
|
||||
# Optional dependency: akshare (A-share fundamentals/company info)
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
self._ak = ak
|
||||
self._has_akshare = True
|
||||
except Exception:
|
||||
self._ak = None
|
||||
self._has_akshare = False
|
||||
|
||||
# AShare spot cache (avoid fetching the full market list repeatedly)
|
||||
self._ashare_spot_cache = None
|
||||
self._ashare_spot_cache_ts = 0
|
||||
self._ashare_spot_cache_ttl = 300 # seconds
|
||||
|
||||
def _get_ashare_spot_df(self):
|
||||
"""Cached AShare spot dataframe via akshare (may be heavy on first load)."""
|
||||
if not self._akshare_required():
|
||||
return None
|
||||
now = int(time.time())
|
||||
if self._ashare_spot_cache is not None and (now - int(self._ashare_spot_cache_ts)) < int(self._ashare_spot_cache_ttl):
|
||||
return self._ashare_spot_cache
|
||||
ak = self._ak
|
||||
if ak is None or not hasattr(ak, "stock_zh_a_spot_em"):
|
||||
return None
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
self._ashare_spot_cache = df
|
||||
self._ashare_spot_cache_ts = now
|
||||
return df
|
||||
|
||||
def _ccxt_exchange(self):
|
||||
"""Create a CCXT exchange client (Binance) with optional proxy support."""
|
||||
cfg: Dict[str, Any] = {'timeout': 5000, 'enableRateLimit': True}
|
||||
# Keep proxy behavior consistent with data sources (.env PROXY_* is supported)
|
||||
from app.config import CCXTConfig
|
||||
proxy = (CCXTConfig.PROXY or '').strip()
|
||||
if proxy:
|
||||
cfg['proxies'] = {'http': proxy, 'https': proxy}
|
||||
return ccxt.binance(cfg)
|
||||
|
||||
def _akshare_required(self) -> bool:
|
||||
"""Whether akshare is available at runtime."""
|
||||
return bool(self._has_akshare and self._ak is not None)
|
||||
|
||||
def get_stock_data(self, market: str, symbol: str, days: int = 30) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get daily Kline data for recent days (best-effort).
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol
|
||||
days: Days
|
||||
|
||||
Returns:
|
||||
List of OHLCV dicts or None
|
||||
"""
|
||||
try:
|
||||
klines = []
|
||||
|
||||
if market == 'USStock':
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
|
||||
|
||||
ticker = yf.Ticker(symbol)
|
||||
df = ticker.history(start=start_date, end=end_date, interval="1d")
|
||||
|
||||
if not df.empty:
|
||||
df = df.tail(days).reset_index()
|
||||
for _, row in df.iterrows():
|
||||
klines.append({
|
||||
"time": row['Date'].strftime('%Y-%m-%d'),
|
||||
"open": round(row['Open'], 4),
|
||||
"high": round(row['High'], 4),
|
||||
"low": round(row['Low'], 4),
|
||||
"close": round(row['Close'], 4),
|
||||
"volume": int(row['Volume'])
|
||||
})
|
||||
return klines
|
||||
|
||||
elif market == 'Crypto':
|
||||
exchange = self._ccxt_exchange()
|
||||
symbol_pair = f'{symbol}/USDT'
|
||||
start_time = int((datetime.now() - timedelta(days=days)).timestamp())
|
||||
ohlcv = exchange.fetch_ohlcv(symbol_pair, '1d', since=start_time * 1000, limit=days)
|
||||
if ohlcv:
|
||||
for candle in ohlcv:
|
||||
klines.append({
|
||||
"time": datetime.fromtimestamp(candle[0] / 1000).strftime('%Y-%m-%d'),
|
||||
"open": candle[1],
|
||||
"high": candle[2],
|
||||
"low": candle[3],
|
||||
"close": candle[4],
|
||||
"volume": candle[5]
|
||||
})
|
||||
return klines
|
||||
|
||||
# CN/HK stocks
|
||||
if market in ('AShare', 'HShare'):
|
||||
# Prefer akshare for AShare (requested), fall back to yfinance.
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
start_date = (datetime.now() - timedelta(days=days + 10)).strftime('%Y%m%d')
|
||||
end_date = datetime.now().strftime('%Y%m%d')
|
||||
# akshare returns a dataframe with Chinese column names.
|
||||
df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq")
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(days)
|
||||
for _, row in df.iterrows():
|
||||
dt = row.get('日期')
|
||||
# dt can be datetime/date/str
|
||||
if hasattr(dt, "strftime"):
|
||||
t = dt.strftime('%Y-%m-%d')
|
||||
else:
|
||||
t = str(dt)[:10]
|
||||
klines.append({
|
||||
"time": t,
|
||||
"open": float(row.get('开盘', 0) or 0),
|
||||
"high": float(row.get('最高', 0) or 0),
|
||||
"low": float(row.get('最低', 0) or 0),
|
||||
"close": float(row.get('收盘', 0) or 0),
|
||||
"volume": float(row.get('成交量', 0) or 0),
|
||||
})
|
||||
return klines
|
||||
except Exception as e:
|
||||
logger.warning(f"akshare AShare kline failed ({symbol}): {e}")
|
||||
|
||||
# yfinance fallback (daily)
|
||||
if market == 'AShare':
|
||||
yf_symbol = f"{symbol}.SS" if symbol.startswith('6') else f"{symbol}.SZ"
|
||||
else:
|
||||
yf_symbol = f"{symbol.zfill(4)}.HK"
|
||||
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days + 5)).strftime('%Y-%m-%d')
|
||||
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
df = ticker.history(start=start_date, end=end_date, interval="1d")
|
||||
|
||||
if not df.empty:
|
||||
df = df.tail(days).reset_index()
|
||||
for _, row in df.iterrows():
|
||||
klines.append({
|
||||
"time": row['Date'].strftime('%Y-%m-%d'),
|
||||
"open": round(row['Open'], 4),
|
||||
"high": round(row['High'], 4),
|
||||
"low": round(row['Low'], 4),
|
||||
"close": round(row['Close'], 4),
|
||||
"volume": int(row['Volume'])
|
||||
})
|
||||
return klines
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch kline data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_current_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get current price (best-effort)."""
|
||||
try:
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
quote = self.finnhub_client.quote(symbol)
|
||||
if quote and quote.get('c'):
|
||||
return {
|
||||
"price": quote.get('c', 0),
|
||||
"change": quote.get('d', 0),
|
||||
"changePercent": quote.get('dp', 0),
|
||||
"high": quote.get('h', 0),
|
||||
"low": quote.get('l', 0),
|
||||
"open": quote.get('o', 0),
|
||||
"previousClose": quote.get('pc', 0)
|
||||
}
|
||||
elif market == 'Crypto':
|
||||
exchange = self._ccxt_exchange()
|
||||
symbol_pair = f'{symbol}/USDT'
|
||||
ticker = exchange.fetch_ticker(symbol_pair)
|
||||
if ticker:
|
||||
return {
|
||||
"price": ticker.get('last', 0),
|
||||
"change": ticker.get('change', 0),
|
||||
"changePercent": ticker.get('percentage', 0),
|
||||
"high": ticker.get('high', 0),
|
||||
"low": ticker.get('low', 0),
|
||||
"open": ticker.get('open', 0),
|
||||
"volume": ticker.get('quoteVolume', 0)
|
||||
}
|
||||
|
||||
# CN/HK stocks: prefer akshare for AShare (requested)
|
||||
if market in ('AShare', 'HShare'):
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
df = self._get_ashare_spot_df()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol].iloc[0]
|
||||
price = float(row.get('最新价', 0) or 0)
|
||||
change = float(row.get('涨跌额', 0) or 0)
|
||||
change_pct = float(row.get('涨跌幅', 0) or 0)
|
||||
high = float(row.get('最高', 0) or 0)
|
||||
low = float(row.get('最低', 0) or 0)
|
||||
open_p = float(row.get('今开', 0) or 0)
|
||||
prev_close = float(row.get('昨收', 0) or 0)
|
||||
return {
|
||||
"price": price,
|
||||
"change": change,
|
||||
"changePercent": change_pct,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"open": open_p,
|
||||
"previousClose": prev_close
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"akshare AShare spot failed ({symbol}): {e}")
|
||||
|
||||
# Do not use Tencent for AShare by default (requested). If akshare is not available,
|
||||
# return None and let the LLM report degrade gracefully.
|
||||
if market == 'AShare':
|
||||
if not self._akshare_required():
|
||||
logger.warning("akshare is not installed; AShare spot price is unavailable.")
|
||||
return None
|
||||
|
||||
# HShare fallback: Tencent quote
|
||||
symbol_code = f'hk{symbol}'
|
||||
|
||||
url = f"http://qt.gtimg.cn/q={symbol_code}"
|
||||
resp = requests.get(url, timeout=10)
|
||||
content = resp.content.decode('gbk', errors='ignore')
|
||||
if '="' in content:
|
||||
data_str = content.split('="')[1].strip('";\n')
|
||||
if data_str:
|
||||
parts = data_str.split('~')
|
||||
if len(parts) > 32:
|
||||
return {
|
||||
"price": float(parts[3]) if parts[3] else 0,
|
||||
"change": float(parts[31]) if parts[31] else 0,
|
||||
"changePercent": float(parts[32]) if parts[32] else 0,
|
||||
"high": float(parts[33]) if len(parts) > 33 and parts[33] else 0,
|
||||
"low": float(parts[34]) if len(parts) > 34 and parts[34] else 0,
|
||||
"open": float(parts[5]) if len(parts) > 5 and parts[5] else 0,
|
||||
"previousClose": float(parts[4]) if parts[4] else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch current price {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_fundamental_data(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get fundamental data (best-effort)."""
|
||||
try:
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
metrics = self.finnhub_client.company_basic_financials(symbol, 'all')
|
||||
profile = self.finnhub_client.company_profile2(symbol=symbol)
|
||||
|
||||
return {
|
||||
"metrics": metrics.get('metric', {}),
|
||||
"profile_metrics": {
|
||||
"marketCapitalization": profile.get('marketCapitalization', 0),
|
||||
"currency": profile.get('currency', 'USD'),
|
||||
"finnhubIndustry": profile.get('finnhubIndustry', ''),
|
||||
}
|
||||
}
|
||||
|
||||
# AShare fundamentals via akshare (requested)
|
||||
if market == 'AShare' and self._akshare_required():
|
||||
ak = self._ak
|
||||
out: Dict[str, Any] = {"metrics": {}, "profile_metrics": {}}
|
||||
|
||||
# 1) Use spot list (fast) for valuation/market cap
|
||||
try:
|
||||
df = self._get_ashare_spot_df()
|
||||
if df is not None and not df.empty:
|
||||
row = df[df['代码'] == symbol].iloc[0]
|
||||
out["metrics"].update({
|
||||
"pe_ttm": row.get('市盈率-动态'),
|
||||
"pb": row.get('市净率'),
|
||||
"turnoverRate": row.get('换手率'),
|
||||
})
|
||||
out["profile_metrics"].update({
|
||||
"marketCapitalization": row.get('总市值'),
|
||||
"floatMarketCap": row.get('流通市值'),
|
||||
"currency": "CNY",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare spot metrics unavailable ({symbol}): {e}")
|
||||
|
||||
# 2) Try akshare indicator endpoints (optional, may be slower / may change)
|
||||
try:
|
||||
if hasattr(ak, "stock_a_lg_indicator"):
|
||||
ind_df = ak.stock_a_lg_indicator(symbol=symbol)
|
||||
if ind_df is not None and not ind_df.empty:
|
||||
last = ind_df.iloc[-1].to_dict()
|
||||
out["metrics"].update(last)
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare indicator fetch failed ({symbol}): {e}")
|
||||
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch fundamental data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_company_data(self, market: str, symbol: str, language: str = "en-US") -> Optional[Dict[str, Any]]:
|
||||
"""Get basic company/project info (best-effort)."""
|
||||
try:
|
||||
# 1) Finnhub (mainly for US stocks)
|
||||
if market == 'USStock' and self.finnhub_client:
|
||||
profile = self.finnhub_client.company_profile2(symbol=symbol)
|
||||
if profile:
|
||||
return {
|
||||
"name": profile.get('name', symbol),
|
||||
"ticker": profile.get('ticker', symbol),
|
||||
"exchange": profile.get('exchange', ''),
|
||||
"industry": profile.get('finnhubIndustry', ''),
|
||||
"website": profile.get('weburl', ''),
|
||||
"marketCapitalization": profile.get('marketCapitalization', 0),
|
||||
"description": f"Sector: {profile.get('finnhubIndustry', '')}, Country: {profile.get('country', '')}"
|
||||
}
|
||||
|
||||
# 2) Basic info for AShare / HShare / Crypto
|
||||
elif market in ('AShare', 'HShare', 'Crypto'):
|
||||
name = symbol
|
||||
if market == 'AShare':
|
||||
# Prefer akshare for AShare (requested)
|
||||
if self._akshare_required():
|
||||
try:
|
||||
ak = self._ak
|
||||
# 1) Individual info (more structured)
|
||||
if hasattr(ak, "stock_individual_info_em"):
|
||||
df = ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
|
||||
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
|
||||
# common keys: 股票简称, 所属行业, 上市时间, 总市值 ...
|
||||
name = str(info.get('股票简称') or info.get('证券简称') or symbol).strip()
|
||||
industry = str(info.get('所属行业') or '').strip()
|
||||
website = str(info.get('公司网址') or '').strip()
|
||||
market_cap = info.get('总市值') or info.get('总市值(元)') or 0
|
||||
return {
|
||||
"name": name or symbol,
|
||||
"ticker": symbol,
|
||||
"market": market,
|
||||
"industry": industry,
|
||||
"website": website,
|
||||
"marketCapitalization": market_cap,
|
||||
"description": f"Industry: {industry}" if industry else ""
|
||||
}
|
||||
# 2) Spot list for name
|
||||
df2 = ak.stock_zh_a_spot_em()
|
||||
if df2 is not None and not df2.empty:
|
||||
row = df2[df2['代码'] == symbol].iloc[0]
|
||||
name = str(row.get('名称') or symbol).strip()
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare company info failed ({symbol}): {e}")
|
||||
|
||||
# Do not use Tencent for AShare by default (requested).
|
||||
if not self._akshare_required():
|
||||
logger.warning("akshare is not installed; AShare company info is limited.")
|
||||
elif market == 'Crypto':
|
||||
name = f"{symbol} Cryptocurrency"
|
||||
|
||||
# Enrich description via web search (best-effort)
|
||||
# Query language should follow UI language when possible.
|
||||
if str(language).lower().startswith('zh'):
|
||||
search_query = f"{name} {symbol} 公司 简介" if market != 'Crypto' else f"{symbol} 加密 项目 介绍"
|
||||
else:
|
||||
search_query = f"{name} {symbol} company profile" if market != 'Crypto' else f"{symbol} crypto project info"
|
||||
search_results = self.search_service.search(search_query, num_results=1)
|
||||
description = ""
|
||||
if search_results:
|
||||
description = search_results[0].get('snippet', '')
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"ticker": symbol,
|
||||
"market": market,
|
||||
"description": description
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch company data {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _fetch_page_content(self, url: str) -> str:
|
||||
"""
|
||||
Fetch readable page content via Jina Reader.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
|
||||
Returns:
|
||||
Extracted content (markdown-ish), truncated
|
||||
"""
|
||||
try:
|
||||
jina_url = f"https://r.jina.ai/{url}"
|
||||
# Use a slightly longer timeout for content extraction
|
||||
response = requests.get(jina_url, timeout=15)
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
# Truncate to avoid huge prompts
|
||||
if len(content) > 3000:
|
||||
content = content[:3000] + "..."
|
||||
return content
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Jina Reader content fetch failed {url}: {e}")
|
||||
return ""
|
||||
|
||||
def get_news(self, market: str, symbol: str, days: int = 7, company_name: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get news items (Finnhub + search engine) and optionally enrich via Jina Reader.
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol/pair
|
||||
days: Lookback days
|
||||
company_name: Optional company/project name to improve search
|
||||
|
||||
Returns:
|
||||
List of news items
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
# 1) Finnhub news (if available)
|
||||
try:
|
||||
if self.finnhub_client:
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
raw_news = []
|
||||
|
||||
if market == 'USStock':
|
||||
raw_news = self.finnhub_client.company_news(symbol, _from=start_date, to=end_date)
|
||||
elif market == 'Crypto':
|
||||
crypto_symbol = symbol.split('/')[0] if '/' in symbol else symbol
|
||||
raw_news = self.finnhub_client.crypto_news(crypto_symbol)
|
||||
else:
|
||||
raw_news = self.finnhub_client.general_news('general', min_id=0)
|
||||
|
||||
if raw_news:
|
||||
for item in raw_news:
|
||||
if not item.get('headline') or not item.get('summary'):
|
||||
continue
|
||||
news_list.append({
|
||||
"id": str(item.get('id', '')),
|
||||
"datetime": datetime.fromtimestamp(item.get('datetime', 0)).strftime('%Y-%m-%d %H:%M'),
|
||||
"headline": item.get('headline', ''),
|
||||
"summary": item.get('summary', ''),
|
||||
"source": f"Finnhub ({item.get('source', '')})",
|
||||
"url": item.get('url', '')
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub news fetch failed: {e}")
|
||||
|
||||
# 2) Supplement with search engine results (useful for non-US markets or specific events)
|
||||
try:
|
||||
# Build search query (use company name to improve relevance)
|
||||
search_query = ""
|
||||
search_name = company_name if company_name else symbol
|
||||
|
||||
# Time restriction for Google CSE
|
||||
date_restrict = f"d{days}"
|
||||
|
||||
if market == 'AShare':
|
||||
# AShare CN keywords
|
||||
search_query = f'"{search_name}" {symbol} (利好 OR 利空 OR 财报 OR 公告 OR 业绩) after:{datetime.now().year-1}'
|
||||
elif market == 'HShare':
|
||||
search_query = f'"{search_name}" {symbol} (港股 OR 股价 OR 业绩) after:{datetime.now().year-1}'
|
||||
elif market == 'Crypto':
|
||||
search_query = f'"{search_name}" {symbol} crypto news analysis'
|
||||
else:
|
||||
search_query = f'"{search_name}" {symbol} stock news'
|
||||
|
||||
logger.info(f"Running news search: {search_query}")
|
||||
# Google CSE uses `dateRestrict` as a separate param; SearchService supports it.
|
||||
search_results = self.search_service.search(search_query, num_results=10, date_restrict=date_restrict)
|
||||
|
||||
for i, item in enumerate(search_results):
|
||||
# Default: use snippet as summary
|
||||
summary = f"{item.get('snippet', '')} (Source: {item.get('source', '')})"
|
||||
|
||||
# Jina Reader: deep-read only first 2 items to avoid slowdowns
|
||||
if i < 2 and item.get('link'):
|
||||
logger.info(f"Deep reading: {item.get('title')}")
|
||||
full_content = self._fetch_page_content(item.get('link'))
|
||||
if full_content:
|
||||
summary = f"Deep content:\n{full_content}\n(Source: {item.get('source', '')})"
|
||||
|
||||
news_list.append({
|
||||
"id": item.get('link', ''), # Use link as a stable id
|
||||
"datetime": item.get('published', datetime.now().strftime('%Y-%m-%d')), # Fallback to today if missing
|
||||
"headline": item.get('title', ''),
|
||||
"summary": summary,
|
||||
"source": f"Search ({item.get('source', '')})",
|
||||
"url": item.get('link', '')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Search news failed: {e}")
|
||||
|
||||
# Sort by time desc and keep latest items (best-effort; time formats may vary)
|
||||
news_list.sort(key=lambda x: x.get('datetime', ''), reverse=True)
|
||||
return news_list[:20]
|
||||
|
||||
def calculate_technical_indicators(self, kline_data: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate basic technical indicators from kline data.
|
||||
|
||||
Args:
|
||||
kline_data: List of OHLCV dicts
|
||||
|
||||
Returns:
|
||||
Indicators dict
|
||||
"""
|
||||
if not kline_data or len(kline_data) < 20:
|
||||
return {}
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(kline_data)
|
||||
df['close'] = pd.to_numeric(df['close'], errors='coerce')
|
||||
df['high'] = pd.to_numeric(df['high'], errors='coerce')
|
||||
df['low'] = pd.to_numeric(df['low'], errors='coerce')
|
||||
df['volume'] = pd.to_numeric(df['volume'], errors='coerce')
|
||||
|
||||
indicators = {}
|
||||
|
||||
# Moving averages
|
||||
if len(df) >= 20:
|
||||
indicators['MA20'] = round(df['close'].tail(20).mean(), 4)
|
||||
if len(df) >= 50:
|
||||
indicators['MA50'] = round(df['close'].tail(50).mean(), 4)
|
||||
|
||||
# RSI
|
||||
if len(df) >= 14:
|
||||
delta = df['close'].diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
|
||||
rs = gain / loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
indicators['RSI'] = round(rsi.iloc[-1], 2) if not rsi.empty else None
|
||||
|
||||
# MACD
|
||||
if len(df) >= 26:
|
||||
exp1 = df['close'].ewm(span=12, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=26, adjust=False).mean()
|
||||
macd = exp1 - exp2
|
||||
signal = macd.ewm(span=9, adjust=False).mean()
|
||||
indicators['MACD'] = round(macd.iloc[-1], 4) if not macd.empty else None
|
||||
indicators['MACD_Signal'] = round(signal.iloc[-1], 4) if not signal.empty else None
|
||||
indicators['MACD_Histogram'] = round((macd - signal).iloc[-1], 4) if not (macd - signal).empty else None
|
||||
|
||||
# Bollinger bands
|
||||
if len(df) >= 20:
|
||||
sma = df['close'].rolling(window=20).mean()
|
||||
std = df['close'].rolling(window=20).std()
|
||||
indicators['BB_Upper'] = round((sma + 2 * std).iloc[-1], 4) if not sma.empty else None
|
||||
indicators['BB_Middle'] = round(sma.iloc[-1], 4) if not sma.empty else None
|
||||
indicators['BB_Lower'] = round((sma - 2 * std).iloc[-1], 4) if not sma.empty else None
|
||||
|
||||
return indicators
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate technical indicators: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Trader agent.
|
||||
Synthesizes all analysis outputs and produces a final trading decision.
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from .base_agent import BaseAgent
|
||||
from app.services.llm import LLMService
|
||||
|
||||
logger = __import__('app.utils.logger', fromlist=['get_logger']).get_logger(__name__)
|
||||
|
||||
|
||||
class TraderAgent(BaseAgent):
|
||||
"""Trader agent."""
|
||||
|
||||
def __init__(self, memory=None):
|
||||
super().__init__("TraderAgent", memory)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def analyze(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Make a final trading decision."""
|
||||
market = context.get('market')
|
||||
symbol = context.get('symbol')
|
||||
language = context.get('language', 'zh-CN')
|
||||
model = context.get('model')
|
||||
|
||||
# Inputs
|
||||
market_report = context.get('market_report', {})
|
||||
fundamental_report = context.get('fundamental_report', {})
|
||||
news_report = context.get('news_report', {})
|
||||
sentiment_report = context.get('sentiment_report', {})
|
||||
risk_report = context.get('risk_report', {})
|
||||
|
||||
# Debate outputs
|
||||
bull_argument = context.get('bull_argument', {})
|
||||
bear_argument = context.get('bear_argument', {})
|
||||
research_decision = context.get('research_decision', '')
|
||||
|
||||
# Memory
|
||||
situation = f"{market}:{symbol} trading decision"
|
||||
memories = self.get_memories(situation, n_matches=2)
|
||||
memory_prompt = self.format_memories_for_prompt(memories)
|
||||
|
||||
lang_instruction = self._get_language_instruction(language)
|
||||
system_prompt = f"""You are a Trader, needing to make a final trading decision based on all analysis results.
|
||||
{lang_instruction}
|
||||
|
||||
Your tasks:
|
||||
1. Synthesize analysis results from all dimensions.
|
||||
2. Consider both bullish and bearish arguments.
|
||||
3. Make a clear trading decision: BUY, SELL, or HOLD.
|
||||
4. Provide a detailed trading plan.
|
||||
5. Learn from historical experience: {memory_prompt}
|
||||
6. **Confidence Score**: Evaluate your confidence in the decision (0-100). Be realistic. If the signals are mixed, confidence should be lower (e.g., 40-60). Only use high confidence (>80) for very clear strong signals. Do NOT default to 85.
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{{
|
||||
"decision": "BUY/SELL/HOLD",
|
||||
"confidence": 85,
|
||||
"reasoning": "Reason for decision...",
|
||||
"trading_plan": {{
|
||||
"entry_price": "Suggested entry price",
|
||||
"stop_loss": "Stop loss price",
|
||||
"take_profit": "Take profit price",
|
||||
"position_size": "Suggested position size"
|
||||
}},
|
||||
"report": "Detailed trading plan report..."
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Based on all the following analyses, make a trading decision for {symbol} in {market} market:
|
||||
|
||||
**Market Technical Analysis:**
|
||||
{json.dumps(market_report.get('data', {}), ensure_ascii=False, indent=2) if market_report else 'No Data'}
|
||||
|
||||
**Fundamental Analysis:**
|
||||
{json.dumps(fundamental_report.get('data', {}), ensure_ascii=False, indent=2) if fundamental_report else 'No Data'}
|
||||
|
||||
**News Analysis:**
|
||||
{json.dumps(news_report.get('data', {}), ensure_ascii=False, indent=2) if news_report else 'No Data'}
|
||||
|
||||
**Sentiment Analysis:**
|
||||
{json.dumps(sentiment_report.get('data', {}), ensure_ascii=False, indent=2) if sentiment_report else 'No Data'}
|
||||
|
||||
**Risk Analysis:**
|
||||
{json.dumps(risk_report.get('data', {}), ensure_ascii=False, indent=2) if risk_report else 'No Data'}
|
||||
|
||||
**Bullish Argument:**
|
||||
{json.dumps(bull_argument.get('data', {}), ensure_ascii=False, indent=2) if bull_argument else 'No Data'}
|
||||
|
||||
**Bearish Argument:**
|
||||
{json.dumps(bear_argument.get('data', {}), ensure_ascii=False, indent=2) if bear_argument else 'No Data'}
|
||||
|
||||
**Research Manager Decision:**
|
||||
{research_decision if research_decision else 'No Data'}
|
||||
|
||||
Please make a clear trading decision (BUY/SELL/HOLD) and provide a detailed trading plan."""
|
||||
|
||||
result = self.llm_service.safe_call_llm(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
{
|
||||
"decision": "HOLD",
|
||||
"confidence": 50,
|
||||
"reasoning": "",
|
||||
"trading_plan": {},
|
||||
"report": "Failed to parse trader decision"
|
||||
},
|
||||
model=model
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "trader",
|
||||
"data": result
|
||||
}
|
||||
|
||||
def _get_language_instruction(self, language: str) -> str:
|
||||
language_map = {
|
||||
'zh-CN': 'Answer in Simplified Chinese.',
|
||||
'zh-TW': 'Answer in Traditional Chinese.',
|
||||
'en-US': 'Answer in English.',
|
||||
'ja-JP': 'Answer in Japanese.',
|
||||
'ko-KR': 'Answer in Korean.',
|
||||
'vi-VN': 'Answer in Vietnamese.',
|
||||
'th-TH': 'Answer in Thai.',
|
||||
'ar-SA': 'Answer in Arabic.',
|
||||
'fr-FR': 'Answer in French.',
|
||||
'de-DE': 'Answer in German.'
|
||||
}
|
||||
return language_map.get(language, 'Answer in English.')
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Multi-dimensional analysis service.
|
||||
Uses OpenRouter via the internal LLMService and the multi-agent coordinator.
|
||||
Local-only: this project does not implement any paid/credit system itself.
|
||||
"""
|
||||
import json
|
||||
import traceback
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
"""Multi-dimensional analyzer powered by agent coordinator."""
|
||||
|
||||
# Class-level guard to avoid circular-init recursion
|
||||
_initializing = False
|
||||
|
||||
def __init__(self, use_multi_agent: bool = None):
|
||||
"""
|
||||
Args:
|
||||
use_multi_agent: Deprecated; kept for frontend compatibility
|
||||
"""
|
||||
# Avoid circular-init recursion
|
||||
if AnalysisService._initializing:
|
||||
logger.warning("AnalysisService is initializing; skipping duplicate initialization")
|
||||
self.coordinator = None
|
||||
return
|
||||
|
||||
self.coordinator = None
|
||||
|
||||
try:
|
||||
# Mark initializing
|
||||
AnalysisService._initializing = True
|
||||
|
||||
# Lazy import to avoid circular imports
|
||||
from app.services.agents.coordinator import AgentCoordinator
|
||||
self.coordinator = AgentCoordinator(
|
||||
enable_memory=True,
|
||||
max_debate_rounds=2
|
||||
)
|
||||
logger.info("Multi-agent coordinator initialized")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Coordinator init failed: {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
self.coordinator = None
|
||||
finally:
|
||||
AnalysisService._initializing = False
|
||||
|
||||
def analyze(self, market: str, symbol: str, language: str = 'en-US', model: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Args:
|
||||
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
|
||||
symbol: Symbol
|
||||
language: Output language tag (e.g. en-US, zh-CN, zh-TW)
|
||||
model: Optional OpenRouter model id
|
||||
Returns:
|
||||
Result dict
|
||||
"""
|
||||
logger.info(f"Starting analysis {market}:{symbol}, language={language}, mode=multi-agent")
|
||||
|
||||
# Default result structure (keeps frontend compatible even when coordinator fails).
|
||||
result = {
|
||||
"overview": {"report": "Initializing..."},
|
||||
"fundamental": {"report": "Initializing..."},
|
||||
"technical": {"report": "Initializing..."},
|
||||
"news": {"report": "Initializing..."},
|
||||
"sentiment": {"report": "Initializing..."},
|
||||
"risk": {"report": "Initializing..."},
|
||||
"error": None
|
||||
}
|
||||
|
||||
if not self.coordinator:
|
||||
result["error"] = "Analysis service is not ready (coordinator init failed)"
|
||||
return result
|
||||
|
||||
try:
|
||||
logger.info(f"Run coordinator: {market}:{symbol}")
|
||||
agent_result = self.coordinator.run_analysis(market, symbol, language, model=model)
|
||||
|
||||
logger.info(f"Coordinator result keys: {list(agent_result.keys())}")
|
||||
|
||||
# Validate expected keys (defensive)
|
||||
debate = agent_result.get("debate", {})
|
||||
trader_decision = agent_result.get("trader_decision", {})
|
||||
risk_debate = agent_result.get("risk_debate", {})
|
||||
final_decision = agent_result.get("final_decision", {})
|
||||
|
||||
# Keep frontend-compatible shape and fill defaults if empty
|
||||
if "debate" in agent_result and "trader_decision" in agent_result and "risk_debate" in agent_result and "final_decision" in agent_result:
|
||||
if not debate or (isinstance(debate, dict) and len(debate) == 0):
|
||||
logger.warning("debate is empty; using defaults")
|
||||
agent_result["debate"] = {"bull": {}, "bear": {}, "research_decision": "Analyzing..."}
|
||||
if not trader_decision or (isinstance(trader_decision, dict) and len(trader_decision) == 0):
|
||||
logger.warning("trader_decision is empty; using defaults")
|
||||
agent_result["trader_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}
|
||||
if not risk_debate or (isinstance(risk_debate, dict) and len(risk_debate) == 0):
|
||||
logger.warning("risk_debate is empty; using defaults")
|
||||
agent_result["risk_debate"] = {"risky": {}, "neutral": {}, "safe": {}}
|
||||
if not final_decision or (isinstance(final_decision, dict) and len(final_decision) == 0):
|
||||
logger.warning("final_decision is empty; using defaults")
|
||||
agent_result["final_decision"] = {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}
|
||||
|
||||
return agent_result
|
||||
else:
|
||||
logger.warning("Coordinator result format is incomplete; filling defaults")
|
||||
return {
|
||||
"overview": agent_result.get("overview", {"report": "Analyzing..."}),
|
||||
"fundamental": agent_result.get("fundamental", {"report": "Analyzing..."}),
|
||||
"technical": agent_result.get("technical", {"report": "Analyzing..."}),
|
||||
"news": agent_result.get("news", {"report": "Analyzing..."}),
|
||||
"sentiment": agent_result.get("sentiment", {"report": "Analyzing..."}),
|
||||
"risk": agent_result.get("risk", {"report": "Analyzing..."}),
|
||||
"debate": agent_result.get("debate", {"bull": {}, "bear": {}, "research_decision": "Analyzing..."}),
|
||||
"trader_decision": agent_result.get("trader_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}),
|
||||
"risk_debate": agent_result.get("risk_debate", {"risky": {}, "neutral": {}, "safe": {}}),
|
||||
"final_decision": agent_result.get("final_decision", {"decision": "HOLD", "confidence": 50, "reasoning": "Analyzing..."}),
|
||||
"error": agent_result.get("error")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Analysis failed {market}:{symbol} - {error_msg}")
|
||||
|
||||
# If OpenRouter returns 402, it's an upstream billing/credit issue (not a QuantDinger fee).
|
||||
if "402" in error_msg or "Payment Required" in error_msg:
|
||||
result["error"] = f"OpenRouter returned 402 (billing/credits). Please check your OpenRouter account. Details: {error_msg}"
|
||||
else:
|
||||
result["error"] = f"Analysis failed: {error_msg}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def multi_analysis(market: str, symbol: str, language: str = 'en-US', use_multi_agent: bool = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convenience entrypoint for multi-dimensional analysis.
|
||||
|
||||
Args:
|
||||
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
|
||||
symbol: Symbol
|
||||
language: Output language tag
|
||||
use_multi_agent: Deprecated; kept for compatibility
|
||||
"""
|
||||
analyzer = AnalysisService()
|
||||
return analyzer.analyze(market, symbol, language)
|
||||
|
||||
|
||||
def reflect_analysis(market: str, symbol: str, decision: str, returns: float = None, result: str = None):
|
||||
"""
|
||||
Reflection hook: learn from post-trade outcomes (local-only).
|
||||
|
||||
Args:
|
||||
market: Market
|
||||
symbol: Symbol
|
||||
decision: Decision (BUY/SELL/HOLD)
|
||||
returns: Return percentage
|
||||
result: Free-text outcome
|
||||
"""
|
||||
try:
|
||||
analyzer = AnalysisService()
|
||||
if analyzer.coordinator:
|
||||
analyzer.coordinator.reflect_and_learn(market, symbol, decision, returns, result)
|
||||
logger.info(f"Reflection completed: {market}:{symbol}")
|
||||
except Exception as e:
|
||||
logger.error(f"Reflection failed: {e}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Exchange execution helpers (local deployment).
|
||||
|
||||
This module provides helpers for resolving exchange configs and safe logging.
|
||||
|
||||
Notes:
|
||||
- In paper mode, the system only enqueues signals into `pending_orders`.
|
||||
- Real trading execution is intentionally not implemented here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _safe_json_loads(value: Any, default: Any) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return default
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return default
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def mask_secret(s: str, keep: int = 4) -> str:
|
||||
"""Return a masked representation of a secret for safe logs."""
|
||||
if not s:
|
||||
return ""
|
||||
s = str(s)
|
||||
if len(s) <= keep * 2:
|
||||
return s[: max(1, keep)] + "***"
|
||||
return f"{s[:keep]}...{s[-keep:]}"
|
||||
|
||||
|
||||
def safe_exchange_config_for_log(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
out = dict(cfg)
|
||||
for k in ["api_key", "secret_key", "passphrase", "apiKey", "secret", "password"]:
|
||||
if k in out and out.get(k):
|
||||
out[k] = mask_secret(str(out.get(k)))
|
||||
return out
|
||||
|
||||
|
||||
def load_strategy_configs(strategy_id: int) -> Dict[str, Any]:
|
||||
"""Load strategy config fields needed for live execution."""
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, exchange_config, trading_config, market_type, leverage, execution_mode
|
||||
FROM qd_strategies_trading
|
||||
WHERE id = %s
|
||||
""",
|
||||
(int(strategy_id),),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
|
||||
exchange_config = _safe_json_loads(row.get("exchange_config"), {})
|
||||
trading_config = _safe_json_loads(row.get("trading_config"), {})
|
||||
|
||||
market_type = (row.get("market_type") or exchange_config.get("market_type") or "swap").strip()
|
||||
leverage = float(row.get("leverage") or trading_config.get("leverage") or exchange_config.get("leverage") or 1.0)
|
||||
execution_mode = (row.get("execution_mode") or "signal").strip().lower()
|
||||
|
||||
return {
|
||||
"strategy_id": int(strategy_id),
|
||||
"exchange_config": exchange_config if isinstance(exchange_config, dict) else {},
|
||||
"trading_config": trading_config if isinstance(trading_config, dict) else {},
|
||||
"market_type": market_type,
|
||||
"leverage": leverage,
|
||||
"execution_mode": execution_mode,
|
||||
}
|
||||
|
||||
|
||||
def _load_credential_config(credential_id: int, user_id: int = 1) -> Dict[str, Any]:
|
||||
"""Load credential JSON from qd_exchange_credentials (plaintext in local mode)."""
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT encrypted_config
|
||||
FROM qd_exchange_credentials
|
||||
WHERE id = %s AND user_id = %s
|
||||
""",
|
||||
(int(credential_id), int(user_id)),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return _safe_json_loads(row.get("encrypted_config"), {}) or {}
|
||||
|
||||
|
||||
def resolve_exchange_config(exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve exchange config.
|
||||
|
||||
Supports:
|
||||
- direct inline config: {exchange_id, api_key, secret_key, passphrase?}
|
||||
- credential reference: {credential_id: 123, ...overrides}
|
||||
"""
|
||||
if not isinstance(exchange_config, dict):
|
||||
return {}
|
||||
|
||||
merged: Dict[str, Any] = {}
|
||||
credential_id = exchange_config.get("credential_id") or exchange_config.get("credentials_id")
|
||||
try:
|
||||
if credential_id:
|
||||
base = _load_credential_config(int(credential_id), user_id=user_id)
|
||||
if isinstance(base, dict):
|
||||
merged.update(base)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load credential_id={credential_id}: {e}")
|
||||
|
||||
# Overlay strategy-level settings (non-empty wins)
|
||||
for k, v in exchange_config.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, str) and not v.strip():
|
||||
continue
|
||||
merged[k] = v
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
K线数据服务
|
||||
"""
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from app.data_sources import DataSourceFactory
|
||||
from app.utils.cache import CacheManager
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import CacheConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KlineService:
|
||||
"""K线数据服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache = CacheManager()
|
||||
self.cache_ttl = CacheConfig.KLINE_CACHE_TTL
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
market: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int = 300,
|
||||
before_time: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取K线数据
|
||||
|
||||
Args:
|
||||
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
|
||||
symbol: 交易对/股票代码
|
||||
timeframe: 时间周期
|
||||
limit: 数据条数
|
||||
before_time: 获取此时间之前的数据
|
||||
|
||||
Returns:
|
||||
K线数据列表
|
||||
"""
|
||||
# 构建缓存键(历史数据不缓存)
|
||||
if not before_time:
|
||||
cache_key = f"kline:{market}:{symbol}:{timeframe}:{limit}"
|
||||
cached = self.cache.get(cache_key)
|
||||
if cached:
|
||||
# logger.info(f"命中缓存: {cache_key}")
|
||||
return cached
|
||||
|
||||
# 获取数据
|
||||
klines = DataSourceFactory.get_kline(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
limit=limit,
|
||||
before_time=before_time
|
||||
)
|
||||
|
||||
# 设置缓存(仅最新数据)
|
||||
if klines and not before_time:
|
||||
ttl = self.cache_ttl.get(timeframe, 300)
|
||||
self.cache.set(cache_key, klines, ttl)
|
||||
# logger.info(f"缓存设置: {cache_key}, TTL: {ttl}s")
|
||||
|
||||
return klines
|
||||
|
||||
def get_latest_price(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取最新价格"""
|
||||
klines = self.get_kline(market, symbol, '1m', 1)
|
||||
if klines:
|
||||
return klines[-1]
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Live trading (direct exchange REST) clients.
|
||||
|
||||
This package intentionally does NOT use ccxt.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Base REST client helpers for direct exchange connections.
|
||||
|
||||
Notes:
|
||||
- Keep this minimal and dependency-light (requests only).
|
||||
- All secrets must be excluded from logs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveOrderResult:
|
||||
exchange_id: str
|
||||
exchange_order_id: str
|
||||
filled: float
|
||||
avg_price: float
|
||||
raw: Dict[str, Any]
|
||||
|
||||
|
||||
class LiveTradingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BaseRestClient:
|
||||
def __init__(self, base_url: str, timeout_sec: float = 15.0):
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.timeout_sec = float(timeout_sec)
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
p = str(path or "")
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return f"{self.base_url}{p}"
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
data: Optional[Any] = None,
|
||||
) -> Tuple[int, Dict[str, Any], str]:
|
||||
url = self._url(path)
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
text = resp.text or ""
|
||||
parsed: Dict[str, Any] = {}
|
||||
try:
|
||||
parsed = resp.json() if text else {}
|
||||
except Exception:
|
||||
parsed = {"raw_text": text[:2000]}
|
||||
return int(resp.status_code), parsed, text
|
||||
|
||||
@staticmethod
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
@staticmethod
|
||||
def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""
|
||||
Binance USDT-M Futures (direct REST) client.
|
||||
|
||||
API docs (reference):
|
||||
- Signed endpoints use HMAC SHA256 over query string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_binance_futures_symbol
|
||||
|
||||
|
||||
class BinanceFuturesClient(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://fapi.binance.com", timeout_sec: float = 15.0):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Binance api_key/secret_key")
|
||||
|
||||
# Best-effort cache for public symbol filters used to normalize quantities.
|
||||
# Key: symbol -> (fetched_at_ts, filters_dict)
|
||||
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_filter_cache_ttl_sec = 300.0
|
||||
|
||||
# Best-effort cache for account position mode (Hedge vs One-way).
|
||||
# Binance endpoint: GET /fapi/v1/positionSide/dual -> {"dualSidePosition": true/false}
|
||||
self._dual_side_cache: Optional[Tuple[float, bool]] = None
|
||||
self._dual_side_cache_ttl_sec = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
def _sign(self, query_string: str) -> str:
|
||||
sig = hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return sig
|
||||
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
# Use server-accepted timestamp in ms.
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"Binance error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"Binance error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_mark_price(self, *, symbol: str) -> float:
|
||||
"""
|
||||
Best-effort mark price for MIN_NOTIONAL validation.
|
||||
|
||||
Endpoint: GET /fapi/v1/premiumIndex?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return 0.0
|
||||
try:
|
||||
data = self._public_request("GET", "/fapi/v1/premiumIndex", params={"symbol": sym})
|
||||
except Exception:
|
||||
return 0.0
|
||||
try:
|
||||
return float(data.get("markPrice") or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get futures symbol filters from exchangeInfo (best-effort).
|
||||
|
||||
Endpoint: GET /fapi/v1/exchangeInfo?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
now = time.time()
|
||||
cached = self._sym_filter_cache.get(sym)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/fapi/v1/exchangeInfo", params={"symbol": sym})
|
||||
symbols = raw.get("symbols") if isinstance(raw, dict) else None
|
||||
# Important: Binance may still return the full symbols list even when `symbol=...` is provided.
|
||||
# Never assume `symbols[0]` matches the requested symbol.
|
||||
first: Dict[str, Any] = {}
|
||||
if isinstance(symbols, list) and symbols:
|
||||
picked = None
|
||||
try:
|
||||
picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None)
|
||||
except Exception:
|
||||
picked = None
|
||||
first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {})
|
||||
filters = first.get("filters") if isinstance(first, dict) else None
|
||||
fdict: Dict[str, Any] = {}
|
||||
if isinstance(filters, list):
|
||||
for f in filters:
|
||||
if isinstance(f, dict) and f.get("filterType"):
|
||||
fdict[str(f.get("filterType"))] = f
|
||||
# Also keep precision metadata when available (used to avoid -1111).
|
||||
try:
|
||||
qty_prec = first.get("quantityPrecision") if isinstance(first, dict) else None
|
||||
price_prec = first.get("pricePrecision") if isinstance(first, dict) else None
|
||||
meta = {
|
||||
"symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "",
|
||||
"contractType": str(first.get("contractType") or "") if isinstance(first, dict) else "",
|
||||
"quantityPrecision": int(qty_prec) if qty_prec is not None else None,
|
||||
"pricePrecision": int(price_prec) if price_prec is not None else None,
|
||||
}
|
||||
fdict["_meta"] = meta
|
||||
except Exception:
|
||||
pass
|
||||
if fdict:
|
||||
self._sym_filter_cache[sym] = (now, fdict)
|
||||
return fdict
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal:
|
||||
try:
|
||||
if precision is None:
|
||||
return value
|
||||
p = int(precision)
|
||||
except Exception:
|
||||
return value
|
||||
if p < 0 or p > 18:
|
||||
return value
|
||||
try:
|
||||
q = Decimal("1").scaleb(-p) # 1e-precision
|
||||
return value.quantize(q, rounding=ROUND_DOWN)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def _normalize_price(self, *, symbol: str, price: float) -> Decimal:
|
||||
"""
|
||||
Normalize futures limit price using PRICE_FILTER tickSize (best-effort).
|
||||
|
||||
Binance rejects prices/quantities whose precision exceeds allowed decimals (-1111),
|
||||
so we must quantize to tickSize and send as string.
|
||||
"""
|
||||
px = self._to_dec(price)
|
||||
if px <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
filt = fdict.get("PRICE_FILTER") or {}
|
||||
tick = self._to_dec((filt or {}).get("tickSize") or "0")
|
||||
min_px = self._to_dec((filt or {}).get("minPrice") or "0")
|
||||
|
||||
if tick > 0:
|
||||
px = self._floor_to_step(px, tick)
|
||||
# Enforce price precision cap (some symbols reject more decimals even if tick looks permissive).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_px > 0 and px < min_px:
|
||||
return Decimal("0")
|
||||
return px
|
||||
|
||||
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal:
|
||||
"""
|
||||
Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
|
||||
"""
|
||||
q = self._to_dec(quantity)
|
||||
if q <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE"
|
||||
filt = fdict.get(key) or fdict.get("LOT_SIZE") or {}
|
||||
|
||||
step = self._to_dec((filt or {}).get("stepSize") or "0")
|
||||
min_qty = self._to_dec((filt or {}).get("minQty") or "0")
|
||||
|
||||
if step > 0:
|
||||
q = self._floor_to_step(q, step)
|
||||
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_qty > 0 and q < min_qty:
|
||||
return Decimal("0")
|
||||
return q
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/fapi/v1/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def get_account(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Private endpoint to validate credentials.
|
||||
"""
|
||||
return self._signed_request("GET", "/fapi/v2/account", params={})
|
||||
|
||||
def get_dual_side_position(self) -> Optional[bool]:
|
||||
"""
|
||||
Best-effort read of position mode:
|
||||
- True => Hedge Mode (dual-side position enabled): orders must specify positionSide=LONG/SHORT
|
||||
- False => One-way Mode: orders should NOT specify LONG/SHORT
|
||||
|
||||
Endpoint: GET /fapi/v1/positionSide/dual
|
||||
"""
|
||||
now = time.time()
|
||||
cached = self._dual_side_cache
|
||||
if cached:
|
||||
ts, val = cached
|
||||
if (now - float(ts or 0.0)) <= float(self._dual_side_cache_ttl_sec or 60.0):
|
||||
return bool(val)
|
||||
try:
|
||||
data = self._signed_request("GET", "/fapi/v1/positionSide/dual", params={})
|
||||
v = data.get("dualSidePosition") if isinstance(data, dict) else None
|
||||
if v is None:
|
||||
return None
|
||||
val = bool(v)
|
||||
self._dual_side_cache = (now, val)
|
||||
return val
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_err_code(err: Exception, code: int) -> bool:
|
||||
try:
|
||||
s = str(err or "")
|
||||
except Exception:
|
||||
s = ""
|
||||
return f'\"code\":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s
|
||||
|
||||
@staticmethod
|
||||
def _normalize_position_side(pos_side: Optional[str]) -> str:
|
||||
p = (pos_side or "").strip().lower()
|
||||
if p in ("long", "l"):
|
||||
return "LONG"
|
||||
if p in ("short", "s"):
|
||||
return "SHORT"
|
||||
if p in ("both", "net"):
|
||||
return "BOTH"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _infer_position_side(*, side: str, reduce_only: bool) -> str:
|
||||
sd = (side or "").strip().upper()
|
||||
ro = bool(reduce_only)
|
||||
# Open:
|
||||
# - BUY => LONG
|
||||
# - SELL => SHORT
|
||||
# Reduce/Close:
|
||||
# - SELL reduceOnly => close LONG
|
||||
# - BUY reduceOnly => close SHORT
|
||||
if ro:
|
||||
return "LONG" if sd == "SELL" else "SHORT"
|
||||
return "LONG" if sd == "BUY" else "SHORT"
|
||||
|
||||
def get_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Query order status/details.
|
||||
|
||||
Endpoint: GET /fapi/v1/order
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("Binance get_order requires order_id or client_order_id")
|
||||
return self._signed_request("GET", "/fapi/v1/order", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
max_wait_sec: float = 3.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll order detail to obtain (best-effort) executed quantity and average price.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filled": float,
|
||||
"avg_price": float,
|
||||
"status": str,
|
||||
"order": {...}
|
||||
}
|
||||
"""
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last: Dict[str, Any] = {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
|
||||
except Exception:
|
||||
last = last or {}
|
||||
|
||||
status = str(last.get("status") or "")
|
||||
try:
|
||||
filled = float(last.get("executedQty") or 0.0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
|
||||
# Futures order endpoint usually provides avgPrice; fall back to price/cumQuote.
|
||||
avg_price = 0.0
|
||||
try:
|
||||
if last.get("avgPrice") is not None and str(last.get("avgPrice")).strip() != "":
|
||||
avg_price = float(last.get("avgPrice") or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
if avg_price <= 0 and filled > 0:
|
||||
try:
|
||||
cum_quote = float(last.get("cumQuote") or 0.0)
|
||||
if cum_quote > 0:
|
||||
avg_price = cum_quote / filled
|
||||
except Exception:
|
||||
pass
|
||||
if avg_price <= 0:
|
||||
try:
|
||||
avg_price = float(last.get("price") or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
|
||||
if filled > 0 and avg_price > 0:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
|
||||
if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"):
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
reduce_only: bool = False,
|
||||
position_side: Optional[str] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
|
||||
# Best-effort MIN_NOTIONAL validation (common reason for "open still fails" with small qty).
|
||||
# Use markPrice as an approximation for MARKET order notional.
|
||||
min_notional = Decimal("0")
|
||||
mark_price = 0.0
|
||||
notional = Decimal("0")
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
mn = (fdict.get("MIN_NOTIONAL") or {}).get("notional")
|
||||
min_notional = self._to_dec(mn or "0")
|
||||
if min_notional > 0:
|
||||
mark_price = float(self.get_mark_price(symbol=symbol) or 0.0)
|
||||
if mark_price > 0:
|
||||
notional = q_dec * self._to_dec(mark_price)
|
||||
if notional < min_notional:
|
||||
raise LiveTradingError(
|
||||
"Order notional is below MIN_NOTIONAL. "
|
||||
f"symbol={sym} side={sd} qty={self._dec_str(q_dec)} "
|
||||
f"markPrice={mark_price} notional={self._dec_str(notional)} "
|
||||
f"minNotional={self._dec_str(min_notional)}"
|
||||
)
|
||||
except LiveTradingError:
|
||||
raise
|
||||
except Exception:
|
||||
# Never block order placement due to a best-effort validation failure.
|
||||
pass
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "MARKET",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
|
||||
# Hedge mode requires explicit positionSide (LONG/SHORT). One-way mode should not use LONG/SHORT.
|
||||
dual_side = self.get_dual_side_position()
|
||||
pos_norm = self._normalize_position_side(position_side)
|
||||
if dual_side is True:
|
||||
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
elif dual_side is False:
|
||||
# Keep default (BOTH) by omitting positionSide.
|
||||
params.pop("positionSide", None)
|
||||
else:
|
||||
# Unknown mode: try without positionSide first; we may retry on -4061.
|
||||
params.pop("positionSide", None)
|
||||
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
# Retry once if position mode mismatch (-4061).
|
||||
if self._is_err_code(e, -4061):
|
||||
params2 = dict(params)
|
||||
if params2.get("positionSide"):
|
||||
# Likely one-way mode but we sent LONG/SHORT
|
||||
params2.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
# Cache for future calls.
|
||||
self._dual_side_cache = (time.time(), False)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Likely hedge mode; retry with inferred positionSide.
|
||||
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), True)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attach normalized params for easier debugging of precision issues (-1111).
|
||||
# Also attach best-effort public filters and minNotional diagnostics.
|
||||
step = "n/a"
|
||||
qty_prec = "n/a"
|
||||
min_not = "n/a"
|
||||
filt_symbol = "n/a"
|
||||
contract_type = "n/a"
|
||||
dual_mode = "n/a"
|
||||
pos_side_used = "n/a"
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
lot = fdict.get("MARKET_LOT_SIZE") or fdict.get("LOT_SIZE") or {}
|
||||
step = str(lot.get("stepSize") or "n/a")
|
||||
meta = fdict.get("_meta") or {}
|
||||
if isinstance(meta, dict) and meta.get("quantityPrecision") is not None:
|
||||
qty_prec = str(meta.get("quantityPrecision"))
|
||||
if isinstance(meta, dict) and meta.get("symbol"):
|
||||
filt_symbol = str(meta.get("symbol"))
|
||||
if isinstance(meta, dict) and meta.get("contractType"):
|
||||
contract_type = str(meta.get("contractType"))
|
||||
mn = fdict.get("MIN_NOTIONAL") or {}
|
||||
min_not = str(mn.get("notional") or "n/a")
|
||||
dm = self.get_dual_side_position()
|
||||
dual_mode = "true" if dm is True else ("false" if dm is False else "unknown")
|
||||
pos_side_used = str((params or {}).get("positionSide") or "n/a")
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} "
|
||||
f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} "
|
||||
f"dualSidePosition={dual_mode} positionSide={pos_side_used} "
|
||||
f"markPrice={mark_price} notional={self._dec_str(notional)}"
|
||||
)
|
||||
|
||||
# Best-effort parse fill info.
|
||||
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
|
||||
filled = float(raw.get("executedQty") or 0.0)
|
||||
avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0)
|
||||
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
reduce_only: bool = False,
|
||||
position_side: Optional[str] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid quantity/price")
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
px_dec = self._normalize_price(symbol=symbol, price=px)
|
||||
if float(px_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "GTC",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"price": self._dec_str(px_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
|
||||
dual_side = self.get_dual_side_position()
|
||||
pos_norm = self._normalize_position_side(position_side)
|
||||
if dual_side is True:
|
||||
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
elif dual_side is False:
|
||||
params.pop("positionSide", None)
|
||||
else:
|
||||
params.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
if self._is_err_code(e, -4061):
|
||||
params2 = dict(params)
|
||||
if params2.get("positionSide"):
|
||||
params2.pop("positionSide", None)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), False)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
|
||||
try:
|
||||
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
|
||||
self._dual_side_cache = (time.time(), True)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("avgPrice") or raw.get("price") or 0.0),
|
||||
raw=raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"price_req={px} price_norm={self._dec_str(px_dec)}"
|
||||
)
|
||||
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
|
||||
filled = float(raw.get("executedQty") or 0.0)
|
||||
avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0)
|
||||
return LiveOrderResult(exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw)
|
||||
|
||||
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
|
||||
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
"""
|
||||
Return all futures positions (position risk endpoint).
|
||||
|
||||
Endpoint: GET /fapi/v2/positionRisk
|
||||
"""
|
||||
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Binance Spot (direct REST) client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_binance_futures_symbol
|
||||
|
||||
|
||||
class BinanceSpotClient(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.binance.com", timeout_sec: float = 15.0):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Binance api_key/secret_key")
|
||||
|
||||
# Best-effort cache for public symbol filters used to normalize quantities.
|
||||
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_filter_cache_ttl_sec = 300.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
def _sign(self, query_string: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), query_string.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
def _signed_headers(self) -> Dict[str, str]:
|
||||
return {"X-MBX-APIKEY": self.api_key}
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
p = dict(params or {})
|
||||
p["timestamp"] = int(time.time() * 1000)
|
||||
qs = urlencode(p, doseq=True)
|
||||
p["signature"] = self._sign(qs)
|
||||
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"BinanceSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""
|
||||
Public connectivity check.
|
||||
|
||||
Endpoint: GET /api/v3/time
|
||||
"""
|
||||
code, data, _ = self._request("GET", "/api/v3/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
|
||||
raise LiveTradingError(f"BinanceSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_symbol_filters(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get spot symbol filters from exchangeInfo (best-effort).
|
||||
|
||||
Endpoint: GET /api/v3/exchangeInfo?symbol=...
|
||||
"""
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
now = time.time()
|
||||
cached = self._sym_filter_cache.get(sym)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._sym_filter_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/api/v3/exchangeInfo", params={"symbol": sym})
|
||||
symbols = raw.get("symbols") if isinstance(raw, dict) else None
|
||||
# Defensive: some gateways/proxies may strip query params; Binance may then return full list.
|
||||
first: Dict[str, Any] = {}
|
||||
if isinstance(symbols, list) and symbols:
|
||||
picked = None
|
||||
try:
|
||||
picked = next((s for s in symbols if isinstance(s, dict) and str(s.get("symbol") or "") == sym), None)
|
||||
except Exception:
|
||||
picked = None
|
||||
first = picked if isinstance(picked, dict) else (symbols[0] if isinstance(symbols[0], dict) else {})
|
||||
filters = first.get("filters") if isinstance(first, dict) else None
|
||||
fdict: Dict[str, Any] = {}
|
||||
if isinstance(filters, list):
|
||||
for f in filters:
|
||||
if isinstance(f, dict) and f.get("filterType"):
|
||||
fdict[str(f.get("filterType"))] = f
|
||||
# Also keep precision metadata when available (used to avoid -1111).
|
||||
try:
|
||||
qty_prec = first.get("baseAssetPrecision") if isinstance(first, dict) else None
|
||||
# For spot, price precision is typically quotePrecision/quoteAssetPrecision.
|
||||
price_prec = None
|
||||
if isinstance(first, dict):
|
||||
price_prec = first.get("quotePrecision")
|
||||
if price_prec is None:
|
||||
price_prec = first.get("quoteAssetPrecision")
|
||||
meta = {
|
||||
"symbol": str(first.get("symbol") or "") if isinstance(first, dict) else "",
|
||||
"quantityPrecision": int(qty_prec) if qty_prec is not None else None,
|
||||
"pricePrecision": int(price_prec) if price_prec is not None else None,
|
||||
}
|
||||
fdict["_meta"] = meta
|
||||
except Exception:
|
||||
pass
|
||||
if fdict:
|
||||
self._sym_filter_cache[sym] = (now, fdict)
|
||||
return fdict
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_precision(value: Decimal, precision: Optional[int]) -> Decimal:
|
||||
try:
|
||||
if precision is None:
|
||||
return value
|
||||
p = int(precision)
|
||||
except Exception:
|
||||
return value
|
||||
if p < 0 or p > 18:
|
||||
return value
|
||||
try:
|
||||
q = Decimal("1").scaleb(-p)
|
||||
return value.quantize(q, rounding=ROUND_DOWN)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def _normalize_price(self, *, symbol: str, price: float) -> Decimal:
|
||||
"""
|
||||
Normalize spot limit price using PRICE_FILTER tickSize (best-effort).
|
||||
"""
|
||||
px = self._to_dec(price)
|
||||
if px <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
filt = fdict.get("PRICE_FILTER") or {}
|
||||
tick = self._to_dec((filt or {}).get("tickSize") or "0")
|
||||
min_px = self._to_dec((filt or {}).get("minPrice") or "0")
|
||||
|
||||
if tick > 0:
|
||||
px = self._floor_to_step(px, tick)
|
||||
# Enforce price precision cap (some symbols reject more decimals even if tick looks permissive).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
px = self._floor_to_precision(px, (meta.get("pricePrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_px > 0 and px < min_px:
|
||||
return Decimal("0")
|
||||
return px
|
||||
|
||||
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal:
|
||||
"""
|
||||
Normalize spot order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
|
||||
"""
|
||||
q = self._to_dec(quantity)
|
||||
if q <= 0:
|
||||
return Decimal("0")
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
except Exception:
|
||||
fdict = {}
|
||||
|
||||
key = "MARKET_LOT_SIZE" if for_market else "LOT_SIZE"
|
||||
filt = fdict.get(key) or fdict.get("LOT_SIZE") or {}
|
||||
|
||||
step = self._to_dec((filt or {}).get("stepSize") or "0")
|
||||
min_qty = self._to_dec((filt or {}).get("minQty") or "0")
|
||||
|
||||
if step > 0:
|
||||
q = self._floor_to_step(q, step)
|
||||
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
|
||||
try:
|
||||
meta = fdict.get("_meta") or {}
|
||||
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
|
||||
except Exception:
|
||||
pass
|
||||
if min_qty > 0 and q < min_qty:
|
||||
return Decimal("0")
|
||||
return q
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid quantity/price")
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
px_dec = self._normalize_price(symbol=symbol, price=px)
|
||||
if float(px_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid price (bad tick/minPrice): requested={px}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "GTC",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"price": self._dec_str(px_dec),
|
||||
}
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/api/v3/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
|
||||
f"price_req={px} price_norm={self._dec_str(px_dec)}"
|
||||
)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
sd = (side or "").upper()
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(quantity or 0.0)
|
||||
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "MARKET",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
}
|
||||
if client_order_id:
|
||||
params["newClientOrderId"] = str(client_order_id)
|
||||
try:
|
||||
raw = self._signed_request("POST", "/api/v3/order", params=params)
|
||||
except LiveTradingError as e:
|
||||
raise LiveTradingError(
|
||||
f"{e} | debug: symbol={sym} side={sd} "
|
||||
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)}"
|
||||
)
|
||||
return LiveOrderResult(
|
||||
exchange_id="binance",
|
||||
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
|
||||
filled=float(raw.get("executedQty") or 0.0),
|
||||
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def get_account(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get spot account balances.
|
||||
|
||||
Endpoint: GET /api/v3/account
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v3/account", params={})
|
||||
|
||||
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("BinanceSpot cancel_order requires order_id or client_order_id")
|
||||
return self._signed_request("DELETE", "/api/v3/order", params=params)
|
||||
|
||||
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_binance_futures_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["origClientOrderId"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("BinanceSpot get_order requires order_id or client_order_id")
|
||||
return self._signed_request("GET", "/api/v3/order", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
max_wait_sec: float = 10.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last: Dict[str, Any] = {}
|
||||
while True:
|
||||
try:
|
||||
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
|
||||
except Exception:
|
||||
last = last or {}
|
||||
|
||||
status = str(last.get("status") or "")
|
||||
try:
|
||||
filled = float(last.get("executedQty") or 0.0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
avg_price = 0.0
|
||||
try:
|
||||
cum_quote = float(last.get("cummulativeQuoteQty") or 0.0)
|
||||
if filled > 0 and cum_quote > 0:
|
||||
avg_price = cum_quote / filled
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if filled > 0 and avg_price > 0:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
if status in ("FILLED", "CANCELED", "EXPIRED", "REJECTED"):
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
Bitget (direct REST) client for USDT-margined perpetual orders.
|
||||
|
||||
Signing (Bitget):
|
||||
- ACCESS-SIGN = base64(hmac_sha256(secret, timestamp + method + request_path + body))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_bitget_um_symbol
|
||||
|
||||
|
||||
class BitgetMixClient(BaseRestClient):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
secret_key: str,
|
||||
passphrase: str,
|
||||
base_url: str = "https://api.bitget.com",
|
||||
timeout_sec: float = 15.0,
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.passphrase = (passphrase or "").strip()
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
|
||||
|
||||
# Best-effort cache for public contract metadata used to normalize order sizes.
|
||||
# Key: f"{product_type}:{symbol}" -> (fetched_at_ts, contract_dict)
|
||||
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._contract_cache_ttl_sec = 300.0
|
||||
|
||||
# Best-effort cache for leverage settings to avoid spamming set-leverage on every tick.
|
||||
# Key: f"{product_type}:{symbol}:{margin_coin}:{margin_mode}:{hold_side}:{lever}" -> (fetched_at_ts, True)
|
||||
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
|
||||
self._lev_cache_ttl_sec = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_margin_mode(margin_mode: str) -> str:
|
||||
"""
|
||||
Normalize margin mode for Bitget mix orders.
|
||||
|
||||
Bitget expects:
|
||||
- crossed
|
||||
- isolated
|
||||
|
||||
Our system often uses:
|
||||
- cross
|
||||
- isolated
|
||||
"""
|
||||
m = str(margin_mode or "").strip().lower()
|
||||
if not m:
|
||||
return "crossed"
|
||||
if m in ("cross", "crossed"):
|
||||
return "crossed"
|
||||
if m in ("isolated", "iso"):
|
||||
return "isolated"
|
||||
return "crossed"
|
||||
|
||||
def _sign(self, ts_ms: str, method: str, path: str, body: str) -> str:
|
||||
prehash = f"{ts_ms}{method.upper()}{path}{body}"
|
||||
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.b64encode(mac).decode("utf-8")
|
||||
|
||||
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
|
||||
return {
|
||||
"ACCESS-KEY": self.api_key,
|
||||
"ACCESS-SIGN": sign,
|
||||
"ACCESS-TIMESTAMP": ts_ms,
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bitget signature is computed over (timestamp + method + request_path + body).
|
||||
|
||||
- Use `data=<serialized_json>` to ensure the signed body matches the sent body.
|
||||
- For GET params, include query string into the signed request path.
|
||||
"""
|
||||
ts_ms = str(int(time.time() * 1000))
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
|
||||
qs = ""
|
||||
if params:
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
signed_path = f"{path}?{qs}" if qs else path
|
||||
|
||||
sign = self._sign(ts_ms, method, signed_path, body_str)
|
||||
code, data, text = self._request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
# Bitget uses code == "00000" for success in many endpoints.
|
||||
c = str(data.get("code") or "")
|
||||
if c and c not in ("00000", "0"):
|
||||
raise LiveTradingError(f"Bitget error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
c = str(data.get("code") or "")
|
||||
if c and c not in ("00000", "0"):
|
||||
raise LiveTradingError(f"Bitget error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch contract metadata (best-effort) from public endpoint.
|
||||
|
||||
Endpoint (Bitget v2 mix): GET /api/v2/mix/market/contracts
|
||||
Params: productType, symbol(optional)
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
pt = str(product_type or "USDT-FUTURES")
|
||||
if not sym:
|
||||
return {}
|
||||
key = f"{pt}:{sym}"
|
||||
now = time.time()
|
||||
cached = self._contract_cache.get(key)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/api/v2/mix/market/contracts", params={"productType": pt, "symbol": sym})
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
items = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
|
||||
first: Dict[str, Any] = items[0] if isinstance(items, list) and items else {}
|
||||
if isinstance(first, dict) and first:
|
||||
self._contract_cache[key] = (now, first)
|
||||
return first if isinstance(first, dict) else {}
|
||||
|
||||
def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) -> Decimal:
|
||||
"""
|
||||
Normalize Bitget mix order size.
|
||||
|
||||
This system computes `amount` as base-asset quantity (e.g. BTC amount).
|
||||
Bitget mix `size` is typically in contracts; convert using contractSize if available,
|
||||
then align to size step / min trade number (best-effort).
|
||||
"""
|
||||
req_base = self._to_dec(base_size)
|
||||
if req_base <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
contract: Dict[str, Any] = {}
|
||||
try:
|
||||
contract = self.get_contract(symbol=symbol, product_type=product_type) or {}
|
||||
except Exception:
|
||||
contract = {}
|
||||
|
||||
# Convert base qty -> contracts if contractSize is provided.
|
||||
ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0")
|
||||
qty = req_base
|
||||
if ct > 0:
|
||||
qty = req_base / ct
|
||||
|
||||
# Determine step size.
|
||||
step = self._to_dec(contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0")
|
||||
if step <= 0:
|
||||
sp = contract.get("sizePlace")
|
||||
try:
|
||||
places = int(sp) if sp is not None else 0
|
||||
except Exception:
|
||||
places = 0
|
||||
if places >= 0 and places <= 18:
|
||||
step = Decimal("1") / (Decimal("10") ** Decimal(str(places)))
|
||||
|
||||
if step > 0:
|
||||
qty = self._floor_to_step(qty, step)
|
||||
|
||||
# Enforce min trade number if present.
|
||||
mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0")
|
||||
if mn > 0 and qty < mn:
|
||||
return Decimal("0")
|
||||
return qty
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/api/v2/public/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def get_accounts(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
||||
"""
|
||||
Private endpoint to validate credentials (best-effort).
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
|
||||
|
||||
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
|
||||
"""
|
||||
Get all positions (best-effort).
|
||||
|
||||
Endpoint: GET /api/v2/mix/position/all-position
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
|
||||
|
||||
def set_leverage(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
leverage: float,
|
||||
margin_coin: str = "USDT",
|
||||
product_type: str = "USDT-FUTURES",
|
||||
margin_mode: str = "crossed",
|
||||
hold_side: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Best-effort set leverage for Bitget mix.
|
||||
|
||||
NOTE: Bitget requires leverage configured via a private endpoint; order placement may otherwise use defaults.
|
||||
Endpoint (v2 mix): POST /api/v2/mix/account/set-leverage (best-effort).
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
pt = str(product_type or "USDT-FUTURES")
|
||||
mc = str(margin_coin or "USDT")
|
||||
mm = self._normalize_margin_mode(margin_mode)
|
||||
hs = str(hold_side or "").strip().lower()
|
||||
try:
|
||||
lv = int(float(leverage or 0))
|
||||
except Exception:
|
||||
lv = 0
|
||||
if not sym or lv <= 0:
|
||||
return False
|
||||
|
||||
cache_key = f"{pt}:{sym}:{mc}:{mm}:{hs}:{lv}"
|
||||
now = time.time()
|
||||
cached = self._lev_cache.get(cache_key)
|
||||
if cached:
|
||||
ts, ok = cached
|
||||
if ok and (now - float(ts or 0.0)) <= float(self._lev_cache_ttl_sec or 60.0):
|
||||
return True
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"productType": pt,
|
||||
"marginCoin": mc,
|
||||
"marginMode": mm,
|
||||
"leverage": str(lv),
|
||||
}
|
||||
# Some Bitget accounts require holdSide for hedge mode; keep best-effort.
|
||||
if hs in ("long", "short"):
|
||||
body["holdSide"] = hs
|
||||
|
||||
try:
|
||||
resp = self._signed_request("POST", "/api/v2/mix/account/set-leverage", json_body=body)
|
||||
ok = isinstance(resp, dict) and str(resp.get("code") or "") in ("00000", "0", "")
|
||||
if ok:
|
||||
self._lev_cache[cache_key] = (now, True)
|
||||
return bool(ok)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
margin_coin: str = "USDT",
|
||||
product_type: str = "USDT-FUTURES",
|
||||
margin_mode: str = "crossed",
|
||||
reduce_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
req = float(size or 0.0)
|
||||
sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"marginCoin": str(margin_coin or "USDT"),
|
||||
"marginMode": self._normalize_margin_mode(margin_mode),
|
||||
"side": sd,
|
||||
"orderType": "market",
|
||||
"size": self._dec_str(sz_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "YES"
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
exchange_order_id = ""
|
||||
if isinstance(data, dict):
|
||||
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "")
|
||||
|
||||
return LiveOrderResult(
|
||||
exchange_id="bitget",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=0.0,
|
||||
avg_price=0.0,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
price: float,
|
||||
margin_coin: str = "USDT",
|
||||
product_type: str = "USDT-FUTURES",
|
||||
margin_mode: str = "crossed",
|
||||
reduce_only: bool = False,
|
||||
post_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
req = float(size or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid size/price")
|
||||
sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"marginCoin": str(margin_coin or "USDT"),
|
||||
"marginMode": self._normalize_margin_mode(margin_mode),
|
||||
"side": sd,
|
||||
"orderType": "limit",
|
||||
"price": str(px),
|
||||
"size": self._dec_str(sz_dec),
|
||||
}
|
||||
# Force maker behavior when requested (avoid taker fills).
|
||||
if post_only:
|
||||
body["force"] = "post_only"
|
||||
else:
|
||||
body["force"] = "gtc"
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "YES"
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
|
||||
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
def cancel_order(self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "") -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {
|
||||
"symbol": to_bitget_um_symbol(symbol),
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"marginCoin": str(margin_coin or "USDT"),
|
||||
}
|
||||
if order_id:
|
||||
body["orderId"] = str(order_id)
|
||||
elif client_oid:
|
||||
body["clientOid"] = str(client_oid)
|
||||
else:
|
||||
raise LiveTradingError("Bitget cancel_order requires order_id or client_oid")
|
||||
return self._signed_request("POST", "/api/v2/mix/order/cancel-order", json_body=body)
|
||||
|
||||
def get_order_detail(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
product_type: str,
|
||||
order_id: str = "",
|
||||
client_oid: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": to_bitget_um_symbol(symbol),
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_oid:
|
||||
params["clientOid"] = str(client_oid)
|
||||
else:
|
||||
raise LiveTradingError("Bitget get_order_detail requires order_id or client_oid")
|
||||
return self._signed_request("GET", "/api/v2/mix/order/detail", params=params)
|
||||
|
||||
def get_order_fills(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
product_type: str,
|
||||
order_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {
|
||||
"orderId": str(order_id),
|
||||
"productType": str(product_type or "USDT-FUTURES"),
|
||||
"symbol": to_bitget_um_symbol(symbol),
|
||||
}
|
||||
return self._signed_request("GET", "/api/v2/mix/order/fills", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
product_type: str = "USDT-FUTURES",
|
||||
order_id: str,
|
||||
client_oid: str = "",
|
||||
max_wait_sec: float = 3.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll order fills/detail to obtain (best-effort) executed size and average price.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filled": float,
|
||||
"avg_price": float,
|
||||
"fee": float,
|
||||
"fee_ccy": str,
|
||||
"state": str,
|
||||
"detail": {...},
|
||||
"fills": {...}
|
||||
}
|
||||
"""
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last_detail: Dict[str, Any] = {}
|
||||
last_fills: Dict[str, Any] = {}
|
||||
state = ""
|
||||
|
||||
# For robust parsing: contractSize helps converting contracts->base if needed.
|
||||
ct = Decimal("0")
|
||||
try:
|
||||
contract = self.get_contract(symbol=symbol, product_type=product_type) or {}
|
||||
ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0")
|
||||
except Exception:
|
||||
ct = Decimal("0")
|
||||
|
||||
while True:
|
||||
# Prefer fills endpoint to calculate accurate weighted average.
|
||||
try:
|
||||
last_fills = self.get_order_fills(symbol=symbol, product_type=product_type, order_id=str(order_id))
|
||||
data = last_fills.get("data") if isinstance(last_fills, dict) else None
|
||||
fill_list = []
|
||||
if isinstance(data, dict):
|
||||
fill_list = data.get("fillList") or []
|
||||
total_base = Decimal("0")
|
||||
total_quote = Decimal("0")
|
||||
total_fee = Decimal("0")
|
||||
fee_ccy = ""
|
||||
if isinstance(fill_list, list):
|
||||
for f in fill_list:
|
||||
try:
|
||||
# Bitget fills may provide either baseVolume or size.
|
||||
# Our system standardizes on base-asset quantity.
|
||||
sz_base = self._to_dec(f.get("baseVolume") or "0")
|
||||
if sz_base <= 0:
|
||||
sz_contracts = self._to_dec(f.get("size") or f.get("fillSize") or "0")
|
||||
if sz_contracts > 0 and ct > 0:
|
||||
sz_base = sz_contracts * ct
|
||||
px = self._to_dec(f.get("fillPrice") or f.get("price") or "0")
|
||||
|
||||
fee_v = f.get("fee")
|
||||
if fee_v is None:
|
||||
fee_v = f.get("fillFee")
|
||||
fee = self._to_dec(fee_v or "0")
|
||||
ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or "").strip()
|
||||
|
||||
if sz_base > 0 and px > 0:
|
||||
total_base += sz_base
|
||||
total_quote += sz_base * px
|
||||
if fee != 0:
|
||||
# Fees may be negative; store absolute cost.
|
||||
total_fee += abs(fee)
|
||||
if (not fee_ccy) and ccy:
|
||||
fee_ccy = ccy
|
||||
except Exception:
|
||||
continue
|
||||
if total_base > 0 and total_quote > 0:
|
||||
return {
|
||||
"filled": float(total_base),
|
||||
"avg_price": float(total_quote / total_base),
|
||||
"fee": float(total_fee),
|
||||
"fee_ccy": str(fee_ccy or ""),
|
||||
"state": state,
|
||||
"detail": last_detail,
|
||||
"fills": last_fills,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to order detail (state + sometimes avg/filled fields).
|
||||
try:
|
||||
last_detail = self.get_order_detail(
|
||||
symbol=symbol,
|
||||
product_type=product_type,
|
||||
order_id=str(order_id or ""),
|
||||
client_oid=str(client_oid or ""),
|
||||
)
|
||||
d = last_detail.get("data") if isinstance(last_detail, dict) else None
|
||||
if isinstance(d, dict):
|
||||
state = str(d.get("state") or d.get("status") or "")
|
||||
avg = float(d.get("priceAvg") or d.get("fillPrice") or 0.0) if (d.get("priceAvg") or d.get("fillPrice")) else 0.0
|
||||
filled = float(d.get("baseVolume") or d.get("filledQty") or 0.0) if (d.get("baseVolume") or d.get("filledQty")) else 0.0
|
||||
if filled > 0 and avg > 0:
|
||||
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
||||
if state in ("filled", "canceled", "cancelled"):
|
||||
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Bitget Spot (direct REST) client.
|
||||
|
||||
Endpoints are aligned with hummingbot constants:
|
||||
- POST /api/v2/spot/trade/place-order
|
||||
- POST /api/v2/spot/trade/cancel-order
|
||||
- GET /api/v2/spot/trade/orderInfo
|
||||
- GET /api/v2/spot/trade/fills
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_bitget_um_symbol
|
||||
|
||||
|
||||
class BitgetSpotClient(BaseRestClient):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
secret_key: str,
|
||||
passphrase: str,
|
||||
base_url: str = "https://api.bitget.com",
|
||||
timeout_sec: float = 15.0,
|
||||
channel_api_code: str = "bntva",
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.passphrase = (passphrase or "").strip()
|
||||
self.channel_api_code = (channel_api_code or "").strip()
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
|
||||
|
||||
# Best-effort cache for public symbol metadata used to normalize order sizes.
|
||||
# Key: symbol -> (fetched_at_ts, meta_dict)
|
||||
self._sym_meta_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._sym_meta_cache_ttl_sec = 300.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
def _sign(self, ts_ms: str, method: str, path: str, body: str) -> str:
|
||||
prehash = f"{ts_ms}{method.upper()}{path}{body}"
|
||||
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.b64encode(mac).decode("utf-8")
|
||||
|
||||
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
|
||||
h = {
|
||||
"ACCESS-KEY": self.api_key,
|
||||
"ACCESS-SIGN": sign,
|
||||
"ACCESS-TIMESTAMP": ts_ms,
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.channel_api_code:
|
||||
h["X-CHANNEL-API-CODE"] = self.channel_api_code
|
||||
return h
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bitget signature must match the exact body string sent over the wire.
|
||||
"""
|
||||
ts_ms = str(int(time.time() * 1000))
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
|
||||
qs = ""
|
||||
if params:
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
signed_path = f"{path}?{qs}" if qs else path
|
||||
|
||||
sign = self._sign(ts_ms, method, signed_path, body_str)
|
||||
code, data, text = self._request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
c = str(data.get("code") or "")
|
||||
if c and c not in ("00000", "0"):
|
||||
raise LiveTradingError(f"BitgetSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
c = str(data.get("code") or "")
|
||||
if c and c not in ("00000", "0"):
|
||||
raise LiveTradingError(f"BitgetSpot error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_symbol_meta(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch spot symbol metadata (best-effort).
|
||||
|
||||
Endpoint (Bitget v2 spot): GET /api/v2/spot/public/symbols
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
now = time.time()
|
||||
cached = self._sym_meta_cache.get(sym)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._sym_meta_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/api/v2/spot/public/symbols")
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
items = data if isinstance(data, list) else []
|
||||
found: Dict[str, Any] = {}
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
s = str(it.get("symbol") or it.get("symbolName") or "")
|
||||
if s and s.upper() == sym.upper():
|
||||
found = it
|
||||
break
|
||||
if found:
|
||||
self._sym_meta_cache[sym] = (now, found)
|
||||
return found
|
||||
|
||||
def _normalize_base_size(self, *, symbol: str, base_size: float) -> Decimal:
|
||||
"""
|
||||
Normalize spot base size to lot/step constraints (best-effort).
|
||||
"""
|
||||
req = self._to_dec(base_size)
|
||||
if req <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_symbol_meta(symbol=symbol) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
|
||||
# Try common fields. If unavailable, keep as-is.
|
||||
step = self._to_dec(meta.get("quantityScale") or meta.get("quantityStep") or meta.get("sizeStep") or meta.get("minTradeIncrement") or "0")
|
||||
if step <= 0:
|
||||
# Some endpoints expose decimals instead of step.
|
||||
qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace")
|
||||
try:
|
||||
places = int(qd) if qd is not None else 0
|
||||
except Exception:
|
||||
places = 0
|
||||
if places >= 0 and places <= 18:
|
||||
step = Decimal("1") / (Decimal("10") ** Decimal(str(places)))
|
||||
|
||||
if step > 0:
|
||||
req = self._floor_to_step(req, step)
|
||||
|
||||
mn = self._to_dec(meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0")
|
||||
if mn > 0 and req < mn:
|
||||
return Decimal("0")
|
||||
return req
|
||||
|
||||
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
req = float(size or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if req <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid size/price")
|
||||
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"side": sd,
|
||||
"symbol": sym,
|
||||
"size": self._dec_str(sz_dec),
|
||||
"orderType": "limit",
|
||||
"force": "gtc",
|
||||
"price": str(px),
|
||||
}
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
order_id = str(data.get("orderId") or "") if isinstance(data, dict) else ""
|
||||
return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
|
||||
"""
|
||||
NOTE: Bitget spot market BUY may expect quote amount. We accept `size` as base size,
|
||||
but the caller can also pass a quote-sized value if desired.
|
||||
"""
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
req = float(size or 0.0)
|
||||
if req <= 0:
|
||||
raise LiveTradingError("Invalid size")
|
||||
|
||||
# For Bitget spot market BUY, many APIs interpret size as quote amount.
|
||||
# Our worker may pass quote-sized value for BUY; do not quantize it as base size.
|
||||
if sd == "sell":
|
||||
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
|
||||
sz_str = self._dec_str(sz_dec)
|
||||
else:
|
||||
sz_str = str(req)
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"side": sd,
|
||||
"symbol": sym,
|
||||
"size": sz_str,
|
||||
"orderType": "market",
|
||||
"force": "gtc",
|
||||
}
|
||||
if client_order_id:
|
||||
body["clientOid"] = str(client_order_id)
|
||||
raw = self._signed_request("POST", "/api/v2/spot/trade/place-order", json_body=body)
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
order_id = str(data.get("orderId") or "") if isinstance(data, dict) else ""
|
||||
return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
def cancel_order(self, *, symbol: str, client_order_id: str) -> Dict[str, Any]:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
if not client_order_id:
|
||||
raise LiveTradingError("BitgetSpot cancel_order requires client_order_id")
|
||||
body = {"symbol": sym, "clientOid": str(client_order_id)}
|
||||
return self._signed_request("POST", "/api/v2/spot/trade/cancel-order", json_body=body)
|
||||
|
||||
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym}
|
||||
if order_id:
|
||||
params["orderId"] = str(order_id)
|
||||
elif client_order_id:
|
||||
params["clientOid"] = str(client_order_id)
|
||||
else:
|
||||
raise LiveTradingError("BitgetSpot get_order requires order_id or client_order_id")
|
||||
return self._signed_request("GET", "/api/v2/spot/trade/orderInfo", params=params)
|
||||
|
||||
def get_fills(self, *, symbol: str, order_id: str) -> Dict[str, Any]:
|
||||
sym = to_bitget_um_symbol(symbol)
|
||||
params: Dict[str, Any] = {"symbol": sym, "orderId": str(order_id)}
|
||||
return self._signed_request("GET", "/api/v2/spot/trade/fills", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
order_id: str,
|
||||
client_order_id: str = "",
|
||||
max_wait_sec: float = 10.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last_order: Dict[str, Any] = {}
|
||||
last_fills: Dict[str, Any] = {}
|
||||
state = ""
|
||||
|
||||
while True:
|
||||
# Prefer fills to compute weighted average if available.
|
||||
try:
|
||||
last_fills = self.get_fills(symbol=symbol, order_id=str(order_id))
|
||||
data = last_fills.get("data") if isinstance(last_fills, dict) else None
|
||||
fills = data if isinstance(data, list) else []
|
||||
total_base = 0.0
|
||||
total_quote = 0.0
|
||||
if isinstance(fills, list):
|
||||
for f in fills:
|
||||
try:
|
||||
sz = float(f.get("size") or 0.0)
|
||||
px = float(f.get("priceAvg") or f.get("price") or 0.0)
|
||||
if sz > 0 and px > 0:
|
||||
total_base += sz
|
||||
total_quote += sz * px
|
||||
except Exception:
|
||||
continue
|
||||
if total_base > 0 and total_quote > 0:
|
||||
return {"filled": total_base, "avg_price": total_quote / total_base, "state": state, "order": last_order, "fills": last_fills}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
last_order = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
|
||||
od = last_order.get("data") if isinstance(last_order, dict) else None
|
||||
if isinstance(od, dict):
|
||||
state = str(od.get("status") or od.get("state") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": 0.0, "avg_price": 0.0, "state": state, "order": last_order, "fills": last_fills}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def get_assets(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Spot assets/balances.
|
||||
|
||||
Endpoint: GET /api/v2/spot/account/assets
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v2/spot/account/assets")
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Translate a strategy signal into a direct-exchange order call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
from app.services.live_trading.bitget_spot import BitgetSpotClient
|
||||
|
||||
|
||||
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
||||
"""
|
||||
Returns (side, pos_side, reduce_only)
|
||||
- side: buy/sell
|
||||
- pos_side: long/short (for OKX)
|
||||
"""
|
||||
sig = (signal_type or "").strip().lower()
|
||||
if sig in ("open_long", "add_long"):
|
||||
return "buy", "long", False
|
||||
if sig in ("open_short", "add_short"):
|
||||
return "sell", "short", False
|
||||
if sig in ("close_long", "reduce_long"):
|
||||
return "sell", "long", True
|
||||
if sig in ("close_short", "reduce_short"):
|
||||
return "buy", "short", True
|
||||
raise LiveTradingError(f"Unsupported signal_type: {signal_type}")
|
||||
|
||||
|
||||
def place_order_from_signal(
|
||||
client: BaseRestClient,
|
||||
*,
|
||||
signal_type: str,
|
||||
symbol: str,
|
||||
amount: float,
|
||||
market_type: str = "swap",
|
||||
exchange_config: Optional[Dict[str, Any]] = None,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
if amount is None:
|
||||
amount = 0.0
|
||||
qty = float(amount or 0.0)
|
||||
if qty <= 0:
|
||||
raise LiveTradingError("Invalid amount")
|
||||
|
||||
side, pos_side, reduce_only = _signal_to_sides(signal_type)
|
||||
|
||||
cfg = exchange_config if isinstance(exchange_config, dict) else {}
|
||||
mt = (market_type or cfg.get("market_type") or "swap").strip().lower()
|
||||
if mt in ("futures", "future", "perp", "perpetual"):
|
||||
mt = "swap"
|
||||
|
||||
# Spot does not support short signals in this system.
|
||||
if mt == "spot" and ("short" in (signal_type or "").lower()):
|
||||
raise LiveTradingError("spot market does not support short signals")
|
||||
|
||||
if isinstance(client, BinanceFuturesClient):
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side="BUY" if side == "buy" else "SELL",
|
||||
quantity=qty,
|
||||
reduce_only=reduce_only,
|
||||
position_side=pos_side,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, OkxClient):
|
||||
td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross")
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
pos_side=pos_side,
|
||||
size=qty,
|
||||
td_mode=str(td_mode),
|
||||
reduce_only=reduce_only,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BitgetMixClient):
|
||||
margin_coin = str(cfg.get("margin_coin") or cfg.get("marginCoin") or "USDT")
|
||||
product_type = str(cfg.get("product_type") or cfg.get("productType") or "USDT-FUTURES")
|
||||
margin_mode = str(cfg.get("margin_mode") or cfg.get("marginMode") or cfg.get("td_mode") or "cross")
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=qty,
|
||||
margin_coin=margin_coin,
|
||||
product_type=product_type,
|
||||
margin_mode=margin_mode,
|
||||
reduce_only=reduce_only,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BinanceSpotClient):
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side="BUY" if side == "buy" else "SELL",
|
||||
quantity=qty,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, BitgetSpotClient):
|
||||
# For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed.
|
||||
return client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=qty,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
|
||||
raise LiveTradingError(f"Unsupported client type: {type(client)}")
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Factory for direct exchange clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveTradingError
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
from app.services.live_trading.bitget_spot import BitgetSpotClient
|
||||
|
||||
|
||||
def _get(cfg: Dict[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
v = cfg.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient:
|
||||
if not isinstance(exchange_config, dict):
|
||||
raise LiveTradingError("Invalid exchange_config")
|
||||
exchange_id = _get(exchange_config, "exchange_id", "exchangeId").lower()
|
||||
api_key = _get(exchange_config, "api_key", "apiKey")
|
||||
secret_key = _get(exchange_config, "secret_key", "secret")
|
||||
passphrase = _get(exchange_config, "passphrase", "password")
|
||||
|
||||
mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower()
|
||||
if mt in ("futures", "future", "perp", "perpetual"):
|
||||
mt = "swap"
|
||||
|
||||
if exchange_id == "binance":
|
||||
if mt == "spot":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.binance.com"
|
||||
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
# Default to USDT-M futures
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://fapi.binance.com"
|
||||
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
if exchange_id == "okx":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com"
|
||||
return OkxClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
if exchange_id == "bitget":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
|
||||
if mt == "spot":
|
||||
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "bntva"
|
||||
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
|
||||
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
|
||||
raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}")
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""
|
||||
OKX (direct REST) client for perpetual swap orders.
|
||||
|
||||
Signing:
|
||||
- OK-ACCESS-SIGN = base64(hmac_sha256(secret, timestamp + method + request_path + body))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
|
||||
|
||||
|
||||
class OkxClient(BaseRestClient):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
secret_key: str,
|
||||
passphrase: str,
|
||||
base_url: str = "https://www.okx.com",
|
||||
timeout_sec: float = 15.0,
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.passphrase = (passphrase or "").strip()
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing OKX api_key/secret_key/passphrase")
|
||||
|
||||
# Best-effort cache for public instrument metadata used to normalize order sizes.
|
||||
# Key: f"{inst_type}:{inst_id}" -> (fetched_at_ts, instrument_dict)
|
||||
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._inst_cache_ttl_sec = 300.0
|
||||
|
||||
# Best-effort cache for account config (position mode).
|
||||
# Key: "account_config" -> (fetched_at_ts, config_dict)
|
||||
self._acct_cfg_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._acct_cfg_cache_ttl_sec = 30.0
|
||||
|
||||
# Best-effort cache for leverage settings to avoid spamming set-leverage on every tick.
|
||||
# Key: f"{inst_id}:{mgn_mode}:{pos_side}:{lever}" -> (fetched_at_ts, True)
|
||||
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
|
||||
self._lev_cache_ttl_sec = 60.0
|
||||
|
||||
@staticmethod
|
||||
def _dec_str(d: Decimal) -> str:
|
||||
"""
|
||||
Convert Decimal to a non-scientific string (OKX expects plain decimal strings).
|
||||
"""
|
||||
try:
|
||||
return format(d, "f")
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(x))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step is None:
|
||||
return value
|
||||
try:
|
||||
st = Decimal(step)
|
||||
except Exception:
|
||||
st = Decimal("0")
|
||||
if st <= 0:
|
||||
return value
|
||||
if value <= 0:
|
||||
return Decimal("0")
|
||||
try:
|
||||
n = (value / st).to_integral_value(rounding=ROUND_DOWN)
|
||||
return n * st
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, json_body=None, headers=None, data=None)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
|
||||
raise LiveTradingError(f"OKX error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def get_instrument(self, *, inst_type: str, inst_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch OKX instrument metadata from public endpoint:
|
||||
GET /api/v5/public/instruments?instType=...&instId=...
|
||||
"""
|
||||
it = str(inst_type or "").strip().upper()
|
||||
iid = str(inst_id or "").strip()
|
||||
if not it or not iid:
|
||||
return {}
|
||||
|
||||
key = f"{it}:{iid}"
|
||||
now = time.time()
|
||||
cached = self._inst_cache.get(key)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._inst_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
|
||||
raw = self._public_request("GET", "/api/v5/public/instruments", params={"instType": it, "instId": iid})
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
if isinstance(first, dict) and first:
|
||||
self._inst_cache[key] = (now, first)
|
||||
return first if isinstance(first, dict) else {}
|
||||
|
||||
def _normalize_order_size(self, *, inst_id: str, market_type: str, size: float) -> Decimal:
|
||||
"""
|
||||
Normalize requested size to OKX constraints:
|
||||
- Spot: size is base currency quantity; align to lotSz/minSz.
|
||||
- Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz.
|
||||
|
||||
Note: this system passes `amount` around as base-asset quantity across exchanges.
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
iid = str(inst_id or "").strip()
|
||||
req = self._to_dec(size)
|
||||
if req <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
inst_type = "SPOT" if mt == "spot" else "SWAP"
|
||||
inst: Dict[str, Any] = {}
|
||||
if iid:
|
||||
try:
|
||||
inst = self.get_instrument(inst_type=inst_type, inst_id=iid) or {}
|
||||
except Exception:
|
||||
inst = {}
|
||||
|
||||
lot_sz = self._to_dec((inst or {}).get("lotSz") or "0")
|
||||
min_sz = self._to_dec((inst or {}).get("minSz") or "0")
|
||||
|
||||
# Convert base qty -> contracts for swaps if ctVal is provided.
|
||||
if mt != "spot":
|
||||
ct_val = self._to_dec((inst or {}).get("ctVal") or "0")
|
||||
if ct_val > 0:
|
||||
req = req / ct_val
|
||||
|
||||
# Align to lot size step.
|
||||
if lot_sz > 0:
|
||||
req = self._floor_to_step(req, lot_sz)
|
||||
|
||||
# Enforce min size best-effort.
|
||||
if min_sz > 0 and req < min_sz:
|
||||
return Decimal("0")
|
||||
return req
|
||||
|
||||
def _iso_ts(self) -> str:
|
||||
# OKX requires RFC3339 timestamp with milliseconds, e.g. 2020-12-08T09:08:57.715Z
|
||||
t = time.time()
|
||||
sec = int(t)
|
||||
ms = int((t - sec) * 1000)
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(sec)) + f".{ms:03d}Z"
|
||||
|
||||
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
|
||||
prehash = f"{ts}{method.upper()}{path}{body}"
|
||||
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.b64encode(mac).decode("utf-8")
|
||||
|
||||
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
|
||||
return {
|
||||
"OK-ACCESS-KEY": self.api_key,
|
||||
"OK-ACCESS-SIGN": sign,
|
||||
"OK-ACCESS-TIMESTAMP": ts,
|
||||
"OK-ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Important: the signature must be computed over the exact request body string that is sent.
|
||||
Therefore we use `data=<serialized_json>` instead of `json=<dict>` to avoid re-serialization differences.
|
||||
|
||||
For GET requests with params, the query string must be part of request_path in the prehash.
|
||||
"""
|
||||
ts = self._iso_ts()
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
|
||||
qs = ""
|
||||
if params:
|
||||
# OKX expects the query string in the signed request path. Keep key order stable.
|
||||
# Convert all values to string to avoid "True"/"False" surprises.
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
|
||||
signed_path = f"{path}?{qs}" if qs else path
|
||||
sign = self._sign(ts, method, signed_path, body_str)
|
||||
code, data, text = self._request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
|
||||
raise LiveTradingError(f"OKX error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/api/v5/public/time")
|
||||
return code == 200 and isinstance(data, dict)
|
||||
|
||||
def get_balance(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Private endpoint to validate credentials (best-effort).
|
||||
"""
|
||||
return self._signed_request("GET", "/api/v5/account/balance")
|
||||
|
||||
def get_positions(self, *, inst_id: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Get swap positions (best-effort).
|
||||
|
||||
Endpoint: GET /api/v5/account/positions
|
||||
"""
|
||||
params: Dict[str, Any] = {"instType": "SWAP"}
|
||||
if inst_id:
|
||||
params["instId"] = str(inst_id)
|
||||
return self._signed_request("GET", "/api/v5/account/positions", params=params)
|
||||
|
||||
def set_leverage(self, *, inst_id: str, lever: float, mgn_mode: str = "cross", pos_side: str = "") -> bool:
|
||||
"""
|
||||
Set leverage for an instrument (best-effort).
|
||||
|
||||
Endpoint: POST /api/v5/account/set-leverage
|
||||
Body:
|
||||
- instId
|
||||
- lever
|
||||
- mgnMode: cross / isolated
|
||||
- posSide: net / long / short (required depending on posMode)
|
||||
"""
|
||||
iid = str(inst_id or "").strip()
|
||||
if not iid:
|
||||
return False
|
||||
try:
|
||||
lv = int(float(lever or 0))
|
||||
except Exception:
|
||||
lv = 0
|
||||
if lv <= 0:
|
||||
lv = 1
|
||||
|
||||
mm = str(mgn_mode or "cross").strip().lower()
|
||||
if mm not in ("cross", "isolated"):
|
||||
mm = "cross"
|
||||
|
||||
ps = str(pos_side or "").strip().lower()
|
||||
# In net_mode, OKX requires posSide=net. In long_short_mode, requires long/short.
|
||||
# Caller should pass already resolved posSide; but keep a safe fallback.
|
||||
if ps not in ("net", "long", "short"):
|
||||
try:
|
||||
cfg = self.get_account_config() or {}
|
||||
pm = str(cfg.get("posMode") or "").strip().lower()
|
||||
ps = "net" if pm in ("net_mode", "net") else ""
|
||||
except Exception:
|
||||
ps = ""
|
||||
|
||||
cache_key = f"{iid}:{mm}:{ps}:{lv}"
|
||||
now = time.time()
|
||||
cached = self._lev_cache.get(cache_key)
|
||||
if cached:
|
||||
ts, ok = cached
|
||||
if ok and (now - float(ts or 0.0)) <= float(self._lev_cache_ttl_sec or 60.0):
|
||||
return True
|
||||
|
||||
body: Dict[str, Any] = {"instId": iid, "lever": str(lv), "mgnMode": mm}
|
||||
if ps:
|
||||
body["posSide"] = ps
|
||||
_ = self._signed_request("POST", "/api/v5/account/set-leverage", json_body=body)
|
||||
self._lev_cache[cache_key] = (now, True)
|
||||
return True
|
||||
|
||||
def get_account_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get account configuration (best-effort).
|
||||
|
||||
Endpoint: GET /api/v5/account/config
|
||||
Important field:
|
||||
- posMode: "net_mode" or "long_short_mode"
|
||||
"""
|
||||
key = "account_config"
|
||||
now = time.time()
|
||||
cached = self._acct_cfg_cache.get(key)
|
||||
if cached:
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._acct_cfg_cache_ttl_sec or 30.0):
|
||||
return obj
|
||||
|
||||
raw = self._signed_request("GET", "/api/v5/account/config")
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
if isinstance(first, dict) and first:
|
||||
self._acct_cfg_cache[key] = (now, first)
|
||||
return first if isinstance(first, dict) else {}
|
||||
|
||||
def _resolve_pos_side(self, *, requested_pos_side: str, market_type: str) -> str:
|
||||
"""
|
||||
OKX swap position mode compatibility:
|
||||
- long_short_mode: posSide must be "long" or "short"
|
||||
- net_mode: posSide must be "net" (and close orders should use reduceOnly=true)
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
if mt == "spot":
|
||||
return ""
|
||||
|
||||
ps = (requested_pos_side or "").strip().lower()
|
||||
# Default to long/short requested.
|
||||
pos_mode = ""
|
||||
try:
|
||||
cfg = self.get_account_config() or {}
|
||||
pos_mode = str(cfg.get("posMode") or "").strip().lower()
|
||||
except Exception:
|
||||
pos_mode = ""
|
||||
|
||||
if pos_mode in ("net_mode", "net"):
|
||||
return "net"
|
||||
if pos_mode in ("long_short_mode", "longshort_mode", "long_short", "longshort"):
|
||||
if ps not in ("long", "short"):
|
||||
raise LiveTradingError(f"Invalid posSide for long_short_mode: {requested_pos_side}")
|
||||
return ps
|
||||
|
||||
# Unknown mode: be permissive but keep existing validation.
|
||||
if ps not in ("long", "short"):
|
||||
raise LiveTradingError(f"Invalid posSide: {requested_pos_side}")
|
||||
return ps
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
market_type: str = "swap",
|
||||
pos_side: str = "",
|
||||
td_mode: str = "cross",
|
||||
reduce_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol)
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
sz_raw = float(size or 0.0)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
|
||||
if mt == "spot":
|
||||
body: Dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": sd,
|
||||
"ordType": "market",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
# Follow hummingbot approach so "sz" is in base currency.
|
||||
"tgtCcy": "base_ccy",
|
||||
}
|
||||
else:
|
||||
ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt)
|
||||
td = (td_mode or "cross").lower()
|
||||
if td not in ("cross", "isolated"):
|
||||
td = "cross"
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td,
|
||||
"side": sd,
|
||||
"posSide": ps,
|
||||
"ordType": "market",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "true"
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "")
|
||||
|
||||
# OKX place-order does not guarantee fill fields. Keep them best-effort.
|
||||
filled = 0.0
|
||||
avg_price = 0.0
|
||||
return LiveOrderResult(
|
||||
exchange_id="okx",
|
||||
exchange_order_id=exchange_order_id,
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
*,
|
||||
market_type: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
price: float,
|
||||
pos_side: str = "",
|
||||
td_mode: str = "cross",
|
||||
reduce_only: bool = False,
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
sd = (side or "").lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
sz_raw = float(size or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if sz_raw <= 0 or px <= 0:
|
||||
raise LiveTradingError("Invalid size/price")
|
||||
|
||||
if mt == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
body: Dict[str, Any] = {
|
||||
"instId": inst_id,
|
||||
"tdMode": "cash",
|
||||
"side": sd,
|
||||
"ordType": "limit",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
"px": str(px),
|
||||
}
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt)
|
||||
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
|
||||
if float(sz_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
|
||||
td = (td_mode or "cross").lower()
|
||||
if td not in ("cross", "isolated"):
|
||||
td = "cross"
|
||||
body = {
|
||||
"instId": inst_id,
|
||||
"tdMode": td,
|
||||
"side": sd,
|
||||
"posSide": ps,
|
||||
"ordType": "limit",
|
||||
"sz": self._dec_str(sz_dec),
|
||||
"px": str(px),
|
||||
}
|
||||
if reduce_only:
|
||||
body["reduceOnly"] = "true"
|
||||
|
||||
if client_order_id:
|
||||
body["clOrdId"] = str(client_order_id)
|
||||
|
||||
raw = self._signed_request("POST", "/api/v5/trade/order", json_body=body)
|
||||
data = (raw.get("data") or []) if isinstance(raw, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "")
|
||||
return LiveOrderResult(exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
|
||||
|
||||
def cancel_order(self, *, market_type: str, symbol: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]:
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
if mt == "spot":
|
||||
inst_id = to_okx_spot_inst_id(symbol)
|
||||
else:
|
||||
inst_id = to_okx_swap_inst_id(symbol)
|
||||
body: Dict[str, Any] = {"instId": inst_id}
|
||||
if ord_id:
|
||||
body["ordId"] = str(ord_id)
|
||||
elif cl_ord_id:
|
||||
body["clOrdId"] = str(cl_ord_id)
|
||||
else:
|
||||
raise LiveTradingError("OKX cancel_order requires ord_id or cl_ord_id")
|
||||
return self._signed_request("POST", "/api/v5/trade/cancel-order", json_body=body)
|
||||
|
||||
def get_order(self, *, inst_id: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {"instId": str(inst_id)}
|
||||
if ord_id:
|
||||
params["ordId"] = str(ord_id)
|
||||
elif cl_ord_id:
|
||||
params["clOrdId"] = str(cl_ord_id)
|
||||
else:
|
||||
raise LiveTradingError("OKX get_order requires ord_id or cl_ord_id")
|
||||
resp = self._signed_request("GET", "/api/v5/trade/order", params=params)
|
||||
data = (resp.get("data") or []) if isinstance(resp, dict) else []
|
||||
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
|
||||
return first
|
||||
|
||||
def get_order_fills(self, *, inst_id: str, ord_id: str, inst_type: str = "SWAP") -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {"instId": str(inst_id), "ordId": str(ord_id), "instType": str(inst_type)}
|
||||
return self._signed_request("GET", "/api/v5/trade/fills", params=params)
|
||||
|
||||
def wait_for_fill(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
ord_id: str,
|
||||
cl_ord_id: str = "",
|
||||
market_type: str = "swap",
|
||||
max_wait_sec: float = 3.0,
|
||||
poll_interval_sec: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Poll order detail / fills to obtain (best-effort) executed size and average price.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filled": float,
|
||||
"avg_price": float,
|
||||
"fee": float,
|
||||
"fee_ccy": str,
|
||||
"state": str,
|
||||
"order": {...},
|
||||
"fills": {...}
|
||||
}
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
inst_id = to_okx_spot_inst_id(symbol) if mt == "spot" else to_okx_swap_inst_id(symbol)
|
||||
# IMPORTANT: For OKX SWAP, fillSz/accFillSz are in "contracts" (张数), not base-asset quantity.
|
||||
# Our system standardizes on base-asset quantity everywhere ("币数"), so we convert using ctVal.
|
||||
ct_val = Decimal("0")
|
||||
if mt != "spot":
|
||||
try:
|
||||
inst = self.get_instrument(inst_type="SWAP", inst_id=inst_id) or {}
|
||||
ct_val = self._to_dec(inst.get("ctVal") or "0")
|
||||
except Exception:
|
||||
ct_val = Decimal("0")
|
||||
if ct_val <= 0:
|
||||
# Fallback: keep quantities unchanged if ctVal is unavailable (best-effort).
|
||||
ct_val = Decimal("1")
|
||||
end_ts = time.time() + float(max_wait_sec or 0.0)
|
||||
last_order: Dict[str, Any] = {}
|
||||
last_fills: Dict[str, Any] = {}
|
||||
|
||||
while True:
|
||||
try:
|
||||
last_order = self.get_order(inst_id=inst_id, ord_id=str(ord_id or ""), cl_ord_id=str(cl_ord_id or ""))
|
||||
except Exception:
|
||||
last_order = last_order or {}
|
||||
|
||||
state = str(last_order.get("state") or "")
|
||||
filled_str = str(last_order.get("accFillSz") or last_order.get("fillSz") or "0")
|
||||
avg_str = str(last_order.get("avgPx") or last_order.get("fillPx") or "0")
|
||||
try:
|
||||
filled_contracts = self._to_dec(filled_str or "0")
|
||||
except Exception:
|
||||
filled_contracts = Decimal("0")
|
||||
try:
|
||||
avg_price = float(avg_str or 0.0)
|
||||
except Exception:
|
||||
avg_price = 0.0
|
||||
|
||||
filled_base_dec = filled_contracts
|
||||
if mt != "spot":
|
||||
filled_base_dec = filled_contracts * ct_val
|
||||
try:
|
||||
filled = float(filled_base_dec or 0)
|
||||
except Exception:
|
||||
filled = 0.0
|
||||
|
||||
# Prefer fills endpoint for fee (and more reliable avg/filled aggregation).
|
||||
try:
|
||||
inst_type = "SPOT" if mt == "spot" else "SWAP"
|
||||
last_fills = self.get_order_fills(inst_id=inst_id, ord_id=str(ord_id), inst_type=inst_type)
|
||||
fills = (last_fills.get("data") or []) if isinstance(last_fills, dict) else []
|
||||
total_base = Decimal("0")
|
||||
total_quote = Decimal("0")
|
||||
total_fee = 0.0
|
||||
fee_ccy = ""
|
||||
got_any_fill = False
|
||||
if isinstance(fills, list):
|
||||
for f in fills:
|
||||
try:
|
||||
sz_contracts = self._to_dec(f.get("fillSz") or "0")
|
||||
px = self._to_dec(f.get("fillPx") or "0")
|
||||
fee_v = f.get("fee")
|
||||
if fee_v is None:
|
||||
fee_v = f.get("fillFee")
|
||||
try:
|
||||
fee = float(fee_v or 0.0)
|
||||
except Exception:
|
||||
fee = 0.0
|
||||
ccy = str(f.get("feeCcy") or f.get("fillFeeCcy") or "").strip()
|
||||
sz_base = sz_contracts
|
||||
if mt != "spot":
|
||||
sz_base = sz_contracts * ct_val
|
||||
if sz_base > 0 and px > 0:
|
||||
total_base += sz_base
|
||||
total_quote += sz_base * px
|
||||
got_any_fill = True
|
||||
if fee != 0.0:
|
||||
# OKX fees are often negative for costs; store absolute cost.
|
||||
total_fee += abs(float(fee))
|
||||
if (not fee_ccy) and ccy:
|
||||
fee_ccy = ccy
|
||||
except Exception:
|
||||
continue
|
||||
# If fills are present, they are the best source of fee/avg aggregation.
|
||||
# However, OKX may lag in exposing fills right after an order is filled.
|
||||
# To avoid losing commission, do not fall back early when we haven't seen any fills yet.
|
||||
if got_any_fill and total_base > 0 and total_quote > 0:
|
||||
return {
|
||||
"filled": float(total_base),
|
||||
"avg_price": float(total_quote / total_base),
|
||||
"fee": float(total_fee),
|
||||
"fee_ccy": str(fee_ccy or ""),
|
||||
"state": state,
|
||||
"order": last_order,
|
||||
"fills": last_fills,
|
||||
"filled_unit": "base",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: order detail may include avg/filled but fee is not available.
|
||||
# IMPORTANT: If the order is already filled but fills endpoint hasn't returned data yet,
|
||||
# keep polling until timeout to give fills a chance to show up (so we can record fees).
|
||||
if filled > 0 and avg_price > 0 and time.time() >= end_ts:
|
||||
return {
|
||||
"filled": filled,
|
||||
"avg_price": avg_price,
|
||||
"fee": 0.0,
|
||||
"fee_ccy": "",
|
||||
"state": state,
|
||||
"order": last_order,
|
||||
"fills": last_fills,
|
||||
"filled_unit": "base",
|
||||
}
|
||||
|
||||
# Terminal states: return whatever we have.
|
||||
if state in ("filled", "canceled", "cancelled"):
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
|
||||
|
||||
if time.time() >= end_ts:
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
DB helpers for recording live trades and maintaining local position snapshots.
|
||||
|
||||
Important:
|
||||
- This is a local DB snapshot, not the source of truth (exchange is).
|
||||
- We keep it best-effort to support UI display and strategy state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
|
||||
def record_trade(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
trade_type: str,
|
||||
price: float,
|
||||
amount: float,
|
||||
commission: float = 0.0,
|
||||
commission_ccy: str = "",
|
||||
profit: Optional[float] = None,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
value = float(amount or 0.0) * float(price or 0.0)
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_trades
|
||||
(strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
int(strategy_id),
|
||||
str(symbol),
|
||||
str(trade_type),
|
||||
float(price or 0.0),
|
||||
float(amount or 0.0),
|
||||
float(value),
|
||||
float(commission or 0.0),
|
||||
str(commission_ccy or ""),
|
||||
profit,
|
||||
now,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def _fetch_position(strategy_id: int, symbol: str, side: str) -> Dict[str, Any]:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT * FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s",
|
||||
(int(strategy_id), str(symbol), str(side)),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return row if isinstance(row, dict) else {}
|
||||
|
||||
|
||||
def _delete_position(strategy_id: int, symbol: str, side: str) -> None:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s",
|
||||
(int(strategy_id), str(symbol), str(side)),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def upsert_position(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
side: str,
|
||||
size: float,
|
||||
entry_price: float,
|
||||
current_price: float,
|
||||
highest_price: float = 0.0,
|
||||
lowest_price: float = 0.0,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_positions
|
||||
(strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
entry_price = excluded.entry_price,
|
||||
current_price = excluded.current_price,
|
||||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE highest_price END,
|
||||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE lowest_price END,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0), now),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def apply_fill_to_local_position(
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
filled: float,
|
||||
avg_price: float,
|
||||
) -> Tuple[Optional[float], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Apply a fill to the local position snapshot.
|
||||
|
||||
Returns (profit, updated_position_row_or_none)
|
||||
- profit is only calculated on close/reduce fills (best-effort, based on local entry_price).
|
||||
"""
|
||||
sig = (signal_type or "").strip().lower()
|
||||
filled_qty = float(filled or 0.0)
|
||||
px = float(avg_price or 0.0)
|
||||
if filled_qty <= 0 or px <= 0:
|
||||
return None, None
|
||||
|
||||
if "long" in sig:
|
||||
side = "long"
|
||||
elif "short" in sig:
|
||||
side = "short"
|
||||
else:
|
||||
return None, None
|
||||
|
||||
is_open = sig.startswith("open_") or sig.startswith("add_")
|
||||
is_close = sig.startswith("close_") or sig.startswith("reduce_")
|
||||
|
||||
current = _fetch_position(strategy_id, symbol, side)
|
||||
cur_size = float(current.get("size") or 0.0)
|
||||
cur_entry = float(current.get("entry_price") or 0.0)
|
||||
cur_high = float(current.get("highest_price") or 0.0)
|
||||
cur_low = float(current.get("lowest_price") or 0.0)
|
||||
|
||||
profit: Optional[float] = None
|
||||
|
||||
if is_open:
|
||||
new_size = cur_size + filled_qty
|
||||
if new_size <= 0:
|
||||
return None, None
|
||||
# Weighted average entry.
|
||||
if cur_size > 0 and cur_entry > 0:
|
||||
new_entry = (cur_size * cur_entry + filled_qty * px) / new_size
|
||||
else:
|
||||
new_entry = px
|
||||
new_high = max(cur_high or px, px)
|
||||
new_low = min(cur_low or px, px)
|
||||
upsert_position(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=new_size,
|
||||
entry_price=new_entry,
|
||||
current_price=px,
|
||||
highest_price=new_high,
|
||||
lowest_price=new_low,
|
||||
)
|
||||
return None, _fetch_position(strategy_id, symbol, side)
|
||||
|
||||
if is_close:
|
||||
# Calculate PnL using local entry price.
|
||||
if cur_size > 0 and cur_entry > 0:
|
||||
close_qty = min(cur_size, filled_qty)
|
||||
if side == "long":
|
||||
profit = (px - cur_entry) * close_qty
|
||||
else:
|
||||
profit = (cur_entry - px) * close_qty
|
||||
|
||||
new_size = cur_size - filled_qty
|
||||
if new_size <= 0:
|
||||
_delete_position(strategy_id, symbol, side)
|
||||
return profit, None
|
||||
# Keep entry price for remaining position.
|
||||
new_high = max(cur_high or px, px)
|
||||
new_low = min(cur_low or px, px)
|
||||
upsert_position(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
size=new_size,
|
||||
entry_price=cur_entry if cur_entry > 0 else px,
|
||||
current_price=px,
|
||||
highest_price=new_high,
|
||||
lowest_price=new_low,
|
||||
)
|
||||
return profit, _fetch_position(strategy_id, symbol, side)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Symbol normalization helpers.
|
||||
|
||||
Input symbols may come from UI/strategy config in ccxt-like shape:
|
||||
- "SOL/USDT:USDT"
|
||||
- "SOL/USDT"
|
||||
|
||||
We convert them into exchange-specific identifiers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def _split_base_quote(symbol: str) -> Tuple[str, str]:
|
||||
s = (symbol or "").strip()
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0]
|
||||
if "/" not in s:
|
||||
# Already exchange-specific (best-effort)
|
||||
return s, ""
|
||||
base, quote = s.split("/", 1)
|
||||
return base.strip().upper(), quote.strip().upper()
|
||||
|
||||
|
||||
def to_binance_futures_symbol(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not quote:
|
||||
return (symbol or "").replace("/", "").replace(":", "").upper()
|
||||
return f"{base}{quote}"
|
||||
|
||||
|
||||
def to_okx_swap_inst_id(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not base or not quote:
|
||||
return symbol
|
||||
# OKX perpetual swap instrument id: BASE-QUOTE-SWAP
|
||||
return f"{base}-{quote}-SWAP"
|
||||
|
||||
|
||||
def to_okx_spot_inst_id(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not base or not quote:
|
||||
return symbol
|
||||
return f"{base}-{quote}"
|
||||
|
||||
|
||||
def to_bitget_um_symbol(symbol: str) -> str:
|
||||
base, quote = _split_base_quote(symbol)
|
||||
if not quote:
|
||||
return (symbol or "").replace("/", "").replace(":", "").upper()
|
||||
return f"{base}{quote}"
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
LLM service.
|
||||
Wraps OpenRouter API calls and robust JSON parsing.
|
||||
Kept separate from AnalysisService to avoid circular imports.
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.config import APIKeys
|
||||
from app.utils.config_loader import load_addon_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM provider wrapper."""
|
||||
|
||||
def __init__(self):
|
||||
# Config may not be loaded yet during import time; we resolve lazily via properties.
|
||||
pass
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return APIKeys.OPENROUTER_API_KEY
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
config = load_addon_config()
|
||||
# Keep compatible with old/new config keys.
|
||||
import os
|
||||
return config.get('openrouter', {}).get('base_url') or os.getenv('OPENROUTER_BASE_URL', "https://openrouter.ai/api/v1")
|
||||
|
||||
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
||||
"""Call OpenRouter API, with optional fallback models."""
|
||||
config = load_addon_config()
|
||||
openrouter_config = config.get('openrouter', {})
|
||||
|
||||
default_model = openrouter_config.get('model', 'google/gemini-3-pro-preview')
|
||||
|
||||
if model is None:
|
||||
model = default_model
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://quantdinger.com",
|
||||
"X-Title": "QuantDinger Analysis"
|
||||
}
|
||||
|
||||
# Build model candidates (primary + optional fallbacks).
|
||||
models_to_try = [model]
|
||||
|
||||
# Fallback models are currently hard-coded for local mode.
|
||||
fallback_models = ["openai/gpt-4o-mini"]
|
||||
|
||||
if use_fallback and model == default_model:
|
||||
models_to_try.extend(fallback_models)
|
||||
|
||||
last_error = None
|
||||
|
||||
timeout = int(openrouter_config.get('timeout', 120))
|
||||
|
||||
for current_model in models_to_try:
|
||||
try:
|
||||
data = {
|
||||
"model": current_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"response_format": {"type": "json_object"}
|
||||
}
|
||||
# logger.debug(f"Trying model: {current_model}")
|
||||
|
||||
response = requests.post(url, headers=headers, json=data, timeout=timeout)
|
||||
|
||||
if response.status_code == 402:
|
||||
logger.warning(f"OpenRouter returned 402 for model {current_model}; trying fallback model...")
|
||||
last_error = f"402 Payment Required for model {current_model}"
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
if not content:
|
||||
raise ValueError(f"Model {current_model} returned empty content")
|
||||
|
||||
if current_model != model:
|
||||
logger.info(f"Fallback model succeeded: {current_model}")
|
||||
return content
|
||||
else:
|
||||
logger.error(f"OpenRouter API returned unexpected structure ({current_model}): {json.dumps(result)}")
|
||||
raise ValueError("OpenRouter API response is missing 'choices'")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"OpenRouter API HTTP error ({current_model}): {e.response.text if e.response else str(e)}")
|
||||
last_error = str(e)
|
||||
if not use_fallback or current_model == models_to_try[-1]:
|
||||
raise
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"OpenRouter API request error ({current_model}): {str(e)}")
|
||||
last_error = str(e)
|
||||
if not use_fallback or current_model == models_to_try[-1]:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.warning(f"Model {current_model} returned invalid data: {str(e)}")
|
||||
last_error = str(e)
|
||||
# If this is not the last candidate model, try the next one
|
||||
if current_model == models_to_try[-1]:
|
||||
raise
|
||||
|
||||
error_msg = f"All model calls failed. Last error: {last_error}"
|
||||
logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any], model: str = None) -> Dict[str, Any]:
|
||||
"""Safe LLM call with robust JSON parsing and fallback structure."""
|
||||
response_text = ""
|
||||
try:
|
||||
response_text = self.call_openrouter_api([
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
], model=model)
|
||||
|
||||
# Strip markdown fences if present
|
||||
clean_text = response_text.strip()
|
||||
if clean_text.startswith("```"):
|
||||
first_newline = clean_text.find("\n")
|
||||
if first_newline != -1:
|
||||
clean_text = clean_text[first_newline+1:]
|
||||
if clean_text.endswith("```"):
|
||||
clean_text = clean_text[:-3]
|
||||
clean_text = clean_text.strip()
|
||||
|
||||
# Parse JSON
|
||||
result = json.loads(clean_text)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"JSON parse failed. Raw text: {response_text[:200] if response_text else 'N/A'}")
|
||||
|
||||
# Try extracting JSON substring
|
||||
try:
|
||||
if response_text:
|
||||
start = response_text.find('{')
|
||||
end = response_text.rfind('}') + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(response_text[start:end])
|
||||
return result
|
||||
except:
|
||||
pass
|
||||
|
||||
default_structure['report'] = f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}"
|
||||
return default_structure
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed: {str(e)}")
|
||||
default_structure['report'] = f"Analysis failed: {str(e)}"
|
||||
return default_structure
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Search service.
|
||||
Integrates Google Custom Search (CSE) and Bing Search API.
|
||||
Configuration is provided via environment variables (see env.example) through config_loader.
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.config_loader import load_addon_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""Search service."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load config (re-read env-config on each call for local hot-reload)."""
|
||||
config = load_addon_config()
|
||||
self._config = config.get('search', {})
|
||||
self.provider = self._config.get('provider', 'google')
|
||||
self.max_results = int(self._config.get('max_results', 10))
|
||||
|
||||
def search(self, query: str, num_results: int = None, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a web search.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
num_results: Override default max results
|
||||
date_restrict: Time restriction like 'd7' (past 7 days), Google only
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
# 重新加载配置以支持热更新
|
||||
self._load_config()
|
||||
|
||||
limit = num_results if num_results else self.max_results
|
||||
|
||||
if self.provider == 'bing':
|
||||
return self._search_bing(query, limit)
|
||||
else:
|
||||
return self._search_google(query, limit, date_restrict)
|
||||
|
||||
def _search_google(self, query: str, num_results: int, date_restrict: str = None) -> List[Dict[str, Any]]:
|
||||
"""Google Custom Search (CSE)."""
|
||||
api_key = self._config.get('google', {}).get('api_key')
|
||||
cx = self._config.get('google', {}).get('cx')
|
||||
|
||||
if not api_key or not cx:
|
||||
logger.warning("Google Search is not configured (missing api_key or cx).")
|
||||
return []
|
||||
|
||||
url = "https://www.googleapis.com/customsearch/v1"
|
||||
params = {
|
||||
'key': api_key,
|
||||
'cx': cx,
|
||||
'q': query,
|
||||
'num': min(num_results, 10), # Google API 限制每次最多10条
|
||||
'gl': 'cn' if any(c in query for c in ['A股', '利好', '利空', '财报']) else None # 针对中文内容优化地区
|
||||
}
|
||||
|
||||
# 添加时间限制参数
|
||||
if date_restrict:
|
||||
params['dateRestrict'] = date_restrict
|
||||
|
||||
try:
|
||||
# logger.info(f"正在调用 Google Search API: q={query}, params={params}")
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)}") # 打印全部字符
|
||||
# logger.info(f"Google Search 原始响应: {json.dumps(data, ensure_ascii=False)[:500]}...") # 打印前500字符避免日志过大
|
||||
|
||||
results = []
|
||||
if 'items' in data:
|
||||
# logger.info(f"Google Search 返回了 {len(data['items'])} 条结果")
|
||||
for item in data['items']:
|
||||
logger.debug(f"Search Item: {item.get('title')} - {item.get('link')}")
|
||||
results.append({
|
||||
'title': item.get('title'),
|
||||
'link': item.get('link'),
|
||||
'snippet': item.get('snippet'),
|
||||
'source': 'Google',
|
||||
'published': item.get('pagemap', {}).get('metatags', [{}])[0].get('article:published_time', '')
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Google Search returned no 'items'. Full response: {json.dumps(data, ensure_ascii=False)}")
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google search failed: {e}")
|
||||
if 'response' in locals():
|
||||
logger.error(f"Response: {response.text}")
|
||||
return []
|
||||
|
||||
def _search_bing(self, query: str, num_results: int) -> List[Dict[str, Any]]:
|
||||
"""Bing search."""
|
||||
api_key = self._config.get('bing', {}).get('api_key')
|
||||
|
||||
if not api_key:
|
||||
logger.warning("Bing Search is not configured (missing api_key).")
|
||||
return []
|
||||
|
||||
url = "https://api.bing.microsoft.com/v7.0/search"
|
||||
headers = {"Ocp-Apim-Subscription-Key": api_key}
|
||||
params = {
|
||||
"q": query,
|
||||
"count": num_results,
|
||||
"textDecorations": True,
|
||||
"textFormat": "HTML"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
if 'webPages' in data and 'value' in data['webPages']:
|
||||
for item in data['webPages']['value']:
|
||||
results.append({
|
||||
'title': item.get('name'),
|
||||
'link': item.get('url'),
|
||||
'snippet': item.get('snippet'),
|
||||
'source': 'Bing',
|
||||
'published': item.get('datePublished', '')
|
||||
})
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bing search failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Strategy signal notification service.
|
||||
|
||||
This module implements per-strategy notification channels based on the frontend schema:
|
||||
|
||||
notification_config = {
|
||||
"channels": ["browser", "email", "phone", "telegram", "discord", "webhook"],
|
||||
"targets": {
|
||||
"email": "foo@example.com",
|
||||
"phone": "+15551234567",
|
||||
"telegram": "12345678 or @username",
|
||||
"discord": "https://discord.com/api/webhooks/...",
|
||||
"webhook": "https://example.com/webhook"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(x).strip() for x in value if str(x).strip()]
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return []
|
||||
# Allow comma-separated inputs.
|
||||
if "," in s:
|
||||
return [x.strip() for x in s.split(",") if x.strip()]
|
||||
return [s]
|
||||
|
||||
|
||||
def _safe_json(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
obj = json.loads(value)
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _signal_meta(signal_type: str) -> Dict[str, str]:
|
||||
st = (signal_type or "").strip().lower()
|
||||
action = "signal"
|
||||
if st.startswith("open_"):
|
||||
action = "open"
|
||||
elif st.startswith("add_"):
|
||||
action = "add"
|
||||
elif st.startswith("close_"):
|
||||
action = "close"
|
||||
elif st.startswith("reduce_"):
|
||||
action = "reduce"
|
||||
|
||||
side = "long" if "long" in st else ("short" if "short" in st else "")
|
||||
return {"action": action, "side": side, "type": st}
|
||||
|
||||
|
||||
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
|
||||
try:
|
||||
v = float(value or 0.0)
|
||||
except Exception:
|
||||
v = 0.0
|
||||
s = f"{v:.{int(max_decimals)}f}"
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return s or "0"
|
||||
|
||||
|
||||
class SignalNotifier:
|
||||
"""
|
||||
Notify signal events across channels.
|
||||
|
||||
Provider environment variables:
|
||||
- SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6)
|
||||
|
||||
- SIGNAL_WEBHOOK_TOKEN: optional bearer token for generic webhook channel
|
||||
|
||||
- TELEGRAM_BOT_TOKEN: Telegram bot token (required for telegram channel)
|
||||
|
||||
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS
|
||||
(required for email channel)
|
||||
|
||||
- TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER
|
||||
(optional for phone/SMS channel)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
self.timeout_sec = float(os.getenv("SIGNAL_NOTIFY_TIMEOUT_SEC") or "6")
|
||||
except Exception:
|
||||
self.timeout_sec = 6.0
|
||||
|
||||
self.webhook_token = (os.getenv("SIGNAL_WEBHOOK_TOKEN") or "").strip()
|
||||
|
||||
self.telegram_token = (os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
|
||||
self.smtp_host = (os.getenv("SMTP_HOST") or "").strip()
|
||||
try:
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT") or "587")
|
||||
except Exception:
|
||||
self.smtp_port = 587
|
||||
self.smtp_user = (os.getenv("SMTP_USER") or "").strip()
|
||||
self.smtp_password = (os.getenv("SMTP_PASSWORD") or "").strip()
|
||||
self.smtp_from = (os.getenv("SMTP_FROM") or self.smtp_user or "").strip()
|
||||
self.smtp_use_tls = (os.getenv("SMTP_USE_TLS") or "true").strip().lower() == "true"
|
||||
# Some providers require implicit SSL (port 465). Support it via SMTP_USE_SSL.
|
||||
self.smtp_use_ssl = (os.getenv("SMTP_USE_SSL") or "").strip().lower() == "true"
|
||||
|
||||
self.twilio_sid = (os.getenv("TWILIO_ACCOUNT_SID") or "").strip()
|
||||
self.twilio_token = (os.getenv("TWILIO_AUTH_TOKEN") or "").strip()
|
||||
self.twilio_from = (os.getenv("TWILIO_FROM_NUMBER") or "").strip()
|
||||
|
||||
def notify_signal(
|
||||
self,
|
||||
*,
|
||||
strategy_id: int,
|
||||
strategy_name: str,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
price: float = 0.0,
|
||||
stake_amount: float = 0.0,
|
||||
direction: str = "long",
|
||||
notification_config: Optional[Dict[str, Any]] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
cfg = _safe_json(notification_config or {})
|
||||
channels = _as_list(cfg.get("channels"))
|
||||
if not channels:
|
||||
channels = ["browser"]
|
||||
|
||||
targets = _safe_json(cfg.get("targets") or {})
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
|
||||
payload = self._build_payload(
|
||||
strategy_id=strategy_id,
|
||||
strategy_name=strategy_name,
|
||||
symbol=symbol,
|
||||
signal_type=signal_type,
|
||||
price=price,
|
||||
stake_amount=stake_amount,
|
||||
direction=direction,
|
||||
extra=extra,
|
||||
)
|
||||
rendered = self._render_messages(payload)
|
||||
title = rendered.get("title") or ""
|
||||
message_plain = rendered.get("plain") or ""
|
||||
|
||||
results: Dict[str, Dict[str, Any]] = {}
|
||||
for ch in channels:
|
||||
c = (ch or "").strip().lower()
|
||||
if not c:
|
||||
continue
|
||||
try:
|
||||
if c == "browser":
|
||||
ok, err = self._notify_browser(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
signal_type=signal_type,
|
||||
channels=channels,
|
||||
title=title,
|
||||
message=message_plain,
|
||||
payload=payload,
|
||||
)
|
||||
elif c == "webhook":
|
||||
url = (targets.get("webhook") or os.getenv("SIGNAL_WEBHOOK_URL") or "").strip()
|
||||
ok, err = self._notify_webhook(
|
||||
url=url,
|
||||
payload=payload,
|
||||
)
|
||||
elif c == "discord":
|
||||
url = (targets.get("discord") or "").strip()
|
||||
ok, err = self._notify_discord(url=url, payload=payload, fallback_text=message_plain)
|
||||
elif c == "telegram":
|
||||
chat_id = (targets.get("telegram") or "").strip()
|
||||
# Support per-strategy token override (local mode). Falls back to env TELEGRAM_BOT_TOKEN.
|
||||
token_override = ""
|
||||
if not self.telegram_token:
|
||||
try:
|
||||
token_override = str(
|
||||
targets.get("telegram_bot_token")
|
||||
or targets.get("telegram_token")
|
||||
or cfg.get("telegram_bot_token")
|
||||
or cfg.get("telegram_token")
|
||||
or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
token_override = ""
|
||||
ok, err = self._notify_telegram(
|
||||
chat_id=chat_id,
|
||||
text=rendered.get("telegram_html") or message_plain,
|
||||
token_override=token_override,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
elif c == "email":
|
||||
to_email = (targets.get("email") or "").strip()
|
||||
ok, err = self._notify_email(
|
||||
to_email=to_email,
|
||||
subject=title,
|
||||
body_text=message_plain,
|
||||
body_html=rendered.get("email_html") or "",
|
||||
)
|
||||
elif c == "phone":
|
||||
to_phone = (targets.get("phone") or "").strip()
|
||||
ok, err = self._notify_phone(to_phone=to_phone, body=message_plain)
|
||||
else:
|
||||
ok, err = False, f"unsupported_channel:{c}"
|
||||
except Exception as e:
|
||||
ok, err = False, str(e)
|
||||
|
||||
results[c] = {"ok": bool(ok), "error": (err or "")}
|
||||
|
||||
return results
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
*,
|
||||
strategy_id: int,
|
||||
strategy_name: str,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
price: float,
|
||||
stake_amount: float,
|
||||
direction: str,
|
||||
extra: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
now = int(time.time())
|
||||
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
|
||||
meta = _signal_meta(signal_type)
|
||||
|
||||
pending_id = None
|
||||
try:
|
||||
pending_id = int((extra or {}).get("pending_order_id") or 0) or None
|
||||
except Exception:
|
||||
pending_id = None
|
||||
|
||||
return {
|
||||
"event": "qd.signal",
|
||||
"version": 1,
|
||||
"timestamp": now,
|
||||
"timestamp_iso": iso,
|
||||
"strategy": {
|
||||
"id": int(strategy_id),
|
||||
"name": str(strategy_name or ""),
|
||||
},
|
||||
"instrument": {
|
||||
"symbol": str(symbol or ""),
|
||||
},
|
||||
"signal": {
|
||||
"type": meta.get("type") or str(signal_type or ""),
|
||||
"action": meta.get("action") or "signal",
|
||||
"side": meta.get("side") or "",
|
||||
"direction": str(direction or ""),
|
||||
},
|
||||
"order": {
|
||||
"ref_price": float(price or 0.0),
|
||||
"stake_amount": float(stake_amount or 0.0),
|
||||
},
|
||||
"trace": {
|
||||
"pending_order_id": pending_id,
|
||||
"mode": str((extra or {}).get("mode") or ""),
|
||||
},
|
||||
"extra": extra or {},
|
||||
}
|
||||
|
||||
def _render_messages(self, payload: Dict[str, Any]) -> Dict[str, str]:
|
||||
strategy = (payload or {}).get("strategy") or {}
|
||||
instrument = (payload or {}).get("instrument") or {}
|
||||
sig = (payload or {}).get("signal") or {}
|
||||
order = (payload or {}).get("order") or {}
|
||||
trace = (payload or {}).get("trace") or {}
|
||||
|
||||
symbol = str(instrument.get("symbol") or "")
|
||||
stype = str(sig.get("type") or "")
|
||||
action = str(sig.get("action") or "").upper()
|
||||
side = str(sig.get("side") or "").upper()
|
||||
title = f"QD Signal | {symbol} | {action} {side}".strip()
|
||||
|
||||
price_s = _fmt_float(order.get("ref_price") or 0.0, max_decimals=10)
|
||||
stake_s = _fmt_float(order.get("stake_amount") or 0.0, max_decimals=12)
|
||||
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
|
||||
mode = str(trace.get("mode") or "")
|
||||
ts_iso = str(payload.get("timestamp_iso") or "")
|
||||
|
||||
plain_lines = [
|
||||
"QuantDinger Signal",
|
||||
f"Strategy: {strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})",
|
||||
f"Symbol: {symbol}",
|
||||
f"Signal: {stype}",
|
||||
f"Price: {price_s}",
|
||||
f"Stake: {stake_s}",
|
||||
]
|
||||
if pending_id:
|
||||
plain_lines.append(f"PendingOrder: {pending_id}")
|
||||
if mode:
|
||||
plain_lines.append(f"Mode: {mode}")
|
||||
if ts_iso:
|
||||
plain_lines.append(f"Time(UTC): {ts_iso}")
|
||||
|
||||
# Telegram (HTML) message. Escape all dynamic values.
|
||||
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
|
||||
telegram_lines = [
|
||||
"<b>QuantDinger Signal</b>",
|
||||
"",
|
||||
f"<b>Strategy</b>: <code>{html.escape(str(t_strategy))}</code>",
|
||||
f"<b>Symbol</b>: <code>{html.escape(symbol)}</code>",
|
||||
f"<b>Signal</b>: <code>{html.escape(stype)}</code>",
|
||||
f"<b>Price</b>: <code>{html.escape(price_s)}</code>",
|
||||
f"<b>Stake</b>: <code>{html.escape(stake_s)}</code>",
|
||||
]
|
||||
if pending_id:
|
||||
telegram_lines.append(f"<b>PendingOrder</b>: <code>{pending_id}</code>")
|
||||
if mode:
|
||||
telegram_lines.append(f"<b>Mode</b>: <code>{html.escape(mode)}</code>")
|
||||
if ts_iso:
|
||||
telegram_lines.append(f"<b>Time (UTC)</b>: <code>{html.escape(ts_iso)}</code>")
|
||||
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
|
||||
|
||||
# Email (HTML) message. Keep inline CSS for maximum compatibility.
|
||||
email_html = self._build_email_html(
|
||||
title_text="QuantDinger Signal",
|
||||
strategy_text=t_strategy,
|
||||
symbol=symbol,
|
||||
signal_type=stype,
|
||||
price_text=price_s,
|
||||
stake_text=stake_s,
|
||||
pending_id=pending_id or None,
|
||||
mode_text=mode or "",
|
||||
timestamp_iso=ts_iso or "",
|
||||
)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"plain": "\n".join(plain_lines),
|
||||
"telegram_html": telegram_html,
|
||||
"email_html": email_html,
|
||||
}
|
||||
|
||||
def _build_email_html(
|
||||
self,
|
||||
*,
|
||||
title_text: str,
|
||||
strategy_text: str,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
price_text: str,
|
||||
stake_text: str,
|
||||
pending_id: Optional[int],
|
||||
mode_text: str,
|
||||
timestamp_iso: str,
|
||||
) -> str:
|
||||
def esc(s: Any) -> str:
|
||||
return html.escape(str(s or ""))
|
||||
|
||||
rows: List[Tuple[str, str]] = [
|
||||
("Strategy", strategy_text),
|
||||
("Symbol", symbol),
|
||||
("Signal", signal_type),
|
||||
("Price", price_text),
|
||||
("Stake", stake_text),
|
||||
]
|
||||
if pending_id:
|
||||
rows.append(("PendingOrder", str(int(pending_id))))
|
||||
if mode_text:
|
||||
rows.append(("Mode", mode_text))
|
||||
if timestamp_iso:
|
||||
rows.append(("Time (UTC)", timestamp_iso))
|
||||
|
||||
tr_html = "\n".join(
|
||||
[
|
||||
(
|
||||
"<tr>"
|
||||
"<td style='padding:10px 12px;border-top:1px solid #eaecef;color:#57606a;width:160px;'>"
|
||||
f"{esc(k)}"
|
||||
"</td>"
|
||||
"<td style='padding:10px 12px;border-top:1px solid #eaecef;color:#24292f;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;'>"
|
||||
f"{esc(v)}"
|
||||
"</td>"
|
||||
"</tr>"
|
||||
)
|
||||
for (k, v) in rows
|
||||
]
|
||||
)
|
||||
|
||||
return f"""\
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f6f8fa;">
|
||||
<div style="max-width:640px;margin:0 auto;padding:24px;">
|
||||
<div style="background:#111827;color:#ffffff;padding:16px 18px;border-radius:12px 12px 0 0;">
|
||||
<div style="font-size:16px;letter-spacing:0.2px;font-weight:600;">{esc(title_text)}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_iso) if timestamp_iso else ""}</div>
|
||||
</div>
|
||||
<div style="background:#ffffff;border:1px solid #eaecef;border-top:0;border-radius:0 0 12px 12px;overflow:hidden;">
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
|
||||
{tr_html}
|
||||
</table>
|
||||
<div style="padding:14px 16px;color:#6e7781;font-size:12px;border-top:1px solid #eaecef;">
|
||||
Generated by QuantDinger
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _notify_browser(
|
||||
self,
|
||||
*,
|
||||
strategy_id: int,
|
||||
symbol: str,
|
||||
signal_type: str,
|
||||
channels: List[str],
|
||||
title: str,
|
||||
message: str,
|
||||
payload: Dict[str, Any],
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategy_notifications
|
||||
(strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(strategy_id),
|
||||
str(symbol or ""),
|
||||
str(signal_type or ""),
|
||||
",".join([str(c) for c in (channels or [])]),
|
||||
str(title or ""),
|
||||
str(message or ""),
|
||||
json.dumps(payload or {}, ensure_ascii=False),
|
||||
int(now),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
logger.warning(f"browser notify persist failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def _notify_webhook(self, *, url: str, payload: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
if not url:
|
||||
return False, "missing_webhook_url"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.webhook_token:
|
||||
headers["Authorization"] = f"Bearer {self.webhook_token}"
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=self.timeout_sec)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return True, ""
|
||||
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]:
|
||||
if not url:
|
||||
return False, "missing_discord_webhook_url"
|
||||
|
||||
strategy = (payload or {}).get("strategy") or {}
|
||||
instrument = (payload or {}).get("instrument") or {}
|
||||
sig = (payload or {}).get("signal") or {}
|
||||
order = (payload or {}).get("order") or {}
|
||||
trace = (payload or {}).get("trace") or {}
|
||||
|
||||
action = str(sig.get("action") or "").lower()
|
||||
color = 0x3498DB
|
||||
if action in ("open", "add"):
|
||||
color = 0x2ECC71
|
||||
if action in ("close", "reduce"):
|
||||
color = 0xE74C3C
|
||||
|
||||
embed: Dict[str, Any] = {
|
||||
"title": "QuantDinger Signal",
|
||||
"color": int(color),
|
||||
"fields": [
|
||||
{"name": "Strategy", "value": f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})", "inline": True},
|
||||
{"name": "Symbol", "value": str(instrument.get("symbol") or ""), "inline": True},
|
||||
{"name": "Signal", "value": str(sig.get("type") or ""), "inline": False},
|
||||
{"name": "Price", "value": str(float(order.get('ref_price') or 0.0)), "inline": True},
|
||||
{"name": "Stake", "value": str(float(order.get('stake_amount') or 0.0)), "inline": True},
|
||||
],
|
||||
}
|
||||
if payload.get("timestamp_iso"):
|
||||
embed["timestamp"] = str(payload.get("timestamp_iso") or "")
|
||||
if trace.get("pending_order_id"):
|
||||
embed["footer"] = {"text": f"pending_order_id={int(trace.get('pending_order_id'))}"}
|
||||
try:
|
||||
resp = requests.post(url, json={"content": "", "embeds": [embed]}, timeout=self.timeout_sec)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return True, ""
|
||||
# Fallback: try plain text.
|
||||
try:
|
||||
resp2 = requests.post(url, json={"content": str(fallback_text or "")[:1900]}, timeout=self.timeout_sec)
|
||||
if 200 <= resp2.status_code < 300:
|
||||
return True, ""
|
||||
except Exception:
|
||||
pass
|
||||
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _notify_telegram(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
token_override: str = "",
|
||||
parse_mode: str = "",
|
||||
) -> Tuple[bool, str]:
|
||||
token = (token_override or "").strip() or (self.telegram_token or "").strip()
|
||||
if not token:
|
||||
return False, "missing_TELEGRAM_BOT_TOKEN"
|
||||
if not chat_id:
|
||||
return False, "missing_telegram_chat_id"
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
try:
|
||||
data: Dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": str(text or "")[:3900],
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if (parse_mode or "").strip():
|
||||
data["parse_mode"] = str(parse_mode).strip()
|
||||
resp = requests.post(
|
||||
url,
|
||||
data=data,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return True, ""
|
||||
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _notify_email(self, *, to_email: str, subject: str, body_text: str, body_html: str = "") -> Tuple[bool, str]:
|
||||
if not to_email:
|
||||
return False, "missing_email_target"
|
||||
if not self.smtp_host:
|
||||
return False, "missing_SMTP_HOST"
|
||||
if not self.smtp_from:
|
||||
return False, "missing_SMTP_FROM"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = self.smtp_from
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = str(subject or "Signal")
|
||||
msg.set_content(str(body_text or ""))
|
||||
if (body_html or "").strip():
|
||||
msg.add_alternative(str(body_html or ""), subtype="html")
|
||||
|
||||
try:
|
||||
# Heuristic: if port is 465 and SMTP_USE_SSL is not explicitly set, assume SSL.
|
||||
use_ssl = bool(self.smtp_use_ssl) or int(self.smtp_port or 0) == 465
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server:
|
||||
server.ehlo()
|
||||
if self.smtp_user and self.smtp_password:
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=self.timeout_sec) as server:
|
||||
server.ehlo()
|
||||
if self.smtp_use_tls:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if self.smtp_user and self.smtp_password:
|
||||
server.login(self.smtp_user, self.smtp_password)
|
||||
server.send_message(msg)
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _notify_phone(self, *, to_phone: str, body: str) -> Tuple[bool, str]:
|
||||
# Optional provider: Twilio via REST (no extra dependency).
|
||||
if not to_phone:
|
||||
return False, "missing_phone_target"
|
||||
if not (self.twilio_sid and self.twilio_token and self.twilio_from):
|
||||
return False, "missing_TWILIO_config"
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{self.twilio_sid}/Messages.json"
|
||||
data = {"To": to_phone, "From": self.twilio_from, "Body": str(body or "")[:1500]}
|
||||
try:
|
||||
resp = requests.post(url, data=data, auth=(self.twilio_sid, self.twilio_token), timeout=self.timeout_sec)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return True, ""
|
||||
return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class StrategyService:
|
||||
"""Strategy service."""
|
||||
|
||||
# 类变量:限制连接测试并发数
|
||||
_connection_test_semaphore = threading.Semaphore(5)
|
||||
|
||||
def __init__(self):
|
||||
# Local deployment: do not use encryption/decryption.
|
||||
pass
|
||||
|
||||
def get_running_strategies(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有运行中的策略(仅ID)"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
query = "SELECT id FROM qd_strategies_trading WHERE status = 'running'"
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
cursor.close()
|
||||
return [row['id'] for row in results]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch running strategies: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_running_strategies_with_type(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有运行中的策略(包含类型信息)"""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cursor = db.cursor()
|
||||
# 假设 qd_strategies_trading 表中有 strategy_type 字段
|
||||
# 如果没有,可能需要关联查询或者根据其他字段判断
|
||||
# 这里假设表结构已更新
|
||||
query = "SELECT id, strategy_type FROM qd_strategies_trading WHERE status = 'running'"
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
strategies = [{'id': row['id'], 'strategy_type': row.get('strategy_type', '')} for row in results]
|
||||
logger.info(f"Found {len(strategies)} running strategies: {strategies}")
|
||||
return strategies
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch running strategies: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_exchange_symbols(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
获取交易所交易对列表 (无需API Key)
|
||||
"""
|
||||
try:
|
||||
import ccxt
|
||||
|
||||
exchange_id = exchange_config.get('exchange_id', '')
|
||||
proxies = exchange_config.get('proxies')
|
||||
|
||||
if not exchange_id:
|
||||
return {'success': False, 'message': '请选择交易所', 'symbols': []}
|
||||
|
||||
# 创建交易所实例 (public only)
|
||||
exchange_class = getattr(ccxt, exchange_id, None)
|
||||
if not exchange_class:
|
||||
return {'success': False, 'message': f'不支持的交易所: {exchange_id}', 'symbols': []}
|
||||
|
||||
exchange_config_dict = {
|
||||
'enableRateLimit': True,
|
||||
'options': {'defaultType': 'swap'} # 默认为 swap
|
||||
}
|
||||
if proxies:
|
||||
exchange_config_dict['proxies'] = proxies
|
||||
|
||||
exchange = exchange_class(exchange_config_dict)
|
||||
markets = exchange.load_markets()
|
||||
|
||||
symbols = []
|
||||
for symbol, market in markets.items():
|
||||
if market.get('active', False) and market.get('quote') == 'USDT':
|
||||
symbols.append(symbol)
|
||||
|
||||
symbols.sort()
|
||||
return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch symbols: {str(e)}")
|
||||
return {'success': False, 'message': f'获取交易对失败: {str(e)}', 'symbols': []}
|
||||
|
||||
def test_exchange_connection(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Test exchange connection via direct REST clients (no ccxt).
|
||||
|
||||
Notes:
|
||||
- This is local-only; failures are returned as user-friendly messages.
|
||||
- We do not log secrets.
|
||||
"""
|
||||
# Limit concurrency to protect CPU / rate limits
|
||||
with StrategyService._connection_test_semaphore:
|
||||
try:
|
||||
from app.services.exchange_execution import resolve_exchange_config, safe_exchange_config_for_log
|
||||
from app.services.live_trading.factory import create_client
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||||
from app.services.live_trading.okx import OkxClient
|
||||
from app.services.live_trading.bitget import BitgetMixClient
|
||||
|
||||
resolved = resolve_exchange_config(exchange_config or {})
|
||||
safe_cfg = safe_exchange_config_for_log(resolved)
|
||||
|
||||
exchange_id = (resolved.get("exchange_id") or "").strip().lower()
|
||||
if not exchange_id:
|
||||
return {'success': False, 'message': 'Missing exchange_id', 'data': None}
|
||||
|
||||
# IMPORTANT:
|
||||
# Test connection should respect configured market_type (spot vs swap).
|
||||
# Otherwise Binance will default to futures endpoints (fapi) and spot-only keys will fail with -2015.
|
||||
market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "swap").strip().lower()
|
||||
client = create_client(resolved, market_type=market_type)
|
||||
client_kind = type(client).__name__
|
||||
|
||||
# Best-effort detect current egress IP (for Binance IP whitelist debugging).
|
||||
egress_ip = ""
|
||||
try:
|
||||
import requests as _rq
|
||||
egress_ip = str(_rq.get("https://ifconfig.me/ip", timeout=5).text or "").strip()
|
||||
except Exception:
|
||||
egress_ip = ""
|
||||
|
||||
# 1) Public connectivity
|
||||
ok_public = False
|
||||
try:
|
||||
ok_public = bool(getattr(client, "ping")())
|
||||
except Exception:
|
||||
ok_public = False
|
||||
if not ok_public:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Public ping failed: {exchange_id}',
|
||||
'data': {'exchange': safe_cfg, 'client': client_kind, 'market_type': market_type, 'egress_ip': egress_ip},
|
||||
}
|
||||
|
||||
# 2) Private credential validation (best-effort)
|
||||
priv_data = None
|
||||
try:
|
||||
if isinstance(client, BinanceFuturesClient):
|
||||
priv_data = client.get_account()
|
||||
elif isinstance(client, BinanceSpotClient):
|
||||
priv_data = client.get_account()
|
||||
elif isinstance(client, OkxClient):
|
||||
priv_data = client.get_balance()
|
||||
elif isinstance(client, BitgetMixClient):
|
||||
product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES")
|
||||
priv_data = client.get_accounts(product_type=product_type)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
# Add actionable hints for the most common Binance auth error.
|
||||
if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg):
|
||||
# Auto A/B test: try the other market_type once to pinpoint permission mismatch.
|
||||
alt_market_type = "spot" if market_type != "spot" else "swap"
|
||||
alt_client_kind = ""
|
||||
alt_base_url = ""
|
||||
alt_ok = False
|
||||
try:
|
||||
alt_client = create_client(resolved, market_type=alt_market_type)
|
||||
alt_client_kind = type(alt_client).__name__
|
||||
alt_base_url = getattr(alt_client, "base_url", "") or ""
|
||||
if isinstance(alt_client, BinanceFuturesClient) or isinstance(alt_client, BinanceSpotClient):
|
||||
_ = alt_client.get_account()
|
||||
alt_ok = True
|
||||
except Exception:
|
||||
alt_ok = False
|
||||
|
||||
base_url = getattr(client, "base_url", "") or ""
|
||||
hint = (
|
||||
f"Binance auth failed (-2015). Verify: "
|
||||
f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, "
|
||||
f"(2) API key permissions match market_type={market_type} "
|
||||
f"(spot requires Spot permissions; swap requires Futures permissions), "
|
||||
f"(3) you're using binance.com keys for base_url={base_url or 'unknown'}."
|
||||
)
|
||||
if alt_ok:
|
||||
hint += (
|
||||
f" Auto-check: your key works for market_type={alt_market_type} "
|
||||
f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) "
|
||||
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
|
||||
)
|
||||
msg = f"{msg} | {hint}"
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Auth failed: {msg}',
|
||||
'data': {
|
||||
'exchange': safe_cfg,
|
||||
'client': client_kind,
|
||||
'market_type': market_type,
|
||||
'egress_ip': egress_ip,
|
||||
'base_url': getattr(client, "base_url", "") or "",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Connection OK',
|
||||
'data': {
|
||||
'exchange': safe_cfg,
|
||||
'client': client_kind,
|
||||
'market_type': market_type,
|
||||
'egress_ip': egress_ip,
|
||||
'base_url': getattr(client, "base_url", "") or "",
|
||||
'private': priv_data,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"test_exchange_connection failed: {str(e)}")
|
||||
return {'success': False, 'message': f'Connection failed: {str(e)}', 'data': None}
|
||||
|
||||
def get_strategy_type(self, strategy_id: int) -> str:
|
||||
"""Get strategy type from DB."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"SELECT strategy_type FROM qd_strategies_trading WHERE id = ?",
|
||||
(strategy_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return (row or {}).get('strategy_type') or 'IndicatorStrategy'
|
||||
except Exception:
|
||||
return 'IndicatorStrategy'
|
||||
|
||||
def update_strategy_status(self, strategy_id: int, status: str) -> bool:
|
||||
"""Update strategy status."""
|
||||
try:
|
||||
now = int(time.time())
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"UPDATE qd_strategies_trading SET status = ?, updated_at = ? WHERE id = ?",
|
||||
(status, now, strategy_id)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"update_strategy_status failed: {e}")
|
||||
return False
|
||||
|
||||
def _safe_json_loads(self, value: Any, default: Any):
|
||||
"""Load JSON string into Python object (local deployment: plaintext only)."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return default
|
||||
s = value.strip()
|
||||
if not s:
|
||||
return default
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _dump_json_or_encrypt(self, obj: Any, encrypt: bool = False) -> str:
|
||||
if obj is None:
|
||||
return ''
|
||||
# Local deployment: always store plaintext JSON.
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
def list_strategies(self, user_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""List strategies for local single-user."""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM qd_strategies_trading
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
ex = self._safe_json_loads(r.get('exchange_config'), {})
|
||||
ind = self._safe_json_loads(r.get('indicator_config'), {})
|
||||
tr = self._safe_json_loads(r.get('trading_config'), {})
|
||||
ai = self._safe_json_loads(r.get('ai_model_config'), {})
|
||||
notify = self._safe_json_loads(r.get('notification_config'), {})
|
||||
out.append({
|
||||
**r,
|
||||
'exchange_config': ex,
|
||||
'indicator_config': ind,
|
||||
'trading_config': tr,
|
||||
'ai_model_config': ai,
|
||||
'notification_config': notify
|
||||
})
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.error(f"list_strategies failed: {e}")
|
||||
return []
|
||||
|
||||
def get_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
r = cur.fetchone()
|
||||
cur.close()
|
||||
if not r:
|
||||
return None
|
||||
r['exchange_config'] = self._safe_json_loads(r.get('exchange_config'), {})
|
||||
r['indicator_config'] = self._safe_json_loads(r.get('indicator_config'), {})
|
||||
r['trading_config'] = self._safe_json_loads(r.get('trading_config'), {})
|
||||
r['ai_model_config'] = self._safe_json_loads(r.get('ai_model_config'), {})
|
||||
r['notification_config'] = self._safe_json_loads(r.get('notification_config'), {})
|
||||
return r
|
||||
except Exception as e:
|
||||
logger.error(f"get_strategy failed: {e}")
|
||||
return None
|
||||
|
||||
def create_strategy(self, payload: Dict[str, Any]) -> int:
|
||||
now = int(time.time())
|
||||
name = (payload.get('strategy_name') or '').strip()
|
||||
if not name:
|
||||
raise ValueError("strategy_name is required")
|
||||
|
||||
strategy_type = payload.get('strategy_type') or 'IndicatorStrategy'
|
||||
market_category = payload.get('market_category') or 'Crypto'
|
||||
execution_mode = payload.get('execution_mode') or 'signal'
|
||||
notification_config = payload.get('notification_config') or {}
|
||||
|
||||
indicator_config = payload.get('indicator_config') or {}
|
||||
trading_config = payload.get('trading_config') or {}
|
||||
exchange_config = payload.get('exchange_config') or {}
|
||||
|
||||
# Denormalized fields for quick list rendering
|
||||
symbol = (trading_config or {}).get('symbol')
|
||||
timeframe = (trading_config or {}).get('timeframe')
|
||||
initial_capital = (trading_config or {}).get('initial_capital') or payload.get('initial_capital') or 1000
|
||||
leverage = (trading_config or {}).get('leverage') or 1
|
||||
market_type = (trading_config or {}).get('market_type') or 'swap'
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO qd_strategies_trading
|
||||
(strategy_name, strategy_type, market_category, execution_mode, notification_config,
|
||||
status, symbol, timeframe, initial_capital, leverage, market_type,
|
||||
exchange_config, indicator_config, trading_config, ai_model_config, decide_interval,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
name,
|
||||
strategy_type,
|
||||
market_category,
|
||||
execution_mode,
|
||||
self._dump_json_or_encrypt(notification_config, encrypt=False),
|
||||
payload.get('status') or 'stopped',
|
||||
symbol,
|
||||
timeframe,
|
||||
float(initial_capital or 1000),
|
||||
int(leverage or 1),
|
||||
market_type,
|
||||
self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '',
|
||||
self._dump_json_or_encrypt(indicator_config, encrypt=False),
|
||||
self._dump_json_or_encrypt(trading_config, encrypt=False),
|
||||
self._dump_json_or_encrypt(payload.get('ai_model_config') or {}, encrypt=False),
|
||||
int(payload.get('decide_interval') or 300),
|
||||
now,
|
||||
now
|
||||
)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
db.commit()
|
||||
cur.close()
|
||||
return int(new_id)
|
||||
|
||||
def update_strategy(self, strategy_id: int, payload: Dict[str, Any]) -> bool:
|
||||
now = int(time.time())
|
||||
existing = self.get_strategy(strategy_id)
|
||||
if not existing:
|
||||
return False
|
||||
|
||||
# Merge: allow partial updates
|
||||
name = (payload.get('strategy_name') or existing.get('strategy_name') or '').strip()
|
||||
market_category = payload.get('market_category') or existing.get('market_category') or 'Crypto'
|
||||
execution_mode = payload.get('execution_mode') or existing.get('execution_mode') or 'signal'
|
||||
notification_config = payload.get('notification_config') if payload.get('notification_config') is not None else (existing.get('notification_config') or {})
|
||||
|
||||
indicator_config = payload.get('indicator_config') if payload.get('indicator_config') is not None else (existing.get('indicator_config') or {})
|
||||
trading_config = payload.get('trading_config') if payload.get('trading_config') is not None else (existing.get('trading_config') or {})
|
||||
exchange_config = payload.get('exchange_config') if payload.get('exchange_config') is not None else (existing.get('exchange_config') or {})
|
||||
|
||||
symbol = (trading_config or {}).get('symbol')
|
||||
timeframe = (trading_config or {}).get('timeframe')
|
||||
initial_capital = (trading_config or {}).get('initial_capital') or existing.get('initial_capital') or 1000
|
||||
leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1
|
||||
market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap'
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategies_trading
|
||||
SET strategy_name = ?,
|
||||
market_category = ?,
|
||||
execution_mode = ?,
|
||||
notification_config = ?,
|
||||
symbol = ?,
|
||||
timeframe = ?,
|
||||
initial_capital = ?,
|
||||
leverage = ?,
|
||||
market_type = ?,
|
||||
exchange_config = ?,
|
||||
indicator_config = ?,
|
||||
trading_config = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
name,
|
||||
market_category,
|
||||
execution_mode,
|
||||
self._dump_json_or_encrypt(notification_config, encrypt=False),
|
||||
symbol,
|
||||
timeframe,
|
||||
float(initial_capital or 1000),
|
||||
int(leverage or 1),
|
||||
market_type,
|
||||
self._dump_json_or_encrypt(exchange_config, encrypt=False) if exchange_config else '',
|
||||
self._dump_json_or_encrypt(indicator_config, encrypt=False),
|
||||
self._dump_json_or_encrypt(trading_config, encrypt=False),
|
||||
now,
|
||||
strategy_id
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
def delete_strategy(self, strategy_id: int) -> bool:
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute("DELETE FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||||
db.commit()
|
||||
cur.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"delete_strategy failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,688 @@
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
|
||||
class StrategyCompiler:
|
||||
def compile(self, config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Compiles the strategy configuration JSON into executable Python code.
|
||||
"""
|
||||
|
||||
# Extract configurations
|
||||
name = config.get('name', 'Generated Strategy')
|
||||
entry_rules = config.get('entry_rules', [])
|
||||
position_config = config.get('position_config', {})
|
||||
pyramiding_rules = config.get('pyramiding_rules', {})
|
||||
risk_management = config.get('risk_management', {})
|
||||
|
||||
# 1. Imports and Setup
|
||||
code = self._get_header(name)
|
||||
|
||||
# 2. Parameters (Variables)
|
||||
code += self._get_parameters(position_config, pyramiding_rules, risk_management)
|
||||
|
||||
# 3. Indicators Calculation
|
||||
code += self._get_indicators_calculation(entry_rules)
|
||||
|
||||
# 4. Signal Logic (Entry Conditions)
|
||||
code += self._get_entry_logic(entry_rules)
|
||||
|
||||
# 5. Core Loop (Position Management) - Based on code2.py
|
||||
code += self._get_core_loop(position_config, pyramiding_rules, risk_management)
|
||||
|
||||
# 6. Output Formatting
|
||||
code += self._get_output_section(name, entry_rules)
|
||||
|
||||
return code
|
||||
|
||||
def _get_header(self, name):
|
||||
return f'''
|
||||
# Generated Strategy: {name}
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
# Helper function for checking signals safely
|
||||
def get_val(arr, i, default=0):
|
||||
if i < 0 or i >= len(arr): return default
|
||||
return arr[i]
|
||||
'''
|
||||
|
||||
def _get_parameters(self, pos_config, pyr_rules, risk_mgmt):
|
||||
# Default values
|
||||
initial_size = pos_config.get('initial_size_pct', 10) / 100.0
|
||||
leverage = pos_config.get('leverage', 1)
|
||||
max_pyramiding = pos_config.get('max_pyramiding', 0)
|
||||
|
||||
pyr_enabled = pyr_rules.get('enabled', False)
|
||||
add_size = pyr_rules.get('size_pct', 0) / 100.0 if pyr_enabled else 0
|
||||
add_threshold = pyr_rules.get('value', 0) / 100.0
|
||||
|
||||
stop_loss = risk_mgmt.get('stop_loss', {})
|
||||
sl_enabled = stop_loss.get('enabled', False)
|
||||
sl_pct = stop_loss.get('value', 0) / 100.0 if sl_enabled else 0.0
|
||||
|
||||
trailing = risk_mgmt.get('trailing_stop', {})
|
||||
ts_enabled = trailing.get('enabled', False)
|
||||
ts_activation = trailing.get('activation_profit', 0) / 100.0
|
||||
ts_callback = trailing.get('callback_pct', 0) / 100.0
|
||||
|
||||
return f'''
|
||||
# ===========================
|
||||
# 1. Parameters
|
||||
# ===========================
|
||||
initial_position_pct = {initial_size}
|
||||
leverage = {leverage}
|
||||
max_pyramiding = {max_pyramiding}
|
||||
|
||||
# Pyramiding
|
||||
add_position_pct = {add_size}
|
||||
add_threshold_pct = {add_threshold}
|
||||
|
||||
# Risk Management
|
||||
stop_loss_pct = {sl_pct}
|
||||
take_profit_activation = {ts_activation}
|
||||
trailing_callback = {ts_callback}
|
||||
'''
|
||||
|
||||
def _get_indicators_calculation(self, rules):
|
||||
code = """
|
||||
# ===========================
|
||||
# 2. Indicators Calculation
|
||||
# ===========================
|
||||
"""
|
||||
calculated = set()
|
||||
|
||||
for rule in rules:
|
||||
ind = rule.get('indicator')
|
||||
params = rule.get('params', {})
|
||||
|
||||
if ind == 'supertrend':
|
||||
key = f"st_{params.get('period')}_{params.get('multiplier')}"
|
||||
if key not in calculated:
|
||||
code += f"""
|
||||
# SuperTrend ({params.get('period')}, {params.get('multiplier')})
|
||||
period = {params.get('period', 14)}
|
||||
multiplier = {params.get('multiplier', 3.0)}
|
||||
df['hl2'] = (df['high'] + df['low']) / 2
|
||||
df['tr'] = np.maximum(df['high'] - df['low'], np.maximum(abs(df['high'] - df['close'].shift(1)), abs(df['low'] - df['close'].shift(1))))
|
||||
df['atr'] = df['tr'].ewm(alpha=1/period, adjust=False).mean()
|
||||
df['basic_upper'] = df['hl2'] + (multiplier * df['atr'])
|
||||
df['basic_lower'] = df['hl2'] - (multiplier * df['atr'])
|
||||
|
||||
final_upper = [0.0] * len(df)
|
||||
final_lower = [0.0] * len(df)
|
||||
trend = [1] * len(df)
|
||||
close_arr = df['close'].values
|
||||
basic_upper = np.nan_to_num(df['basic_upper'].values)
|
||||
basic_lower = np.nan_to_num(df['basic_lower'].values)
|
||||
|
||||
for i in range(1, len(df)):
|
||||
if basic_upper[i] < final_upper[i-1] or close_arr[i-1] > final_upper[i-1]:
|
||||
final_upper[i] = basic_upper[i]
|
||||
else:
|
||||
final_upper[i] = final_upper[i-1]
|
||||
|
||||
if basic_lower[i] > final_lower[i-1] or close_arr[i-1] < final_lower[i-1]:
|
||||
final_lower[i] = basic_lower[i]
|
||||
else:
|
||||
final_lower[i] = final_lower[i-1]
|
||||
|
||||
prev_trend = trend[i-1]
|
||||
if prev_trend == -1 and close_arr[i] > final_upper[i-1]:
|
||||
trend[i] = 1
|
||||
elif prev_trend == 1 and close_arr[i] < final_lower[i-1]:
|
||||
trend[i] = -1
|
||||
else:
|
||||
trend[i] = prev_trend
|
||||
|
||||
df['st_trend'] = trend
|
||||
df['st_upper'] = final_upper
|
||||
df['st_lower'] = final_lower
|
||||
"""
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'ema':
|
||||
period = params.get('period', 20)
|
||||
key = f"ema_{period}"
|
||||
if key not in calculated:
|
||||
code += f"\ndf['ema_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n"
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'rsi':
|
||||
period = params.get('period', 14)
|
||||
key = f"rsi_{period}"
|
||||
if key not in calculated:
|
||||
code += f"""
|
||||
# RSI ({period})
|
||||
delta = df['close'].diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window={period}).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window={period}).mean()
|
||||
rs = gain / loss
|
||||
df['rsi_{period}'] = 100 - (100 / (1 + rs))
|
||||
"""
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'macd':
|
||||
fast = params.get('fast_period', 12)
|
||||
slow = params.get('slow_period', 26)
|
||||
signal = params.get('signal_period', 9)
|
||||
key = f"macd_{fast}_{slow}_{signal}"
|
||||
if key not in calculated:
|
||||
code += f"""
|
||||
# MACD ({fast}, {slow}, {signal})
|
||||
exp1 = df['close'].ewm(span={fast}, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span={slow}, adjust=False).mean()
|
||||
df['macd_{fast}_{slow}_{signal}_line'] = exp1 - exp2
|
||||
df['macd_{fast}_{slow}_{signal}_signal'] = df['macd_{fast}_{slow}_{signal}_line'].ewm(span={signal}, adjust=False).mean()
|
||||
df['macd_{fast}_{slow}_{signal}_hist'] = df['macd_{fast}_{slow}_{signal}_line'] - df['macd_{fast}_{slow}_{signal}_signal']
|
||||
"""
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'bollinger':
|
||||
period = params.get('period', 20)
|
||||
std_dev = params.get('std_dev', 2.0)
|
||||
key = f"bb_{period}_{std_dev}"
|
||||
if key not in calculated:
|
||||
code += f"""
|
||||
# Bollinger Bands ({period}, {std_dev})
|
||||
sma = df['close'].rolling(window={period}).mean()
|
||||
std = df['close'].rolling(window={period}).std()
|
||||
df['bb_{period}_{std_dev}_upper'] = sma + ({std_dev} * std)
|
||||
df['bb_{period}_{std_dev}_lower'] = sma - ({std_dev} * std)
|
||||
df['bb_{period}_{std_dev}_mid'] = sma
|
||||
"""
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'kdj':
|
||||
period = params.get('period', 9)
|
||||
signal_period = params.get('signal_period', 3)
|
||||
key = f"kdj_{period}_{signal_period}"
|
||||
if key not in calculated:
|
||||
code += f"""
|
||||
# KDJ ({period}, {signal_period})
|
||||
low_min = df['low'].rolling(window={period}).min()
|
||||
high_max = df['high'].rolling(window={period}).max()
|
||||
rsv = (df['close'] - low_min) / (high_max - low_min) * 100
|
||||
df['kdj_{period}_{signal_period}_k'] = rsv.ewm(alpha=1/{signal_period}, adjust=False).mean()
|
||||
df['kdj_{period}_{signal_period}_d'] = df['kdj_{period}_{signal_period}_k'].ewm(alpha=1/{signal_period}, adjust=False).mean()
|
||||
df['kdj_{period}_{signal_period}_j'] = 3 * df['kdj_{period}_{signal_period}_k'] - 2 * df['kdj_{period}_{signal_period}_d']
|
||||
"""
|
||||
calculated.add(key)
|
||||
|
||||
elif ind == 'ma':
|
||||
period = params.get('period', 20)
|
||||
ma_type = params.get('ma_type', 'sma')
|
||||
key = f"ma_{ma_type}_{period}"
|
||||
if key not in calculated:
|
||||
if ma_type == 'ema':
|
||||
code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].ewm(span={period}, adjust=False).mean()\n"
|
||||
else:
|
||||
code += f"\ndf['ma_{ma_type}_{period}'] = df['close'].rolling(window={period}).mean()\n"
|
||||
calculated.add(key)
|
||||
|
||||
return code
|
||||
|
||||
def _get_entry_logic(self, rules):
|
||||
code = """
|
||||
# ===========================
|
||||
# 3. Entry Signal Logic
|
||||
# ===========================
|
||||
# Default False
|
||||
df['raw_buy'] = False
|
||||
df['raw_sell'] = False
|
||||
"""
|
||||
conditions_buy = []
|
||||
conditions_sell = []
|
||||
|
||||
for rule in rules:
|
||||
ind = rule.get('indicator')
|
||||
params = rule.get('params', {})
|
||||
|
||||
if ind == 'supertrend':
|
||||
signal = rule.get('signal', 'trend_bullish')
|
||||
if signal == 'trend_bullish':
|
||||
conditions_buy.append("(df['st_trend'] == 1) & (df['st_trend'].shift(1) == -1)")
|
||||
conditions_sell.append("(df['st_trend'] == -1) & (df['st_trend'].shift(1) == 1)")
|
||||
elif signal == 'is_uptrend':
|
||||
conditions_buy.append("(df['st_trend'] == 1)")
|
||||
conditions_sell.append("(df['st_trend'] == -1)")
|
||||
|
||||
elif ind == 'ema':
|
||||
period = params.get('period', 20)
|
||||
operator = rule.get('operator', 'price_above')
|
||||
col = f"df['ema_{period}']"
|
||||
if operator == 'price_above':
|
||||
conditions_buy.append(f"(df['close'] > {col})")
|
||||
conditions_sell.append(f"(df['close'] < {col})")
|
||||
elif operator == 'price_below':
|
||||
conditions_buy.append(f"(df['close'] < {col})")
|
||||
conditions_sell.append(f"(df['close'] > {col})")
|
||||
elif operator == 'cross_up':
|
||||
conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))")
|
||||
elif operator == 'cross_down':
|
||||
conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))")
|
||||
|
||||
elif ind == 'rsi':
|
||||
period = params.get('period', 14)
|
||||
operator = rule.get('operator', '<')
|
||||
thresh = params.get('threshold', 30)
|
||||
col = f"df['rsi_{period}']"
|
||||
if operator == '<':
|
||||
conditions_buy.append(f"({col} < {thresh})")
|
||||
conditions_sell.append(f"({col} > {100-thresh})")
|
||||
elif operator == '>':
|
||||
conditions_buy.append(f"({col} > {thresh})")
|
||||
conditions_sell.append(f"({col} < {100-thresh})")
|
||||
elif operator == 'cross_up':
|
||||
conditions_buy.append(f"({col} > {thresh}) & ({col}.shift(1) <= {thresh})")
|
||||
conditions_sell.append(f"({col} < {100-thresh}) & ({col}.shift(1) >= {100-thresh})")
|
||||
elif operator == 'cross_down':
|
||||
conditions_buy.append(f"({col} < {thresh}) & ({col}.shift(1) >= {thresh})")
|
||||
conditions_sell.append(f"({col} > {100-thresh}) & ({col}.shift(1) <= {100-thresh})")
|
||||
|
||||
elif ind == 'macd':
|
||||
fast = params.get('fast_period', 12)
|
||||
slow = params.get('slow_period', 26)
|
||||
signal = params.get('signal_period', 9)
|
||||
operator = rule.get('operator', 'diff_gt_dea')
|
||||
line_col = f"df['macd_{fast}_{slow}_{signal}_line']"
|
||||
sig_col = f"df['macd_{fast}_{slow}_{signal}_signal']"
|
||||
|
||||
if operator == 'diff_gt_dea':
|
||||
conditions_buy.append(f"({line_col} > {sig_col})")
|
||||
conditions_sell.append(f"({line_col} < {sig_col})")
|
||||
elif operator == 'diff_lt_dea':
|
||||
conditions_buy.append(f"({line_col} < {sig_col})")
|
||||
conditions_sell.append(f"({line_col} > {sig_col})")
|
||||
elif operator == 'cross_up':
|
||||
conditions_buy.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))")
|
||||
conditions_sell.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))")
|
||||
elif operator == 'cross_down':
|
||||
conditions_buy.append(f"({line_col} < {sig_col}) & ({line_col}.shift(1) >= {sig_col}.shift(1))")
|
||||
conditions_sell.append(f"({line_col} > {sig_col}) & ({line_col}.shift(1) <= {sig_col}.shift(1))")
|
||||
|
||||
elif ind == 'bollinger':
|
||||
period = params.get('period', 20)
|
||||
std_dev = params.get('std_dev', 2.0)
|
||||
operator = rule.get('operator', 'price_above_upper')
|
||||
upper = f"df['bb_{period}_{std_dev}_upper']"
|
||||
lower = f"df['bb_{period}_{std_dev}_lower']"
|
||||
mid = f"df['bb_{period}_{std_dev}_mid']"
|
||||
|
||||
if operator == 'price_above_upper':
|
||||
conditions_buy.append(f"(df['close'] > {upper})")
|
||||
conditions_sell.append(f"(df['close'] < {lower})")
|
||||
elif operator == 'price_below_lower':
|
||||
conditions_buy.append(f"(df['close'] < {lower})")
|
||||
conditions_sell.append(f"(df['close'] > {upper})")
|
||||
elif operator == 'price_above_mid':
|
||||
conditions_buy.append(f"(df['close'] > {mid})")
|
||||
conditions_sell.append(f"(df['close'] < {mid})")
|
||||
elif operator == 'price_below_mid':
|
||||
conditions_buy.append(f"(df['close'] < {mid})")
|
||||
conditions_sell.append(f"(df['close'] > {mid})")
|
||||
elif operator == 'cross_up_lower':
|
||||
conditions_buy.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))")
|
||||
elif operator == 'cross_down_upper':
|
||||
conditions_buy.append(f"(df['close'] < {upper}) & (df['close'].shift(1) >= {upper}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] > {lower}) & (df['close'].shift(1) <= {lower}.shift(1))")
|
||||
|
||||
elif ind == 'kdj':
|
||||
period = params.get('period', 9)
|
||||
signal_period = params.get('signal_period', 3)
|
||||
operator = rule.get('operator', 'k_gt_d')
|
||||
k_col = f"df['kdj_{period}_{signal_period}_k']"
|
||||
d_col = f"df['kdj_{period}_{signal_period}_d']"
|
||||
|
||||
if operator == 'k_gt_d':
|
||||
conditions_buy.append(f"({k_col} > {d_col})")
|
||||
conditions_sell.append(f"({k_col} < {d_col})")
|
||||
elif operator == 'k_lt_d':
|
||||
conditions_buy.append(f"({k_col} < {d_col})")
|
||||
conditions_sell.append(f"({k_col} > {d_col})")
|
||||
elif operator == 'gold_cross':
|
||||
conditions_buy.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))")
|
||||
conditions_sell.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))")
|
||||
elif operator == 'death_cross':
|
||||
conditions_buy.append(f"({k_col} < {d_col}) & ({k_col}.shift(1) >= {d_col}.shift(1))")
|
||||
conditions_sell.append(f"({k_col} > {d_col}) & ({k_col}.shift(1) <= {d_col}.shift(1))")
|
||||
|
||||
elif ind == 'ma':
|
||||
period = params.get('period', 20)
|
||||
ma_type = params.get('ma_type', 'sma')
|
||||
operator = rule.get('operator', 'price_above')
|
||||
col = f"df['ma_{ma_type}_{period}']"
|
||||
|
||||
if operator == 'price_above':
|
||||
conditions_buy.append(f"(df['close'] > {col})")
|
||||
conditions_sell.append(f"(df['close'] < {col})")
|
||||
elif operator == 'price_below':
|
||||
conditions_buy.append(f"(df['close'] < {col})")
|
||||
conditions_sell.append(f"(df['close'] > {col})")
|
||||
elif operator == 'cross_up':
|
||||
conditions_buy.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))")
|
||||
elif operator == 'cross_down':
|
||||
conditions_buy.append(f"(df['close'] < {col}) & (df['close'].shift(1) >= {col}.shift(1))")
|
||||
conditions_sell.append(f"(df['close'] > {col}) & (df['close'].shift(1) <= {col}.shift(1))")
|
||||
|
||||
if conditions_buy:
|
||||
code += f"\ndf['raw_buy'] = {' & '.join(conditions_buy)}\n"
|
||||
if conditions_sell:
|
||||
code += f"\ndf['raw_sell'] = {' & '.join(conditions_sell)}\n"
|
||||
|
||||
return code
|
||||
|
||||
def _get_core_loop(self, pos_config, pyr_rules, risk_mgmt):
|
||||
# This mirrors the logic in code2.py loop
|
||||
return """
|
||||
# ===========================
|
||||
# 4. Core Loop (Backtest)
|
||||
# ===========================
|
||||
open_long_signals = [False] * len(df)
|
||||
add_long_signals = [False] * len(df)
|
||||
close_long_signals = [False] * len(df)
|
||||
|
||||
open_long_text = [None] * len(df)
|
||||
add_long_text = [None] * len(df)
|
||||
|
||||
open_long_price = [0.0] * len(df)
|
||||
add_long_price = [0.0] * len(df)
|
||||
close_long_price = [0.0] * len(df)
|
||||
close_long_text = [None] * len(df)
|
||||
|
||||
open_short_signals = [False] * len(df)
|
||||
add_short_signals = [False] * len(df)
|
||||
close_short_signals = [False] * len(df)
|
||||
|
||||
open_short_price = [0.0] * len(df)
|
||||
add_short_price = [0.0] * len(df)
|
||||
close_short_price = [0.0] * len(df)
|
||||
close_short_text = [None] * len(df)
|
||||
|
||||
position = 0 # 0, 1 (Long), -1 (Short)
|
||||
position_count = 0
|
||||
avg_entry_price = 0.0
|
||||
last_add_price = 0.0
|
||||
highest_price = 0.0 # For Long: Highest High; For Short: Lowest Low
|
||||
|
||||
close_arr = df['close'].values
|
||||
high_arr = df['high'].values
|
||||
low_arr = df['low'].values
|
||||
raw_buy_arr = df['raw_buy'].values
|
||||
raw_sell_arr = df['raw_sell'].values
|
||||
|
||||
for i in range(len(df)):
|
||||
current_close = close_arr[i]
|
||||
current_high = high_arr[i]
|
||||
current_low = low_arr[i]
|
||||
|
||||
if position == 1:
|
||||
# Long Position
|
||||
if current_high > highest_price:
|
||||
highest_price = current_high
|
||||
|
||||
profit_pct = (highest_price - avg_entry_price) / avg_entry_price
|
||||
current_profit_pct = (current_close - avg_entry_price) / avg_entry_price
|
||||
|
||||
# 1. Trailing Stop
|
||||
if take_profit_activation > 0 and profit_pct >= take_profit_activation:
|
||||
drawdown = (highest_price - current_close) / avg_entry_price
|
||||
if drawdown >= trailing_callback:
|
||||
close_long_signals[i] = True
|
||||
close_long_price[i] = current_close
|
||||
close_long_text[i] = "Trailing Stop"
|
||||
position = 0
|
||||
position_count = 0
|
||||
continue
|
||||
|
||||
# 2. Stop Loss
|
||||
if stop_loss_pct > 0:
|
||||
loss_pct = (avg_entry_price - current_low) / avg_entry_price
|
||||
if loss_pct >= stop_loss_pct:
|
||||
close_long_signals[i] = True
|
||||
close_long_price[i] = avg_entry_price * (1 - stop_loss_pct)
|
||||
close_long_text[i] = "Stop Loss"
|
||||
position = 0
|
||||
position_count = 0
|
||||
continue
|
||||
|
||||
# 3. Signal Exit (if enabled)
|
||||
# Note: Code2 uses raw_sell_arr for exit
|
||||
if raw_sell_arr[i]:
|
||||
close_long_signals[i] = True
|
||||
close_long_price[i] = current_close
|
||||
close_long_text[i] = "Signal Exit"
|
||||
position = 0
|
||||
position_count = 0
|
||||
|
||||
# Reverse to Short if trade_direction allows (simplified here)
|
||||
# For now we just close.
|
||||
continue
|
||||
|
||||
# 4. Pyramiding (Add Long)
|
||||
if max_pyramiding > 0 and position_count < max_pyramiding + 1 and current_profit_pct > 0:
|
||||
# Condition: Price rise by threshold
|
||||
if add_threshold_pct > 0:
|
||||
target_price = last_add_price * (1 + add_threshold_pct)
|
||||
if current_high >= target_price:
|
||||
add_long_signals[i] = True
|
||||
add_long_price[i] = target_price
|
||||
add_long_text[i] = "Add Long"
|
||||
position_count += 1
|
||||
last_add_price = target_price
|
||||
|
||||
elif position == -1:
|
||||
# Short Position
|
||||
# For Short, highest_price tracks the LOWEST price (best profit scenario)
|
||||
if highest_price == 0: highest_price = avg_entry_price
|
||||
if current_low < highest_price:
|
||||
highest_price = current_low
|
||||
|
||||
# Profit: (Entry - Lowest) / Entry
|
||||
profit_pct = (avg_entry_price - highest_price) / avg_entry_price
|
||||
current_profit_pct = (avg_entry_price - current_close) / avg_entry_price
|
||||
|
||||
# 1. Trailing Stop
|
||||
if take_profit_activation > 0 and profit_pct >= take_profit_activation:
|
||||
# Drawdown: (Current - Lowest) / Entry
|
||||
drawdown = (current_close - highest_price) / avg_entry_price
|
||||
if drawdown >= trailing_callback:
|
||||
close_short_signals[i] = True
|
||||
close_short_price[i] = current_close
|
||||
close_short_text[i] = "Trailing Stop"
|
||||
position = 0
|
||||
position_count = 0
|
||||
continue
|
||||
|
||||
# 2. Stop Loss
|
||||
if stop_loss_pct > 0:
|
||||
# Loss: Price went up. (High - Entry) / Entry
|
||||
loss_pct = (current_high - avg_entry_price) / avg_entry_price
|
||||
if loss_pct >= stop_loss_pct:
|
||||
close_short_signals[i] = True
|
||||
close_short_price[i] = avg_entry_price * (1 + stop_loss_pct)
|
||||
close_short_text[i] = "Stop Loss"
|
||||
position = 0
|
||||
position_count = 0
|
||||
continue
|
||||
|
||||
# 3. Signal Exit
|
||||
if raw_buy_arr[i]:
|
||||
close_short_signals[i] = True
|
||||
close_short_price[i] = current_close
|
||||
close_short_text[i] = "Signal Exit"
|
||||
position = 0
|
||||
position_count = 0
|
||||
continue
|
||||
|
||||
# 4. Pyramiding (Add Short)
|
||||
if max_pyramiding > 0 and position_count < max_pyramiding + 1 and current_profit_pct > 0:
|
||||
# Condition: Price drop by threshold
|
||||
if add_threshold_pct > 0:
|
||||
target_price = last_add_price * (1 - add_threshold_pct)
|
||||
if current_low <= target_price:
|
||||
add_short_signals[i] = True
|
||||
add_short_price[i] = target_price
|
||||
add_short_text[i] = "Add Short"
|
||||
position_count += 1
|
||||
last_add_price = target_price
|
||||
|
||||
else:
|
||||
# No Position
|
||||
if raw_buy_arr[i]:
|
||||
open_long_signals[i] = True
|
||||
open_long_price[i] = current_close
|
||||
open_long_text[i] = "Open Long"
|
||||
position = 1
|
||||
position_count = 1
|
||||
avg_entry_price = current_close
|
||||
last_add_price = current_close
|
||||
highest_price = current_close
|
||||
|
||||
elif raw_sell_arr[i]:
|
||||
open_short_signals[i] = True
|
||||
open_short_price[i] = current_close
|
||||
open_short_text[i] = "Open Short"
|
||||
position = -1
|
||||
position_count = 1
|
||||
avg_entry_price = current_close
|
||||
last_add_price = current_close
|
||||
highest_price = current_close # Init with Entry
|
||||
|
||||
# Append columns
|
||||
df['open_long'] = open_long_signals
|
||||
df['add_long'] = add_long_signals
|
||||
df['close_long'] = close_long_signals
|
||||
df['open_long_price'] = [p if s else None for p, s in zip(open_long_price, open_long_signals)]
|
||||
df['add_long_price'] = [p if s else None for p, s in zip(add_long_price, add_long_signals)]
|
||||
df['close_long_price'] = [p if s else None for p, s in zip(close_long_price, close_long_signals)]
|
||||
df['open_long_text'] = open_long_text
|
||||
df['add_long_text'] = add_long_text
|
||||
df['close_long_text'] = close_long_text
|
||||
"""
|
||||
|
||||
def _get_output_section(self, name, rules):
|
||||
# Generate plot configs based on indicators
|
||||
plots = []
|
||||
for rule in rules:
|
||||
ind = rule.get('indicator')
|
||||
params = rule.get('params', {})
|
||||
if ind == 'supertrend':
|
||||
plots.append({
|
||||
"name": "SuperTrend Up", "type": "line", "data": "df['st_lower'].tolist()", "color": "#00FF00", "overlay": True
|
||||
})
|
||||
plots.append({
|
||||
"name": "SuperTrend Down", "type": "line", "data": "df['st_upper'].tolist()", "color": "#FF0000", "overlay": True
|
||||
})
|
||||
elif ind == 'ema':
|
||||
p = params.get('period', 20)
|
||||
plots.append({
|
||||
"name": f"EMA {p}", "type": "line", "data": f"df['ema_{p}'].tolist()", "color": "#FFA500", "overlay": True
|
||||
})
|
||||
elif ind == 'ma':
|
||||
p = params.get('period', 20)
|
||||
t = params.get('ma_type', 'sma')
|
||||
plots.append({
|
||||
"name": f"{t.upper()} {p}", "type": "line", "data": f"df['ma_{t}_{p}'].tolist()", "color": "#FFA500", "overlay": True
|
||||
})
|
||||
elif ind == 'bollinger':
|
||||
p = params.get('period', 20)
|
||||
d = params.get('std_dev', 2.0)
|
||||
plots.append({
|
||||
"name": "BB Upper", "type": "line", "data": f"df['bb_{p}_{d}_upper'].tolist()", "color": "#0088FE", "overlay": True
|
||||
})
|
||||
plots.append({
|
||||
"name": "BB Lower", "type": "line", "data": f"df['bb_{p}_{d}_lower'].tolist()", "color": "#0088FE", "overlay": True
|
||||
})
|
||||
# MACD, RSI, KDJ are typically separate panes, not overlay. The `overlay` param controls this.
|
||||
elif ind == 'macd':
|
||||
f = params.get('fast_period', 12)
|
||||
s = params.get('slow_period', 26)
|
||||
si = params.get('signal_period', 9)
|
||||
plots.append({
|
||||
"name": "MACD", "type": "line", "data": f"df['macd_{f}_{s}_{si}_line'].tolist()", "color": "#0088FE", "overlay": False
|
||||
})
|
||||
plots.append({
|
||||
"name": "Signal", "type": "line", "data": f"df['macd_{f}_{s}_{si}_signal'].tolist()", "color": "#FF8042", "overlay": False
|
||||
})
|
||||
elif ind == 'rsi':
|
||||
p = params.get('period', 14)
|
||||
plots.append({
|
||||
"name": f"RSI {p}", "type": "line", "data": f"df['rsi_{p}'].tolist()", "color": "#8884d8", "overlay": False
|
||||
})
|
||||
elif ind == 'kdj':
|
||||
p = params.get('period', 9)
|
||||
si = params.get('signal_period', 3)
|
||||
plots.append({
|
||||
"name": "K", "type": "line", "data": f"df['kdj_{p}_{si}_k'].tolist()", "color": "#8884d8", "overlay": False
|
||||
})
|
||||
plots.append({
|
||||
"name": "D", "type": "line", "data": f"df['kdj_{p}_{si}_d'].tolist()", "color": "#82ca9d", "overlay": False
|
||||
})
|
||||
plots.append({
|
||||
"name": "J", "type": "line", "data": f"df['kdj_{p}_{si}_j'].tolist()", "color": "#ffc658", "overlay": False
|
||||
})
|
||||
|
||||
# Convert plots to string representation valid in Python
|
||||
plots_py = "[\n"
|
||||
for p in plots:
|
||||
plots_py += f" {{'name': '{p['name']}', 'type': '{p['type']}', 'data': {p['data']}, 'color': '{p['color']}', 'overlay': {p['overlay']}}},\n"
|
||||
plots_py += "]"
|
||||
|
||||
return f"""
|
||||
# ===========================
|
||||
# 5. Output
|
||||
# ===========================
|
||||
output = {{
|
||||
"name": "{name}",
|
||||
"plots": {plots_py},
|
||||
"signals": [
|
||||
{{
|
||||
"name": "Open Long",
|
||||
"type": "buy",
|
||||
"data": df['open_long_price'].tolist(),
|
||||
"color": "#00FF00",
|
||||
"text": "Open Long"
|
||||
}},
|
||||
{{
|
||||
"name": "Add Long",
|
||||
"type": "buy",
|
||||
"data": df['add_long_price'].tolist(),
|
||||
"color": "#00DD00",
|
||||
"text": "Add Long"
|
||||
}},
|
||||
{{
|
||||
"name": "Close Long",
|
||||
"type": "sell",
|
||||
"data": df['close_long_price'].tolist(),
|
||||
"color": "#FF6600",
|
||||
"text": "Close Long"
|
||||
}},
|
||||
{{
|
||||
"name": "Open Short",
|
||||
"type": "sell",
|
||||
"data": df['open_short_price'].tolist(),
|
||||
"color": "#FF0000",
|
||||
"text": "Open Short"
|
||||
}},
|
||||
{{
|
||||
"name": "Add Short",
|
||||
"type": "sell",
|
||||
"data": df['add_short_price'].tolist(),
|
||||
"color": "#DD0000",
|
||||
"text": "Add Short"
|
||||
}},
|
||||
{{
|
||||
"name": "Close Short",
|
||||
"type": "buy",
|
||||
"data": df['close_short_price'].tolist(),
|
||||
"color": "#00CCFF",
|
||||
"text": "Close Short"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Symbol/company name resolver for local-only mode.
|
||||
|
||||
Goal:
|
||||
- When a symbol is not present in our seed list, try to resolve a human-readable name
|
||||
from public data sources, then persist it into watchlist records.
|
||||
|
||||
Notes:
|
||||
- For A shares we prefer akshare when available (requested).
|
||||
- For H shares we use Tencent quote API (no key required).
|
||||
- For US stocks we use Finnhub (if configured) or yfinance.
|
||||
- For Crypto/Forex/Futures we provide best-effort fallbacks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import re
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
HAS_AKSHARE = True
|
||||
except Exception:
|
||||
ak = None
|
||||
HAS_AKSHARE = False
|
||||
|
||||
|
||||
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if not m or not s:
|
||||
return s
|
||||
|
||||
if m == 'AShare' and s.isdigit():
|
||||
return s.zfill(6)
|
||||
if m == 'HShare' and s.isdigit():
|
||||
return s.zfill(5)
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def _tencent_quote_code(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Convert symbol to Tencent quote code, e.g.
|
||||
- AShare: sh600000 / sz000001 / bj430047
|
||||
- HShare: hk00700
|
||||
"""
|
||||
m = (market or '').strip()
|
||||
s = _normalize_symbol_for_market(m, symbol)
|
||||
if not s:
|
||||
return None
|
||||
|
||||
if m == 'AShare':
|
||||
if s.startswith('6'):
|
||||
return f"sh{s}"
|
||||
if s.startswith('0') or s.startswith('3'):
|
||||
return f"sz{s}"
|
||||
if s.startswith('4') or s.startswith('8'):
|
||||
return f"bj{s}"
|
||||
return None
|
||||
|
||||
if m == 'HShare':
|
||||
if s.isdigit():
|
||||
return f"hk{s}"
|
||||
# allow already prefixed
|
||||
if s.startswith('HK') and s[2:].isdigit():
|
||||
return f"hk{s[2:]}"
|
||||
if s.startswith('HK') and len(s) > 2:
|
||||
return f"hk{s[2:]}"
|
||||
return f"hk{s}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_name_from_tencent(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Tencent quote endpoint: http://qt.gtimg.cn/q=sz000858
|
||||
Returns:
|
||||
v_sz000858="51~五 粮 液~000858~..."; -> name is the 2nd field split by '~'
|
||||
"""
|
||||
code = _tencent_quote_code(market, symbol)
|
||||
if not code:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"http://qt.gtimg.cn/q={code}"
|
||||
resp = requests.get(url, timeout=5)
|
||||
# Tencent often responds in GBK for Chinese names
|
||||
resp.encoding = 'gbk'
|
||||
text = resp.text or ''
|
||||
|
||||
# Extract quoted payload
|
||||
m = re.search(r'="([^"]*)"', text)
|
||||
payload = m.group(1) if m else ''
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
parts = payload.split('~')
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
name = (parts[1] or '').strip().replace(' ', '')
|
||||
return name if name else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Tencent name resolve failed: {market} {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_name_from_akshare_ashare(symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve A-share name via akshare (no API key required).
|
||||
"""
|
||||
if not HAS_AKSHARE or ak is None:
|
||||
return None
|
||||
try:
|
||||
# Prefer per-symbol endpoint (avoids fetching the whole market list).
|
||||
if hasattr(ak, "stock_individual_info_em"):
|
||||
df = ak.stock_individual_info_em(symbol=symbol)
|
||||
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
|
||||
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
|
||||
name = str(info.get('股票简称') or info.get('证券简称') or '').strip()
|
||||
return name if name else None
|
||||
|
||||
# Fallback: spot list (may be slow / large)
|
||||
if hasattr(ak, "stock_zh_a_spot_em"):
|
||||
df2 = ak.stock_zh_a_spot_em()
|
||||
if df2 is not None and not df2.empty:
|
||||
row = df2[df2['代码'] == symbol].iloc[0]
|
||||
name = str(row.get('名称') or '').strip()
|
||||
return name if name else None
|
||||
except Exception as e:
|
||||
logger.debug(f"akshare name resolve failed (AShare {symbol}): {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def _resolve_name_from_yfinance(symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Best-effort company name via yfinance.
|
||||
"""
|
||||
def _try_one(sym: str) -> Optional[str]:
|
||||
import yfinance as yf
|
||||
t = yf.Ticker(sym)
|
||||
info = getattr(t, "info", None)
|
||||
if not isinstance(info, dict) or not info:
|
||||
return None
|
||||
name = (info.get('longName') or info.get('shortName') or '').strip()
|
||||
return name if name else None
|
||||
try:
|
||||
# yfinance uses '-' for some tickers (e.g. BRK-B) while users may input 'BRK.B'
|
||||
out = _try_one(symbol)
|
||||
if out:
|
||||
return out
|
||||
if '.' in symbol:
|
||||
out = _try_one(symbol.replace('.', '-'))
|
||||
if out:
|
||||
return out
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"yfinance name resolve failed: {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_name_from_finnhub(symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Finnhub company profile (requires FINNHUB_API_KEY).
|
||||
https://finnhub.io/docs/api/company-profile2
|
||||
"""
|
||||
try:
|
||||
api_key = (os.getenv('FINNHUB_API_KEY') or '').strip()
|
||||
if not api_key:
|
||||
return None
|
||||
url = "https://finnhub.io/api/v1/stock/profile2"
|
||||
resp = requests.get(url, params={"symbol": symbol, "token": api_key}, timeout=8)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json() if resp.text else {}
|
||||
if not isinstance(data, dict) or not data:
|
||||
return None
|
||||
name = (data.get("name") or data.get("ticker") or '').strip()
|
||||
return name if name else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Finnhub name resolve failed: {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a display name for a symbol.
|
||||
Priority:
|
||||
1) Seed mapping (fast, offline)
|
||||
2) Market-specific public sources
|
||||
3) Reasonable fallback (None)
|
||||
"""
|
||||
m = (market or '').strip()
|
||||
s = _normalize_symbol_for_market(m, symbol)
|
||||
if not m or not s:
|
||||
return None
|
||||
|
||||
# 1) Seed
|
||||
seed = seed_get_symbol_name(m, s)
|
||||
if seed:
|
||||
return seed
|
||||
|
||||
# 2) Market-specific
|
||||
if m == 'AShare':
|
||||
# Requested: use akshare for A shares, do not depend on Tencent by default.
|
||||
return _resolve_name_from_akshare_ashare(s)
|
||||
|
||||
if m == 'HShare':
|
||||
return _resolve_name_from_tencent(m, s)
|
||||
|
||||
if m == 'USStock':
|
||||
# Prefer Finnhub if configured (more stable for company name),
|
||||
# otherwise fall back to yfinance.
|
||||
return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s)
|
||||
|
||||
# Crypto: at least return base ticker-like display (not a "company", but better than empty)
|
||||
if m == 'Crypto':
|
||||
if '/' in s:
|
||||
base = s.split('/')[0].strip()
|
||||
return base if base else None
|
||||
return s
|
||||
|
||||
# Forex: keep as-is (e.g. EURUSD) – you can later replace with a nicer mapping if needed.
|
||||
if m == 'Forex':
|
||||
return s
|
||||
|
||||
# Futures: keep as-is
|
||||
if m == 'Futures':
|
||||
return s
|
||||
|
||||
return None
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user