@@ -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')
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 d’ensemble", "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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user