2025-12-29 03:06:49 +08:00
"""
Backtest API routes
"""
2026-01-14 05:29:55 +08:00
from flask import Blueprint , request , jsonify , g
2025-12-29 03:06:49 +08:00
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
2026-01-14 05:29:55 +08:00
from app.utils.auth import login_required
2025-12-29 03:06:49 +08:00
import requests
logger = get_logger ( __name__ )
backtest_bp = Blueprint ( 'backtest' , __name__ )
backtest_service = BacktestService ()
def _openrouter_base_and_key () -> tuple [ str , str ]:
2026-01-24 03:22:14 +08:00
from app.config import APIKeys
# Use APIKeys to get the key (handles env var + config cache properly)
key = APIKeys . OPENROUTER_API_KEY or ""
2025-12-29 03:06:49 +08:00
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"
2026-01-14 05:29:55 +08:00
@backtest_bp.route ( '/backtest/precision-info' , methods = [ 'GET' ])
2026-01-12 04:01:17 +08:00
def get_precision_info ():
"""
获取回测精度信息(用于前端提示)
2026-01-14 05:29:55 +08:00
Params (Query String):
2026-01-12 04:01:17 +08:00
market: 市场类型
startDate: 开始日期 (YYYY-MM-DD)
endDate: 结束日期 (YYYY-MM-DD)
Returns:
精度信息,包含推荐的执行时间框架和预估K线数量
"""
try :
2026-01-14 05:29:55 +08:00
# Use request.args for GET params
market = request . args . get ( 'market' , 'crypto' )
start_date_str = request . args . get ( 'startDate' , '' )
end_date_str = request . args . get ( 'endDate' , '' )
2026-01-12 04:01:17 +08:00
if not start_date_str or not end_date_str :
return jsonify ({ 'code' : 0 , 'msg' : 'startDate and endDate are required' }), 400
start_date = datetime . strptime ( start_date_str , '%Y-%m- %d ' )
end_date = datetime . strptime ( end_date_str , '%Y-%m- %d ' )
exec_tf , precision_info = backtest_service . get_execution_timeframe ( start_date , end_date , market )
return jsonify ({
'code' : 1 ,
'msg' : 'success' ,
'data' : precision_info
})
except Exception as e :
logger . error ( f "Get precision info failed: { e } " )
return jsonify ({ 'code' : 0 , 'msg' : str ( e )}), 400
2025-12-29 03:06:49 +08:00
@backtest_bp.route ( '/backtest' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2025-12-29 03:06:49 +08:00
def run_backtest ():
"""
2026-01-14 05:29:55 +08:00
Run indicator backtest for the current user.
2025-12-29 03:06:49 +08:00
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)
2026-01-12 04:01:17 +08:00
enableMtf: Enable multi-timeframe backtest (default true, only for crypto)
2025-12-29 03:06:49 +08:00
"""
try :
data = request . get_json ()
if not data :
return jsonify ({
'code' : 0 ,
'msg' : 'Request body is required' ,
'data' : None
}), 400
2026-01-14 05:29:55 +08:00
# Extract params - use current user's ID
user_id = g . user_id
2025-12-29 03:06:49 +08:00
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 {}
2026-01-12 04:01:17 +08:00
# 多时间框架回测开关(默认开启,仅加密货币市场有效)
enable_mtf = data . get ( 'enableMtf' , True )
if isinstance ( enable_mtf , str ):
enable_mtf = enable_mtf . lower () in [ 'true' , '1' , 'yes' ]
2025-12-29 03:06:49 +08:00
# (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个月
2025-12-30 19:58:18 +08:00
max_range_text = '1 month'
2025-12-29 03:06:49 +08:00
elif timeframe == '5m' :
max_days = 180 # 5分钟K线最多6个月
2025-12-30 19:58:18 +08:00
max_range_text = '6 months'
2025-12-29 03:06:49 +08:00
elif timeframe in [ '15m' , '30m' ]:
max_days = 365 # 15分钟和30分钟K线最多1年
2025-12-30 19:58:18 +08:00
max_range_text = '1 year'
2025-12-29 03:06:49 +08:00
else : # 1H, 4H, 1D, 1W
max_days = 1095 # 1小时及以上最多3年
2025-12-30 19:58:18 +08:00
max_range_text = '3 years'
2025-12-29 03:06:49 +08:00
if days_diff > max_days :
return jsonify ({
'code' : 0 ,
2025-12-30 19:58:18 +08:00
'msg' : f 'Backtest range exceeds limit: timeframe { timeframe } supports up to { max_range_text } ( { max_days } days), but you selected { days_diff } days' ,
2025-12-29 03:06:49 +08:00
'data' : None
}), 400
2026-01-12 04:01:17 +08:00
# 执行回测(支持多时间框架高精度回测)
# 加密货币市场且启用MTF时,使用多时间框架回测
if enable_mtf and market . lower () in [ 'crypto' , 'cryptocurrency' ]:
result = backtest_service . run_multi_timeframe (
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 ,
enable_mtf = True
)
else :
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
)
# 添加标准回测的精度信息
result [ 'precision_info' ] = {
'enabled' : False ,
'timeframe' : timeframe ,
'precision' : 'standard' ,
'message' : '使用标准K线回测'
}
2025-12-29 03:06:49 +08:00
# Persist backtest run for AI optimization / history
run_id = None
try :
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)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
2025-12-29 03:06:49 +08:00
""" ,
(
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' ,
'' ,
2026-01-14 05:29:55 +08:00
json . dumps ( result or {}, ensure_ascii = False )
2025-12-29 03:06:49 +08:00
)
)
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 {}
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2025-12-29 03:06:49 +08:00
indicator_id = data . get ( 'indicatorId' )
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)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
2025-12-29 03:06:49 +08:00
""" ,
(
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 ),
2026-01-14 05:29:55 +08:00
''
2025-12-29 03:06:49 +08:00
)
)
db . commit ()
cur . close ()
except Exception :
pass
return jsonify ({
'code' : 0 ,
'msg' : f 'Backtest failed: { str ( e ) } ' ,
'data' : None
}), 500
2026-01-14 05:29:55 +08:00
@backtest_bp.route ( '/backtest/history' , methods = [ 'GET' ])
@login_required
2025-12-29 03:06:49 +08:00
def get_backtest_history ():
"""
2026-01-14 05:29:55 +08:00
Get backtest run history for the current user.
2025-12-29 03:06:49 +08:00
2026-01-14 05:29:55 +08:00
Params (Query String):
2025-12-29 03:06:49 +08:00
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 :
2026-01-14 05:29:55 +08:00
# Use current user's ID
user_id = g . user_id
limit = int ( request . args . get ( 'limit' ) or 50 )
offset = int ( request . args . get ( 'offset' ) or 0 )
2025-12-29 03:06:49 +08:00
limit = max ( 1 , min ( limit , 200 ))
offset = max ( 0 , offset )
2026-01-14 05:29:55 +08:00
indicator_id = request . args . get ( 'indicatorId' )
symbol = ( request . args . get ( 'symbol' ) or '' ) . strip ()
market = ( request . args . get ( 'market' ) or '' ) . strip ()
timeframe = ( request . args . get ( 'timeframe' ) or '' ) . strip ()
2025-12-29 03:06:49 +08:00
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
2026-01-14 05:29:55 +08:00
@backtest_bp.route ( '/backtest/get' , methods = [ 'GET' ])
@login_required
2025-12-29 03:06:49 +08:00
def get_backtest_run ():
"""
2026-01-14 05:29:55 +08:00
Get a backtest run detail by run id for the current user.
2025-12-29 03:06:49 +08:00
2026-01-14 05:29:55 +08:00
Params (Query String):
2025-12-29 03:06:49 +08:00
runId: Backtest run id (required)
"""
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
run_id = int ( request . args . get ( 'runId' ) or 0 )
2025-12-29 03:06:49 +08:00
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' ])
2026-01-14 05:29:55 +08:00
@login_required
2025-12-29 03:06:49 +08:00
def ai_analyze_backtest_runs ():
"""
2026-01-14 05:29:55 +08:00
AI analyze selected backtest runs and provide strategy_config tuning suggestions
for the current user.
2025-12-29 03:06:49 +08:00
Params:
runIds: list[int] (required)
"""
try :
data = request . get_json () or {}
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2025-12-29 03:06:49 +08:00
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