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:
TIANHE
2026-01-06 19:53:23 +08:00
parent eea7d75615
commit c1dc3f3a71
7 changed files with 227 additions and 2 deletions
+146 -2
View File
@@ -14,9 +14,12 @@ import json
import os
import re
import time
import traceback
from typing import Any, Dict, List
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.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"])
def get_indicators():
"""
@@ -225,6 +260,117 @@ def delete_indicator():
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"])
def ai_generate():
"""
@@ -429,5 +575,3 @@ IMPORTANT: Output Python code directly, without explanations, without descriptio
"X-Accel-Buffering": "no",
},
)
@@ -621,6 +621,10 @@ const locale = {
'dashboard.indicator.editor.aiPromptRequired': 'Please enter your idea',
'dashboard.indicator.editor.aiGenerateSuccess': 'Code generated successfully',
'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.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',
@@ -617,6 +617,10 @@ const locale = {
'dashboard.indicator.editor.aiPromptRequired': 'あなたの考えを入力してください',
'dashboard.indicator.editor.aiGenerateSuccess': 'コード生成が成功しました',
'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.config': 'バックテストパラメータ',
'dashboard.indicator.backtest.startDate': '開始日',
@@ -616,6 +616,10 @@ const locale = {
'dashboard.indicator.editor.aiPromptRequired': '당신의 생각을 입력해주세요',
'dashboard.indicator.editor.aiGenerateSuccess': '코드 생성 성공',
'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.config': '백테스트 매개변수',
'dashboard.indicator.backtest.startDate': '시작일',
@@ -618,6 +618,10 @@ const locale = {
'dashboard.indicator.editor.aiPromptRequired': '请输入您的想法',
'dashboard.indicator.editor.aiGenerateSuccess': '代码生成成功',
'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.indicatorRule': "指标脚本请只输出 buy/sell(并设定 df['buy']/df['sell'])。不要在脚本内撰写仓位管理、止盈止损、加减仓。",
'dashboard.indicator.boundary.backtestRule': '规则:同一根K线若出现主信号(buy/sell→开/平仓/反手),本K线将跳过所有加仓与减仓。',
@@ -618,6 +618,10 @@ const locale = {
'dashboard.indicator.editor.aiPromptRequired': '請輸入您的想法',
'dashboard.indicator.editor.aiGenerateSuccess': '代碼生成成功',
'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.indicatorRule': "指標腳本請只輸出 buy/sell(並設定 df['buy']/df['sell'])。不要在腳本內撰寫倉位管理、止盈止損、加減倉。",
'dashboard.indicator.boundary.backtestRule': '規則:同一根K線若出現主信號(buy/sell→開/平倉/反手),本K線將跳過所有加倉與減倉。',
@@ -25,6 +25,16 @@
<span class="section-title">{{ $t('dashboard.indicator.editor.code') }}</span>
</div>
<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-icon type="book" />
{{ $t('dashboard.indicator.editor.guide') }}
@@ -105,6 +115,7 @@ import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/addon/selection/active-line'
import storage from 'store'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import request from '@/utils/request'
export default {
name: 'IndicatorEditor',
@@ -128,6 +139,7 @@ export default {
codeEditor: null,
aiPrompt: '',
aiGenerating: false,
verifying: false,
isMobile: false
}
},
@@ -393,6 +405,55 @@ export default {
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
cleanMarkdownCodeBlocks (code) {
if (!code || typeof code !== 'string') {