feat(trading-assistant): refactor strategy creation and enhance script mode functionality

- Removed conditional rendering for the assistant guide bar.
- Simplified strategy overview and strategy list item components.
- Introduced a new modal for selecting strategy mode.
- Enhanced strategy creation modal to support script strategies with a dedicated editor.
- Updated form handling for script strategies, including validation and submission logic.
- Improved user experience with better messaging and streamlined UI components.
- Updated translations for better clarity in Chinese.
This commit is contained in:
dienakdz
2026-04-08 07:27:26 +07:00
parent 2dc29a215e
commit c3cf230104
89 changed files with 4653 additions and 1735 deletions
@@ -84,7 +84,7 @@ class AnalysisMemory:
cur.execute("""
DO $$
BEGIN
-- 添加 user_id 列(如果不存在)
-- Add the user_id column if it does not exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id'
@@ -92,7 +92,7 @@ class AnalysisMemory:
ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT;
END IF;
-- 添加 raw_result 列(如果不存在)
-- Add the raw_result column if it does not exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'raw_result'
+3 -3
View File
@@ -3378,7 +3378,7 @@ import pandas as pd
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Long liquidation! entry={entry_price:.2f}, low={low:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}")
is_liquidated = True
liquidation_loss = self._liquidation_loss(capital)
capital = 0
@@ -3402,7 +3402,7 @@ import pandas as pd
stop_loss_price = close_short_price_arr[i] if has_stop_loss else 0
logger.warning(f"[candle {i}] Short hit liquidation! entry={entry_price:.2f}, high={high:.2f}, liq_price={liquidation_price:.2f}, "
f"止损信号={close_short_arr[i]}, 止损价={stop_loss_price:.4f}, 时间={timestamp}")
f"stop_loss_signal={close_short_arr[i]}, stop_loss_price={stop_loss_price:.4f}, time={timestamp}")
# Determine SL or liquidation first
if has_stop_loss and stop_loss_price < liquidation_price:
@@ -3425,7 +3425,7 @@ import pandas as pd
else:
# SL not strict enough, liquidation triggered
logger.warning(f"Short liquidation! entry={entry_price:.2f}, high={high:.2f}, "
f"爆仓线={liquidation_price:.2f}, 止损价={stop_loss_price:.2f}")
f"liq_price={liquidation_price:.2f}, stop_loss_price={stop_loss_price:.2f}")
is_liquidated = True
liquidation_loss = self._liquidation_loss(capital)
capital = 0
@@ -155,7 +155,7 @@ class CommunityService:
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_indicator_detail(self, indicator_id: int, user_id: int = None) -> Optional[Dict[str, Any]]:
"""获取指标详情"""
"""Get indicator details."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -235,8 +235,8 @@ class CommunityService:
def purchase_indicator(self, buyer_id: int, indicator_id: int) -> Tuple[bool, str, Dict[str, Any]]:
"""
购买指标
Purchase an indicator.
Returns:
(success, message, data)
"""
@@ -367,7 +367,7 @@ class CommunityService:
return False, f'error: {str(e)}', {}
def get_my_purchases(self, user_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取用户购买的指标列表"""
"""Get the list of indicators purchased by the user."""
offset = (page - 1) * page_size
try:
@@ -433,7 +433,7 @@ class CommunityService:
# ==========================================
def get_comments(self, indicator_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""获取指标评论列表"""
"""Get the list of indicator comments."""
offset = (page - 1) * page_size
try:
@@ -495,7 +495,9 @@ class CommunityService:
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
添加评论只有购买过的用户可以评论且只能评论一次
Add a comment.
Only users who purchased the indicator can comment, and only once.
"""
try:
# Verify score range
@@ -577,7 +579,9 @@ class CommunityService:
content: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
更新评论只能修改自己的评论
Update a comment.
Users can only edit their own comments.
"""
try:
rating = max(1, min(5, int(rating)))
@@ -628,7 +632,7 @@ class CommunityService:
return False, f'error: {str(e)}', {}
def get_user_comment(self, user_id: int, indicator_id: int) -> Optional[Dict[str, Any]]:
"""获取用户对某个指标的评论"""
"""Get the user's comment for a specific indicator."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -665,7 +669,7 @@ class CommunityService:
page_size: int = 20,
review_status: str = 'pending' # 'pending' / 'approved' / 'rejected' / 'all'
) -> Dict[str, Any]:
"""获取待审核的指标列表(管理员用)"""
"""Get the list of indicators pending review for admins."""
offset = (page - 1) * page_size
try:
@@ -753,7 +757,7 @@ class CommunityService:
action: str, # 'approve' / 'reject'
note: str = ''
) -> Tuple[bool, str]:
"""审核指标"""
"""Review an indicator."""
try:
new_status = 'approved' if action == 'approve' else 'rejected'
note = (note or '').strip()[:500]
@@ -790,7 +794,7 @@ class CommunityService:
return False, f'error: {str(e)}'
def unpublish_indicator(self, admin_id: int, indicator_id: int, note: str = '') -> Tuple[bool, str]:
"""下架指标(取消发布)"""
"""Unpublish an indicator."""
try:
note = (note or '').strip()[:500]
@@ -826,7 +830,7 @@ class CommunityService:
return False, f'error: {str(e)}'
def admin_delete_indicator(self, admin_id: int, indicator_id: int) -> Tuple[bool, str]:
"""管理员删除指标"""
"""Delete an indicator as an admin."""
try:
with get_db_connection() as db:
cur = db.cursor()
@@ -889,11 +893,11 @@ class CommunityService:
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
"""
获取指标的实盘表现统计
Get live performance statistics for an indicator.
数据来源
1. qd_backtest_runs - 回测记录result_json 内含 totalReturn / winRate
2. qd_strategy_trades + qd_strategies_trading - 真实实盘交易记录
Data sources:
1. qd_backtest_runs - Backtest records (result_json contains totalReturn, winRate, etc.)
2. qd_strategy_trades + qd_strategies_trading - Real live trading records
"""
default_result = {
'strategy_count': 0,
@@ -1032,7 +1036,7 @@ _community_service = None
def get_community_service() -> CommunityService:
"""获取社区服务单例"""
"""Get the community service singleton."""
global _community_service
if _community_service is None:
_community_service = CommunityService()
@@ -272,13 +272,13 @@ class IndicatorCaller:
def get_indicator_params(indicator_id: int) -> List[Dict[str, Any]]:
"""
获取指标的参数声明供API调用
Get the indicator parameter declarations for API usage
Args:
indicator_id: 指标ID
indicator_id: Indicator ID
Returns:
参数声明列表
A list of parameter declarations
"""
try:
with get_db_connection() as db:
@@ -722,8 +722,8 @@ class OkxClient(BaseRestClient):
"""
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.
# IMPORTANT: For OKX SWAP, fillSz/accFillSz are in contract counts, 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:
+3 -3
View File
@@ -212,11 +212,11 @@ class LLMService:
if "openrouter" in (base_url or "").lower():
from app.config.api_keys import APIKeys
if not APIKeys.OPENROUTER_API_KEY:
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
error_msg += ". OPENROUTER_API_KEY is not configured. Set it in backend_api_python/.env"
elif response.status_code == 403:
error_msg += ". 可能原因:API 密钥无效/过期、余额不足、或无模型权限。请检查 https://openrouter.ai/keys"
error_msg += ". Possible causes: API key is invalid or expired, account balance is insufficient, or the model is not permitted. Check https://openrouter.ai/keys"
elif response.status_code == 404:
error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy"
error_msg += ". Possible causes: the model is unavailable or blocked by account privacy/data-policy settings. Check https://openrouter.ai/settings/privacy"
raise ValueError(error_msg)
@@ -534,7 +534,7 @@ class MarketDataCollector:
def _calc_macd(self, closes: List[float]) -> Dict[str, float]:
"""
MACD(12,26,9)DIF = EMA12(close) EMA26(close)DEA = EMA9(DIF) = DIF DEA
MACD(12,26,9): DIF = EMA12(close) - EMA26(close), DEA = EMA9(DIF), histogram = DIF - DEA.
Each EMA uses SMA seeds; DIF is defined from the 26th K onwards, and EMA9 is calculated for the DIF subsequence of the signal line pair.
"""
n = len(closes)
@@ -866,32 +866,32 @@ class MarketDataCollector:
crypto_info = {
'BTC': {
'name': 'Bitcoin',
'description': '比特币,数字黄金,市值第一的加密货币,作为价值存储和避险资产',
'description': 'Bitcoin, the leading cryptocurrency by market cap, often viewed as digital gold and a store-of-value asset.',
'category': 'Store of Value',
},
'ETH': {
'name': 'Ethereum',
'description': '以太坊,智能合约平台,DeFi和NFT生态的基础设施',
'description': 'Ethereum, a smart-contract platform and core infrastructure for the DeFi and NFT ecosystem.',
'category': 'Smart Contract Platform',
},
'BNB': {
'name': 'Binance Coin',
'description': '币安币,全球最大交易所的平台代币',
'description': 'Binance Coin, the platform token of one of the world\'s largest exchanges.',
'category': 'Exchange Token',
},
'SOL': {
'name': 'Solana',
'description': '高性能公链,主打高TPS和低Gas费',
'description': 'A high-performance public chain focused on high throughput and low transaction fees.',
'category': 'Smart Contract Platform',
},
'XRP': {
'name': 'Ripple',
'description': '瑞波币,专注跨境支付解决方案',
'description': 'Ripple, focused on cross-border payment solutions.',
'category': 'Payment',
},
'DOGE': {
'name': 'Dogecoin',
'description': '狗狗币,Meme币代表,社区驱动',
'description': 'Dogecoin, a community-driven meme coin.',
'category': 'Meme',
},
}
@@ -905,7 +905,7 @@ class MarketDataCollector:
return {
'name': base,
'description': f'{base} 是一种加密货币',
'description': f'{base} is a cryptocurrency.',
'category': 'Unknown',
}
@@ -960,7 +960,7 @@ class MarketDataCollector:
if cached_sentiment.get('vix'):
vix = cached_sentiment['vix']
result['VIX'] = {
'name': 'VIX恐慌指数',
'name': 'VIX Fear Index',
'description': vix.get('interpretation', ''),
'price': vix.get('value', 0),
'change': vix.get('change', 0),
@@ -971,7 +971,7 @@ class MarketDataCollector:
if cached_sentiment.get('dxy'):
dxy = cached_sentiment['dxy']
result['DXY'] = {
'name': '美元指数',
'name': 'US Dollar Index',
'description': dxy.get('interpretation', ''),
'price': dxy.get('value', 0),
'change': dxy.get('change', 0),
@@ -982,7 +982,7 @@ class MarketDataCollector:
if cached_sentiment.get('yield_curve'):
yc = cached_sentiment['yield_curve']
result['TNX'] = {
'name': '美债10年收益率',
'name': 'US 10Y Treasury Yield',
'description': yc.get('interpretation', ''),
'price': yc.get('yield_10y', 0),
'change': yc.get('change', 0),
@@ -994,7 +994,7 @@ class MarketDataCollector:
if cached_sentiment.get('fear_greed'):
fg = cached_sentiment['fear_greed']
result['FEAR_GREED'] = {
'name': '恐惧贪婪指数',
'name': 'Fear & Greed Index',
'description': fg.get('classification', 'Neutral'),
'price': fg.get('value', 50),
'change': 0,
@@ -1024,7 +1024,7 @@ class MarketDataCollector:
# Convert to unified format
if key == 'VIX':
result[key] = {
'name': 'VIX恐慌指数',
'name': 'VIX Fear Index',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
@@ -1033,7 +1033,7 @@ class MarketDataCollector:
}
elif key == 'DXY':
result[key] = {
'name': '美元指数',
'name': 'US Dollar Index',
'description': data.get('interpretation', ''),
'price': data.get('value', 0),
'change': data.get('change', 0),
@@ -1042,7 +1042,7 @@ class MarketDataCollector:
}
elif key == 'TNX':
result[key] = {
'name': '美债10年收益率',
'name': 'US 10Y Treasury Yield',
'description': data.get('interpretation', ''),
'price': data.get('yield_10y', 0),
'change': data.get('change', 0),
@@ -1052,7 +1052,7 @@ class MarketDataCollector:
}
elif key == 'FEAR_GREED':
result[key] = {
'name': '恐惧贪婪指数',
'name': 'Fear & Greed Index',
'description': data.get('classification', 'Neutral'),
'price': data.get('value', 50),
'change': 0,
@@ -1122,7 +1122,7 @@ class MarketDataCollector:
"url": item.get('url', ''),
"sentiment": item.get('sentiment', 'neutral'),
})
logger.info(f"Finnhub 新闻获取成功: {len(news_list)} ")
logger.info(f"Finnhub news fetched successfully: {len(news_list)} items")
except Exception as e:
logger.debug(f"Finnhub news fetch failed: {e}")
@@ -1199,13 +1199,13 @@ class MarketDataCollector:
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d'),
"headline": result.title,
"summary": result.snippet[:200] if result.snippet else '',
"source": f"搜索:{result.source}",
"source": f"Search:{result.source}",
"url": result.url,
"sentiment": result.sentiment,
})
logger.info(f"搜索引擎新闻补充: {len(news_list)} 条 (来源: {response.provider})")
logger.info(f"Search-engine news supplement: {len(news_list)} items (provider: {response.provider})")
except Exception as e:
logger.debug(f"搜索引擎新闻获取失败: {e}")
logger.debug(f"Search-engine news fetch failed: {e}")
return news_list
@@ -1260,7 +1260,7 @@ class MarketDataCollector:
"datetime": result.published_date or datetime.now().strftime('%Y-%m-%d %H:%M'),
"headline": result.title,
"summary": result.snippet[:300] if result.snippet else '',
"source": f"全球事件:{result.source}",
"source": f"Global event:{result.source}",
"url": result.url,
"sentiment": "negative" if any(kw in text for kw in ["war", "conflict", "attack", "战争", "冲突", "袭击"]) else "neutral",
"is_global_event": True # Flag as global event
+22 -22
View File
@@ -9,7 +9,7 @@ Supported search engines (in order of priority):
4. Bing Search API
5. DuckDuckGo Get the scoop for free
参考daily_stock_analysis-main/src/search_service.py
Reference: daily_stock_analysis-main/src/search_service.py
"""
import requests
import json
@@ -72,9 +72,9 @@ class SearchResponse:
def to_context(self, max_results: int = 5) -> str:
"""Transform search results into context that can be used for AI analysis"""
if not self.success or not self.results:
return f"搜索 '{self.query}' 未找到相关结果。"
return f"No relevant results were found for '{self.query}'."
lines = [f"{self.query} 搜索结果】(来源:{self.provider}"]
lines = [f"[Search results for {self.query}] (source: {self.provider})"]
for i, result in enumerate(self.results[:max_results], 1):
lines.append(f"\n{i}. {result.to_text()}")
@@ -128,7 +128,7 @@ class BaseSearchProvider(ABC):
return key
# There is a problem with all keys, reset the error count and return the first one
logger.warning(f"[{self._name}] 所有 API Key 都有错误记录,重置错误计数")
logger.warning(f"[{self._name}] all API keys have recorded errors, resetting error counters")
self._key_errors = {key: 0 for key in self._api_keys}
return self._api_keys[0] if self._api_keys else None
@@ -142,7 +142,7 @@ class BaseSearchProvider(ABC):
def _record_error(self, key: str) -> None:
"""Log errors"""
self._key_errors[key] = self._key_errors.get(key, 0) + 1
logger.warning(f"[{self._name}] API Key {key[:8]}... 错误计数: {self._key_errors[key]}")
logger.warning(f"[{self._name}] API key {key[:8]}... error count: {self._key_errors[key]}")
@abstractmethod
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
@@ -168,7 +168,7 @@ class BaseSearchProvider(ABC):
results=[],
provider=self._name,
success=False,
error_message=f"{self._name} 未配置 API Key"
error_message=f"{self._name} API key is not configured"
)
start_time = time.time()
@@ -178,7 +178,7 @@ class BaseSearchProvider(ABC):
if response.success:
self._record_success(api_key)
logger.info(f"[{self._name}] 搜索 '{query}' 成功,返回 {len(response.results)} 条结果,耗时 {response.search_time:.2f}s")
logger.info(f"[{self._name}] search '{query}' succeeded, returned {len(response.results)} results in {response.search_time:.2f}s")
else:
self._record_error(api_key)
@@ -187,7 +187,7 @@ class BaseSearchProvider(ABC):
except Exception as e:
self._record_error(api_key)
elapsed = time.time() - start_time
logger.error(f"[{self._name}] 搜索 '{query}' 失败: {e}")
logger.error(f"[{self._name}] search '{query}' failed: {e}")
return SearchResponse(
query=query,
results=[],
@@ -203,9 +203,9 @@ class BaseSearchProvider(ABC):
try:
parsed = urlparse(url)
domain = parsed.netloc.replace('www.', '')
return domain or '未知来源'
return domain or 'Unknown source'
except:
return '未知来源'
return 'Unknown source'
class TavilySearchProvider(BaseSearchProvider):
@@ -265,7 +265,7 @@ class TavilySearchProvider(BaseSearchProvider):
except Exception as e:
error_msg = str(e)
if 'rate limit' in error_msg.lower() or 'quota' in error_msg.lower():
error_msg = f"API 配额已用尽: {error_msg}"
error_msg = f"API quota exhausted: {error_msg}"
return SearchResponse(
query=query,
@@ -483,7 +483,7 @@ class GoogleSearchProvider(BaseSearchProvider):
results=[],
provider=self.name,
success=False,
error_message="Google Search 未配置 CX"
error_message="Google Search CX is not configured"
)
try:
@@ -515,7 +515,7 @@ class GoogleSearchProvider(BaseSearchProvider):
results=[],
provider=self.name,
success=False,
error_message="Google API 配额已用尽"
error_message="Google API quota exhausted"
)
response.raise_for_status()
@@ -744,33 +744,33 @@ class SearchService:
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} API Key")
logger.info(f"Configured Tavily search with {len(tavily_keys)} API keys")
# 2. SerpAPI
serpapi_keys = APIKeys.SERPAPI_KEYS
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} API Key")
logger.info(f"Configured SerpAPI search with {len(serpapi_keys)} API keys")
# 3. Google CSE
google_api_key = self._config.get('google', {}).get('api_key')
google_cx = self._config.get('google', {}).get('cx')
if google_api_key and google_cx:
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
logger.info("已配置 Google CSE 搜索")
logger.info("Configured Google CSE search")
# 4. Bing
bing_api_key = self._config.get('bing', {}).get('api_key')
if bing_api_key:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("已配置 Bing 搜索")
logger.info("Configured Bing search")
# 5. DuckDuckGo (Free Tips)
self._providers.append(DuckDuckGoSearchProvider())
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")
logger.info("Configured DuckDuckGo search as the free fallback")
if len(self._providers) == 1:
logger.warning("仅有 DuckDuckGo 可用,建议配置更多搜索引擎 API Key")
logger.warning("Only DuckDuckGo is available. Configure more search-engine API keys for better coverage.")
@property
def is_available(self) -> bool:
@@ -826,7 +826,7 @@ class SearchService:
if response.success and response.results:
return response
else:
logger.warning(f"{provider.name} 搜索失败: {response.error_message},尝试下一个引擎")
logger.warning(f"{provider.name} search failed: {response.error_message}. Trying the next engine.")
# all engines fail
return SearchResponse(
@@ -834,7 +834,7 @@ class SearchService:
results=[],
provider="None",
success=False,
error_message="所有搜索引擎都不可用或搜索失败"
error_message="All search engines are unavailable or failed"
)
def search_stock_news(
@@ -875,7 +875,7 @@ class SearchService:
else:
query = f"{stock_name} {stock_code} latest news"
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), market={market}, days={search_days}")
logger.info(f"Searching stock news: {stock_name}({stock_code}), market={market}, days={search_days}")
return self.search_with_fallback(query, max_results, search_days)
@@ -501,15 +501,15 @@ class SignalNotifier:
"""
Generic webhook delivery.
用户在个人中心配置
- webhook_url: Webhook 地址
- webhook_token: Bearer Token可选
User configuration in the profile center:
- webhook_url: webhook URL
- webhook_token: optional Bearer token
支持功能
- 自定义 headers: notification_config.targets.webhook_headers
Supported features:
- Custom headers: notification_config.targets.webhook_headers
- Bearer Token: notification_config.targets.webhook_token
- 签名验证: notification_config.targets.webhook_signing_secret
- 自动重试: 429/5xx 时重试一次
- Signature verification: notification_config.targets.webhook_signing_secret
- Automatic retry: retry once on 429/5xx
"""
if not url:
return False, "missing_webhook_url"
@@ -933,6 +933,8 @@ class StrategyService:
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 {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
strategy_mode = payload.get('strategy_mode') if payload.get('strategy_mode') is not None else (existing.get('strategy_mode') or 'signal')
strategy_code = payload.get('strategy_code') if payload.get('strategy_code') is not None else (existing.get('strategy_code') or '')
# When credential_id is present, strip raw API keys to avoid
# storing secrets in the strategy record — they live in qd_exchange_credentials.
@@ -964,6 +966,8 @@ class StrategyService:
"""
UPDATE qd_strategies_trading
SET strategy_name = ?,
strategy_mode = ?,
strategy_code = ?,
market_category = ?,
execution_mode = ?,
notification_config = ?,
@@ -981,6 +985,8 @@ class StrategyService:
""",
(
name,
strategy_mode,
strategy_code,
market_category,
execution_mode,
self._dump_json_or_encrypt(notification_config, encrypt=False),
@@ -88,9 +88,10 @@ class TradingExecutor:
def _normalize_trade_symbol(self, exchange: Any, symbol: str, market_type: str, exchange_id: str) -> str:
"""
将数据库/配置里的 symbol 规范化为交易所合约可用的 CCXT symbol
Normalize symbols from the database/config into the CCXT symbol format accepted by the exchange.
典型场景OKX 永续统一符号通常是 `BNB/USDT:USDT`但前端/数据库可能传 `BNB/USDT`
Typical case: the normalized OKX perpetual symbol is usually `BNB/USDT:USDT`,
while the frontend or database may pass `BNB/USDT`.
"""
try:
# New system: only supports swap (perpetual contract) / spot (spot)
@@ -131,7 +132,7 @@ class TradingExecutor:
return symbol
def _log_resource_status(self, prefix: str = ""):
"""调试:记录线程/内存使用,便于定位 can't start new thread 根因"""
"""Debug helper: record thread and memory usage to diagnose the root cause of 'can't start new thread'."""
try:
import psutil # Use more precise metrics if installed
p = psutil.Process()
@@ -415,7 +416,7 @@ class TradingExecutor:
thread.start()
except Exception as e:
# Capture exceptions such as can't start new thread and record resource status
self._log_resource_status(prefix="启动异常")
self._log_resource_status(prefix="startup exception")
raise e
self.running_strategies[strategy_id] = thread
@@ -631,15 +632,15 @@ class TradingExecutor:
# Even in signal mode, it is necessary to check and clean up the situation when the user manually closes the position on the exchange but the database record is still there.
# This can prevent the strategy from thinking that there are still positions and being unable to execute a new opening signal.
try:
logger.info(f"策略 {strategy_id} 启动时检查持仓同步...")
logger.info(f"Checking position synchronization during startup for strategy {strategy_id}...")
# Call position synchronization logic (check even in signal mode)
from app import get_pending_order_worker
worker = get_pending_order_worker()
if worker and hasattr(worker, '_sync_positions_best_effort'):
worker._sync_positions_best_effort(target_strategy_id=strategy_id)
logger.info(f"策略 {strategy_id} 启动时持仓同步完成")
logger.info(f"Position synchronization completed during startup for strategy {strategy_id}")
except Exception as e:
logger.warning(f"策略 {strategy_id} 启动时持仓同步失败(不影响启动): {e}")
logger.warning(f"Position synchronization failed during startup for strategy {strategy_id} (startup continues): {e}")
# Get the current highest position price (read from local database)
current_pos_list = self._get_current_positions(strategy_id, symbol)
@@ -660,7 +661,7 @@ class TradingExecutor:
# Key diagnostic log: Confirm whether the indicator has obtained the position status
logger.info(
f"策略 {strategy_id} 指标注入持仓状态: count={len(current_pos_list)}, "
f"Injected position state for strategy {strategy_id}: count={len(current_pos_list)}, "
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
)
@@ -1005,7 +1006,7 @@ class TradingExecutor:
market=market_type or 'Crypto',
symbol=symbol,
signal_type=signal_type,
signal_detail=f"策略: {strategy_name}\n信号: {signal_type}\n价格: {execute_price:.4f}"
signal_detail=f"Strategy: {strategy_name}\nSignal: {signal_type}\nPrice: {execute_price:.4f}"
)
except Exception as link_e:
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
@@ -1095,8 +1096,8 @@ class TradingExecutor:
def _is_strategy_running(self, strategy_id: int) -> bool:
"""
检查策略是否在运行
同时检查数据库状态和线程状态避免重启后状态不一致
Check whether the strategy is running.
This checks both the database state and the thread state to avoid mismatches after restart.
"""
try:
# 1. Check database status
@@ -1145,7 +1146,7 @@ class TradingExecutor:
leverage: float = None,
strategy_id: int = None
) -> Optional[ccxt.Exchange]:
"""(Mock) 信号模式不需要真实交易所连接"""
"""(Mock) Signal mode does not require a real exchange connection."""
return None
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
@@ -1872,7 +1873,7 @@ class TradingExecutor:
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"不允许导入模块: {name}")
raise ImportError(f"Importing modules is not allowed: {name}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in [
@@ -1961,7 +1962,7 @@ class TradingExecutor:
return []
def _execute_trading_logic(self, *args, **kwargs):
"""已废弃"""
"""Deprecated."""
pass
def _execute_signal(
@@ -2014,8 +2015,8 @@ class TradingExecutor:
# Best-effort persist a browser notification so UI can show "HOLD due to AI filter".
reason = (ai_info or {}).get("reason") or "ai_filter_rejected"
ai_decision = (ai_info or {}).get("ai_decision") or ""
title = f"AI过滤拦截开仓 | {symbol}"
msg = f"策略信号={sig}AI决策={ai_decision or 'UNKNOWN'},原因={reason};已HOLD(不下单)"
title = f"AI filter blocked opening a position | {symbol}"
msg = f"Strategy signal={sig}, AI decision={ai_decision or 'UNKNOWN'}, reason={reason}; action forced to HOLD (no order placed)"
self._persist_browser_notification(
strategy_id=strategy_id,
symbol=symbol,
@@ -2369,7 +2370,7 @@ class TradingExecutor:
payload: Optional[Dict[str, Any]] = None,
user_id: int = None,
) -> None:
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
"""Best-effort persistence of a notification row for the frontend notifications panel (browser channel)."""
try:
now = int(time.time())
# Get user_id from strategy if not provided
@@ -2776,7 +2777,7 @@ class TradingExecutor:
highest_price: float = 0.0,
lowest_price: float = 0.0,
):
"""更新持仓状态"""
"""Update position state."""
try:
# Get user_id from strategy
user_id = 1
@@ -2811,7 +2812,7 @@ class TradingExecutor:
logger.error(f"Failed to update position: {e}")
def _close_position(self, strategy_id: int, symbol: str, side: str):
"""平仓:删除持仓记录"""
"""Close a position by deleting the position record."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2861,7 +2862,7 @@ class TradingExecutor:
return []
def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool:
"""检查是否应该调仓"""
"""Check whether rebalancing should run."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2892,7 +2893,7 @@ class TradingExecutor:
return True
def _update_last_rebalance(self, strategy_id: int):
"""更新上次调仓时间"""
"""Update the last rebalance timestamp."""
try:
with get_db_connection() as db:
cursor = db.cursor()
@@ -2920,7 +2921,7 @@ class TradingExecutor:
timeframe: str
) -> Optional[Dict[str, Any]]:
"""
执行截面策略指标返回所有标的的评分和排序
Execute cross-sectional strategy indicators and return scores and ranking for all instruments.
"""
try:
# Get K-line data of all targets
@@ -2989,7 +2990,7 @@ class TradingExecutor:
trading_config: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""
根据排序结果生成截面策略信号
Generate cross-sectional strategy signals from the ranking results.
"""
portfolio_size = trading_config.get('portfolio_size', 10)
long_ratio = float(trading_config.get('long_ratio', 0.5))
@@ -3080,7 +3081,7 @@ class TradingExecutor:
indicator_id: Optional[int]
):
"""
截面策略执行循环
Cross-sectional strategy execution loop.
"""
logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}")
@@ -3188,4 +3189,4 @@ class TradingExecutor:
except Exception as e:
logger.error(f"Cross-sectional strategy loop error: {e}")
logger.error(traceback.format_exc())
time.sleep(5) # Wait before retrying
time.sleep(5) # Wait before retrying