feat: Add indicator code verification API and UI
- Add POST /api/indicator/verifyCode endpoint in Python backend. - Update IndicatorEditor.vue with Verify Code button and error modal. - Add i18n support for verification UI. - Update README files.
This commit is contained in:
@@ -14,9 +14,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from app.utils.db import get_db_connection
|
from app.utils.db import get_db_connection
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
@@ -80,6 +83,38 @@ def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_mock_df(length=200):
|
||||||
|
"""Generate mock K-line data for verification."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
dates = [datetime.now() - timedelta(minutes=i) for i in range(length)]
|
||||||
|
dates.reverse()
|
||||||
|
|
||||||
|
# Random walk with trend
|
||||||
|
returns = np.random.normal(0, 0.002, length)
|
||||||
|
price_path = 10000 * np.exp(np.cumsum(returns))
|
||||||
|
|
||||||
|
close = price_path
|
||||||
|
high = close * (1 + np.abs(np.random.normal(0, 0.001, length)))
|
||||||
|
low = close * (1 - np.abs(np.random.normal(0, 0.001, length)))
|
||||||
|
open_p = close * (1 + np.random.normal(0, 0.001, length)) # Slight deviation from close
|
||||||
|
# Ensure High is highest and Low is lowest
|
||||||
|
high = np.maximum(high, np.maximum(open_p, close))
|
||||||
|
low = np.minimum(low, np.minimum(open_p, close))
|
||||||
|
|
||||||
|
volume = np.abs(np.random.normal(100, 50, length)) * 1000
|
||||||
|
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'time': [int(d.timestamp() * 1000) for d in dates],
|
||||||
|
'open': open_p,
|
||||||
|
'high': high,
|
||||||
|
'low': low,
|
||||||
|
'close': close,
|
||||||
|
'volume': volume
|
||||||
|
})
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
@indicator_bp.route("/getIndicators", methods=["POST"])
|
@indicator_bp.route("/getIndicators", methods=["POST"])
|
||||||
def get_indicators():
|
def get_indicators():
|
||||||
"""
|
"""
|
||||||
@@ -225,6 +260,117 @@ def delete_indicator():
|
|||||||
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@indicator_bp.route("/verifyCode", methods=["POST"])
|
||||||
|
def verify_code():
|
||||||
|
"""
|
||||||
|
Verify/Dry-run indicator code with mock data.
|
||||||
|
Checks for:
|
||||||
|
- Syntax errors
|
||||||
|
- Runtime errors
|
||||||
|
- Output format (must define 'output' dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
code = data.get("code") or ""
|
||||||
|
|
||||||
|
if not code or not str(code).strip():
|
||||||
|
return jsonify({"code": 0, "msg": "Code is empty", "data": None}), 400
|
||||||
|
|
||||||
|
# 1. Generate mock data
|
||||||
|
df = _generate_mock_df()
|
||||||
|
|
||||||
|
# 2. Prepare execution environment
|
||||||
|
exec_env = {
|
||||||
|
'df': df.copy(),
|
||||||
|
'pd': pd,
|
||||||
|
'np': np,
|
||||||
|
'output': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Execute code
|
||||||
|
try:
|
||||||
|
exec(code, exec_env)
|
||||||
|
except SyntaxError as e:
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": f"Syntax Error at line {e.lineno}: {e.msg}",
|
||||||
|
"data": {"type": "SyntaxError", "line": e.lineno, "details": str(e)}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# Capture traceback for better debugging
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
# Extract the line number from the exec() call in the traceback if possible
|
||||||
|
# This is tricky because the traceback includes the backend frames.
|
||||||
|
# We'll just return the exception message.
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": f"Runtime Error: {str(e)}",
|
||||||
|
"data": {"type": type(e).__name__, "details": tb}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Check output
|
||||||
|
output = exec_env.get('output')
|
||||||
|
|
||||||
|
if output is None:
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": "Missing 'output' variable. Your code must define an 'output' dictionary.",
|
||||||
|
"data": {"type": "MissingOutput"}
|
||||||
|
})
|
||||||
|
|
||||||
|
if not isinstance(output, dict):
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": f"'output' must be a dictionary, got {type(output).__name__}",
|
||||||
|
"data": {"type": "InvalidOutputType"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
if 'plots' not in output and 'signals' not in output:
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": "'output' dict should contain 'plots' or 'signals' list.",
|
||||||
|
"data": {"type": "InvalidOutputStructure"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Basic check for lengths
|
||||||
|
plots = output.get('plots', [])
|
||||||
|
signals = output.get('signals', [])
|
||||||
|
|
||||||
|
for p in plots:
|
||||||
|
if 'data' not in p:
|
||||||
|
return jsonify({"code": 0, "msg": f"Plot '{p.get('name')}' missing 'data' field.", "data": {"type": "InvalidPlot"}})
|
||||||
|
if len(p['data']) != len(df):
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": f"Plot '{p.get('name')}' data length ({len(p['data'])}) does not match DataFrame length ({len(df)}).",
|
||||||
|
"data": {"type": "LengthMismatch"}
|
||||||
|
})
|
||||||
|
|
||||||
|
for s in signals:
|
||||||
|
if 'data' not in s:
|
||||||
|
return jsonify({"code": 0, "msg": f"Signal '{s.get('type')}' missing 'data' field.", "data": {"type": "InvalidSignal"}})
|
||||||
|
if len(s['data']) != len(df):
|
||||||
|
return jsonify({
|
||||||
|
"code": 0,
|
||||||
|
"msg": f"Signal '{s.get('type')}' data length ({len(s['data'])}) does not match DataFrame length ({len(df)}).",
|
||||||
|
"data": {"type": "LengthMismatch"}
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"code": 1,
|
||||||
|
"msg": "Verification passed! Code executed successfully.",
|
||||||
|
"data": {
|
||||||
|
"plots_count": len(plots),
|
||||||
|
"signals_count": len(signals)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"verify_code failed: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({"code": 0, "msg": f"System Error: {str(e)}", "data": None}), 500
|
||||||
|
|
||||||
|
|
||||||
@indicator_bp.route("/aiGenerate", methods=["POST"])
|
@indicator_bp.route("/aiGenerate", methods=["POST"])
|
||||||
def ai_generate():
|
def ai_generate():
|
||||||
"""
|
"""
|
||||||
@@ -429,5 +575,3 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -621,6 +621,10 @@ const locale = {
|
|||||||
'dashboard.indicator.editor.aiPromptRequired': 'Please enter your idea',
|
'dashboard.indicator.editor.aiPromptRequired': 'Please enter your idea',
|
||||||
'dashboard.indicator.editor.aiGenerateSuccess': 'Code generated successfully',
|
'dashboard.indicator.editor.aiGenerateSuccess': 'Code generated successfully',
|
||||||
'dashboard.indicator.editor.aiGenerateError': 'Code generation failed, please try again later',
|
'dashboard.indicator.editor.aiGenerateError': 'Code generation failed, please try again later',
|
||||||
|
'dashboard.indicator.editor.verifyCode': 'Verify Code',
|
||||||
|
'dashboard.indicator.editor.verifyCodeSuccess': 'Verification Passed',
|
||||||
|
'dashboard.indicator.editor.verifyCodeFailed': 'Verification Failed',
|
||||||
|
'dashboard.indicator.editor.verifyCodeEmpty': 'Code cannot be empty',
|
||||||
'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide',
|
'dashboard.indicator.guide.title': 'Python Indicator & Strategy Development Guide',
|
||||||
'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.',
|
'dashboard.indicator.guide.intro': 'This platform supports writing custom technical indicators and trading signals using Python. The system includes built-in pandas and numpy data analysis libraries. You can use standard DataFrame operations to process K-line data and draw charts or mark buy/sell signals.',
|
||||||
'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables',
|
'dashboard.indicator.guide.section1.title': '1. Runtime Environment & Predefined Variables',
|
||||||
|
|||||||
@@ -617,6 +617,10 @@ const locale = {
|
|||||||
'dashboard.indicator.editor.aiPromptRequired': 'あなたの考えを入力してください',
|
'dashboard.indicator.editor.aiPromptRequired': 'あなたの考えを入力してください',
|
||||||
'dashboard.indicator.editor.aiGenerateSuccess': 'コード生成が成功しました',
|
'dashboard.indicator.editor.aiGenerateSuccess': 'コード生成が成功しました',
|
||||||
'dashboard.indicator.editor.aiGenerateError': 'コード生成に失敗しました。後でもう一度お試しください。',
|
'dashboard.indicator.editor.aiGenerateError': 'コード生成に失敗しました。後でもう一度お試しください。',
|
||||||
|
'dashboard.indicator.editor.verifyCode': 'コード検証',
|
||||||
|
'dashboard.indicator.editor.verifyCodeSuccess': '検証合格',
|
||||||
|
'dashboard.indicator.editor.verifyCodeFailed': '検証失敗',
|
||||||
|
'dashboard.indicator.editor.verifyCodeEmpty': 'コードは空にできません',
|
||||||
'dashboard.indicator.backtest.title': 'インジケーターのバックテスト',
|
'dashboard.indicator.backtest.title': 'インジケーターのバックテスト',
|
||||||
'dashboard.indicator.backtest.config': 'バックテストパラメータ',
|
'dashboard.indicator.backtest.config': 'バックテストパラメータ',
|
||||||
'dashboard.indicator.backtest.startDate': '開始日',
|
'dashboard.indicator.backtest.startDate': '開始日',
|
||||||
|
|||||||
@@ -616,6 +616,10 @@ const locale = {
|
|||||||
'dashboard.indicator.editor.aiPromptRequired': '당신의 생각을 입력해주세요',
|
'dashboard.indicator.editor.aiPromptRequired': '당신의 생각을 입력해주세요',
|
||||||
'dashboard.indicator.editor.aiGenerateSuccess': '코드 생성 성공',
|
'dashboard.indicator.editor.aiGenerateSuccess': '코드 생성 성공',
|
||||||
'dashboard.indicator.editor.aiGenerateError': '코드 생성에 실패했습니다. 나중에 다시 시도해 주세요.',
|
'dashboard.indicator.editor.aiGenerateError': '코드 생성에 실패했습니다. 나중에 다시 시도해 주세요.',
|
||||||
|
'dashboard.indicator.editor.verifyCode': '코드 검증',
|
||||||
|
'dashboard.indicator.editor.verifyCodeSuccess': '검증 통과',
|
||||||
|
'dashboard.indicator.editor.verifyCodeFailed': '검증 실패',
|
||||||
|
'dashboard.indicator.editor.verifyCodeEmpty': '코드는 비워둘 수 없습니다',
|
||||||
'dashboard.indicator.backtest.title': '지표 백테스트',
|
'dashboard.indicator.backtest.title': '지표 백테스트',
|
||||||
'dashboard.indicator.backtest.config': '백테스트 매개변수',
|
'dashboard.indicator.backtest.config': '백테스트 매개변수',
|
||||||
'dashboard.indicator.backtest.startDate': '시작일',
|
'dashboard.indicator.backtest.startDate': '시작일',
|
||||||
|
|||||||
@@ -618,6 +618,10 @@ const locale = {
|
|||||||
'dashboard.indicator.editor.aiPromptRequired': '请输入您的想法',
|
'dashboard.indicator.editor.aiPromptRequired': '请输入您的想法',
|
||||||
'dashboard.indicator.editor.aiGenerateSuccess': '代码生成成功',
|
'dashboard.indicator.editor.aiGenerateSuccess': '代码生成成功',
|
||||||
'dashboard.indicator.editor.aiGenerateError': '代码生成失败,请稍后重试',
|
'dashboard.indicator.editor.aiGenerateError': '代码生成失败,请稍后重试',
|
||||||
|
'dashboard.indicator.editor.verifyCode': '代码检查',
|
||||||
|
'dashboard.indicator.editor.verifyCodeSuccess': '代码检查通过',
|
||||||
|
'dashboard.indicator.editor.verifyCodeFailed': '代码检查未通过',
|
||||||
|
'dashboard.indicator.editor.verifyCodeEmpty': '代码不能为空',
|
||||||
'dashboard.indicator.boundary.message': '提示:指标脚本只负责“计算 + 绘图 + buy/sell 信号”;仓位、风控、加减仓、手续费/滑点属于策略执行配置。',
|
'dashboard.indicator.boundary.message': '提示:指标脚本只负责“计算 + 绘图 + buy/sell 信号”;仓位、风控、加减仓、手续费/滑点属于策略执行配置。',
|
||||||
'dashboard.indicator.boundary.indicatorRule': "指标脚本请只输出 buy/sell(并设定 df['buy']/df['sell'])。不要在脚本内撰写仓位管理、止盈止损、加减仓。",
|
'dashboard.indicator.boundary.indicatorRule': "指标脚本请只输出 buy/sell(并设定 df['buy']/df['sell'])。不要在脚本内撰写仓位管理、止盈止损、加减仓。",
|
||||||
'dashboard.indicator.boundary.backtestRule': '规则:同一根K线若出现主信号(buy/sell→开/平仓/反手),本K线将跳过所有加仓与减仓。',
|
'dashboard.indicator.boundary.backtestRule': '规则:同一根K线若出现主信号(buy/sell→开/平仓/反手),本K线将跳过所有加仓与减仓。',
|
||||||
|
|||||||
@@ -618,6 +618,10 @@ const locale = {
|
|||||||
'dashboard.indicator.editor.aiPromptRequired': '請輸入您的想法',
|
'dashboard.indicator.editor.aiPromptRequired': '請輸入您的想法',
|
||||||
'dashboard.indicator.editor.aiGenerateSuccess': '代碼生成成功',
|
'dashboard.indicator.editor.aiGenerateSuccess': '代碼生成成功',
|
||||||
'dashboard.indicator.editor.aiGenerateError': '代碼生成失敗,請稍後重試',
|
'dashboard.indicator.editor.aiGenerateError': '代碼生成失敗,請稍後重試',
|
||||||
|
'dashboard.indicator.editor.verifyCode': '代碼檢查',
|
||||||
|
'dashboard.indicator.editor.verifyCodeSuccess': '代碼檢查通過',
|
||||||
|
'dashboard.indicator.editor.verifyCodeFailed': '代碼檢查未通過',
|
||||||
|
'dashboard.indicator.editor.verifyCodeEmpty': '代碼不能為空',
|
||||||
'dashboard.indicator.boundary.message': '提示:指標腳本只負責「計算 + 繪圖 + buy/sell 信號」;倉位、風控、加減倉、手續費/滑點屬於策略執行配置。',
|
'dashboard.indicator.boundary.message': '提示:指標腳本只負責「計算 + 繪圖 + buy/sell 信號」;倉位、風控、加減倉、手續費/滑點屬於策略執行配置。',
|
||||||
'dashboard.indicator.boundary.indicatorRule': "指標腳本請只輸出 buy/sell(並設定 df['buy']/df['sell'])。不要在腳本內撰寫倉位管理、止盈止損、加減倉。",
|
'dashboard.indicator.boundary.indicatorRule': "指標腳本請只輸出 buy/sell(並設定 df['buy']/df['sell'])。不要在腳本內撰寫倉位管理、止盈止損、加減倉。",
|
||||||
'dashboard.indicator.boundary.backtestRule': '規則:同一根K線若出現主信號(buy/sell→開/平倉/反手),本K線將跳過所有加倉與減倉。',
|
'dashboard.indicator.boundary.backtestRule': '規則:同一根K線若出現主信號(buy/sell→開/平倉/反手),本K線將跳過所有加倉與減倉。',
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
<span class="section-title">{{ $t('dashboard.indicator.editor.code') }}</span>
|
<span class="section-title">{{ $t('dashboard.indicator.editor.code') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-actions">
|
<div class="section-actions">
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleVerifyCode"
|
||||||
|
:loading="verifying"
|
||||||
|
style="padding: 0 8px; color: #52c41a; font-weight: bold;"
|
||||||
|
>
|
||||||
|
<a-icon type="check-circle" />
|
||||||
|
{{ $t('dashboard.indicator.editor.verifyCode') }}
|
||||||
|
</a-button>
|
||||||
<a-button type="link" size="small" @click="goToDocs" style="padding: 0;">
|
<a-button type="link" size="small" @click="goToDocs" style="padding: 0;">
|
||||||
<a-icon type="book" />
|
<a-icon type="book" />
|
||||||
{{ $t('dashboard.indicator.editor.guide') }}
|
{{ $t('dashboard.indicator.editor.guide') }}
|
||||||
@@ -105,6 +115,7 @@ import 'codemirror/addon/edit/matchbrackets'
|
|||||||
import 'codemirror/addon/selection/active-line'
|
import 'codemirror/addon/selection/active-line'
|
||||||
import storage from 'store'
|
import storage from 'store'
|
||||||
import { ACCESS_TOKEN } from '@/store/mutation-types'
|
import { ACCESS_TOKEN } from '@/store/mutation-types'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'IndicatorEditor',
|
name: 'IndicatorEditor',
|
||||||
@@ -128,6 +139,7 @@ export default {
|
|||||||
codeEditor: null,
|
codeEditor: null,
|
||||||
aiPrompt: '',
|
aiPrompt: '',
|
||||||
aiGenerating: false,
|
aiGenerating: false,
|
||||||
|
verifying: false,
|
||||||
isMobile: false
|
isMobile: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -393,6 +405,55 @@ export default {
|
|||||||
window.open('https://github.com/brokermr810/QuantDinger/blob/main/docs/STRATEGY_DEV_GUIDE.md', '_blank')
|
window.open('https://github.com/brokermr810/QuantDinger/blob/main/docs/STRATEGY_DEV_GUIDE.md', '_blank')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 验证代码
|
||||||
|
handleVerifyCode () {
|
||||||
|
const code = this.codeEditor ? this.codeEditor.getValue() : ''
|
||||||
|
if (!code || !code.trim()) {
|
||||||
|
this.$message.warning(this.$t('dashboard.indicator.editor.verifyCodeEmpty'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verifying = true
|
||||||
|
// 使用 request 工具(axios)发送请求,它会自动处理 baseURL 和 token
|
||||||
|
request({
|
||||||
|
url: '/api/indicator/verifyCode',
|
||||||
|
method: 'post',
|
||||||
|
data: { code: code }
|
||||||
|
}).then(res => {
|
||||||
|
if (res.code === 1) {
|
||||||
|
const data = res.data || {}
|
||||||
|
this.$message.success(`${this.$t('dashboard.indicator.editor.verifyCodeSuccess')} (${data.plots_count || 0} plots, ${data.signals_count || 0} signals)`)
|
||||||
|
} else {
|
||||||
|
// 显示详细错误
|
||||||
|
const errorData = res.data || {}
|
||||||
|
this.$error({
|
||||||
|
title: this.$t('dashboard.indicator.editor.verifyCodeFailed'),
|
||||||
|
width: 600,
|
||||||
|
content: (h) => {
|
||||||
|
return h('div', [
|
||||||
|
h('p', { style: { fontWeight: 'bold', color: '#ff4d4f' } }, res.msg),
|
||||||
|
errorData.details ? h('pre', {
|
||||||
|
style: {
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '8px',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '300px',
|
||||||
|
marginTop: '8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}
|
||||||
|
}, errorData.details) : null
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
this.$message.error('Request Failed: ' + (err.message || 'Unknown Error'))
|
||||||
|
}).finally(() => {
|
||||||
|
this.verifying = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
// 清理代码中的 markdown 代码块标记
|
// 清理代码中的 markdown 代码块标记
|
||||||
cleanMarkdownCodeBlocks (code) {
|
cleanMarkdownCodeBlocks (code) {
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
|
|||||||
Reference in New Issue
Block a user