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
+32
View File
@@ -0,0 +1,32 @@
"""
API 路由模块
"""
from flask import Flask
def register_routes(app: Flask):
"""注册所有 API 路由蓝图"""
from app.routes.kline import kline_bp
from app.routes.analysis import analysis_bp
from app.routes.backtest import backtest_bp
from app.routes.health import health_bp
from app.routes.market import market_bp
from app.routes.strategy import strategy_bp
from app.routes.credentials import credentials_bp
from app.routes.auth import auth_bp
from app.routes.ai_chat import ai_chat_bp
from app.routes.indicator import indicator_bp
from app.routes.dashboard import dashboard_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
app.register_blueprint(kline_bp, url_prefix='/api/indicator')
app.register_blueprint(analysis_bp, url_prefix='/api/analysis')
app.register_blueprint(backtest_bp, url_prefix='/api/indicator')
app.register_blueprint(market_bp, url_prefix='/api/market')
app.register_blueprint(ai_chat_bp, url_prefix='/api/ai')
app.register_blueprint(indicator_bp, url_prefix='/api/indicator')
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')
+46
View File
@@ -0,0 +1,46 @@
"""
AI chat API routes (optional).
Currently kept as a minimal compatibility layer for legacy frontend calls.
"""
from flask import Blueprint, request, jsonify
from app.utils.logger import get_logger
logger = get_logger(__name__)
ai_chat_bp = Blueprint('ai_chat', __name__)
@ai_chat_bp.route('/chat/message', methods=['POST'])
def chat_message():
"""
Minimal placeholder for legacy chat.
Return a friendly message instead of 404, so the UI can evolve gradually.
"""
data = request.get_json() or {}
msg = (data.get('message') or '').strip()
if not msg:
return jsonify({'code': 0, 'msg': 'Missing message', 'data': None}), 400
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'reply': 'Chat API is not implemented yet in local-only mode.',
'echo': msg
}
})
@ai_chat_bp.route('/chat/history', methods=['POST'])
def get_chat_history():
"""Return empty history (compatibility stub)."""
return jsonify({'code': 1, 'msg': 'success', 'data': []})
@ai_chat_bp.route('/chat/history/save', methods=['POST'])
def save_chat_history():
"""No-op save (compatibility stub)."""
return jsonify({'code': 1, 'msg': 'success', 'data': None})
+330
View File
@@ -0,0 +1,330 @@
"""
Analysis API routes (local-only).
Implements multi-dimensional analysis plus lightweight task/history APIs for the frontend.
"""
from flask import Blueprint, request, jsonify, Response
import json
import traceback
import time
from app.services.analysis import AnalysisService, reflect_analysis
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.utils.language import detect_request_language
logger = get_logger(__name__)
analysis_bp = Blueprint('analysis', __name__)
DEFAULT_USER_ID = 1
def _now_ts() -> int:
return int(time.time())
def _normalize_symbol(symbol: str) -> str:
return (symbol or '').strip().upper()
def _store_task(market: str, symbol: str, model: str, language: str, status: str, result: dict = None, error_message: str = "") -> int:
now = _now_ts()
result_json = json.dumps(result or {}, ensure_ascii=False)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_analysis_tasks (user_id, market, symbol, model, language, status, result_json, error_message, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(DEFAULT_USER_ID, market, symbol, model or '', language or 'en-US', status, result_json, error_message or '', now, now if status in ['completed', 'failed'] else None)
)
task_id = cur.lastrowid
db.commit()
cur.close()
return int(task_id)
def _get_task(task_id: int) -> dict:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT * FROM qd_analysis_tasks WHERE id = ? AND user_id = ?", (task_id, DEFAULT_USER_ID))
row = cur.fetchone()
cur.close()
return row or None
def _parse_result_json(row: dict) -> dict:
if not row:
return {}
raw = row.get('result_json') or ''
try:
return json.loads(raw) if raw else {}
except Exception:
return {}
@analysis_bp.route('/multi', methods=['POST'])
@analysis_bp.route('/multiAnalysis', methods=['POST']) # compatibility with legacy naming
def multi_analysis():
"""
Multi-dimensional analysis.
Request body:
market: Market (AShare, USStock, HShare, Crypto, Forex, Futures)
symbol: Symbol
language: Optional; if omitted we will detect from request headers (X-App-Lang / Accept-Language)
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
language = detect_request_language(request, body=data, default='en-US')
model = data.get('model', None)
use_multi_agent = data.get('use_multi_agent', None) # None -> use backend default
if not symbol or not market:
return jsonify({
'code': 0,
'msg': 'Missing required parameters',
'data': None
}), 400
# Normalize/defend input for local-only mode.
market = str(market).strip()
symbol = _normalize_symbol(symbol)
language = str(language or 'en-US')
model = str(model) if model else None
logger.info(f"Analyze request: {market}:{symbol}, use_multi_agent={use_multi_agent}, model={model}")
# Create analysis service instance (local-only; no paid credits)
service = AnalysisService(use_multi_agent=use_multi_agent)
result = service.analyze(market, symbol, language, model=model)
# Persist as "completed" history (no paid credits in local mode).
task_id = _store_task(market, symbol, model or '', language, 'completed', result=result, error_message='')
# Keep frontend compatible: if it expects task polling, it can still use the id.
result_payload = dict(result or {})
result_payload['task_id'] = task_id
return jsonify({'code': 1, 'msg': 'success', 'data': result_payload})
except Exception as e:
logger.error(f"Analysis failed: {str(e)}")
logger.error(traceback.format_exc())
try:
market = (data or {}).get('market', '') if 'data' in locals() else ''
symbol = (data or {}).get('symbol', '') if 'data' in locals() else ''
language = detect_request_language(request, body=(data or {}), default='en-US')
model = (data or {}).get('model', '') if 'data' in locals() else ''
market = str(market).strip()
symbol = _normalize_symbol(symbol)
_store_task(market, symbol, model, language, 'failed', result={}, error_message=str(e))
except Exception:
pass
return jsonify({
'code': 0,
'msg': f'Analysis failed: {str(e)}',
'data': None
}), 500
@analysis_bp.route('/getTaskStatus', methods=['POST'])
def get_task_status():
"""Frontend compatibility: return task status + result by task_id."""
try:
data = request.get_json() or {}
task_id = int(data.get('task_id') or 0)
if not task_id:
return jsonify({'code': 0, 'msg': 'Missing task_id', 'data': None}), 400
row = _get_task(task_id)
if not row:
return jsonify({'code': 0, 'msg': 'Task not found', 'data': None}), 404
payload = {
'id': row.get('id'),
'market': row.get('market'),
'symbol': row.get('symbol'),
'status': row.get('status'),
'error_message': row.get('error_message') or '',
'result': _parse_result_json(row)
}
return jsonify({'code': 1, 'msg': 'success', 'data': payload})
except Exception as e:
logger.error(f"get_task_status failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@analysis_bp.route('/getHistoryList', methods=['POST'])
def get_history_list():
"""Frontend compatibility: paginated analysis history for the single user."""
try:
data = request.get_json() or {}
page = int(data.get('page') or 1)
pagesize = int(data.get('pagesize') or 20)
page = max(page, 1)
pagesize = min(max(pagesize, 1), 100)
offset = (page - 1) * pagesize
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(1) as cnt FROM qd_analysis_tasks WHERE user_id = ?", (DEFAULT_USER_ID,))
total = int((cur.fetchone() or {}).get('cnt') or 0)
cur.execute(
"""
SELECT id, market, symbol, model, status, error_message, created_at, completed_at, result_json
FROM qd_analysis_tasks
WHERE user_id = ?
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(DEFAULT_USER_ID, pagesize, offset)
)
rows = cur.fetchall() or []
cur.close()
out = []
for r in rows:
has_result = bool((r.get('result_json') or '').strip())
out.append({
'id': r.get('id'),
'market': r.get('market'),
'symbol': r.get('symbol'),
'model': r.get('model') or '',
'status': r.get('status'),
'has_result': has_result,
'error_message': r.get('error_message') or '',
'createtime': int(r.get('created_at') or 0),
'completetime': int(r.get('completed_at') or 0) if r.get('completed_at') else None
})
return jsonify({'code': 1, 'msg': 'success', 'data': {'list': out, 'total': total}})
except Exception as e:
logger.error(f"get_history_list failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'list': [], 'total': 0}}), 500
@analysis_bp.route('/createTask', methods=['POST'])
def create_task():
"""
Compatibility endpoint for legacy frontend.
In local-only mode we do not run a separate async worker; we create a completed task record immediately.
"""
try:
data = request.get_json() or {}
market = str((data.get('market') or '')).strip()
symbol = _normalize_symbol(data.get('symbol'))
language = detect_request_language(request, body=data, default='en-US')
model = data.get('model') or ''
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
# Create a placeholder "pending" task so frontend can show task_id if it needs it.
task_id = _store_task(market, symbol, str(model), language, 'pending', result={}, error_message='')
return jsonify({'code': 1, 'msg': 'success', 'data': {'task_id': task_id, 'status': 'pending'}})
except Exception as e:
logger.error(f"create_task failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@analysis_bp.route('/stream', methods=['POST'])
def stream_analysis():
"""Streaming analysis (SSE)."""
try:
data = request.get_json()
if not data:
return jsonify({'code': 0, 'msg': 'Request body is required'}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
language = detect_request_language(request, body=data, default='en-US')
use_multi_agent = data.get('use_multi_agent', None)
def generate():
try:
yield f"data: {json.dumps({'status': 'started', 'message': 'Analysis started'})}\n\n"
service = AnalysisService(use_multi_agent=use_multi_agent)
result = service.analyze(market, symbol, language)
yield f"data: {json.dumps({'status': 'completed', 'data': result})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'status': 'error', 'message': str(e)})}\n\n"
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
except Exception as e:
logger.error(f"Streaming analysis failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500
@analysis_bp.route('/reflect', methods=['POST'])
def reflect():
"""
Reflection API.
Learn from post-trade outcomes and update agent memory (local-only).
Body:
market: Market
symbol: Symbol
decision: BUY/SELL/HOLD
returns: Optional return percentage
result: Optional free-text outcome
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
decision = data.get('decision', '')
returns = data.get('returns', None)
result = data.get('result', None)
if not symbol or not market or not decision:
return jsonify({
'code': 0,
'msg': 'Missing required parameters (market, symbol, decision)',
'data': None
}), 400
logger.info(f"Reflection: {market}:{symbol}, decision={decision}, returns={returns}")
reflect_analysis(market, symbol, decision, returns, result)
return jsonify({
'code': 1,
'msg': 'success',
'data': None
})
except Exception as e:
logger.error(f"Reflection failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'Reflection failed: {str(e)}',
'data': None
}), 500
+68
View File
@@ -0,0 +1,68 @@
from flask import Blueprint, request, jsonify
from app.config.settings import Config
from app.utils.auth import generate_token
from app.utils.logger import get_logger
auth_bp = Blueprint('auth', __name__)
logger = get_logger(__name__)
@auth_bp.route('/login', methods=['POST'])
def login():
"""Login (single-user, env-configured credentials)."""
try:
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': 'No data provided', 'data': None}), 400
username = data.get('username') or data.get('account')
password = data.get('password')
if not username or not password:
return jsonify({'code': 400, 'msg': 'Missing username or password', 'data': None}), 400
# Validate credentials from environment / settings
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
token = generate_token(username)
if token:
return jsonify({
'code': 1,
'msg': 'Login successful',
'data': {
'token': token,
'userinfo': {
'username': username,
'nickname': 'Admin',
'avatar': ''
}
}
})
else:
return jsonify({'code': 500, 'msg': 'Token generation error', 'data': None}), 500
else:
return jsonify({'code': 0, 'msg': 'Invalid credentials', 'data': None}), 401
except Exception as e:
logger.error(f"Login error: {e}")
return jsonify({'code': 500, 'msg': str(e), 'data': None}), 500
@auth_bp.route('/logout', methods=['POST'])
def logout():
"""Logout (client removes token; server is stateless)."""
return jsonify({'code': 1, 'msg': 'Logout successful', 'data': None})
@auth_bp.route('/info', methods=['GET'])
def get_user_info():
"""Get user info (single-user mock)."""
return jsonify({
'code': 1,
'msg': 'Success',
'data': {
'id': 1,
'username': Config.ADMIN_USER,
'nickname': 'Admin',
'avatar': '/avatar2.jpg',
'role': {'id': 'admin', 'permissions': ['dashboard', 'exception', 'account']}
}
})
+801
View File
@@ -0,0 +1,801 @@
"""
Backtest API routes
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
import traceback
import json
import time
import os
from app.services.backtest import BacktestService
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
import requests
logger = get_logger(__name__)
backtest_bp = Blueprint('backtest', __name__)
backtest_service = BacktestService()
def _openrouter_base_and_key() -> tuple[str, str]:
key = os.getenv("OPENROUTER_API_KEY", "").strip()
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
if not base:
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
if api_url.endswith("/chat/completions"):
base = api_url[: -len("/chat/completions")]
if not base:
base = "https://openrouter.ai/api/v1"
return base, key
def _normalize_lang(lang: str | None) -> str:
"""
Normalize language code for AI output.
This should align with frontend i18n locales under `quantdinger_vue/src/locales/lang`.
Supported:
- zh-CN, zh-TW, en-US, ko-KR, th-TH, vi-VN, ar-SA, de-DE, fr-FR, ja-JP
Default: zh-CN
"""
supported = {
"zh-CN",
"zh-TW",
"en-US",
"ko-KR",
"th-TH",
"vi-VN",
"ar-SA",
"de-DE",
"fr-FR",
"ja-JP",
}
l = (lang or "").strip()
if not l:
return "zh-CN"
alias = {
"zh": "zh-CN",
"zh-cn": "zh-CN",
"zh-hans": "zh-CN",
"zh-tw": "zh-TW",
"zh-hant": "zh-TW",
"en": "en-US",
"en-us": "en-US",
"ko": "ko-KR",
"ko-kr": "ko-KR",
"ja": "ja-JP",
"ja-jp": "ja-JP",
"fr": "fr-FR",
"fr-fr": "fr-FR",
"de": "de-DE",
"de-de": "de-DE",
"vi": "vi-VN",
"vi-vn": "vi-VN",
"th": "th-TH",
"th-th": "th-TH",
"ar": "ar-SA",
"ar-sa": "ar-SA",
}
l2 = alias.get(l.lower(), l)
return l2 if l2 in supported else "zh-CN"
@backtest_bp.route('/backtest', methods=['POST'])
def run_backtest():
"""
Run indicator backtest
Params:
indicatorId: Indicator ID (optional)
indicatorCode: Indicator Python code
symbol: Symbol
market: Market type
timeframe: Timeframe
startDate: Start date (YYYY-MM-DD)
endDate: End date (YYYY-MM-DD)
initialCapital: Initial capital (default 10000)
commission: Commission rate (default 0.001)
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': 'Request body is required',
'data': None
}), 400
# Extract params
user_id = int(data.get('userid') or data.get('userId') or 1)
indicator_code = data.get('indicatorCode', '')
indicator_id = data.get('indicatorId')
symbol = data.get('symbol', '')
market = data.get('market', '')
timeframe = data.get('timeframe', '1D')
start_date_str = data.get('startDate', '')
end_date_str = data.get('endDate', '')
initial_capital = float(data.get('initialCapital', 10000))
commission = float(data.get('commission', 0.001))
slippage = float(data.get('slippage', 0.0))
leverage = int(data.get('leverage', 1))
trade_direction = data.get('tradeDirection', 'long') # long, short, both
strategy_config = data.get('strategyConfig') or {}
# (Debug) log received params if needed
# If frontend only provides indicatorId, load code from local DB.
if (not indicator_code or not str(indicator_code).strip()) and indicator_id:
try:
iid = int(indicator_id)
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (iid,))
row = cur.fetchone()
cur.close()
if row and row.get('code'):
indicator_code = row.get('code')
except Exception:
pass
# 参数验证
if not all([indicator_code, symbol, market, timeframe, start_date_str, end_date_str]):
return jsonify({
'code': 0,
'msg': 'Missing required parameters',
'data': None
}), 400
# 转换日期
# 开始日期:当天的 00:00:00
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
# 结束日期:当天的 23:59:59,确保包含整天的数据
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
# 验证时间范围限制
days_diff = (end_date - start_date).days
# 根据周期设置不同的时间限制
if timeframe == '1m':
max_days = 30 # 1分钟K线最多1个月
max_range_text = '1个月'
elif timeframe == '5m':
max_days = 180 # 5分钟K线最多6个月
max_range_text = '6个月'
elif timeframe in ['15m', '30m']:
max_days = 365 # 15分钟和30分钟K线最多1年
max_range_text = '1年'
else: # 1H, 4H, 1D, 1W
max_days = 1095 # 1小时及以上最多3年
max_range_text = '3年'
if days_diff > max_days:
return jsonify({
'code': 0,
'msg': f'回测时间范围超出限制:{timeframe}周期最多可回测{max_range_text}{max_days}天),当前选择了{days_diff}',
'data': None
}), 400
# 执行回测
result = backtest_service.run(
indicator_code=indicator_code,
market=market,
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
initial_capital=initial_capital,
commission=commission,
slippage=slippage,
leverage=leverage,
trade_direction=trade_direction,
strategy_config=strategy_config
)
# Persist backtest run for AI optimization / history
run_id = None
try:
now_ts = int(time.time())
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_backtest_runs
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
initial_capital, commission, slippage, leverage, trade_direction,
strategy_config, status, error_message, result_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
int(indicator_id) if indicator_id is not None else None,
market,
symbol,
timeframe,
start_date_str,
end_date_str,
initial_capital,
commission,
slippage,
leverage,
trade_direction,
json.dumps(strategy_config or {}, ensure_ascii=False),
'success',
'',
json.dumps(result or {}, ensure_ascii=False),
now_ts
)
)
run_id = cur.lastrowid
db.commit()
cur.close()
except Exception:
# Do not break the main backtest response if persistence fails.
logger.warning("Failed to persist backtest run", exc_info=True)
return jsonify({
'code': 1,
'msg': 'Backtest succeeded',
'data': {
'runId': run_id,
'result': result
}
})
except ValueError as e:
logger.warning(f"Invalid backtest parameters: {str(e)}")
return jsonify({
'code': 0,
'msg': str(e),
'data': None
}), 400
except Exception as e:
logger.error(f"Backtest failed: {str(e)}")
logger.error(traceback.format_exc())
# Best-effort persist failed run (if we have enough context)
try:
data = data if isinstance(data, dict) else {}
user_id = int(data.get('userid') or data.get('userId') or 1)
indicator_id = data.get('indicatorId')
now_ts = int(time.time())
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_backtest_runs
(user_id, indicator_id, market, symbol, timeframe, start_date, end_date,
initial_capital, commission, slippage, leverage, trade_direction,
strategy_config, status, error_message, result_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
int(indicator_id) if indicator_id is not None else None,
str(data.get('market', '') or ''),
str(data.get('symbol', '') or ''),
str(data.get('timeframe', '') or ''),
str(data.get('startDate', '') or ''),
str(data.get('endDate', '') or ''),
float(data.get('initialCapital', 0) or 0),
float(data.get('commission', 0) or 0),
float(data.get('slippage', 0) or 0),
int(data.get('leverage', 1) or 1),
str(data.get('tradeDirection', 'long') or 'long'),
json.dumps(data.get('strategyConfig') or {}, ensure_ascii=False),
'failed',
str(e),
'',
now_ts
)
)
db.commit()
cur.close()
except Exception:
pass
return jsonify({
'code': 0,
'msg': f'Backtest failed: {str(e)}',
'data': None
}), 500
@backtest_bp.route('/backtest/history', methods=['POST'])
def get_backtest_history():
"""
Get backtest run history (saved in SQLite).
Params:
userid: User ID (default 1)
limit: Page size (default 50, max 200)
offset: Offset (default 0)
indicatorId: Optional indicator id filter
symbol: Optional symbol filter
market: Optional market filter
timeframe: Optional timeframe filter
"""
try:
data = request.get_json() or {}
user_id = int(data.get('userid') or data.get('userId') or 1)
limit = int(data.get('limit') or 50)
offset = int(data.get('offset') or 0)
limit = max(1, min(limit, 200))
offset = max(0, offset)
indicator_id = data.get('indicatorId')
symbol = (data.get('symbol') or '').strip()
market = (data.get('market') or '').strip()
timeframe = (data.get('timeframe') or '').strip()
where = ["user_id = ?"]
params = [user_id]
if indicator_id is not None and str(indicator_id).strip() != "":
try:
where.append("indicator_id = ?")
params.append(int(indicator_id))
except Exception:
pass
if symbol:
where.append("symbol = ?")
params.append(symbol)
if market:
where.append("market = ?")
params.append(market)
if timeframe:
where.append("timeframe = ?")
params.append(timeframe)
where_sql = " AND ".join(where)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
created_at
FROM qd_backtest_runs
WHERE {where_sql}
ORDER BY id DESC
LIMIT ? OFFSET ?
""",
(*params, limit, offset)
)
rows = cur.fetchall() or []
cur.close()
# Parse strategy_config JSON best-effort
for r in rows:
try:
r['strategy_config'] = json.loads(r.get('strategy_config') or '{}')
except Exception:
pass
return jsonify({'code': 1, 'msg': 'OK', 'data': rows})
except Exception as e:
logger.error(f"get_backtest_history failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@backtest_bp.route('/backtest/get', methods=['POST'])
def get_backtest_run():
"""
Get a backtest run detail by run id (includes result_json).
Params:
userid: User ID (default 1)
runId: Backtest run id (required)
"""
try:
data = request.get_json() or {}
user_id = int(data.get('userid') or data.get('userId') or 1)
run_id = int(data.get('runId') or 0)
if not run_id:
return jsonify({'code': 0, 'msg': 'runId is required', 'data': None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
result_json, created_at
FROM qd_backtest_runs
WHERE id = ? AND user_id = ?
""",
(run_id, user_id),
)
row = cur.fetchone()
cur.close()
if not row:
return jsonify({'code': 0, 'msg': 'run not found', 'data': None}), 404
try:
row['strategy_config'] = json.loads(row.get('strategy_config') or '{}')
except Exception:
pass
try:
row['result'] = json.loads(row.get('result_json') or '{}')
except Exception:
row['result'] = {}
row.pop('result_json', None)
return jsonify({'code': 1, 'msg': 'OK', 'data': row})
except Exception as e:
logger.error(f"get_backtest_run failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
def _heuristic_ai_advice(runs: list[dict], lang: str) -> str:
"""
Heuristic fallback when no model key is configured.
Returns Chinese suggestions for parameter tuning.
"""
if not runs:
msg_map = {
"zh-CN": "未找到可分析的回测记录。",
"zh-TW": "未找到可分析的回測記錄。",
"en-US": "No backtest runs selected.",
"ko-KR": "분석할 백테스트 기록을 찾을 수 없습니다.",
"th-TH": "ไม่พบประวัติแบ็กเทสต์สำหรับการวิเคราะห์",
"vi-VN": "Không tìm thấy lịch sử backtest để phân tích.",
"ar-SA": "لم يتم العثور على سجلات اختبار خلفي لتحليلها.",
"de-DE": "Keine Backtest-Läufe zur Analyse ausgewählt.",
"fr-FR": "Aucune exécution de backtest sélectionnée pour analyse.",
"ja-JP": "分析するバックテスト記録が見つかりません。",
}
return msg_map.get(lang, msg_map["en-US"])
# Use the last run as primary context, but mention multi-run comparison if provided.
r0 = runs[0]
result = (r0.get("result") or {}) if isinstance(r0, dict) else {}
cfg = (r0.get("strategy_config") or {}) if isinstance(r0, dict) else {}
risk = cfg.get("risk") or {}
pos = cfg.get("position") or {}
scale = cfg.get("scale") or {}
total_return = float(result.get("totalReturn") or 0.0)
max_dd = float(result.get("maxDrawdown") or 0.0)
sharpe = float(result.get("sharpeRatio") or 0.0)
win_rate = float(result.get("winRate") or 0.0)
profit_factor = float(result.get("profitFactor") or 0.0)
trades = int(result.get("totalTrades") or 0)
stop_loss = float(risk.get("stopLossPct") or 0.0)
take_profit = float(risk.get("takeProfitPct") or 0.0)
trailing = (risk.get("trailing") or {}) if isinstance(risk.get("trailing"), dict) else {}
trailing_enabled = bool(trailing.get("enabled"))
trailing_pct = float(trailing.get("pct") or 0.0)
trailing_act = float(trailing.get("activationPct") or 0.0)
entry_pct = float(pos.get("entryPct") or 1.0)
trend_add = scale.get("trendAdd") or {}
dca_add = scale.get("dcaAdd") or {}
trend_reduce = scale.get("trendReduce") or {}
adverse_reduce = scale.get("adverseReduce") or {}
# Minimal localized headings to keep heuristic readable across locales.
headings = {
"zh-CN": {"overall": "【总体建议】", "params": "【参数建议(可直接改回测配置测试)】", "next": "【下一步建议的回测方法】"},
"zh-TW": {"overall": "【總體建議】", "params": "【參數建議(可直接改回測配置測試)】", "next": "【下一步回測方法建議】"},
"en-US": {"overall": "Overall", "params": "Parameter suggestions (edit backtest config and re-run)", "next": "Next steps"},
"ko-KR": {"overall": "요약", "params": "파라미터 제안(백테스트 설정 변경)", "next": "다음 단계"},
"th-TH": {"overall": "สรุป", "params": "ข้อเสนอแนะพารามิเตอร์ (ปรับค่าที่ตั้งแบ็กเทสต์)", "next": "ขั้นตอนถัดไป"},
"vi-VN": {"overall": "Tổng quan", "params": "Gợi ý tham số (sửa cấu hình backtest và chạy lại)", "next": "Bước tiếp theo"},
"ar-SA": {"overall": "ملخص", "params": "اقتراحات المعلمات (عدّل إعدادات الاختبار وأعد التشغيل)", "next": "الخطوات التالية"},
"de-DE": {"overall": "Überblick", "params": "Parameter-Vorschläge (Backtest-Konfiguration anpassen)", "next": "Nächste Schritte"},
"fr-FR": {"overall": "Vue densemble", "params": "Suggestions de paramètres (modifier la config et relancer)", "next": "Étapes suivantes"},
"ja-JP": {"overall": "概要", "params": "パラメータ提案(設定変更→再バックテスト)", "next": "次のステップ"},
}
h = headings.get(lang, headings["en-US"])
lines = []
if lang == "en-US":
if len(runs) > 1:
lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.")
lines.append(h["overall"])
elif lang == "zh-TW":
if len(runs) > 1:
lines.append(f"已收到 {len(runs)} 條回測記錄。以下以記錄 #{r0.get('id','')} 為主給出參數調整建議,並建議你用多組記錄做 A/B 驗證。")
lines.append(h["overall"])
else:
if len(runs) > 1:
if lang == "ko-KR":
lines.append(f"{len(runs)}개의 백테스트 기록을 받았습니다. 아래는 #{r0.get('id','')} 기준으로 제안하며, 여러 기록으로 A/B 검증을 권장합니다.")
elif lang == "th-TH":
lines.append(f"ได้รับประวัติแบ็กเทสต์ {len(runs)} รายการ ข้อเสนอแนะด้านล่างอิงจาก #{r0.get('id','')} และแนะนำให้ทำ A/B test เทียบหลายชุด")
elif lang == "vi-VN":
lines.append(f"Đã nhận {len(runs)} bản ghi backtest. Gợi ý bên dưới tập trung vào #{r0.get('id','')} và khuyến nghị A/B test với nhiều bản ghi.")
elif lang == "ar-SA":
lines.append(f"تم استلام {len(runs)} من سجلات الاختبار الخلفي. تركّز الاقتراحات أدناه على التشغيل #{r0.get('id','')} مع توصية باختبارات A/B.")
elif lang == "de-DE":
lines.append(f"{len(runs)} Backtest-Läufe empfangen. Vorschläge unten fokussieren auf Lauf #{r0.get('id','')}; A/B-Tests über mehrere Läufe empfohlen.")
elif lang == "fr-FR":
lines.append(f"{len(runs)} exécutions de backtest reçues. Suggestions ci-dessous centrées sur #{r0.get('id','')}; A/B tests recommandés.")
elif lang == "ja-JP":
lines.append(f"{len(runs)} 件のバックテスト記録を受け取りました。以下は #{r0.get('id','')} を中心に提案し、複数記録でA/B検証を推奨します。")
else:
lines.append(f"Received {len(runs)} backtest runs. Suggestions below focus on run #{r0.get('id','')}; validate with A/B tests across runs.")
lines.append(h["overall"])
if sharpe < 0 or total_return < 0:
if lang == "en-US":
lines.append("- Strategy is losing/unstable: reduce risk first (lower entryPct, fewer/smaller scale-ins), then refine signal filters.")
elif lang == "zh-TW":
lines.append("- 目前策略偏虧損/不穩定:先降低風險暴露(降低開倉資金占比 entryPct、減少加倉次數/比例),再調整信號過濾。")
else:
lines.append("- 当前策略整体偏亏损/不稳定:优先降低风险暴露(降低开仓资金占比 entryPct、减少加仓次数/比例),再调信号过滤。")
if max_dd > 30:
if lang == "en-US":
lines.append("- Max drawdown is high: tighten stop-loss or reduce leverage/entry size; consider enabling trailing to protect profits.")
elif lang == "zh-TW":
lines.append("- 最大回撤偏大:建議優先收緊止損或降低槓桿/開倉倉位;同時考慮啟用移動止盈以保護盈利回撤。")
else:
lines.append("- 最大回撤较大:建议优先收紧止损或降低杠杆/开仓仓位;同时考虑启用移动止盈保护盈利回撤。")
if trades < 10:
if lang == "en-US":
lines.append("- Too few trades: rules may be too strict; relax thresholds or remove one filter to get enough samples.")
elif lang == "zh-TW":
lines.append("- 交易次數偏少:可能條件過嚴,建議適度放寬信號門檻或減少過濾條件,確保有足夠樣本驗證。")
else:
lines.append("- 交易次数偏少:可能条件过严,建议适当放宽信号阈值或减少过滤条件,确保有足够样本验证。")
if win_rate < 35 and profit_factor >= 1.2:
if lang == "en-US":
lines.append("- Low win rate but decent PF: consider slightly wider stop-loss and use trailing to lock profits.")
elif lang == "zh-TW":
lines.append("- 勝率偏低但盈虧比不差:可考慮略放寬止損(讓盈利單跑起來),並用移動止盈鎖住利潤。")
else:
lines.append("- 胜率偏低但盈亏比不差:可以考虑放宽止损(让盈利单跑起来)并用移动止盈锁利润。")
if win_rate >= 55 and profit_factor < 1.1:
if lang == "en-US":
lines.append("- Win rate is OK but PF is low: raise take-profit or enable trailing to improve winners; avoid taking profits too early.")
elif lang == "zh-TW":
lines.append("- 勝率不低但盈虧比偏小:考慮提高止盈或啟用移動止盈,讓單筆盈利更充分;避免過早止盈。")
else:
lines.append("- 胜率不低但盈亏比偏小:考虑提高止盈或启用移动止盈,让单笔盈利更充分;避免过早止盈。")
lines.append("\n" + h["params"])
if stop_loss <= 0:
if lang == "en-US":
lines.append("- Stop-loss: set stopLossPct (margin PnL basis). For crypto leverage, start with 2%~6% (then consider leverage conversion) and grid test.")
elif lang == "zh-TW":
lines.append("- 止損:建議設定 stopLossPct(按保證金口徑)。在加密+槓桿下,先從 2%~6%(再結合槓桿換算)做網格測試。")
else:
lines.append("- 止损:建议设置 stopLossPct(按保证金口径)。在加密+杠杆下,先从 2%~6%(再结合杠杆换算)做网格测试。")
else:
if lang == "en-US":
lines.append(f"- Stop-loss: current stopLossPct={stop_loss:.4f} (margin basis). Test ±30% around it and monitor drawdown/liquidations.")
elif lang == "zh-TW":
lines.append(f"- 止損:目前 stopLossPct={stop_loss:.4f}(保證金口徑)。建議圍繞它做 ±30% 區間測試,並觀察回撤/爆倉次數變化。")
else:
lines.append(f"- 止损:当前 stopLossPct={stop_loss:.4f}(保证金口径)。建议围绕它做 ±30% 的区间测试,并观察回撤/爆仓次数变化。")
if take_profit > 0 and (not trailing_enabled):
if lang == "en-US":
lines.append(f"- Take-profit: current takeProfitPct={take_profit:.4f}. Also test enabling trailing to reduce profit giveback.")
elif lang == "zh-TW":
lines.append(f"- 止盈:目前 takeProfitPct={take_profit:.4f}。建議同時測試啟用移動止盈(trailing)以降低盈利回撤。")
else:
lines.append(f"- 止盈:当前 takeProfitPct={take_profit:.4f}。建议同时测试开启移动止盈(trailing)以降低盈利回撤。")
if trailing_enabled:
if lang == "en-US":
lines.append(f"- Trailing: enabled, pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}. Set activation near typical winner PnL and test pct at 0.5x~1.5x.")
elif lang == "zh-TW":
lines.append(f"- 移動止盈:已啟用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建議將 activationPct 設為略低於常見單筆盈利水平,並把 pct 做 0.5x~1.5x 測試。")
else:
lines.append(f"- 移动止盈:已启用,pct={trailing_pct:.4f}, activationPct={trailing_act:.4f}。建议把 activationPct 设为略低于常见单笔盈利水平,并把 pct 做 0.5x~1.5x 测试。")
else:
if lang == "en-US":
lines.append("- Trailing: consider trailing.enabled=true; start with pct=1%~3% (margin basis) and test.")
elif lang == "zh-TW":
lines.append("- 移動止盈:建議開啟 trailing.enabled=true,並從 pct=1%~3%(保證金口徑換算後)開始測試。")
else:
lines.append("- 移动止盈:建议开启 trailing.enabled=true,并从 pct=1%~3%(保证金口径换算后)开始测试。")
if lang == "en-US":
lines.append(f"- Entry sizing: entryPct={entry_pct:.4f}. Test 0.2/0.3/0.5/0.8 to find a better return/drawdown sweet spot.")
elif lang == "zh-TW":
lines.append(f"- 開倉倉位:目前 entryPct={entry_pct:.4f}。建議先用 0.2/0.3/0.5/0.8 分層回測,找收益/回撤更優的甜區。")
else:
lines.append(f"- 开仓仓位:当前 entryPct={entry_pct:.4f}。建议先用 0.2/0.3/0.5/0.8 做分层回测,找收益/回撤更优的甜区。")
# Scaling (very light guidance)
if isinstance(trend_add, dict) and trend_add.get("enabled"):
if lang == "en-US":
lines.append("- Trend scale-in: reduce sizePct or maxTimes to avoid drawdown expansion; verify same-bar conflict rules match expectations.")
elif lang == "zh-TW":
lines.append("- 順勢加倉:建議優先降低 sizePct 或 maxTimes,避免回撤擴大;並確認同K線主信號禁用加減倉規則符合預期。")
else:
lines.append("- 顺势加仓:建议优先降低 sizePct 或 maxTimes,避免回撤扩大;并确保同K线主信号禁用加减仓的规则与你预期一致。")
if isinstance(dca_add, dict) and dca_add.get("enabled"):
if lang == "en-US":
lines.append("- DCA scale-in: very risky under leverage; keep maxTimes small, sizePct low, and use stricter stop-loss.")
elif lang == "zh-TW":
lines.append("- 逆勢加倉:加密槓桿下風險極高,建議 maxTimes 更小、sizePct 更低,並採用更嚴格止損。")
else:
lines.append("- 逆势加仓:加密杠杆下风险极高,建议 maxTimes 更小、sizePct 更低,并强制更严格止损。")
if isinstance(trend_reduce, dict) and trend_reduce.get("enabled"):
if lang == "en-US":
lines.append("- Trend reduce: can lower volatility but may reduce returns; test together with trailing.")
elif lang == "zh-TW":
lines.append("- 順勢減倉:有助降低波動,但可能降低收益;建議搭配移動止盈一起做對比測試。")
else:
lines.append("- 顺势减仓:适合降低波动,但可能降低收益;建议和移动止盈一起对比测试。")
if isinstance(adverse_reduce, dict) and adverse_reduce.get("enabled"):
if lang == "en-US":
lines.append("- Adverse reduce: can control drawdowns but increases fees/slippage; consider enabling under higher leverage.")
elif lang == "zh-TW":
lines.append("- 逆勢減倉:可用於控回撤,但可能增加手續費/滑點成本;建議優先在高槓桿時開啟。")
else:
lines.append("- 逆势减仓:可用于控回撤,但可能增加手续费/滑点成本;建议优先在高杠杆时开启。")
lines.append("\n" + h["next"])
if lang == "zh-CN":
lines.append("- 固定信号逻辑不变,只用参数做网格/分组测试(先粗再细)。每次只改 1~2 个参数,避免结论不可归因。")
lines.append("- 重点同时看:总收益、最大回撤、夏普、交易次数、爆仓/止损触发次数。")
elif lang == "zh-TW":
lines.append("- 固定信號邏輯不變,只用參數做網格/分組測試(先粗後細)。每次只改 1~2 個參數,避免結論不可歸因。")
lines.append("- 重點同時看:總收益、最大回撤、夏普、交易次數、爆倉/止損觸發次數。")
else:
# Keep English for other locales to ensure readability in fallback mode.
lines.append("- Keep signal logic fixed; run parameter grid tests (coarse → fine). Change only 1-2 params per run.")
lines.append("- Track: total return, max drawdown, Sharpe, trade count, liquidation/stop-loss triggers.")
return "\n".join(lines)
@backtest_bp.route('/backtest/aiAnalyze', methods=['POST'])
def ai_analyze_backtest_runs():
"""
AI analyze selected backtest runs and provide strategy_config tuning suggestions.
Params:
userid: User ID (default 1)
runIds: list[int] (required)
"""
try:
data = request.get_json() or {}
user_id = int(data.get('userid') or data.get('userId') or 1)
lang = _normalize_lang(data.get('lang'))
run_ids = data.get('runIds') or []
if not isinstance(run_ids, list) or not run_ids:
return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400
# Limit to avoid huge prompts / payload.
run_ids = [int(x) for x in run_ids if str(x).strip().isdigit()]
run_ids = run_ids[:10]
if not run_ids:
return jsonify({'code': 0, 'msg': 'runIds is required', 'data': None}), 400
placeholders = ",".join(["?"] * len(run_ids))
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT id, user_id, indicator_id, market, symbol, timeframe,
start_date, end_date, initial_capital, commission, slippage,
leverage, trade_direction, strategy_config, status, error_message,
result_json, created_at
FROM qd_backtest_runs
WHERE user_id = ? AND id IN ({placeholders})
ORDER BY id DESC
""",
(user_id, *run_ids),
)
rows = cur.fetchall() or []
cur.close()
runs: list[dict] = []
for r in rows:
try:
r['strategy_config'] = json.loads(r.get('strategy_config') or '{}')
except Exception:
r['strategy_config'] = {}
try:
r['result'] = json.loads(r.get('result_json') or '{}')
except Exception:
r['result'] = {}
r.pop('result_json', None)
runs.append(r)
if not runs:
return jsonify({'code': 0, 'msg': 'runs not found', 'data': None}), 404
# OpenRouter (optional)
base_url, api_key = _openrouter_base_and_key()
if not api_key:
analysis = _heuristic_ai_advice(runs, lang)
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic', 'lang': lang}})
model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini"
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.4") or 0.4)
output_lang_map = {
"zh-CN": "Simplified Chinese",
"zh-TW": "Traditional Chinese",
"en-US": "English",
"ko-KR": "Korean",
"th-TH": "Thai",
"vi-VN": "Vietnamese",
"ar-SA": "Arabic",
"de-DE": "German",
"fr-FR": "French",
"ja-JP": "Japanese",
}
output_lang = output_lang_map.get(lang, "English")
system_prompt = (
"You are an expert quantitative trading researcher specialized in crypto leveraged trading. "
"Your job is to analyze backtest configurations and results, then propose actionable parameter tuning suggestions. "
f"Output in {output_lang}. Be concise and practical. "
"Do NOT change indicator code logic. Focus on strategy_config parameters only: risk (stopLossPct/takeProfitPct/trailing), "
"position (entryPct), scale (trendAdd/dcaAdd/trendReduce/adverseReduce), execution assumptions. "
"Provide: (1) diagnosis, (2) recommended parameter ranges, (3) suggested A/B test plan (few steps). "
"Avoid investment advice language; focus on engineering/experimental recommendations."
)
user_payload = {
"selectedRuns": [
{
"id": r.get("id"),
"market": r.get("market"),
"symbol": r.get("symbol"),
"timeframe": r.get("timeframe"),
"start_date": r.get("start_date"),
"end_date": r.get("end_date"),
"leverage": r.get("leverage"),
"trade_direction": r.get("trade_direction"),
"strategy_config": r.get("strategy_config") or {},
"result": r.get("result") or {},
"status": r.get("status"),
}
for r in runs
]
}
resp = requests.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": model,
"temperature": temperature,
"stream": False,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
],
},
timeout=120,
)
try:
resp.raise_for_status()
j = resp.json()
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
analysis = content.strip()
if not analysis:
analysis = _heuristic_ai_advice(runs, lang)
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'heuristic_fallback', 'lang': lang}})
return jsonify({'code': 1, 'msg': 'OK', 'data': {'analysis': analysis, 'mode': 'llm', 'lang': lang}})
except requests.exceptions.RequestException as e:
# Do not fail the whole endpoint if LLM provider is misconfigured or rate-limited.
logger.error(f"OpenRouter request failed, falling back to heuristic: {e}")
analysis = _heuristic_ai_advice(runs, lang)
return jsonify(
{
'code': 1,
'msg': 'OK',
'data': {
'analysis': analysis,
'mode': 'heuristic_fallback',
'lang': lang,
'llmError': str(e),
},
}
)
except Exception as e:
logger.error(f"ai_analyze_backtest_runs failed: {e}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@@ -0,0 +1,181 @@
"""
Exchange credentials vault (local-only).
Local deployment notes:
- No encryption/decryption is used.
- Credentials are stored as plaintext JSON in DB (encrypted_config column kept for compatibility).
"""
import time
import traceback
import json
from flask import Blueprint, request, jsonify
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
credentials_bp = Blueprint('credentials', __name__)
DEFAULT_USER_ID = 1
def _api_key_hint(api_key: str) -> str:
if not api_key:
return ''
s = str(api_key)
if len(s) <= 8:
return s[:2] + '***'
return f"{s[:4]}...{s[-4:]}"
@credentials_bp.route('/list', methods=['GET'])
def list_credentials():
try:
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, user_id, name, exchange_id, api_key_hint, created_at, updated_at
FROM qd_exchange_credentials
WHERE user_id = ?
ORDER BY id DESC
""",
(user_id,)
)
rows = cur.fetchall() or []
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
except Exception as e:
logger.error(f"list_credentials failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
@credentials_bp.route('/create', methods=['POST'])
def create_credential():
try:
data = request.get_json() or {}
user_id = int(data.get('user_id') or DEFAULT_USER_ID)
name = (data.get('name') or '').strip()
exchange_id = (data.get('exchange_id') or '').strip()
api_key = (data.get('api_key') or '').strip()
secret_key = (data.get('secret_key') or '').strip()
passphrase = (data.get('passphrase') or '').strip()
if not exchange_id:
return jsonify({'code': 0, 'msg': 'Missing exchange_id', 'data': None}), 400
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': 'Missing api_key/secret_key', 'data': None}), 400
plaintext_config = json.dumps({
'exchange_id': exchange_id,
'api_key': api_key,
'secret_key': secret_key,
'passphrase': passphrase
}, ensure_ascii=False)
now = int(time.time())
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_exchange_credentials (user_id, name, exchange_id, api_key_hint, encrypted_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, exchange_id, _api_key_hint(api_key), plaintext_config, now, now)
)
new_id = cur.lastrowid
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
except Exception as e:
logger.error(f"create_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@credentials_bp.route('/delete', methods=['DELETE'])
def delete_credential():
try:
cred_id = request.args.get('id', type=int)
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
if not cred_id:
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_exchange_credentials WHERE id = ? AND user_id = ?",
(cred_id, user_id)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"delete_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@credentials_bp.route('/get', methods=['GET'])
def get_credential():
"""
Return decrypted credential for form auto-fill.
NOTE: In a production system, this must be protected by strong authentication/authorization.
"""
try:
cred_id = request.args.get('id', type=int)
user_id = request.args.get('user_id', type=int) or DEFAULT_USER_ID
if not cred_id:
return jsonify({'code': 0, 'msg': 'Missing id', 'data': None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, user_id, name, exchange_id, encrypted_config, api_key_hint, created_at, updated_at
FROM qd_exchange_credentials
WHERE id = ? AND user_id = ?
""",
(cred_id, user_id)
)
row = cur.fetchone()
cur.close()
if not row:
return jsonify({'code': 0, 'msg': 'Not found', 'data': None}), 404
decrypted = {}
raw = row.get('encrypted_config') or ''
if isinstance(raw, str) and raw.strip():
try:
decrypted = json.loads(raw)
except Exception:
decrypted = {}
# Ensure exchange_id is present
decrypted['exchange_id'] = row.get('exchange_id') or decrypted.get('exchange_id')
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'id': row.get('id'),
'name': row.get('name'),
'exchange_id': row.get('exchange_id'),
'api_key_hint': row.get('api_key_hint'),
'config': decrypted
}
})
except Exception as e:
logger.error(f"get_credential failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
+370
View File
@@ -0,0 +1,370 @@
"""
Dashboard APIs (local-first).
Endpoints:
- GET /api/dashboard/summary
- GET /api/dashboard/pendingOrders?page=1&pageSize=20
Notes:
- Paper mode: no real trading execution. Metrics are best-effort based on local DB tables.
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, List, Tuple
from flask import Blueprint, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
dashboard_bp = Blueprint("dashboard", __name__)
def _safe_int(v: Any, default: int) -> int:
try:
return int(v)
except Exception:
return default
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 _as_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return [str(x) for x in value if str(x or "").strip()]
if isinstance(value, str):
s = value.strip()
if not s:
return []
# allow comma-separated
if "," in s:
return [p.strip() for p in s.split(",") if p.strip()]
return [s]
return []
def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float:
try:
ep = float(entry_price or 0.0)
cp = float(current_price or 0.0)
sz = float(size or 0.0)
if ep <= 0 or cp <= 0 or sz <= 0:
return 0.0
s = (side or "").strip().lower()
if s == "short":
return (ep - cp) * sz
return (cp - ep) * sz
except Exception:
return 0.0
def _calc_pnl_percent(entry_price: float, size: float, pnl: float, leverage: float = 1.0, market_type: str = "spot") -> float:
try:
denom = float(entry_price or 0.0) * float(size or 0.0)
if denom <= 0:
return 0.0
lev = float(leverage or 1.0)
if lev <= 0:
lev = 1.0
mt = str(market_type or "").strip().lower()
# Margin PnL% (user expectation): pnl / (notional / leverage)
# = pnl / notional * leverage
mult = lev if mt in ("swap", "futures", "future", "perp", "perpetual") else 1.0
return float(pnl) / denom * 100.0 * float(mult)
except Exception:
return 0.0
@dashboard_bp.route("/summary", methods=["GET"])
def summary():
"""
Return dashboard summary used by `quantdinger_vue/src/views/dashboard/index.vue`.
"""
try:
# Strategy counts
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_name, strategy_type, status, initial_capital
FROM qd_strategies_trading
"""
)
strategies = cur.fetchall() or []
cur.close()
running = [s for s in strategies if (s.get("status") or "").strip().lower() == "running"]
indicator_strategy_count = len([s for s in running if (s.get("strategy_type") or "") == "IndicatorStrategy"])
ai_strategy_count = max(0, len(running) - indicator_strategy_count)
# Positions (best-effort)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT p.*, s.strategy_name, s.initial_capital, s.leverage, s.market_type
FROM qd_strategy_positions p
LEFT JOIN qd_strategies_trading s ON s.id = p.strategy_id
ORDER BY p.updated_at DESC
"""
)
rows = cur.fetchall() or []
cur.close()
current_positions: List[Dict[str, Any]] = []
total_unrealized_pnl = 0.0
for r in rows:
pnl = _calc_unrealized_pnl(
side=str(r.get("side") or ""),
entry_price=float(r.get("entry_price") or 0.0),
current_price=float(r.get("current_price") or 0.0),
size=float(r.get("size") or 0.0),
)
pct = _calc_pnl_percent(
float(r.get("entry_price") or 0.0),
float(r.get("size") or 0.0),
pnl,
leverage=float(r.get("leverage") or 1.0),
market_type=str(r.get("market_type") or "spot"),
)
total_unrealized_pnl += float(pnl)
current_positions.append(
{
**r,
"strategy_name": r.get("strategy_name") or "",
"unrealized_pnl": float(pnl),
"pnl_percent": float(pct),
}
)
# Recent trades (best-effort)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT t.*, s.strategy_name
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
"""
)
recent_trades = cur.fetchall() or []
cur.close()
# Total equity/pnl (best-effort)
total_initial_capital = 0.0
for s in strategies:
try:
total_initial_capital += float(s.get("initial_capital") or 0.0)
except Exception:
pass
total_pnl = float(total_unrealized_pnl)
total_equity = float(total_initial_capital + total_pnl)
# Daily PnL chart (uses realized profit field if present, otherwise 0)
# Keep output stable even if profit is mostly empty.
day_to_profit: Dict[str, float] = {}
for trow in recent_trades:
ts = _safe_int(trow.get("created_at"), 0)
if ts <= 0:
continue
day = time.strftime("%Y-%m-%d", time.localtime(ts))
try:
p = float(trow.get("profit") or 0.0)
except Exception:
p = 0.0
day_to_profit[day] = float(day_to_profit.get(day, 0.0) + p)
daily_pnl_chart = [{"date": d, "profit": float(v)} for d, v in sorted(day_to_profit.items())]
# Strategy performance pie (use unrealized pnl by strategy as best-effort)
sid_to_unreal: Dict[int, float] = {}
sid_to_name: Dict[int, str] = {}
for p in current_positions:
sid = _safe_int(p.get("strategy_id"), 0)
sid_to_name[sid] = str(p.get("strategy_name") or f"Strategy_{sid}")
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()]
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"ai_strategy_count": int(ai_strategy_count),
"indicator_strategy_count": int(indicator_strategy_count),
"total_equity": float(total_equity),
"total_pnl": float(total_pnl),
"daily_pnl_chart": daily_pnl_chart,
"strategy_pnl_chart": strategy_pnl_chart,
"recent_trades": recent_trades,
"current_positions": current_positions,
},
}
)
except Exception as e:
logger.error(f"dashboard summary failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@dashboard_bp.route("/pendingOrders", methods=["GET"])
def pending_orders():
"""
Return pending orders list for dashboard page.
"""
try:
page = max(1, _safe_int(request.args.get("page"), 1))
page_size = max(1, min(200, _safe_int(request.args.get("pageSize"), 20)))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT COUNT(1) AS cnt FROM pending_orders")
total = int((cur.fetchone() or {}).get("cnt") or 0)
cur.close()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT o.*,
s.strategy_name,
s.notification_config AS strategy_notification_config,
s.exchange_config AS strategy_exchange_config,
s.market_type AS strategy_market_type,
s.market_category AS strategy_market_category,
s.execution_mode AS strategy_execution_mode
FROM pending_orders o
LEFT JOIN qd_strategies_trading s ON s.id = o.strategy_id
ORDER BY o.id DESC
LIMIT %s OFFSET %s
""",
(int(page_size), int(offset)),
)
rows = cur.fetchall() or []
cur.close()
out: List[Dict[str, Any]] = []
for r in rows:
status = (r.get("status") or "").strip().lower()
if status == "sent":
status = "completed"
if status == "deferred":
status = "pending"
# Frontend expects these keys:
# - filled_amount, filled_price, error_message
filled_amount = float(r.get("filled") or 0.0)
filled_price = float(r.get("avg_price") or 0.0) if float(r.get("avg_price") or 0.0) > 0 else float(r.get("price") or 0.0)
# Derive exchange_id + notify channels without leaking secrets to frontend.
ex_cfg = _safe_json_loads(r.get("strategy_exchange_config"), {}) or {}
notify_cfg = _safe_json_loads(r.get("strategy_notification_config"), {}) or {}
exchange_id = (r.get("exchange_id") or ex_cfg.get("exchange_id") or ex_cfg.get("exchangeId") or "").strip().lower()
notify_channels = _as_list((notify_cfg or {}).get("channels"))
if not notify_channels:
notify_channels = ["browser"]
market_type = (r.get("market_type") or r.get("strategy_market_type") or ex_cfg.get("market_type") or ex_cfg.get("marketType") or "").strip().lower()
market_category = str(r.get("strategy_market_category") or "").strip().lower()
execution_mode = str(r.get("strategy_execution_mode") or r.get("execution_mode") or "").strip().lower()
# If non-crypto markets are "signal-only", show SIGNAL instead of blank exchange.
exchange_display = exchange_id
if not exchange_display:
if execution_mode == "signal" or (market_category and market_category != "crypto"):
exchange_display = "signal"
out.append(
{
**r,
"strategy_name": r.get("strategy_name") or "",
"status": status,
"filled_amount": filled_amount,
"filled_price": filled_price,
"error_message": r.get("last_error") or "",
"exchange_id": exchange_id,
"exchange_display": exchange_display,
"notify_channels": notify_channels,
"market_type": market_type or (r.get("market_type") or ""),
}
)
# Never expose these strategy-level config blobs.
for item in out:
try:
item.pop("strategy_exchange_config", None)
item.pop("strategy_notification_config", None)
item.pop("strategy_market_type", None)
item.pop("strategy_market_category", None)
item.pop("strategy_execution_mode", None)
except Exception:
pass
return jsonify(
{
"code": 1,
"msg": "success",
"data": {
"list": out,
"page": page,
"pageSize": page_size,
"total": total,
},
}
)
except Exception as e:
logger.error(f"dashboard pendingOrders failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@dashboard_bp.route("/pendingOrders/<int:order_id>", methods=["DELETE"])
def delete_pending_order(order_id: int):
"""
Delete a pending order record (dashboard operation).
"""
try:
oid = int(order_id or 0)
if oid <= 0:
return jsonify({"code": 0, "msg": "invalid_id", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT id, status FROM pending_orders WHERE id = %s", (oid,))
row = cur.fetchone() or {}
if not row:
cur.close()
return jsonify({"code": 0, "msg": "not_found", "data": None}), 404
st = (row.get("status") or "").strip().lower()
if st == "processing":
cur.close()
return jsonify({"code": 0, "msg": "cannot_delete_processing", "data": None}), 400
cur.execute("DELETE FROM pending_orders WHERE id = %s", (oid,))
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": {"id": oid}})
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
+28
View File
@@ -0,0 +1,28 @@
"""
健康检查路由
"""
from flask import Blueprint, jsonify
from datetime import datetime
health_bp = Blueprint('health', __name__)
@health_bp.route('/', methods=['GET'])
def index():
"""API 首页"""
return jsonify({
'name': 'QuantDinger Python API',
'version': '2.0.0',
'status': 'running',
'timestamp': datetime.now().isoformat()
})
@health_bp.route('/health', methods=['GET'])
def health_check():
"""健康检查"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat()
})
+433
View File
@@ -0,0 +1,433 @@
"""
Indicator APIs (local-first).
These endpoints are used by the frontend `/indicator-analysis` page.
In the original architecture, the frontend called PHP endpoints like:
`/addons/quantdinger/indicator/getIndicators`.
For local mode, we expose Python equivalents under `/api/indicator/*`.
"""
from __future__ import annotations
import json
import os
import re
import time
from typing import Any, Dict, List
from flask import Blueprint, Response, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
import requests
logger = get_logger(__name__)
indicator_bp = Blueprint("indicator", __name__)
def _now_ts() -> int:
return int(time.time())
def _extract_indicator_meta_from_code(code: str) -> Dict[str, str]:
"""
Extract indicator name/description from python code.
Expected variables:
my_indicator_name = "..."
my_indicator_description = "..."
"""
if not code or not isinstance(code, str):
return {"name": "", "description": ""}
# Simple assignment capture for single/double quoted strings.
name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
name = (name_match.group(2).strip() if name_match else "")[:100]
description = (desc_match.group(2).strip() if desc_match else "")[:500]
return {"name": name, "description": description}
def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
"""
Map SQLite row -> frontend expected indicator shape.
Frontend uses:
- id, name, description, code
- is_buy (1 bought, 0 custom)
- user_id / userId
- end_time (optional)
"""
return {
"id": row.get("id"),
"user_id": row.get("user_id") if row.get("user_id") is not None else user_id,
"is_buy": row.get("is_buy") if row.get("is_buy") is not None else 0,
"end_time": row.get("end_time") if row.get("end_time") is not None else 1,
"name": row.get("name") or "",
"code": row.get("code") or "",
"description": row.get("description") or "",
"publish_to_community": row.get("publish_to_community") if row.get("publish_to_community") is not None else 0,
"pricing_type": row.get("pricing_type") or "free",
"price": row.get("price") if row.get("price") is not None else 0,
# Local mode: encryption is not supported; keep field for frontend compatibility (always 0).
"is_encrypted": 0,
"preview_image": row.get("preview_image") or "",
# Prefer MySQL-like time fields; fallback to legacy local columns.
"createtime": row.get("createtime") or row.get("created_at"),
"updatetime": row.get("updatetime") or row.get("updated_at"),
}
@indicator_bp.route("/getIndicators", methods=["POST"])
def get_indicators():
"""
Get indicator list for a user.
Request:
{ userid: number }
Response:
{ code: 1, data: [ ... ] }
"""
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
with get_db_connection() as db:
cur = db.cursor()
# Local mode: "我的指标" should include both purchased and custom indicators.
cur.execute(
"""
SELECT
id, user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, is_encrypted, preview_image,
createtime, updatetime, created_at, updated_at
FROM qd_indicator_codes
WHERE user_id = ?
ORDER BY id DESC
""",
(user_id,),
)
rows = cur.fetchall() or []
cur.close()
out = [_row_to_indicator(r, user_id) for r in rows]
return jsonify({"code": 1, "msg": "success", "data": out})
except Exception as e:
logger.error(f"get_indicators failed: {str(e)}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
@indicator_bp.route("/saveIndicator", methods=["POST"])
def save_indicator():
"""
Create or update an indicator.
Request (frontend sends many extra fields; we store only the essentials):
{
userid: number,
id: number (0 for create),
name: string,
code: string,
description?: string,
...
}
"""
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
indicator_id = int(data.get("id") or 0)
code = data.get("code") or ""
name = (data.get("name") or "").strip()
description = (data.get("description") or "").strip()
publish_to_community = 1 if data.get("publishToCommunity") or data.get("publish_to_community") else 0
pricing_type = (data.get("pricingType") or data.get("pricing_type") or "free").strip() or "free"
try:
price = float(data.get("price") or 0)
except Exception:
price = 0.0
preview_image = (data.get("previewImage") or data.get("preview_image") or "").strip()
if not code or not str(code).strip():
return jsonify({"code": 0, "msg": "code is required", "data": None}), 400
# Local dev UX: if name/description not provided, derive from code variables.
if not name or not description:
meta = _extract_indicator_meta_from_code(code)
if not name:
name = meta.get("name") or ""
if not description:
description = meta.get("description") or ""
if not name:
name = "Custom Indicator"
now = _now_ts()
with get_db_connection() as db:
cur = db.cursor()
if indicator_id and indicator_id > 0:
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
updatetime = ?, updated_at = ?
WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)
""",
(name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, indicator_id, user_id),
)
else:
cur.execute(
"""
INSERT INTO qd_indicator_codes
(user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, preview_image,
createtime, updatetime, created_at, updated_at)
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, now, now, now, now),
)
indicator_id = int(cur.lastrowid or 0)
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": {"id": indicator_id, "userid": user_id}})
except Exception as e:
logger.error(f"save_indicator failed: {str(e)}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@indicator_bp.route("/deleteIndicator", methods=["POST"])
def delete_indicator():
"""Delete an indicator by id."""
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
indicator_id = int(data.get("id") or 0)
if not indicator_id:
return jsonify({"code": 0, "msg": "id is required", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_indicator_codes WHERE id = ? AND user_id = ? AND (is_buy IS NULL OR is_buy = 0)",
(indicator_id, user_id),
)
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": None})
except Exception as e:
logger.error(f"delete_indicator failed: {str(e)}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@indicator_bp.route("/aiGenerate", methods=["POST"])
def ai_generate():
"""
SSE endpoint to generate indicator code.
Frontend expects 'text/event-stream' with chunks:
data: {"content":"..."}\n\n
then:
data: [DONE]\n\n
Local-first: if OpenRouter key is not configured, we return a reasonable template.
"""
data = request.get_json() or {}
prompt = (data.get("prompt") or "").strip()
existing = (data.get("existingCode") or "").strip()
if not prompt:
# Keep SSE contract (match PHP behavior) so frontend doesn't look "stuck".
def _err_stream():
yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return Response(
_err_stream(),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# System prompt copied/adapted from the legacy PHP implementation.
SYSTEM_PROMPT = """# Role
You are an expert Python quantitative trading developer. Your task is to write custom indicator or strategy scripts for a professional K-line chart component running in a browser (Pyodide environment).
# Context & Environment
1. **Runtime Environment**: Code runs in a browser sandbox, **network access is prohibited** (cannot use `pip` or `requests`).
2. **Pre-installed Libraries**: The system has already imported `pandas as pd` and `numpy as np`. **DO NOT** include `import pandas as pd` or `import numpy as np` in your generated code. Use `pd` and `np` directly.
3. **Input Data**: The system provides a variable `df` (Pandas DataFrame) with index from 0 to N.
- Columns include: `df['time']` (timestamp), `df['open']`, `df['high']`, `df['low']`, `df['close']`, `df['volume']`.
# Output Requirement (Strict)
At the end of code execution, you **MUST** define a dictionary variable named `output`. The system only reads this variable to render the chart.
Additionally, you MUST define:
- my_indicator_name = "..."
- my_indicator_description = "..."
`output` MUST follow this shape:
output = {
"name": my_indicator_name,
"plots": [ { "name": str, "data": list, "color": "#RRGGBB", "overlay": bool, "type": "line" (optional) } ],
"signals": [ { "type": "buy"|"sell", "text": str, "data": list, "color": "#RRGGBB" } ] (optional),
"calculatedVars": {} (optional)
}
Where `data` lists MUST have the same length as `df` and use `None` for "no value".
Backtest/execution compatibility (recommended):
- Also set df['buy'] and df['sell'] as boolean columns (same length as df).
# Signal confirmation / execution timing (IMPORTANT)
- Signals are generally confirmed on bar close. The backtest engine may execute them on the next bar open to better match live trading and avoid look-ahead bias.
# Robustness requirements (IMPORTANT)
- Always handle NaN/inf and division-by-zero (common in RSI/BB/RSV calculations).
- Avoid overly restrictive entry/exit logic that results in zero buy or zero sell signals.
For multi-indicator strategies, do NOT require a crossover AND extreme RSI on the same bar unless explicitly requested.
- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive signals:
buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))
sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))
- If your final conditions produce no buys or no sells in the visible range, relax logically (e.g., remove one filter or widen thresholds).
IMPORTANT: Output Python code directly, without explanations, without descriptions, start directly with code, and do NOT use markdown code blocks like ```python.
"""
def _template_code() -> str:
# Fallback template that follows the project expectations.
header = (
f"my_indicator_name = \"Custom Indicator\"\n"
f"my_indicator_description = \"{prompt.replace('\\n', ' ')[:200]}\"\n\n"
)
body = (
"df = df.copy()\n\n"
"# Example: robust RSI with edge-triggered buy/sell (no position management, no TP/SL on chart)\n"
"rsi_len = 14\n"
"delta = df['close'].diff()\n"
"gain = delta.clip(lower=0)\n"
"loss = (-delta).clip(lower=0)\n"
"# Wilder-style smoothing (stable and avoids early NaN explosion)\n"
"avg_gain = gain.ewm(alpha=1/rsi_len, adjust=False).mean()\n"
"avg_loss = loss.ewm(alpha=1/rsi_len, adjust=False).mean()\n"
"rs = avg_gain / avg_loss.replace(0, np.nan)\n"
"rsi = 100 - (100 / (1 + rs))\n"
"rsi = rsi.fillna(50)\n\n"
"# Raw conditions (avoid overly strict filters)\n"
"raw_buy = (rsi < 30)\n"
"raw_sell = (rsi > 70)\n"
"# One-shot signals\n"
"buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n"
"sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n"
"df['buy'] = buy.astype(bool)\n"
"df['sell'] = sell.astype(bool)\n\n"
"buy_marks = [df['low'].iloc[i] * 0.995 if bool(buy.iloc[i]) else None for i in range(len(df))]\n"
"sell_marks = [df['high'].iloc[i] * 1.005 if bool(sell.iloc[i]) else None for i in range(len(df))]\n\n"
"output = {\n"
" 'name': my_indicator_name,\n"
" 'plots': [\n"
" {'name': 'RSI(14)', 'data': rsi.tolist(), 'color': '#faad14', 'overlay': False}\n"
" ],\n"
" 'signals': [\n"
" {'type': 'buy', 'text': 'B', 'data': buy_marks, 'color': '#00E676'},\n"
" {'type': 'sell', 'text': 'S', 'data': sell_marks, 'color': '#FF5252'}\n"
" ]\n"
"}\n"
)
if existing:
header = "# Existing code was provided as context.\n" + header
return header + body
def _openrouter_base_and_key() -> tuple[str, str]:
"""
Support both:
- OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
- OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
"""
key = os.getenv("OPENROUTER_API_KEY", "").strip()
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
if not base:
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
if api_url.endswith("/chat/completions"):
base = api_url[: -len("/chat/completions")]
if not base:
base = "https://openrouter.ai/api/v1"
return base, key
def _generate_code_via_openrouter() -> str:
base_url, api_key = _openrouter_base_and_key()
if not api_key:
return _template_code()
model = os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini").strip() or "openai/gpt-4o-mini"
# Match legacy PHP default more closely
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
# Build user prompt (match PHP behavior)
user_prompt = prompt
if existing:
user_prompt = (
"# Existing Code (modify based on this):\n\n```python\n"
+ existing.strip()
+ "\n```\n\n# Modification Requirements:\n\n"
+ prompt
+ "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation."
)
payload = {
"model": model,
"temperature": temperature,
"stream": False,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}
resp = requests.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=120,
)
resp.raise_for_status()
j = resp.json()
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
return content.strip() or _template_code()
def stream():
# 不扣任何 QDT:开源本地版直接生成/返回代码
try:
code_text = _generate_code_via_openrouter()
except Exception as e:
logger.warning(f"ai_generate openrouter failed, fallback to template: {e}")
code_text = _template_code()
# Stream in chunks (front-end appends).
chunk_size = 200
for i in range(0, len(code_text), chunk_size):
chunk = code_text[i : i + chunk_size]
yield "data: " + json.dumps({"content": chunk}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return Response(
stream(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
+121
View File
@@ -0,0 +1,121 @@
"""
K线数据 API 路由
"""
from flask import Blueprint, request, jsonify
from datetime import datetime
import traceback
from app.services.kline import KlineService
from app.utils.logger import get_logger
logger = get_logger(__name__)
kline_bp = Blueprint('kline', __name__)
kline_service = KlineService()
@kline_bp.route('/kline', methods=['GET', 'POST'])
def get_kline():
"""
获取K线数据
参数:
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
symbol: 交易对/股票代码
timeframe: 时间周期 (1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W)
limit: 数据条数 (默认300)
before_time: 获取此时间之前的数据 (可选,Unix时间戳)
"""
try:
# 支持 GET 和 POST
if request.method == 'POST':
data = request.get_json() or {}
else:
data = request.args
market = data.get('market', 'USStock')
symbol = data.get('symbol', '')
timeframe = data.get('timeframe', '1D')
limit = int(data.get('limit', 300))
before_time = data.get('before_time') or data.get('beforeTime')
if before_time:
before_time = int(before_time)
if not symbol:
return jsonify({
'code': 0,
'msg': '缺少交易标的参数',
'data': None
}), 400
logger.info(f"Requesting K-lines: {market}:{symbol}, timeframe={timeframe}, limit={limit}")
klines = kline_service.get_kline(
market=market,
symbol=symbol,
timeframe=timeframe,
limit=limit,
before_time=before_time
)
if not klines:
return jsonify({
'code': 0,
'msg': '未获取到数据',
'data': []
})
return jsonify({
'code': 1,
'msg': 'success',
'data': klines
})
except Exception as e:
logger.error(f"Failed to fetch K-lines: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'获取K线数据失败: {str(e)}',
'data': None
}), 500
@kline_bp.route('/price', methods=['GET'])
def get_price():
"""获取最新价格"""
try:
market = request.args.get('market', 'USStock')
symbol = request.args.get('symbol', '')
if not symbol:
return jsonify({
'code': 0,
'msg': '缺少交易标的参数',
'data': None
}), 400
price_data = kline_service.get_latest_price(market, symbol)
if not price_data:
return jsonify({
'code': 0,
'msg': '未获取到价格数据',
'data': None
})
return jsonify({
'code': 1,
'msg': 'success',
'data': price_data
})
except Exception as e:
logger.error(f"Failed to fetch price: {str(e)}")
return jsonify({
'code': 0,
'msg': f'获取价格失败: {str(e)}',
'data': None
}), 500
+582
View File
@@ -0,0 +1,582 @@
"""
Market API routes (local-only).
Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend.
"""
from flask import Blueprint, request, jsonify
import traceback
import json
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from app.services.kline import KlineService
from app.utils.logger import get_logger
from app.utils.cache import CacheManager
from app.utils.db import get_db_connection
from app.utils.config_loader import load_addon_config
from app.data.market_symbols_seed import (
get_hot_symbols as seed_get_hot_symbols,
search_symbols as seed_search_symbols,
get_symbol_name as seed_get_symbol_name
)
from app.services.symbol_name import resolve_symbol_name
logger = get_logger(__name__)
market_bp = Blueprint('market', __name__)
kline_service = KlineService()
cache = CacheManager()
# 线程池用于并行获取价格
executor = ThreadPoolExecutor(max_workers=10)
DEFAULT_USER_ID = 1
def _now_ts() -> int:
return int(time.time())
def _normalize_symbol(symbol: str) -> str:
return (symbol or '').strip().upper()
def _ensure_watchlist_table():
# Table is created by db schema init; this is only a sanity hook.
return True
@market_bp.route('/config', methods=['GET'])
def get_public_config():
"""
Public config for frontend (local mode).
Mirrors the old PHP `/addons/quantdinger/index/getConfig` shape.
"""
try:
cfg = load_addon_config()
models = (cfg.get('ai', {}) or {}).get('models')
if not isinstance(models, dict) or not models:
# Fallback defaults (offline friendly)
models = {
# Keep some legacy defaults
'openai/gpt-4o': 'GPT-4o',
# Unified frontend model list (OpenRouter-style ids)
'x-ai/grok-code-fast-1': 'xAI: Grok Code Fast 1',
'x-ai/grok-4-fast': 'xAI: Grok 4 Fast',
'x-ai/grok-4.1-fast': 'xAI: Grok 4.1 Fast',
'google/gemini-2.5-flash': 'Google: Gemini 2.5 Flash',
'google/gemini-2.0-flash-001': 'Google: Gemini 2.0 Flash',
'google/gemini-3-pro-preview': 'Google: Gemini 3 Pro Preview',
'google/gemini-2.5-flash-lite': 'Google: Gemini 2.5 Flash Lite',
'google/gemini-2.5-pro': 'Google: Gemini 2.5 Pro',
'openai/gpt-4o-mini': 'OpenAI: GPT-4o-mini',
'openai/gpt-5-mini': 'OpenAI: GPT-5 Mini',
'openai/gpt-oss-120b': 'OpenAI: gpt-oss-120b',
'deepseek/deepseek-v3.2': 'DeepSeek: DeepSeek V3.2',
'minimax/minimax-m2': 'MiniMax: MiniMax M2',
'anthropic/claude-sonnet-4': 'Anthropic: Claude Sonnet 4',
'anthropic/claude-sonnet-4.5': 'Anthropic: Claude Sonnet 4.5',
'anthropic/claude-opus-4.5': 'Anthropic: Claude Opus 4.5',
'anthropic/claude-haiku-4.5': 'Anthropic: Claude Haiku 4.5',
'z-ai/glm-4.6': 'Z.AI: GLM 4.6',
}
return jsonify({'code': 1, 'msg': 'success', 'data': {'models': models, 'qdt_cost': {}}})
except Exception as e:
logger.error(f"get_public_config failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@market_bp.route('/types', methods=['GET'])
def get_market_types():
"""Return supported market types for the add-watchlist modal."""
cfg = load_addon_config()
data = (cfg.get('market', {}) or {}).get('types')
if not isinstance(data, list) or not data:
data = [
{'value': 'AShare', 'i18nKey': 'dashboard.analysis.market.AShare'},
{'value': 'USStock', 'i18nKey': 'dashboard.analysis.market.USStock'},
{'value': 'HShare', 'i18nKey': 'dashboard.analysis.market.HShare'},
{'value': 'Crypto', 'i18nKey': 'dashboard.analysis.market.Crypto'},
{'value': 'Forex', 'i18nKey': 'dashboard.analysis.market.Forex'},
{'value': 'Futures', 'i18nKey': 'dashboard.analysis.market.Futures'}
]
return jsonify({'code': 1, 'msg': 'success', 'data': data})
@market_bp.route('/menuFooterConfig', methods=['POST'])
def get_menu_footer_config():
"""
Compatibility stub for old PHP `getMenuFooterConfig`.
Frontend can also hardcode this locally; this endpoint remains for completeness.
"""
data = {
'contact': {
'support_url': 'https://github.com/',
'feature_request_url': 'https://github.com/',
'email': 'support@example.com',
'live_chat_url': 'https://github.com/'
},
'social_accounts': [
{'name': 'GitHub', 'icon': 'github', 'url': 'https://github.com/'},
{'name': 'X', 'icon': 'x', 'url': 'https://x.com/'}
],
'legal': {
'user_agreement': '',
'privacy_policy': ''
},
'copyright': '© 2025-2026 QuantDinger'
}
return jsonify({'code': 1, 'msg': 'success', 'data': data})
@market_bp.route('/symbols/search', methods=['POST'])
def search_symbols():
"""
Lightweight symbol search.
In local-only mode we keep this simple; frontend allows manual input when no results.
"""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
keyword = (data.get('keyword') or '').strip().upper()
limit = int(data.get('limit') or 20)
if not market or not keyword:
return jsonify({'code': 1, 'msg': 'success', 'data': []})
out = seed_search_symbols(market=market, keyword=keyword, limit=limit)
return jsonify({'code': 1, 'msg': 'success', 'data': out})
except Exception as e:
logger.error(f"search_symbols failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@market_bp.route('/symbols/hot', methods=['POST'])
def get_hot_symbols():
"""Return a small curated hot list per market (local-only)."""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
limit = int(data.get('limit') or 10)
hot = seed_get_hot_symbols(market=market, limit=limit)
return jsonify({'code': 1, 'msg': 'success', 'data': hot})
except Exception as e:
logger.error(f"get_hot_symbols failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@market_bp.route('/watchlist/get', methods=['POST'])
def get_watchlist():
"""Get local watchlist for the single user."""
try:
_ensure_watchlist_table()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT id, market, symbol, name FROM qd_watchlist WHERE user_id = ? ORDER BY id DESC",
(DEFAULT_USER_ID,)
)
rows = cur.fetchall() or []
# Backfill display names for legacy rows (name empty or equals symbol).
# This keeps UI consistent without requiring users to re-add items.
for row in rows:
try:
market = row.get('market')
symbol = row.get('symbol')
current_name = (row.get('name') or '').strip()
if not market or not symbol:
continue
if current_name and current_name != symbol:
continue
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
if resolved and resolved != current_name:
row['name'] = resolved
cur.execute(
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
(resolved, _now_ts(), DEFAULT_USER_ID, market, symbol)
)
except Exception:
continue
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': rows})
except Exception as e:
logger.error(f"get_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@market_bp.route('/watchlist/add', methods=['POST'])
def add_watchlist():
"""Add a symbol to local watchlist (no credits/fees in local mode)."""
try:
data = request.get_json() or {}
market = (data.get('market') or '').strip()
symbol = _normalize_symbol(data.get('symbol'))
name_in = (data.get('name') or '').strip()
if not market or not symbol:
return jsonify({'code': 0, 'msg': 'Missing market or symbol', 'data': None}), 400
# Prefer frontend-provided name (search results), otherwise resolve via seed/public sources.
resolved = resolve_symbol_name(market, symbol) or seed_get_symbol_name(market, symbol)
name = name_in or resolved or symbol
now = _now_ts()
with get_db_connection() as db:
cur = db.cursor()
# Insert or ignore duplicates.
cur.execute(
"INSERT OR IGNORE INTO qd_watchlist (user_id, market, symbol, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(DEFAULT_USER_ID, market, symbol, name, now, now)
)
# If already exists, keep it fresh and sync name.
cur.execute(
"UPDATE qd_watchlist SET name = ?, updated_at = ? WHERE user_id = ? AND market = ? AND symbol = ?",
(name, now, DEFAULT_USER_ID, market, symbol)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"add_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@market_bp.route('/watchlist/remove', methods=['POST'])
def remove_watchlist():
"""Remove a symbol from watchlist. Frontend only passes symbol, so we delete across markets."""
try:
data = request.get_json() or {}
symbol = _normalize_symbol(data.get('symbol'))
if not symbol:
return jsonify({'code': 0, 'msg': 'Missing symbol', 'data': None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"DELETE FROM qd_watchlist WHERE user_id = ? AND symbol = ?",
(DEFAULT_USER_ID, symbol)
)
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"remove_watchlist failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
def get_single_price(market: str, symbol: str) -> dict:
"""获取单个标的的价格数据"""
try:
# 先尝试从缓存获取(60秒缓存)
cache_key = f"watchlist_price:{market}:{symbol}"
cached_data = cache.get(cache_key)
if cached_data:
logger.debug(f"Cache hit: {market}:{symbol}")
return {
'market': market,
'symbol': symbol,
'price': cached_data.get('price', 0),
'change': cached_data.get('change', 0),
'changePercent': cached_data.get('changePercent', 0)
}
# 获取最新的一根K线
klines = kline_service.get_kline(market, symbol, '1D', 2)
if klines and len(klines) > 0:
latest = klines[-1]
prev_close = klines[-2]['close'] if len(klines) > 1 else latest.get('open', 0)
current_price = latest.get('close', 0)
change = round(current_price - prev_close, 4) if prev_close else 0
change_percent = round(change / prev_close * 100, 2) if prev_close and prev_close > 0 else 0
result = {
'market': market,
'symbol': symbol,
'price': current_price,
'change': change,
'changePercent': change_percent
}
# 缓存60秒
cache.set(cache_key, result, 60)
return result
else:
return {
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
}
except Exception as e:
logger.error(f"Failed to fetch price {market}:{symbol} - {str(e)}")
return {
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
}
@market_bp.route('/watchlist/prices', methods=['POST'])
def get_watchlist_prices():
"""
批量获取自选股价格
请求体:
{
"watchlist": [
{"market": "USStock", "symbol": "AAPL"},
{"market": "Crypto", "symbol": "BTC"},
...
]
}
响应:
{
"code": 1,
"msg": "success",
"data": [
{"market": "USStock", "symbol": "AAPL", "price": 150.0, "change": 1.5, "changePercent": 1.0},
{"market": "Crypto", "symbol": "BTC", "price": 95000.0, "change": 1000, "changePercent": 1.05}
]
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': '请求参数不能为空',
'data': []
}), 400
watchlist = data.get('watchlist', [])
if not watchlist or not isinstance(watchlist, list):
return jsonify({
'code': 0,
'msg': 'watchlist参数格式错误',
'data': []
}), 400
# logger.info(f"开始获取 {len(watchlist)} 个自选股价格数据")
results = []
# 使用线程池并行获取价格
futures = {}
for item in watchlist:
market = item.get('market', '')
symbol = item.get('symbol', '')
if market and symbol:
future = executor.submit(get_single_price, market, symbol)
futures[future] = (market, symbol)
# 收集结果(保持顺序)
for future in as_completed(futures, timeout=30):
try:
result = future.result()
results.append(result)
except Exception as e:
market, symbol = futures[future]
logger.error(f"Price fetch timed out or failed: {market}:{symbol} - {str(e)}")
results.append({
'market': market,
'symbol': symbol,
'price': 0,
'change': 0,
'changePercent': 0
})
success_count = sum(1 for r in results if r.get('price', 0) > 0)
# logger.info(f"批量获取完成,成功: {success_count}/{len(results)}")
return jsonify({
'code': 1,
'msg': 'success',
'data': results
})
except Exception as e:
logger.error(f"Batch watchlist price fetch failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'获取失败: {str(e)}',
'data': []
}), 500
@market_bp.route('/price', methods=['GET'])
def get_price():
"""
获取单个标的价格
参数:
market: 市场类型
symbol: 交易标的
"""
try:
market = request.args.get('market', '')
symbol = request.args.get('symbol', '')
if not market or not symbol:
return jsonify({
'code': 0,
'msg': '缺少 market 或 symbol 参数',
'data': None
}), 400
result = get_single_price(market, symbol)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
except Exception as e:
logger.error(f"Failed to fetch price: {str(e)}")
return jsonify({
'code': 0,
'msg': f'获取失败: {str(e)}',
'data': None
}), 500
@market_bp.route('/stock/name', methods=['POST'])
def get_stock_name():
"""
获取股票名称
请求体:
{
"market": "USStock",
"symbol": "AAPL"
}
响应:
{
"code": 1,
"msg": "success",
"data": {
"name": "Apple Inc."
}
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'code': 0,
'msg': '请求参数不能为空',
'data': None
}), 400
market = data.get('market', '')
symbol = data.get('symbol', '')
if not market or not symbol:
return jsonify({
'code': 0,
'msg': '缺少 market 或 symbol 参数',
'data': None
}), 400
# 尝试从缓存获取(1天缓存)
cache_key = f"stock_name:{market}:{symbol}"
cached_name = cache.get(cache_key)
if cached_name:
logger.debug(f"Stock name cache hit: {market}:{symbol}")
return jsonify({
'code': 1,
'msg': 'success',
'data': {'name': cached_name}
})
# 根据不同市场获取股票名称
stock_name = symbol # 默认使用代码
try:
if market in ['USStock', 'AShare', 'HShare']:
# 对于股票,尝试获取基本信息
import yfinance as yf
# 转换symbol格式
if market == 'USStock':
yf_symbol = symbol
elif market == 'AShare':
yf_symbol = symbol + '.SS' if symbol.startswith('6') else symbol + '.SZ'
elif market == 'HShare':
# 港股需要补齐4位数字并添加.HK
hk_code = symbol.zfill(4)
yf_symbol = hk_code + '.HK'
else:
yf_symbol = symbol
ticker = yf.Ticker(yf_symbol)
info = ticker.info
# 尝试获取名称
stock_name = info.get('longName') or info.get('shortName') or symbol
elif market == 'Crypto':
# 加密货币,使用交易对格式
if '/' in symbol:
stock_name = symbol
else:
stock_name = f"{symbol}/USDT"
elif market == 'Forex':
# 外汇
forex_names = {
'XAUUSD': '黄金',
'XAGUSD': '白银',
'EURUSD': '欧元/美元',
'GBPUSD': '英镑/美元',
'USDJPY': '美元/日元',
'AUDUSD': '澳元/美元',
'USDCAD': '美元/加元',
'USDCHF': '美元/瑞郎',
}
stock_name = forex_names.get(symbol, symbol)
elif market == 'Futures':
# 期货
futures_names = {
'GC': '黄金期货',
'SI': '白银期货',
'CL': '原油期货',
'NG': '天然气期货',
'ZC': '玉米期货',
'ZW': '小麦期货',
'BTCUSDT': 'BTC永续合约',
'ETHUSDT': 'ETH永续合约',
}
stock_name = futures_names.get(symbol, symbol)
except Exception as e:
logger.warning(f"Failed to fetch stock name; falling back to symbol: {market}:{symbol} - {str(e)}")
stock_name = symbol
# 缓存1天
cache.set(cache_key, stock_name, 86400)
return jsonify({
'code': 1,
'msg': 'success',
'data': {'name': stock_name}
})
except Exception as e:
logger.error(f"Failed to fetch stock name: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'获取失败: {str(e)}',
'data': None
}), 500
+590
View File
@@ -0,0 +1,590 @@
"""
交易策略 API 路由
"""
from flask import Blueprint, request, jsonify
import traceback
import time
from app.services.strategy import StrategyService
from app.services.strategy_compiler import StrategyCompiler
from app.services.backtest import BacktestService
from app import get_trading_executor
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.data_sources import DataSourceFactory
logger = get_logger(__name__)
strategy_bp = Blueprint('strategy', __name__)
# Local mode: avoid heavy initialization during module import.
# Instantiate services lazily on first use to keep startup clean.
_strategy_service = None
def get_strategy_service() -> StrategyService:
global _strategy_service
if _strategy_service is None:
_strategy_service = StrategyService()
return _strategy_service
@strategy_bp.route('/strategies', methods=['GET'])
def list_strategies():
"""
策略列表(本地版:单用户)
"""
try:
user_id = request.args.get('user_id', type=int) or 1
items = get_strategy_service().list_strategies(user_id=user_id)
return jsonify({'code': 1, 'msg': 'success', 'data': {'strategies': items}})
except Exception as e:
logger.error(f"list_strategies failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'strategies': []}}), 500
@strategy_bp.route('/strategies/detail', methods=['GET'])
def get_strategy_detail():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400
st = get_strategy_service().get_strategy(strategy_id)
if not st:
return jsonify({'code': 0, 'msg': '策略不存在', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': st})
except Exception as e:
logger.error(f"get_strategy_detail failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/create', methods=['POST'])
def create_strategy():
try:
payload = request.get_json() or {}
# Local mode default user
payload['user_id'] = int(payload.get('user_id') or 1)
payload['strategy_type'] = payload.get('strategy_type') or 'IndicatorStrategy'
new_id = get_strategy_service().create_strategy(payload)
return jsonify({'code': 1, 'msg': 'success', 'data': {'id': new_id}})
except Exception as e:
logger.error(f"create_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/update', methods=['PUT'])
def update_strategy():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400
payload = request.get_json() or {}
ok = get_strategy_service().update_strategy(strategy_id, payload)
if not ok:
return jsonify({'code': 0, 'msg': '策略不存在', 'data': None}), 404
return jsonify({'code': 1, 'msg': 'success', 'data': None})
except Exception as e:
logger.error(f"update_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/delete', methods=['DELETE'])
def delete_strategy():
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': None}), 400
ok = get_strategy_service().delete_strategy(strategy_id)
return jsonify({'code': 1 if ok else 0, 'msg': 'success' if ok else 'failed', 'data': None})
except Exception as e:
logger.error(f"delete_strategy failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@strategy_bp.route('/strategies/trades', methods=['GET'])
def get_trades():
"""交易记录(从本地 SQLite 读取)"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': {'trades': [], 'items': []}}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at
FROM qd_strategy_trades
WHERE strategy_id = ?
ORDER BY id DESC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
# Frontend expects data.trades; keep data.items for compatibility with list-style components.
return jsonify({'code': 1, 'msg': 'success', 'data': {'trades': rows, 'items': rows}})
except Exception as e:
logger.error(f"get_trades failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'trades': [], 'items': []}}), 500
@strategy_bp.route('/strategies/positions', methods=['GET'])
def get_positions():
"""持仓记录(从本地 SQLite 读取)"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': {'positions': [], 'items': []}}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_id, symbol, side, size, entry_price, current_price, highest_price,
unrealized_pnl, pnl_percent, equity, updated_at
FROM qd_strategy_positions
WHERE strategy_id = ?
ORDER BY id DESC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
# Sync current price and PnL on read (frontend polls every few seconds).
def _calc_unrealized_pnl(side: str, entry_price: float, current_price: float, size: float) -> float:
ep = float(entry_price or 0.0)
cp = float(current_price or 0.0)
sz = float(size or 0.0)
if ep <= 0 or cp <= 0 or sz <= 0:
return 0.0
s = (side or "").strip().lower()
if s == "short":
return (ep - cp) * sz
return (cp - ep) * sz
def _calc_pnl_percent(entry_price: float, size: float, pnl: float) -> float:
ep = float(entry_price or 0.0)
sz = float(size or 0.0)
denom = ep * sz
if denom <= 0:
return 0.0
return float(pnl) / denom * 100.0
now = int(time.time())
# Fetch prices once per symbol to reduce API calls.
sym_to_price: Dict[str, float] = {}
ds = DataSourceFactory.get_source("Crypto")
for r in rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
if sym in sym_to_price:
continue
try:
t = ds.get_ticker(sym) or {}
px = float(t.get("last") or t.get("close") or 0.0)
if px > 0:
sym_to_price[sym] = px
except Exception:
continue
# Apply to rows and persist best-effort
out = []
with get_db_connection() as db:
cur = db.cursor()
for r in rows:
sym = (r.get("symbol") or "").strip()
side = (r.get("side") or "").strip().lower()
entry = float(r.get("entry_price") or 0.0)
size = float(r.get("size") or 0.0)
cp = float(sym_to_price.get(sym) or r.get("current_price") or 0.0)
pnl = _calc_unrealized_pnl(side, entry, cp, size)
pct = _calc_pnl_percent(entry, size, pnl)
rr = dict(r)
rr["current_price"] = float(cp or 0.0)
rr["unrealized_pnl"] = float(pnl)
rr["pnl_percent"] = float(pct)
rr["updated_at"] = now
out.append(rr)
try:
cur.execute(
"""
UPDATE qd_strategy_positions
SET current_price = ?, unrealized_pnl = ?, pnl_percent = ?, updated_at = ?
WHERE id = ?
""",
(float(cp or 0.0), float(pnl), float(pct), int(now), int(rr.get("id"))),
)
except Exception:
pass
db.commit()
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'positions': out, 'items': out}})
except Exception as e:
logger.error(f"get_positions failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'positions': [], 'items': []}}), 500
@strategy_bp.route('/strategies/equityCurve', methods=['GET'])
def get_equity_curve():
"""净值曲线(本地简单计算:initial_capital + 累计 profit"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({'code': 0, 'msg': '缺少策略ID参数', 'data': []}), 400
st = get_strategy_service().get_strategy(strategy_id) or {}
initial = float(st.get('initial_capital') or (st.get('trading_config') or {}).get('initial_capital') or 0)
if initial <= 0:
initial = 1000.0
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT created_at, profit
FROM qd_strategy_trades
WHERE strategy_id = ?
ORDER BY created_at ASC
""",
(strategy_id,)
)
rows = cur.fetchall() or []
cur.close()
equity = initial
curve = []
for r in rows:
try:
equity += float(r.get('profit') or 0)
except Exception:
pass
ts = int(r.get('created_at') or time.time())
curve.append({'time': ts, 'equity': equity})
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
except Exception as e:
logger.error(f"get_equity_curve failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': []}), 500
@strategy_bp.route('/strategies/stop', methods=['POST'])
def stop_strategy():
"""
停止策略
参数:
id: 策略ID
"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({
'code': 0,
'msg': '缺少策略ID参数',
'data': None
}), 400
# 获取策略类型
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
if strategy_type == 'PromptBasedStrategy':
return jsonify({'code': 0, 'msg': 'AI策略已移除,本地版不支持启动/停止 AI 策略', 'data': None}), 400
# 指标策略
get_trading_executor().stop_strategy(strategy_id)
# 更新策略状态
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
return jsonify({
'code': 1,
'msg': '停止成功',
'data': None
})
except Exception as e:
logger.error(f"Failed to stop strategy: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'停止策略失败: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/start', methods=['POST'])
def start_strategy():
"""
启动策略
参数:
id: 策略ID
"""
try:
strategy_id = request.args.get('id', type=int)
if not strategy_id:
return jsonify({
'code': 0,
'msg': '缺少策略ID参数',
'data': None
}), 400
# 获取策略类型
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
# 更新策略状态
get_strategy_service().update_strategy_status(strategy_id, 'running')
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
if strategy_type == 'PromptBasedStrategy':
return jsonify({'code': 0, 'msg': 'AI策略已移除,本地版不支持启动 AI 策略', 'data': None}), 400
# 指标策略
success = get_trading_executor().start_strategy(strategy_id)
if not success:
# 如果启动失败,恢复状态
get_strategy_service().update_strategy_status(strategy_id, 'stopped')
return jsonify({
'code': 0,
'msg': '启动策略执行器失败',
'data': None
}), 500
return jsonify({
'code': 1,
'msg': '启动成功',
'data': None
})
except Exception as e:
logger.error(f"Failed to start strategy: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'启动策略失败: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/test-connection', methods=['POST'])
def test_connection():
"""
测试交易所连接
请求体:
exchange_config: 交易所配置
"""
try:
data = request.get_json() or {}
# 记录请求数据(用于调试,但不记录敏感信息)
logger.debug(f"Connection test request keys: {list(data.keys())}")
# 获取交易所配置
exchange_config = data.get('exchange_config', data)
# Local deployment: no encryption/decryption; accept dict or JSON string.
if isinstance(exchange_config, str):
try:
import json
exchange_config = json.loads(exchange_config)
except Exception:
pass
# 验证 exchange_config 是否为字典
if not isinstance(exchange_config, dict):
logger.error(f"Invalid exchange_config type: {type(exchange_config)}, data: {str(exchange_config)[:200]}")
# Frontend expects HTTP 200 with {code:0} for business failures.
return jsonify({'code': 0, 'msg': '交易所配置格式错误,请检查数据格式', 'data': None})
# 验证必要字段
if not exchange_config.get('exchange_id'):
return jsonify({'code': 0, 'msg': '请选择交易所', 'data': None})
api_key = exchange_config.get('api_key', '')
secret_key = exchange_config.get('secret_key', '')
# 详细日志排查
logger.info(f"Testing connection: exchange_id={exchange_config.get('exchange_id')}")
logger.info(f"API Key: {api_key[:5]}... (len={len(api_key)})")
logger.info(f"Secret Key: {secret_key[:5]}... (len={len(secret_key)})")
# 检查是否有特殊字符
if api_key.strip() != api_key:
logger.warning("API key contains leading/trailing whitespace")
if secret_key.strip() != secret_key:
logger.warning("Secret key contains leading/trailing whitespace")
if not api_key or not secret_key:
return jsonify({'code': 0, 'msg': '请填写API密钥和Secret密钥', 'data': None})
result = get_strategy_service().test_exchange_connection(exchange_config)
if result['success']:
return jsonify({'code': 1, 'msg': result.get('message') or '连接成功', 'data': result.get('data')})
# Always return HTTP 200 for business-level failures.
return jsonify({'code': 0, 'msg': result.get('message') or '连接失败', 'data': result.get('data')})
except Exception as e:
logger.error(f"Connection test failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'测试连接失败: {str(e)}',
'data': None
}), 500
@strategy_bp.route('/strategies/get-symbols', methods=['POST'])
def get_symbols():
"""
获取交易所交易对列表
请求体:
exchange_config: 交易所配置
"""
try:
data = request.get_json() or {}
exchange_config = data.get('exchange_config', data)
result = get_strategy_service().get_exchange_symbols(exchange_config)
if result['success']:
return jsonify({
'code': 1,
'msg': result['message'],
'data': {
'symbols': result['symbols']
}
})
else:
return jsonify({
'code': 0,
'msg': result['message'],
'data': {
'symbols': []
}
})
except Exception as e:
logger.error(f"Failed to fetch symbols: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
'code': 0,
'msg': f'获取交易对失败: {str(e)}',
'data': {
'symbols': []
}
}), 500
@strategy_bp.route('/strategies/preview-compile', methods=['POST'])
def preview_compile():
"""
预览编译后的策略结果
"""
try:
data = request.get_json() or {}
# strategy_config is passed as 'config'
config = data.get('config')
if not config:
return jsonify({'code': 0, 'msg': 'Missing config'}), 400
# Compile
compiler = StrategyCompiler()
try:
code = compiler.compile(config)
except Exception as e:
return jsonify({'code': 0, 'msg': f'Compilation failed: {str(e)}'}), 400
# Execute
symbol = config.get('symbol', 'BTC/USDT')
timeframe = config.get('timeframe', '4h')
backtest_service = BacktestService()
result = backtest_service.run_code_strategy(
code=code,
symbol=symbol,
timeframe=timeframe,
limit=500
)
if result.get('error'):
return jsonify({'code': 0, 'msg': f"Execution failed: {result['error']}"}), 400
return jsonify({
'code': 1,
'msg': 'Success',
'data': result
})
except Exception as e:
logger.error(f"Preview failed: {e}")
return jsonify({'code': 0, 'msg': str(e)}), 500
@strategy_bp.route('/strategies/notifications', methods=['GET'])
def get_strategy_notifications():
"""
Strategy signal notifications (browser channel persistence).
Query:
- id: strategy id (optional)
- limit: default 50, max 200
- since_id: return rows with id > since_id (optional)
"""
try:
strategy_id = request.args.get('id', type=int)
limit = request.args.get('limit', type=int) or 50
limit = max(1, min(200, int(limit)))
since_id = request.args.get('since_id', type=int) or 0
where = []
args = []
if strategy_id:
where.append("strategy_id = ?")
args.append(int(strategy_id))
if since_id:
where.append("id > ?")
args.append(int(since_id))
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
f"""
SELECT *
FROM qd_strategy_notifications
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(args + [int(limit)]),
)
rows = cur.fetchall() or []
cur.close()
return jsonify({'code': 1, 'msg': 'success', 'data': {'items': rows}})
except Exception as e:
logger.error(f"get_strategy_notifications failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': {'items': []}}), 500
@@ -0,0 +1,276 @@
"""
Indicator-analysis Strategy APIs (local-first).
These "strategies" are user-authored Python scripts used on `/indicator-analysis`:
- visualize signals on Kline (via output.plots/output.signals)
- optionally support backtest engine expectations (df signal columns)
They are different from the live trading executor strategies in `app/routes/strategy.py`.
"""
from __future__ import annotations
import json
import os
import re
import time
from typing import Any, Dict
import requests
from flask import Blueprint, Response, jsonify, request
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
strategy_code_bp = Blueprint("strategy_code", __name__)
def _now_ts() -> int:
return int(time.time())
def _extract_meta_from_code(code: str) -> Dict[str, str]:
if not code or not isinstance(code, str):
return {"name": "", "description": ""}
name_match = re.search(r'^\s*my_indicator_name\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
desc_match = re.search(r'^\s*my_indicator_description\s*=\s*([\'"])(.*?)\1\s*$', code, re.MULTILINE)
name = (name_match.group(2).strip() if name_match else "")[:100]
description = (desc_match.group(2).strip() if desc_match else "")[:500]
return {"name": name, "description": description}
@strategy_code_bp.route("/strategy/getStrategies", methods=["POST"])
def get_strategies():
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT id, user_id, name, code, description, createtime, updatetime FROM qd_strategy_codes WHERE user_id = ? ORDER BY id DESC",
(user_id,),
)
rows = cur.fetchall() or []
cur.close()
return jsonify({"code": 1, "msg": "success", "data": rows})
except Exception as e:
logger.error(f"get_strategies failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": []}), 500
@strategy_code_bp.route("/strategy/saveStrategy", methods=["POST"])
def save_strategy():
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
strategy_id = int(data.get("id") or 0)
code = data.get("code") or ""
if not str(code).strip():
return jsonify({"code": 0, "msg": "code is required", "data": None}), 400
name = (data.get("name") or "").strip()
description = (data.get("description") or "").strip()
if not name or not description:
meta = _extract_meta_from_code(code)
if not name:
name = meta.get("name") or ""
if not description:
description = meta.get("description") or ""
if not name:
name = "Custom Strategy"
now = _now_ts()
with get_db_connection() as db:
cur = db.cursor()
if strategy_id and strategy_id > 0:
cur.execute(
"UPDATE qd_strategy_codes SET name = ?, code = ?, description = ?, updatetime = ? WHERE id = ? AND user_id = ?",
(name, code, description, now, strategy_id, user_id),
)
else:
cur.execute(
"INSERT INTO qd_strategy_codes (user_id, name, code, description, createtime, updatetime) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, name, code, description, now, now),
)
strategy_id = int(cur.lastrowid or 0)
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": {"id": strategy_id, "userid": user_id}})
except Exception as e:
logger.error(f"save_strategy failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@strategy_code_bp.route("/strategy/deleteStrategy", methods=["POST"])
def delete_strategy():
try:
data = request.get_json() or {}
user_id = int(data.get("userid") or 1)
strategy_id = int(data.get("id") or 0)
if not strategy_id:
return jsonify({"code": 0, "msg": "id is required", "data": None}), 400
with get_db_connection() as db:
cur = db.cursor()
cur.execute("DELETE FROM qd_strategy_codes WHERE id = ? AND user_id = ?", (strategy_id, user_id))
db.commit()
cur.close()
return jsonify({"code": 1, "msg": "success", "data": None})
except Exception as e:
logger.error(f"delete_strategy failed: {e}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@strategy_code_bp.route("/strategy/aiGenerate", methods=["POST"])
def ai_generate_strategy():
"""
SSE code generation for strategy scripts (local-first, no QDT deduction).
"""
data = request.get_json() or {}
prompt = (data.get("prompt") or "").strip()
existing = (data.get("existingCode") or "").strip()
if not prompt:
def _err_stream():
yield "data: " + json.dumps({"error": "提示词不能为空"}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return Response(_err_stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
SYSTEM_PROMPT = """# Role
You are an expert Python quantitative trading developer.
# Environment
- Runs in browser (Pyodide): NO network access, no pip, no requests.
- pandas is already imported as pd, numpy as np. DO NOT import them.
- Input: df with columns time/open/high/low/close/volume.
# Required output (STRICT)
- You MUST define:
- my_indicator_name = "..."
- my_indicator_description = "..."
- output = {"name":..., "plots":[...], "signals":[...]}
# Chart signal rules (MUST)
- output["signals"] MAY exist, but if present it MUST contain ONLY two types: "buy" and "sell".
- Signals must be aligned with df length: signals[].data length == len(df), use None for "no signal".
- Default signal text MUST be English (recommended "B"/"S" or "Buy"/"Sell"). Do NOT output Chinese text.
# Execution/backtest compatibility (MUST)
- You MUST set boolean columns:
- df["buy"] and df["sell"]
- Backend will normalize buy/sell into open/close long/short actions based on trade_direction and current position.
- Do NOT emit open_long/close_long/open_short/close_short/add_* in output["signals"].
- Do NOT implement position sizing, TP/SL, trailing, pyramiding in the script. Those belong to strategy_config / backend.
- Signals are typically confirmed on bar close and executed by backtest on the next bar open (to avoid look-ahead bias).
# Robustness requirements (IMPORTANT)
- Always handle division-by-zero and NaN/inf when computing indicators (e.g., RSV denominator can be 0).
- Avoid overly restrictive entry conditions that result in zero buys or zero sells. Prefer crossover/event-based signals.
- For multi-indicator strategies, avoid requiring a crossover AND extreme RSI/BB condition on the same bar unless explicitly requested.
- Prefer edge-triggered signals (one-shot) to avoid repeated consecutive buy/sell bars:
buy = raw_buy & ~raw_buy.shift(1).fillna(False)
sell = raw_sell & ~raw_sell.shift(1).fillna(False)
# Execution rule (IMPORTANT)
- The backtest engine may apply parameterized scaling (scale-in/out) from strategy_config.
- If a candle has a main signal (buy/sell mapped to open/close/reverse), scaling in/out is skipped on the same candle.
# Output style
- Output Python code only. No markdown code blocks. No extra explanations.
- Keep code comments and default strings in English.
"""
def _openrouter_base_and_key() -> tuple[str, str]:
key = os.getenv("OPENROUTER_API_KEY", "").strip()
base = os.getenv("OPENROUTER_BASE_URL", "").strip()
if not base:
api_url = os.getenv("OPENROUTER_API_URL", "").strip()
if api_url.endswith("/chat/completions"):
base = api_url[: -len("/chat/completions")]
if not base:
base = "https://openrouter.ai/api/v1"
return base, key
def _template_code() -> str:
return (
f'my_indicator_name = "Custom Strategy"\n'
f'my_indicator_description = "{prompt.replace("\\n", " ")[:200]}"\n\n'
"# Buy/Sell only. Execution is normalized in backend.\n"
"df = df.copy()\n"
"sma = df['close'].rolling(14).mean()\n"
"raw_buy = (df['close'] > sma) & (df['close'].shift(1) <= sma.shift(1))\n"
"raw_sell = (df['close'] < sma) & (df['close'].shift(1) >= sma.shift(1))\n"
"# Edge-triggered signals (avoid repeated consecutive signals)\n"
"buy = raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))\n"
"sell = raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))\n"
"df['buy'] = buy.astype(bool)\n"
"df['sell'] = sell.astype(bool)\n"
"\n"
"buy_marks = [df['low'].iloc[i]*0.995 if bool(df['buy'].iloc[i]) else None for i in range(len(df))]\n"
"sell_marks = [df['high'].iloc[i]*1.005 if bool(df['sell'].iloc[i]) else None for i in range(len(df))]\n"
"output = {\n"
" 'name': my_indicator_name,\n"
" 'plots': [ {'name':'SMA 14','data': sma.tolist(),'color':'#1890ff','overlay': True} ],\n"
" 'signals': [\n"
" {'type':'buy','text':'B','data': buy_marks,'color':'#00E676'},\n"
" {'type':'sell','text':'S','data': sell_marks,'color':'#FF5252'}\n"
" ]\n"
"}\n"
)
def _generate() -> str:
base_url, api_key = _openrouter_base_and_key()
if not api_key:
return _template_code()
model = (os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") or "").strip() or "openai/gpt-4o-mini"
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
user_prompt = prompt
if existing:
user_prompt = (
"# Existing Code (modify based on this):\n\n```python\n"
+ existing.strip()
+ "\n```\n\n# Modification Requirements:\n\n"
+ prompt
+ "\n\nPlease generate complete new Python code based on the existing code above and my modification requirements. Output the complete Python code directly, without explanations, without segmentation."
)
resp = requests.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": model,
"temperature": temperature,
"stream": False,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
},
timeout=120,
)
resp.raise_for_status()
j = resp.json()
content = (((j.get("choices") or [{}])[0]).get("message") or {}).get("content") or ""
return content.strip() or _template_code()
def stream():
try:
code_text = _generate()
except Exception as e:
logger.warning(f"strategy aiGenerate failed, fallback template: {e}")
code_text = _template_code()
chunk_size = 200
for i in range(0, len(code_text), chunk_size):
yield "data: " + json.dumps({"content": code_text[i : i + chunk_size]}, ensure_ascii=False) + "\n\n"
yield "data: [DONE]\n\n"
return Response(stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})