Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-29 19:05:17 +08:00
parent 278b88ec71
commit 3337bcfc14
32 changed files with 5275 additions and 2770 deletions
+44
View File
@@ -0,0 +1,44 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
logs/
*.log
# Database (在 docker-compose 中挂载)
*.db
# Environment
.env
.env.local
# Data (在 docker-compose 中挂载)
data/memory/*.db
# Git
.git/
.gitignore
# Tests
tests/
pytest_cache/
.coverage
# Documentation
*.md
!README.md
+35
View File
@@ -0,0 +1,35 @@
# QuantDinger Backend API Dockerfile
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libffi-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy dependency file
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create log and data directories
RUN mkdir -p logs data/memory
# Expose port
EXPOSE 5000
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHON_API_HOST=0.0.0.0
ENV PYTHON_API_PORT=5000
# Start command
CMD ["python", "run.py"]
@@ -17,6 +17,7 @@ def register_routes(app: Flask):
from app.routes.ai_chat import ai_chat_bp
from app.routes.indicator import indicator_bp
from app.routes.dashboard import dashboard_bp
from app.routes.settings import settings_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
@@ -29,4 +30,5 @@ def register_routes(app: Flask):
app.register_blueprint(strategy_bp, url_prefix='/api')
app.register_blueprint(credentials_bp, url_prefix='/api/credentials')
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
+257 -6
View File
@@ -32,6 +32,13 @@ def _safe_int(v: Any, default: int) -> int:
return default
def _safe_float(v: Any, default: float = 0.0) -> float:
try:
return float(v)
except Exception:
return default
def _safe_json_loads(value: Any, default: Any) -> Any:
if value is None:
return default
@@ -96,6 +103,157 @@ def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: flo
return 0.0
def _compute_performance_stats(trades: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Compute performance statistics from trade history.
Returns: {
total_trades, winning_trades, losing_trades, win_rate,
total_profit, total_loss, profit_factor,
avg_win, avg_loss, avg_trade,
max_win, max_loss, max_drawdown, max_drawdown_pct
}
"""
total_trades = len(trades)
if total_trades == 0:
return {
"total_trades": 0,
"winning_trades": 0,
"losing_trades": 0,
"win_rate": 0.0,
"total_profit": 0.0,
"total_loss": 0.0,
"profit_factor": 0.0,
"avg_win": 0.0,
"avg_loss": 0.0,
"avg_trade": 0.0,
"max_win": 0.0,
"max_loss": 0.0,
"max_drawdown": 0.0,
"max_drawdown_pct": 0.0,
"best_day": 0.0,
"worst_day": 0.0,
}
profits = [_safe_float(t.get("profit"), 0.0) for t in trades]
wins = [p for p in profits if p > 0]
losses = [p for p in profits if p < 0]
winning_trades = len(wins)
losing_trades = len(losses)
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0.0
total_profit = sum(wins) if wins else 0.0
total_loss = abs(sum(losses)) if losses else 0.0
profit_factor = (total_profit / total_loss) if total_loss > 0 else (total_profit if total_profit > 0 else 0.0)
avg_win = (total_profit / winning_trades) if winning_trades > 0 else 0.0
avg_loss = (total_loss / losing_trades) if losing_trades > 0 else 0.0
avg_trade = sum(profits) / total_trades if total_trades > 0 else 0.0
max_win = max(profits) if profits else 0.0
max_loss = min(profits) if profits else 0.0
# Calculate max drawdown from cumulative equity
cumulative = []
acc = 0.0
for p in profits:
acc += p
cumulative.append(acc)
peak = 0.0
max_drawdown = 0.0
for val in cumulative:
if val > peak:
peak = val
dd = peak - val
if dd > max_drawdown:
max_drawdown = dd
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0.0
# Best/worst day
day_profits: Dict[str, float] = {}
for t in trades:
ts = _safe_int(t.get("created_at"), 0)
if ts <= 0:
continue
day = time.strftime("%Y-%m-%d", time.localtime(ts))
profit = _safe_float(t.get("profit"), 0.0)
day_profits[day] = day_profits.get(day, 0.0) + profit
best_day = max(day_profits.values()) if day_profits else 0.0
worst_day = min(day_profits.values()) if day_profits else 0.0
return {
"total_trades": total_trades,
"winning_trades": winning_trades,
"losing_trades": losing_trades,
"win_rate": round(win_rate, 2),
"total_profit": round(total_profit, 2),
"total_loss": round(total_loss, 2),
"profit_factor": round(profit_factor, 2),
"avg_win": round(avg_win, 2),
"avg_loss": round(avg_loss, 2),
"avg_trade": round(avg_trade, 2),
"max_win": round(max_win, 2),
"max_loss": round(max_loss, 2),
"max_drawdown": round(max_drawdown, 2),
"max_drawdown_pct": round(max_drawdown_pct, 2),
"best_day": round(best_day, 2),
"worst_day": round(worst_day, 2),
}
def _compute_strategy_stats(trades: List[Dict[str, Any]], strategies: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Compute per-strategy statistics.
Only includes strategies that still exist (not deleted).
"""
# Build set of existing strategy IDs
existing_strategy_ids: set = set()
sid_to_name: Dict[int, str] = {}
sid_to_capital: Dict[int, float] = {}
for s in strategies:
sid = _safe_int(s.get("id"), 0)
if sid > 0:
existing_strategy_ids.add(sid)
sid_to_name[sid] = str(s.get("strategy_name") or f"Strategy_{sid}")
sid_to_capital[sid] = _safe_float(s.get("initial_capital"), 0.0)
# Group trades by strategy (only for existing strategies)
sid_to_trades: Dict[int, List[Dict[str, Any]]] = {}
for t in trades:
sid = _safe_int(t.get("strategy_id"), 0)
# Skip trades from deleted strategies
if sid not in existing_strategy_ids:
continue
if sid not in sid_to_trades:
sid_to_trades[sid] = []
sid_to_trades[sid].append(t)
result = []
for sid, strades in sid_to_trades.items():
stats = _compute_performance_stats(strades)
total_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in strades)
capital = sid_to_capital.get(sid, 0.0)
roi = (total_pnl / capital * 100) if capital > 0 else 0.0
result.append({
"strategy_id": sid,
"strategy_name": sid_to_name.get(sid, f"Strategy_{sid}"),
"total_trades": stats["total_trades"],
"win_rate": stats["win_rate"],
"profit_factor": stats["profit_factor"],
"total_pnl": round(total_pnl, 2),
"roi": round(roi, 2),
"max_drawdown": stats["max_drawdown"],
})
# Sort by total PnL descending
result.sort(key=lambda x: x.get("total_pnl", 0), reverse=True)
return result
@dashboard_bp.route("/summary", methods=["GET"])
def summary():
"""
@@ -183,12 +341,18 @@ def summary():
FROM qd_strategy_trades t
LEFT JOIN qd_strategies_trading s ON s.id = t.strategy_id
ORDER BY t.created_at DESC
LIMIT 200
LIMIT 500
"""
)
recent_trades = cur.fetchall() or []
cur.close()
# Compute performance statistics
perf_stats = _compute_performance_stats(recent_trades)
# Compute per-strategy statistics
strategy_stats = _compute_strategy_stats(recent_trades, strategies)
# Total equity/pnl (best-effort)
total_initial_capital = 0.0
for s in strategies:
@@ -196,7 +360,10 @@ def summary():
total_initial_capital += float(s.get("initial_capital") or 0.0)
except Exception:
pass
total_pnl = float(total_unrealized_pnl)
# Include realized PnL from trades
total_realized_pnl = sum(_safe_float(t.get("profit"), 0.0) for t in recent_trades)
total_pnl = float(total_unrealized_pnl + total_realized_pnl)
total_equity = float(total_initial_capital + total_pnl)
# Daily PnL chart (uses realized profit field if present, otherwise 0)
@@ -223,6 +390,80 @@ def summary():
sid_to_unreal[sid] = float(sid_to_unreal.get(sid, 0.0) + float(p.get("unrealized_pnl") or 0.0))
strategy_pnl_chart = [{"name": sid_to_name[sid], "value": float(val)} for sid, val in sid_to_unreal.items()]
# Monthly returns for heatmap
month_to_profit: Dict[str, float] = {}
for trow in recent_trades:
ts = _safe_int(trow.get("created_at"), 0)
if ts <= 0:
continue
month = time.strftime("%Y-%m", time.localtime(ts))
try:
p = float(trow.get("profit") or 0.0)
except Exception:
p = 0.0
month_to_profit[month] = month_to_profit.get(month, 0.0) + p
monthly_returns = [{"month": m, "profit": round(v, 2)} for m, v in sorted(month_to_profit.items())]
# Hourly distribution
hour_to_count: Dict[int, int] = {}
hour_to_profit: Dict[int, float] = {}
for trow in recent_trades:
ts = _safe_int(trow.get("created_at"), 0)
if ts <= 0:
continue
hour = int(time.strftime("%H", time.localtime(ts)))
hour_to_count[hour] = hour_to_count.get(hour, 0) + 1
hour_to_profit[hour] = hour_to_profit.get(hour, 0.0) + _safe_float(trow.get("profit"), 0.0)
hourly_distribution = [
{"hour": h, "count": hour_to_count.get(h, 0), "profit": round(hour_to_profit.get(h, 0.0), 2)}
for h in range(24)
]
# Calendar data: organized by month for monthly calendar view
# Format: { "2024-01": { "days": { "01": 123.45, "02": -50.0, ... }, "total": 500.0 }, ... }
import calendar as cal_module
from datetime import datetime, timedelta
calendar_data: Dict[str, Dict[str, Any]] = {}
for d, p in day_to_profit.items():
try:
dt = datetime.strptime(d, "%Y-%m-%d")
month_key = dt.strftime("%Y-%m")
day_num = dt.strftime("%d")
if month_key not in calendar_data:
# Get number of days in month
year, month = int(dt.strftime("%Y")), int(dt.strftime("%m"))
_, days_in_month = cal_module.monthrange(year, month)
# Get first day of month (0=Monday, 6=Sunday)
first_weekday = cal_module.monthrange(year, month)[0]
calendar_data[month_key] = {
"year": year,
"month": month,
"days_in_month": days_in_month,
"first_weekday": first_weekday, # 0=Mon, 6=Sun
"days": {},
"total": 0.0,
"win_days": 0,
"lose_days": 0,
}
calendar_data[month_key]["days"][day_num] = round(p, 2)
calendar_data[month_key]["total"] = round(calendar_data[month_key]["total"] + p, 2)
if p > 0:
calendar_data[month_key]["win_days"] += 1
elif p < 0:
calendar_data[month_key]["lose_days"] += 1
except Exception:
pass
# Convert to sorted list for frontend
calendar_months = []
for month_key in sorted(calendar_data.keys(), reverse=True):
data = calendar_data[month_key]
calendar_months.append({
"month_key": month_key,
**data
})
return jsonify(
{
"code": 1,
@@ -230,11 +471,22 @@ def summary():
"data": {
"ai_strategy_count": int(ai_enabled_strategy_count),
"indicator_strategy_count": int(indicator_strategy_count),
"total_equity": float(total_equity),
"total_pnl": float(total_pnl),
"total_equity": round(total_equity, 2),
"total_pnl": round(total_pnl, 2),
"total_realized_pnl": round(total_realized_pnl, 2),
"total_unrealized_pnl": round(total_unrealized_pnl, 2),
# Performance KPIs
"performance": perf_stats,
# Strategy-level stats
"strategy_stats": strategy_stats,
# Chart data
"daily_pnl_chart": daily_pnl_chart,
"strategy_pnl_chart": strategy_pnl_chart,
"recent_trades": recent_trades,
"monthly_returns": monthly_returns,
"hourly_distribution": hourly_distribution,
"calendar_months": calendar_months, # Monthly calendar data
# Lists
"recent_trades": recent_trades[:100], # Limit for frontend
"current_positions": current_positions,
},
}
@@ -383,4 +635,3 @@ def delete_pending_order(order_id: int):
except Exception as e:
logger.error(f"dashboard delete pendingOrders failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
+5
View File
@@ -26,3 +26,8 @@ def health_check():
'timestamp': datetime.now().isoformat()
})
@health_bp.route('/api/health', methods=['GET'])
def api_health_check():
"""兼容路径:用于容器健康检查/反代探针等场景。"""
return health_check()
+363
View File
@@ -0,0 +1,363 @@
"""
Settings API - 读取和保存 .env 配置
"""
import os
import re
from flask import Blueprint, request, jsonify
from app.utils.logger import get_logger
from app.utils.config_loader import clear_config_cache
logger = get_logger(__name__)
settings_bp = Blueprint('settings', __name__)
# .env 文件路径
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
# 配置项定义(分组)- 完整对照 env.example
CONFIG_SCHEMA = {
'auth': {
'title': '认证配置',
'items': [
{'key': 'SECRET_KEY', 'label': 'Secret Key', 'type': 'password', 'default': 'quantdinger-secret-key-change-me'},
{'key': 'ADMIN_USER', 'label': '管理员用户名', 'type': 'text', 'default': 'quantdinger'},
{'key': 'ADMIN_PASSWORD', 'label': '管理员密码', 'type': 'password', 'default': '123456'},
]
},
'server': {
'title': '服务器配置',
'items': [
{'key': 'PYTHON_API_HOST', 'label': '监听地址', 'type': 'text', 'default': '0.0.0.0'},
{'key': 'PYTHON_API_PORT', 'label': '端口', 'type': 'number', 'default': '5000'},
{'key': 'PYTHON_API_DEBUG', 'label': '调试模式', 'type': 'boolean', 'default': 'False'},
]
},
'worker': {
'title': '订单处理配置',
'items': [
{'key': 'ENABLE_PENDING_ORDER_WORKER', 'label': '启用订单处理Worker', 'type': 'boolean', 'default': 'True'},
{'key': 'PENDING_ORDER_STALE_SEC', 'label': '订单超时时间(秒)', 'type': 'number', 'default': '90'},
]
},
'notification': {
'title': '信号通知配置',
'items': [
{'key': 'SIGNAL_WEBHOOK_URL', 'label': 'Webhook URL', 'type': 'text', 'required': False},
{'key': 'SIGNAL_WEBHOOK_TOKEN', 'label': 'Webhook Token', 'type': 'password', 'required': False},
{'key': 'SIGNAL_NOTIFY_TIMEOUT_SEC', 'label': '通知超时(秒)', 'type': 'number', 'default': '6'},
{'key': 'TELEGRAM_BOT_TOKEN', 'label': 'Telegram Bot Token', 'type': 'password', 'required': False, 'link': 'https://t.me/BotFather', 'link_text': 'settings.link.createBot'},
]
},
'smtp': {
'title': '邮件SMTP配置',
'items': [
{'key': 'SMTP_HOST', 'label': 'SMTP服务器', 'type': 'text', 'required': False},
{'key': 'SMTP_PORT', 'label': 'SMTP端口', 'type': 'number', 'default': '587'},
{'key': 'SMTP_USER', 'label': 'SMTP用户名', 'type': 'text', 'required': False},
{'key': 'SMTP_PASSWORD', 'label': 'SMTP密码', 'type': 'password', 'required': False},
{'key': 'SMTP_FROM', 'label': '发件人地址', 'type': 'text', 'required': False},
{'key': 'SMTP_USE_TLS', 'label': '使用TLS', 'type': 'boolean', 'default': 'True'},
{'key': 'SMTP_USE_SSL', 'label': '使用SSL', 'type': 'boolean', 'default': 'False'},
]
},
'twilio': {
'title': 'Twilio短信配置',
'items': [
{'key': 'TWILIO_ACCOUNT_SID', 'label': 'Account SID', 'type': 'password', 'required': False, 'link': 'https://console.twilio.com/', 'link_text': 'settings.link.getApi'},
{'key': 'TWILIO_AUTH_TOKEN', 'label': 'Auth Token', 'type': 'password', 'required': False},
{'key': 'TWILIO_FROM_NUMBER', 'label': '发送号码', 'type': 'text', 'required': False},
]
},
'strategy': {
'title': '策略执行配置',
'items': [
{'key': 'DISABLE_RESTORE_RUNNING_STRATEGIES', 'label': '禁用自动恢复策略', 'type': 'boolean', 'default': 'False'},
{'key': 'STRATEGY_TICK_INTERVAL_SEC', 'label': '策略Tick间隔(秒)', 'type': 'number', 'default': '10'},
{'key': 'PRICE_CACHE_TTL_SEC', 'label': '价格缓存TTL(秒)', 'type': 'number', 'default': '10'},
]
},
'proxy': {
'title': '代理配置',
'items': [
{'key': 'PROXY_PORT', 'label': '代理端口', 'type': 'text', 'required': False},
{'key': 'PROXY_HOST', 'label': '代理主机', 'type': 'text', 'default': '127.0.0.1'},
{'key': 'PROXY_SCHEME', 'label': '代理协议', 'type': 'select', 'options': ['socks5h', 'socks5', 'http', 'https'], 'default': 'socks5h'},
{'key': 'PROXY_URL', 'label': '完整代理URL', 'type': 'text', 'required': False},
]
},
'app': {
'title': '应用配置',
'items': [
{'key': 'CORS_ORIGINS', 'label': 'CORS来源', 'type': 'text', 'default': '*'},
{'key': 'RATE_LIMIT', 'label': '速率限制(每分钟)', 'type': 'number', 'default': '100'},
{'key': 'ENABLE_CACHE', 'label': '启用缓存', 'type': 'boolean', 'default': 'False'},
{'key': 'ENABLE_REQUEST_LOG', 'label': '启用请求日志', 'type': 'boolean', 'default': 'True'},
{'key': 'ENABLE_AI_ANALYSIS', 'label': '启用AI分析', 'type': 'boolean', 'default': 'True'},
]
},
'ai': {
'title': 'AI/LLM配置',
'items': [
{'key': 'OPENROUTER_API_KEY', 'label': 'OpenRouter API Key', 'type': 'password', 'required': False, 'link': 'https://openrouter.ai/keys', 'link_text': 'settings.link.getApiKey'},
{'key': 'OPENROUTER_API_URL', 'label': 'OpenRouter API URL', 'type': 'text', 'default': 'https://openrouter.ai/api/v1/chat/completions'},
{'key': 'OPENROUTER_MODEL', 'label': '默认模型', 'type': 'text', 'default': 'openai/gpt-4o', 'link': 'https://openrouter.ai/models', 'link_text': 'settings.link.viewModels'},
{'key': 'OPENROUTER_TEMPERATURE', 'label': 'Temperature', 'type': 'number', 'default': '0.7'},
{'key': 'OPENROUTER_MAX_TOKENS', 'label': 'Max Tokens', 'type': 'number', 'default': '4000'},
{'key': 'OPENROUTER_TIMEOUT', 'label': '超时时间(秒)', 'type': 'number', 'default': '300'},
{'key': 'OPENROUTER_CONNECT_TIMEOUT', 'label': '连接超时(秒)', 'type': 'number', 'default': '30'},
{'key': 'AI_MODELS_JSON', 'label': '模型列表(JSON)', 'type': 'text', 'default': '{}', 'required': False},
]
},
'market': {
'title': '市场预设',
'items': [
{'key': 'MARKET_TYPES_JSON', 'label': '市场类型(JSON)', 'type': 'text', 'default': '[]', 'required': False},
{'key': 'TRADING_SUPPORTED_SYMBOLS_JSON', 'label': '支持的交易对(JSON)', 'type': 'text', 'default': '[]', 'required': False},
]
},
'data_source': {
'title': '数据源配置',
'items': [
{'key': 'DATA_SOURCE_TIMEOUT', 'label': '数据源超时(秒)', 'type': 'number', 'default': '30'},
{'key': 'DATA_SOURCE_RETRY', 'label': '重试次数', 'type': 'number', 'default': '3'},
{'key': 'DATA_SOURCE_RETRY_BACKOFF', 'label': '重试退避(秒)', 'type': 'number', 'default': '0.5'},
{'key': 'FINNHUB_API_KEY', 'label': 'Finnhub API Key', 'type': 'password', 'required': False, 'link': 'https://finnhub.io/register', 'link_text': 'settings.link.freeRegister'},
{'key': 'FINNHUB_TIMEOUT', 'label': 'Finnhub超时(秒)', 'type': 'number', 'default': '10'},
{'key': 'FINNHUB_RATE_LIMIT', 'label': 'Finnhub速率限制', 'type': 'number', 'default': '60'},
{'key': 'CCXT_DEFAULT_EXCHANGE', 'label': 'CCXT默认交易所', 'type': 'text', 'default': 'binance', 'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets', 'link_text': 'settings.link.supportedExchanges'},
{'key': 'CCXT_TIMEOUT', 'label': 'CCXT超时(ms)', 'type': 'number', 'default': '10000'},
{'key': 'CCXT_PROXY', 'label': 'CCXT代理', 'type': 'text', 'required': False},
{'key': 'AKSHARE_TIMEOUT', 'label': 'Akshare超时(秒)', 'type': 'number', 'default': '30'},
{'key': 'YFINANCE_TIMEOUT', 'label': 'YFinance超时(秒)', 'type': 'number', 'default': '30'},
{'key': 'TIINGO_API_KEY', 'label': 'Tiingo API Key', 'type': 'password', 'required': False, 'link': 'https://www.tiingo.com/account/api/token', 'link_text': 'settings.link.getToken'},
{'key': 'TIINGO_TIMEOUT', 'label': 'Tiingo超时(秒)', 'type': 'number', 'default': '10'},
]
},
'search': {
'title': '搜索配置',
'items': [
{'key': 'SEARCH_PROVIDER', 'label': '搜索提供商', 'type': 'select', 'options': ['google', 'bing', 'none'], 'default': 'google'},
{'key': 'SEARCH_MAX_RESULTS', 'label': '最大结果数', 'type': 'number', 'default': '10'},
{'key': 'SEARCH_GOOGLE_API_KEY', 'label': 'Google API Key', 'type': 'password', 'required': False, 'link': 'https://developers.google.com/custom-search/v1/introduction', 'link_text': 'settings.link.applyApi'},
{'key': 'SEARCH_GOOGLE_CX', 'label': 'Google CX', 'type': 'text', 'required': False, 'link': 'https://programmablesearchengine.google.com/controlpanel/all', 'link_text': 'settings.link.createSearchEngine'},
{'key': 'SEARCH_BING_API_KEY', 'label': 'Bing API Key', 'type': 'password', 'required': False, 'link': 'https://www.microsoft.com/en-us/bing/apis/bing-web-search-api', 'link_text': 'settings.link.applyApi'},
{'key': 'INTERNAL_API_KEY', 'label': '内部API Key', 'type': 'password', 'required': False},
]
},
}
def read_env_file():
"""读取 .env 文件"""
env_values = {}
if not os.path.exists(ENV_FILE_PATH):
logger.warning(f".env file not found at {ENV_FILE_PATH}")
return env_values
try:
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# 跳过空行和注释
if not line or line.startswith('#'):
continue
# 解析 KEY=VALUE
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# 移除引号
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
env_values[key] = value
except Exception as e:
logger.error(f"Failed to read .env file: {e}")
return env_values
def write_env_file(env_values):
"""写入 .env 文件,保留注释和格式"""
lines = []
existing_keys = set()
# 读取原文件保留格式
if os.path.exists(ENV_FILE_PATH):
try:
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
for line in f:
original_line = line
stripped = line.strip()
# 保留空行和注释
if not stripped or stripped.startswith('#'):
lines.append(original_line)
continue
# 更新已存在的键
if '=' in stripped:
key = stripped.split('=', 1)[0].strip()
if key in env_values:
existing_keys.add(key)
value = env_values[key]
# 如果值包含特殊字符,用引号包裹
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
lines.append(f'{key}="{value}"\n')
else:
lines.append(f'{key}={value}\n')
else:
lines.append(original_line)
else:
lines.append(original_line)
except Exception as e:
logger.error(f"Failed to read .env file for update: {e}")
# 添加新的键
new_keys = set(env_values.keys()) - existing_keys
if new_keys:
if lines and not lines[-1].endswith('\n'):
lines.append('\n')
lines.append('\n# Added by Settings UI\n')
for key in sorted(new_keys):
value = env_values[key]
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
lines.append(f'{key}="{value}"\n')
else:
lines.append(f'{key}={value}\n')
# 写入文件
try:
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
f.writelines(lines)
return True
except Exception as e:
logger.error(f"Failed to write .env file: {e}")
return False
@settings_bp.route('/schema', methods=['GET'])
def get_settings_schema():
"""获取配置项定义"""
return jsonify({
'code': 1,
'msg': 'success',
'data': CONFIG_SCHEMA
})
@settings_bp.route('/values', methods=['GET'])
def get_settings_values():
"""获取当前配置值 - 包括敏感信息(真实值)"""
env_values = read_env_file()
# 构建返回数据,返回真实值
result = {}
for group_key, group in CONFIG_SCHEMA.items():
result[group_key] = {}
for item in group['items']:
key = item['key']
value = env_values.get(key, item.get('default', ''))
result[group_key][key] = value
# 标记密码类型是否已配置
if item['type'] == 'password':
result[group_key][f'{key}_configured'] = bool(value)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
@settings_bp.route('/save', methods=['POST'])
def save_settings():
"""保存配置"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 0, 'msg': '无效的请求数据'})
# 读取当前配置
current_env = read_env_file()
# 更新配置
updates = {}
for group_key, group_values in data.items():
if group_key not in CONFIG_SCHEMA:
continue
for item in CONFIG_SCHEMA[group_key]['items']:
key = item['key']
if key in group_values:
new_value = group_values[key]
# 空值处理
if new_value is None or new_value == '':
if not item.get('required', True):
updates[key] = ''
else:
updates[key] = str(new_value)
# 合并更新
current_env.update(updates)
# 写入文件
if write_env_file(current_env):
# 清除配置缓存
clear_config_cache()
return jsonify({
'code': 1,
'msg': '配置保存成功',
'data': {
'updated_keys': list(updates.keys()),
'requires_restart': True # 标记需要重启
}
})
else:
return jsonify({'code': 0, 'msg': '保存配置失败'})
except Exception as e:
logger.error(f"Failed to save settings: {e}")
return jsonify({'code': 0, 'msg': f'保存失败: {str(e)}'})
@settings_bp.route('/test-connection', methods=['POST'])
def test_connection():
"""测试API连接"""
try:
data = request.get_json()
service = data.get('service')
if service == 'openrouter':
# 测试 OpenRouter 连接
from app.services.llm import LLMService
llm = LLMService()
result = llm.test_connection()
if result:
return jsonify({'code': 1, 'msg': 'OpenRouter连接成功'})
else:
return jsonify({'code': 0, 'msg': 'OpenRouter连接失败'})
elif service == 'finnhub':
# 测试 Finnhub 连接
import requests
api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY')
if not api_key:
return jsonify({'code': 0, 'msg': 'API Key未配置'})
resp = requests.get(
f'https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}',
timeout=10
)
if resp.status_code == 200:
return jsonify({'code': 1, 'msg': 'Finnhub连接成功'})
else:
return jsonify({'code': 0, 'msg': f'Finnhub连接失败: {resp.status_code}'})
return jsonify({'code': 0, 'msg': '未知服务'})
except Exception as e:
logger.error(f"Connection test failed: {e}")
return jsonify({'code': 0, 'msg': f'测试失败: {str(e)}'})
@@ -593,6 +593,7 @@ class StrategyService:
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 {})
ai_model_config = payload.get('ai_model_config') if payload.get('ai_model_config') is not None else (existing.get('ai_model_config') or {})
symbol = (trading_config or {}).get('symbol')
timeframe = (trading_config or {}).get('timeframe')
@@ -617,6 +618,7 @@ class StrategyService:
exchange_config = ?,
indicator_config = ?,
trading_config = ?,
ai_model_config = ?,
updated_at = ?
WHERE id = ?
""",
@@ -633,6 +635,7 @@ class StrategyService:
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(ai_model_config, encrypt=False),
now,
strategy_id
)
@@ -9,7 +9,7 @@ try:
import resource # Linux/Unix only
except Exception:
resource = None
from typing import Dict, List, Any, Optional
from typing import Dict, List, Any, Optional, Tuple
from datetime import datetime
import json
from decimal import Decimal, ROUND_DOWN, ROUND_UP
@@ -486,10 +486,12 @@ class TradingExecutor:
# 初始化策略状态
trading_config = strategy['trading_config']
indicator_config = strategy['indicator_config']
ai_model_config = strategy.get('ai_model_config') or {}
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
if execution_mode not in ['signal', 'live']:
execution_mode = 'signal'
notification_config = strategy.get('notification_config') or {}
strategy_name = strategy.get('strategy_name') or f"strategy_{int(strategy_id)}"
symbol = trading_config.get('symbol', '')
timeframe = trading_config.get('timeframe', '1H')
@@ -943,6 +945,7 @@ class TradingExecutor:
ok = self._execute_signal(
strategy_id=strategy_id,
strategy_name=strategy_name,
exchange=exchange,
symbol=symbol,
current_price=execute_price,
@@ -957,6 +960,7 @@ class TradingExecutor:
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
ai_model_config=ai_model_config,
)
if ok:
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
@@ -1005,7 +1009,7 @@ class TradingExecutor:
id, strategy_name, strategy_type, status,
initial_capital, leverage, decide_interval,
execution_mode, notification_config,
indicator_config, exchange_config, trading_config
indicator_config, exchange_config, trading_config, ai_model_config
FROM qd_strategies_trading
WHERE id = %s
"""
@@ -1015,7 +1019,7 @@ class TradingExecutor:
if strategy:
# 解析JSON字段
for field in ['indicator_config', 'trading_config', 'notification_config']:
for field in ['indicator_config', 'trading_config', 'notification_config', 'ai_model_config']:
if isinstance(strategy.get(field), str):
try:
strategy[field] = json.loads(strategy[field])
@@ -1839,6 +1843,7 @@ class TradingExecutor:
def _execute_signal(
self,
strategy_id: int,
strategy_name: str,
exchange: Any,
symbol: str,
current_price: float,
@@ -1855,6 +1860,7 @@ class TradingExecutor:
execution_mode: str = 'signal',
notification_config: Optional[Dict[str, Any]] = None,
trading_config: Optional[Dict[str, Any]] = None,
ai_model_config: Optional[Dict[str, Any]] = None,
signal_ts: int = 0,
):
"""执行具体的交易信号"""
@@ -1868,11 +1874,49 @@ class TradingExecutor:
if market_type == 'spot' and 'short' in signal_type:
return False
sig = (signal_type or "").strip().lower()
# 1.1 开仓 AI 过滤(仅 open_*
if sig in ("open_long", "open_short") and self._is_entry_ai_filter_enabled(ai_model_config=ai_model_config, trading_config=trading_config):
ok_ai, ai_info = self._entry_ai_filter_allows(
strategy_id=strategy_id,
symbol=symbol,
signal_type=sig,
ai_model_config=ai_model_config,
trading_config=trading_config,
)
if not ok_ai:
# 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(不下单)"
self._persist_browser_notification(
strategy_id=strategy_id,
symbol=symbol,
signal_type="ai_filter_hold",
title=title,
message=msg,
payload={
"event": "qd.ai_filter",
"strategy_id": int(strategy_id),
"strategy_name": str(strategy_name or ""),
"symbol": str(symbol or ""),
"signal_type": str(sig),
"ai_decision": str(ai_decision),
"reason": str(reason),
"signal_ts": int(signal_ts or 0),
},
)
logger.info(
f"AI entry filter rejected: strategy_id={strategy_id} symbol={symbol} signal={sig} ai={ai_decision} reason={reason}"
)
return False
# 2. 计算下单数量
available_capital = self._get_available_capital(strategy_id, initial_capital)
amount = 0.0
sig = (signal_type or "").strip().lower()
# Frontend position sizing alignment:
# - open_* uses entry_pct from trading_config if provided (0~1 or 0~100 are both accepted)
@@ -2006,6 +2050,164 @@ class TradingExecutor:
logger.error(f"Failed to execute signal: {e}")
return False
def _is_entry_ai_filter_enabled(self, *, ai_model_config: Optional[Dict[str, Any]], trading_config: Optional[Dict[str, Any]]) -> bool:
"""Detect whether the strategy enabled 'AI filter on entry (open positions only)'."""
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
tc = trading_config if isinstance(trading_config, dict) else {}
# Accept multiple key names for forward/backward compatibility.
candidates = [
amc.get("entry_ai_filter_enabled"),
amc.get("entryAiFilterEnabled"),
amc.get("ai_filter_enabled"),
amc.get("aiFilterEnabled"),
amc.get("enable_ai_filter"),
amc.get("enableAiFilter"),
tc.get("entry_ai_filter_enabled"),
tc.get("ai_filter_enabled"),
tc.get("enable_ai_filter"),
tc.get("enableAiFilter"),
]
for v in candidates:
if v is None:
continue
if isinstance(v, bool):
return bool(v)
s = str(v).strip().lower()
if s in ("1", "true", "yes", "y", "on", "enabled"):
return True
if s in ("0", "false", "no", "n", "off", "disabled"):
return False
return False
def _entry_ai_filter_allows(
self,
*,
strategy_id: int,
symbol: str,
signal_type: str,
ai_model_config: Optional[Dict[str, Any]],
trading_config: Optional[Dict[str, Any]],
) -> Tuple[bool, Dict[str, Any]]:
"""
Run internal AI analysis and decide whether an entry signal is allowed.
Returns:
(allowed, info)
- allowed: True -> proceed; False -> hold (reject open)
- info: {ai_decision, reason, analysis_error?}
"""
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
tc = trading_config if isinstance(trading_config, dict) else {}
# Market for AnalysisService. Live trading executor is Crypto-focused.
market = str(amc.get("market") or amc.get("analysis_market") or "Crypto").strip() or "Crypto"
# Optional model override (OpenRouter model id)
model = amc.get("model") or amc.get("openrouter_model") or amc.get("openrouterModel") or None
model = str(model).strip() if model else None
# Prefer zh-CN for local UI; can be overridden.
language = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
language = str(language or "zh-CN")
try:
# Lazy import to avoid circular deps + heavy init unless the filter is enabled and entry signal happens.
from app.services.analysis import AnalysisService
service = AnalysisService()
result = service.analyze(market, symbol, language, model=model)
if isinstance(result, dict) and result.get("error"):
return False, {"ai_decision": "", "reason": "analysis_error", "analysis_error": str(result.get("error") or "")}
ai_dec = self._extract_ai_trade_decision(result)
if not ai_dec:
return False, {"ai_decision": "", "reason": "missing_ai_decision"}
expected = "BUY" if signal_type == "open_long" else "SELL"
if ai_dec == expected:
return True, {"ai_decision": ai_dec, "reason": "match"}
if ai_dec == "HOLD":
return False, {"ai_decision": ai_dec, "reason": "ai_hold"}
return False, {"ai_decision": ai_dec, "reason": "direction_mismatch"}
except Exception as e:
return False, {"ai_decision": "", "reason": "analysis_exception", "analysis_error": str(e)}
def _extract_ai_trade_decision(self, analysis_result: Any) -> str:
"""
Normalize AI analysis output into one of: BUY / SELL / HOLD / "".
We primarily look at final_decision.decision, with fallbacks.
"""
if not isinstance(analysis_result, dict):
return ""
def _pick(*paths: str) -> str:
for p in paths:
cur: Any = analysis_result
ok = True
for k in p.split("."):
if not isinstance(cur, dict):
ok = False
break
cur = cur.get(k)
if ok and cur is not None:
s = str(cur).strip()
if s:
return s
return ""
raw = _pick("final_decision.decision", "trader_decision.decision", "decision", "final.decision")
s = raw.strip().upper()
if not s:
return ""
# Common variants / synonyms
if "BUY" in s or s == "LONG" or "LONG" in s:
return "BUY"
if "SELL" in s or s == "SHORT" or "SHORT" in s:
return "SELL"
if "HOLD" in s or "WAIT" in s or "NEUTRAL" in s:
return "HOLD"
return s if s in ("BUY", "SELL", "HOLD") else ""
def _persist_browser_notification(
self,
*,
strategy_id: int,
symbol: str,
signal_type: str,
title: str,
message: str,
payload: Optional[Dict[str, Any]] = None,
) -> None:
"""Best-effort persist notification row for the frontend '通知' panel (browser channel)."""
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 ""),
"browser",
str(title or ""),
str(message or ""),
json.dumps(payload or {}, ensure_ascii=False),
int(now),
),
)
db.commit()
cur.close()
except Exception as e:
logger.warning(f"persist_browser_notification failed: {e}")
def _execute_exchange_order(
self,
exchange: Any,