Files
DinQuant/backend_api_python/app/routes/indicator.py
T
TIANHE b7451c63fb fix: Fix trading precision issues and improve error handling
- Fix quantity precision calculation for Binance, OKX, Bybit, Bitget, Deepcoin exchanges
- Improve OpenRouter API error handling with detailed error messages
- Add SECRET_KEY validation in Docker deployment entrypoint
- Fix K-line chart measurement tool click issue
- Adapt billing page text colors for dark theme
- Update frontend build files
2026-03-12 00:02:53 +08:00

813 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
import traceback
from typing import Any, Dict, List
import builtins
from flask import Blueprint, Response, jsonify, request, g
import pandas as pd
import numpy as np
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.auth import login_required
from app.services.indicator_params import IndicatorCaller
from app.utils.safe_exec import validate_code_safety, safe_exec_code
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 database 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,
# VIP-free indicator flag (community publishing)
"vip_free": 1 if (row.get("vip_free") or 0) 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"),
}
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=["GET"])
@login_required
def get_indicators():
"""
Get indicator list for the current user.
Response:
{ code: 1, data: [ ... ] }
"""
try:
user_id = g.user_id
with get_db_connection() as db:
cur = db.cursor()
# Best-effort schema upgrade for VIP-free indicators
try:
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
except Exception:
pass
# Get user's own indicators (both purchased and custom).
cur.execute(
"""
SELECT
id, user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free,
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"])
@login_required
def save_indicator():
"""
Create or update an indicator for the current user.
Request (frontend sends many extra fields; we store only the essentials):
{
id: number (0 for create),
name: string,
code: string,
description?: string,
...
}
"""
try:
data = request.get_json() or {}
user_id = g.user_id
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"
vip_free = bool(data.get("vipFree") or data.get("vip_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() # For BIGINT fields (createtime, updatetime)
# 检查用户是否是管理员(管理员发布的指标自动通过审核)
user_role = getattr(g, 'user_role', 'user')
is_admin = user_role == 'admin'
with get_db_connection() as db:
cur = db.cursor()
# Best-effort schema upgrade for VIP-free indicators
try:
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
except Exception:
pass
if indicator_id and indicator_id > 0:
# 检查是否从未发布改为发布,需要设置审核状态
if publish_to_community:
cur.execute(
"SELECT publish_to_community, review_status FROM qd_indicator_codes WHERE id = ? AND user_id = ?",
(indicator_id, user_id)
)
existing = cur.fetchone()
was_published = existing and existing.get('publish_to_community')
# 如果之前未发布,现在发布,设置审核状态
# 管理员发布的直接通过,普通用户需要待审核
new_review_status = 'approved' if is_admin else 'pending'
if not was_published:
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
vip_free = ?,
review_status = ?, review_note = '', reviewed_at = NOW(), reviewed_by = ?,
updatetime = ?, updated_at = NOW()
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, vip_free,
new_review_status, user_id if is_admin else None, now, indicator_id, user_id),
)
else:
# 已发布过的更新,保持原审核状态
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
vip_free = ?,
updatetime = ?, updated_at = NOW()
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, vip_free, now, indicator_id, user_id),
)
else:
# 取消发布,清除审核状态
cur.execute(
"""
UPDATE qd_indicator_codes
SET name = ?, code = ?, description = ?,
publish_to_community = ?, pricing_type = ?, price = ?, preview_image = ?,
vip_free = FALSE,
review_status = NULL, review_note = '', reviewed_at = NULL, reviewed_by = NULL,
updatetime = ?, updated_at = NOW()
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, indicator_id, user_id),
)
else:
# 新建指标 - 管理员发布的直接通过,普通用户需要待审核
review_status = None
if publish_to_community:
review_status = 'approved' if is_admin else 'pending'
cur.execute(
"""
INSERT INTO qd_indicator_codes
(user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, preview_image, vip_free, review_status,
createtime, updatetime, created_at, updated_at)
VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
""",
(user_id, name, code, description, publish_to_community, pricing_type, price, preview_image, vip_free, review_status, 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"])
@login_required
def delete_indicator():
"""Delete an indicator by id for the current user."""
try:
data = request.get_json() or {}
user_id = g.user_id
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("/getIndicatorParams", methods=["GET"])
@login_required
def get_indicator_params():
"""
获取指标的参数声明
用于前端在策略创建时显示可配置的参数表单。
Query params:
indicator_id: 指标ID
Returns:
params: [
{
"name": "ma_fast",
"type": "int",
"default": 5,
"description": "短期均线周期"
},
...
]
"""
try:
from app.services.indicator_params import get_indicator_params as get_params
indicator_id = request.args.get("indicator_id")
if not indicator_id:
return jsonify({"code": 0, "msg": "indicator_id is required", "data": None}), 400
try:
indicator_id = int(indicator_id)
except ValueError:
return jsonify({"code": 0, "msg": "indicator_id must be an integer", "data": None}), 400
params = get_params(indicator_id)
return jsonify({"code": 1, "msg": "success", "data": params})
except Exception as e:
logger.error(f"get_indicator_params failed: {str(e)}", exc_info=True)
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
@indicator_bp.route("/verifyCode", methods=["POST"])
@login_required
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 (sandboxed)
exec_env = {
'df': df.copy(),
'pd': pd,
'np': np,
'output': None
}
# 2.1 Create restricted __import__ that only allows safe modules
def safe_import(name, *args, **kwargs):
"""Only allow importing a small set of safe modules inside indicator scripts."""
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
safe_builtins = {
k: getattr(builtins, k)
for k in dir(builtins)
if not k.startswith('_') and k not in [
'eval', 'exec', 'compile', 'open', 'input',
'help', 'exit', 'quit',
'copyright', 'credits', 'license'
]
}
safe_builtins['__import__'] = safe_import
exec_env_sandbox = exec_env.copy()
exec_env_sandbox['__builtins__'] = safe_builtins
# 2.2 Pre-import commonly used modules into the sandbox
pre_import_code = """
import numpy as np
import pandas as pd
"""
try:
exec(pre_import_code, exec_env_sandbox)
except Exception as e:
tb = traceback.format_exc()
return jsonify({
"code": 0,
"msg": f"Runtime Error during pre-import: {str(e)}",
"data": {"type": type(e).__name__, "details": tb}
})
# 3. Static safety check
is_safe, error_msg = validate_code_safety(code)
if not is_safe:
logger.error(f"Indicator verifyCode security check failed: {error_msg}")
return jsonify({
"code": 0,
"msg": f"Code contains unsafe operations: {error_msg}",
"data": {"type": "SecurityError", "details": error_msg}
})
# 4. Execute code with timeout in sandbox
exec_result = safe_exec_code(
code=code,
exec_globals=exec_env_sandbox,
exec_locals=exec_env_sandbox,
timeout=20 # indicator verification should be quick
)
if not exec_result.get('success'):
error_detail = exec_result.get('error') or 'Unknown error'
return jsonify({
"code": 0,
"msg": f"Runtime Error: {error_detail}",
"data": {"type": "RuntimeError", "details": error_detail}
})
# 5. Check output
output = exec_env_sandbox.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"])
@login_required
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 _generate_code_via_llm() -> str:
"""Use unified LLMService to support all configured providers (OpenRouter, OpenAI, Grok, etc.)."""
from app.services.llm import LLMService
llm = LLMService()
# Get provider and model from env config (no frontend override)
current_provider = llm.provider
current_model = llm.get_default_model()
current_api_key = llm.get_api_key()
base_url = llm.get_base_url()
logger.info(f"AI Code Generation - Provider: {current_provider.value}, Model: {current_model}, Base URL: {base_url}, API Key configured: {bool(current_api_key)}")
# Check if any LLM provider is configured
if not current_api_key:
logger.warning("No LLM API key configured, using template code")
return _template_code()
# 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."
)
temperature = float(os.getenv("OPENROUTER_TEMPERATURE", "0.7") or 0.7)
# Call LLM using the unified API (auto-selects provider based on LLM_PROVIDER env)
# use_json_mode=False because we want raw Python code output
content = llm.call_llm_api(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=temperature,
use_json_mode=False # Code generation doesn't need JSON mode
)
# Clean up markdown code blocks if present
content = content.strip()
if content.startswith("```python"):
content = content[9:]
elif content.startswith("```"):
content = content[3:]
if content.endswith("```"):
content = content[:-3]
return content.strip() or _template_code()
def stream():
# 不扣任何 QDT:开源本地版直接生成/返回代码
try:
code_text = _generate_code_via_llm()
except Exception as e:
logger.error(f"ai_generate LLM failed, fallback to template. Error: {type(e).__name__}: {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",
},
)
@indicator_bp.route("/callIndicator", methods=["POST"])
@login_required
def call_indicator():
"""
调用另一个指标(供前端 Pyodide 环境使用)
POST /api/indicator/callIndicator
Body: {
"indicatorRef": int | str, # 指标ID或名称
"klineData": List[Dict], # K线数据
"params": Dict, # 传递给被调用指标的参数(可选)
"currentIndicatorId": int # 当前指标ID(用于循环依赖检测,可选)
}
Returns:
{
"code": 1,
"data": {
"df": List[Dict], # 执行后的DataFrame(转换为JSON
"columns": List[str] # DataFrame的列名
}
}
"""
try:
data = request.get_json() or {}
indicator_ref = data.get("indicatorRef")
kline_data = data.get("klineData", [])
params = data.get("params") or {}
current_indicator_id = data.get("currentIndicatorId")
if not indicator_ref:
return jsonify({
"code": 0,
"msg": "indicatorRef is required",
"data": None
}), 400
if not kline_data or not isinstance(kline_data, list):
return jsonify({
"code": 0,
"msg": "klineData must be a non-empty list",
"data": None
}), 400
# 获取用户ID
user_id = g.user_id
# 创建 IndicatorCaller
indicator_caller = IndicatorCaller(user_id, current_indicator_id)
# 将前端传入的K线数据转换为DataFrame
df = pd.DataFrame(kline_data)
# 确保必要的列存在
required_columns = ['open', 'high', 'low', 'close', 'volume']
for col in required_columns:
if col not in df.columns:
df[col] = 0.0
# 转换数据类型
df['open'] = df['open'].astype('float64')
df['high'] = df['high'].astype('float64')
df['low'] = df['low'].astype('float64')
df['close'] = df['close'].astype('float64')
df['volume'] = df['volume'].astype('float64')
# 调用指标
result_df = indicator_caller.call_indicator(indicator_ref, df, params)
# 将DataFrame转换为JSON格式(前端可以使用的格式)
result_dict = result_df.to_dict(orient='records')
return jsonify({
"code": 1,
"msg": "success",
"data": {
"df": result_dict,
"columns": list(result_df.columns)
}
})
except Exception as e:
logger.error(f"Error calling indicator: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500