Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-29 03:06:49 +08:00
commit f43312a858
292 changed files with 103739 additions and 0 deletions
@@ -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.')
+168
View File
@@ -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
+73
View File
@@ -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}"
+161
View File
@@ -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
+142
View File
@@ -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)
+465
View File
@@ -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