""" 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 builtins import json import os import re import time import traceback from typing import Any, Dict import numpy as np import pandas as pd from flask import Blueprint, Response, g, jsonify, request from app.services.indicator_params import IndicatorCaller from app.utils.auth import login_required from app.utils.db import get_db_connection from app.utils.logger import get_logger from app.utils.safe_exec import safe_exec_code, validate_code_safety 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) # Check whether the user is an administrator (indicators published by the administrator automatically pass the review) 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: # Check whether the change from unpublished to published requires setting the review status 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") # If it has not been published before, publish it now and set the review status # Posted by the administrator, it passes directly, and ordinary users need to wait for review. 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: # Updates that have been released will remain in their original review status. 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: # Unpublish and clear review status 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: # New indicators - those released by administrators are passed directly, ordinary users need to wait for review 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(): """ Get parameter declaration of indicator Used by the front end to display a configurable parameter form when creating a policy. Query params: indicator_id: indicator ID Returns: params: [ { "name": "ma_fast", "type": "int", "default": 5, "description": "Short-term moving average cycle" }, ... ] """ 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": "The prompt word cannot be empty"}, 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_code_generation_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}, ], model=current_model, 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() # Capture user_id before generator runs (generator executes outside request context) user_id = g.user_id def stream(): from app.services.billing_service import get_billing_service billing = get_billing_service() ok, msg = billing.check_and_consume( user_id=user_id, feature="ai_code_gen", reference_id=f"ai_code_gen_{user_id}_{int(time.time())}" ) if not ok: yield "data: " + json.dumps({"error": f"Insufficient credits: {msg}"}, ensure_ascii=False) + "\n\n" yield "data: [DONE]\n\n" return 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(): """ Call another indicator (for use by the front-end Pyodide environment) POST /api/indicator/callIndicator Body: { "indicatorRef": int | str, # indicator ID or name "klineData": List[Dict], # K-line data "params": Dict, # Parameters passed to the called indicator (optional) "currentIndicatorId": int # Current indicator ID (used for circular dependency detection, optional) } Returns: { "code": 1, "data": { "df": List[Dict], # DataFrame after execution (converted to JSON) "columns": List[str] # DataFrame column name } } """ 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 # Get user ID user_id = g.user_id # Create IndicatorCaller indicator_caller = IndicatorCaller(user_id, current_indicator_id) # Convert the K-line data passed in from the front end into a DataFrame df = pd.DataFrame(kline_data) # Make sure necessary columns exist required_columns = ["open", "high", "low", "close", "volume"] for col in required_columns: if col not in df.columns: df[col] = 0.0 # Convert data type 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") # call indicator result_df = indicator_caller.call_indicator(indicator_ref, df, params) # Convert the DataFrame to JSON format (a format that the front end can use) 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