2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Indicator Parameters Parser and Helper Functions
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Supports two core functions:
|
|
|
|
|
|
1. External transfer of indicator parameters - parse the @param statement in the indicator code
|
|
|
|
|
|
2. Indicators call other indicators - provide call_indicator() function
|
2026-02-01 15:02:14 +08:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Parameter declaration format:
|
|
|
|
|
|
# @param param_name type default_value description
|
|
|
|
|
|
# @param ma_fast int 5 short-term moving average period
|
|
|
|
|
|
# @param ma_slow int 20 long-term moving average period
|
|
|
|
|
|
# @param threshold float 0.5 threshold
|
2026-02-01 15:02:14 +08:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Supported types: int, float, bool, str
|
2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
import re
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
from app.utils.db import get_db_connection
|
2026-04-09 14:30:51 +07:00
|
|
|
|
from app.utils.logger import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
import pandas as pd
|
2026-02-01 15:02:14 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class IndicatorParamsParser:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Parsing parameter declarations in indicator code"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Parameter declaration rules: # @param name type default description
|
2026-04-09 14:30:51 +07:00
|
|
|
|
PARAM_PATTERN = re.compile(r"#\s*@param\s+(\w+)\s+(int|float|bool|str|string)\s+(\S+)\s*(.*)", re.IGNORECASE)
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def parse_params(cls, indicator_code: str) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Parsing parameter declarations in indicator code
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Returns:
|
|
|
|
|
|
List of param definitions:
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "ma_fast",
|
|
|
|
|
|
"type": "int",
|
|
|
|
|
|
"default": 5,
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"description": "Short-term moving average cycle"
|
2026-02-01 15:02:14 +08:00
|
|
|
|
},
|
|
|
|
|
|
...
|
|
|
|
|
|
]
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = []
|
|
|
|
|
|
if not indicator_code:
|
|
|
|
|
|
return params
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
for line in indicator_code.split("\n"):
|
2026-02-01 15:02:14 +08:00
|
|
|
|
line = line.strip()
|
|
|
|
|
|
match = cls.PARAM_PATTERN.match(line)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
name = match.group(1)
|
|
|
|
|
|
param_type = match.group(2).lower()
|
|
|
|
|
|
default_str = match.group(3)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
description = match.group(4).strip() if match.group(4) else ""
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Convert default value type
|
2026-02-01 15:02:14 +08:00
|
|
|
|
default = cls._convert_value(default_str, param_type)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Canonical type name
|
2026-04-09 14:30:51 +07:00
|
|
|
|
if param_type == "string":
|
|
|
|
|
|
param_type = "str"
|
|
|
|
|
|
|
|
|
|
|
|
params.append({"name": name, "type": param_type, "default": default, "description": description})
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return params
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def _convert_value(cls, value_str: str, param_type: str) -> Any:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Convert string value to corresponding type"""
|
2026-02-01 15:02:14 +08:00
|
|
|
|
try:
|
|
|
|
|
|
param_type = param_type.lower()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
if param_type == "int":
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return int(value_str)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
elif param_type == "float":
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return float(value_str)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
elif param_type == "bool":
|
|
|
|
|
|
return value_str.lower() in ("true", "1", "yes", "on")
|
2026-02-01 15:02:14 +08:00
|
|
|
|
else: # str/string
|
|
|
|
|
|
return value_str
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
return value_str
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def merge_params(cls, declared_params: List[Dict], user_params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Merge declared parameters with user-supplied parameters
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
declared_params: parameter declarations parsed from code
|
|
|
|
|
|
user_params: user-provided parameter values
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Merged parameter dictionary (using user values or default values)
|
2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
result = {}
|
|
|
|
|
|
for param in declared_params:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
name = param["name"]
|
|
|
|
|
|
param_type = param["type"]
|
|
|
|
|
|
default = param["default"]
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
if name in user_params:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# User supplied value, converted to correct type
|
2026-02-01 15:02:14 +08:00
|
|
|
|
result[name] = cls._convert_value(str(user_params[name]), param_type)
|
|
|
|
|
|
else:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Use default value
|
2026-02-01 15:02:14 +08:00
|
|
|
|
result[name] = default
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class IndicatorCaller:
|
|
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Indicator caller - allows one indicator to call another indicator
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Usage (in indicator code):
|
|
|
|
|
|
# Call by ID
|
2026-02-01 15:02:14 +08:00
|
|
|
|
rsi_df = call_indicator(5, df)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Call by name (own indicator)
|
2026-02-01 15:02:14 +08:00
|
|
|
|
macd_df = call_indicator('My MACD', df)
|
|
|
|
|
|
"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Maximum call depth to prevent circular dependencies
|
2026-02-01 15:02:14 +08:00
|
|
|
|
MAX_CALL_DEPTH = 5
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
def __init__(self, user_id: int, current_indicator_id: int = None):
|
|
|
|
|
|
self.user_id = user_id
|
|
|
|
|
|
self.current_indicator_id = current_indicator_id
|
2026-04-06 16:47:36 +07:00
|
|
|
|
self._call_stack = [] # Call stack for detecting circular dependencies
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
def call_indicator(
|
2026-04-09 14:30:51 +07:00
|
|
|
|
self,
|
2026-04-06 16:47:36 +07:00
|
|
|
|
indicator_ref: Any, # int (ID) or str (name)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
df: pd.DataFrame,
|
2026-02-01 15:02:14 +08:00
|
|
|
|
params: Dict[str, Any] = None,
|
2026-04-09 14:30:51 +07:00
|
|
|
|
_depth: int = 0,
|
|
|
|
|
|
) -> Optional[pd.DataFrame]:
|
2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
2026-04-06 16:47:36 +07:00
|
|
|
|
Call another indicator and return the result
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Args:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
indicator_ref: indicator ID or name
|
|
|
|
|
|
df: input K-line data
|
|
|
|
|
|
params: parameters passed to the called indicator
|
|
|
|
|
|
_depth: used internally to track call depth
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Returns:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
DataFrame after execution, containing columns calculated by the called indicator
|
2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
import numpy as np
|
2026-04-09 14:30:51 +07:00
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Check call depth
|
2026-02-01 15:02:14 +08:00
|
|
|
|
if _depth >= self.MAX_CALL_DEPTH:
|
|
|
|
|
|
logger.error(f"Indicator call depth exceeded {self.MAX_CALL_DEPTH}")
|
|
|
|
|
|
return df.copy()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Get indicator code
|
2026-02-01 15:02:14 +08:00
|
|
|
|
indicator_code, indicator_id = self._get_indicator_code(indicator_ref)
|
|
|
|
|
|
if not indicator_code:
|
|
|
|
|
|
logger.warning(f"Indicator not found: {indicator_ref}")
|
|
|
|
|
|
return df.copy()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Check for circular dependencies
|
2026-02-01 15:02:14 +08:00
|
|
|
|
if indicator_id in self._call_stack:
|
|
|
|
|
|
logger.error(f"Circular dependency detected: {self._call_stack} -> {indicator_id}")
|
|
|
|
|
|
return df.copy()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
self._call_stack.append(indicator_id)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
try:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Parse and merge parameters
|
2026-02-01 15:02:14 +08:00
|
|
|
|
declared_params = IndicatorParamsParser.parse_params(indicator_code)
|
|
|
|
|
|
merged_params = IndicatorParamsParser.merge_params(declared_params, params or {})
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Prepare execution environment
|
2026-02-01 15:02:14 +08:00
|
|
|
|
df_copy = df.copy()
|
|
|
|
|
|
local_vars = {
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"df": df_copy,
|
|
|
|
|
|
"open": df_copy["open"].astype("float64") if "open" in df_copy.columns else pd.Series(dtype="float64"),
|
|
|
|
|
|
"high": df_copy["high"].astype("float64") if "high" in df_copy.columns else pd.Series(dtype="float64"),
|
|
|
|
|
|
"low": df_copy["low"].astype("float64") if "low" in df_copy.columns else pd.Series(dtype="float64"),
|
|
|
|
|
|
"close": df_copy["close"].astype("float64")
|
|
|
|
|
|
if "close" in df_copy.columns
|
|
|
|
|
|
else pd.Series(dtype="float64"),
|
|
|
|
|
|
"volume": df_copy["volume"].astype("float64")
|
|
|
|
|
|
if "volume" in df_copy.columns
|
|
|
|
|
|
else pd.Series(dtype="float64"),
|
|
|
|
|
|
"signals": pd.Series(0, index=df_copy.index, dtype="float64"),
|
|
|
|
|
|
"np": np,
|
|
|
|
|
|
"pd": pd,
|
|
|
|
|
|
"params": merged_params,
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Recursive call support
|
2026-04-09 14:30:51 +07:00
|
|
|
|
"call_indicator": lambda ref, d, p=None: self.call_indicator(ref, d, p, _depth + 1),
|
2026-02-01 15:02:14 +08:00
|
|
|
|
}
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Safe execution
|
2026-02-01 15:02:14 +08:00
|
|
|
|
import builtins
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
def safe_import(name, *args, **kwargs):
|
2026-04-09 14:30:51 +07:00
|
|
|
|
allowed_modules = ["numpy", "pandas", "math", "json", "time"]
|
|
|
|
|
|
if name in allowed_modules or name.split(".")[0] in allowed_modules:
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return builtins.__import__(name, *args, **kwargs)
|
|
|
|
|
|
raise ImportError(f"Module not allowed: {name}")
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
"__import__",
|
|
|
|
|
|
"copyright",
|
|
|
|
|
|
"credits",
|
|
|
|
|
|
"license",
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
safe_builtins["__import__"] = safe_import
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
exec_env = local_vars.copy()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
exec_env["__builtins__"] = safe_builtins
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
pre_import = "import numpy as np\nimport pandas as pd\n"
|
|
|
|
|
|
exec(pre_import, exec_env)
|
|
|
|
|
|
exec(indicator_code, exec_env)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
return exec_env.get("df", df_copy)
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error calling indicator {indicator_ref}: {e}")
|
|
|
|
|
|
return df.copy()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._call_stack.pop()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
def _get_indicator_code(self, indicator_ref: Any) -> Tuple[Optional[str], Optional[int]]:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
"""Get indicator code"""
|
2026-02-01 15:02:14 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cursor = db.cursor()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
if isinstance(indicator_ref, int):
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Query by ID
|
2026-04-09 14:30:51 +07:00
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, code FROM qd_indicator_codes
|
2026-02-01 15:02:14 +08:00
|
|
|
|
WHERE id = %s AND (user_id = %s OR publish_to_community = 1)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
""",
|
|
|
|
|
|
(indicator_ref, self.user_id),
|
|
|
|
|
|
)
|
2026-02-01 15:02:14 +08:00
|
|
|
|
else:
|
2026-04-06 16:47:36 +07:00
|
|
|
|
# Query by name (priority to own indicators)
|
2026-04-09 14:30:51 +07:00
|
|
|
|
cursor.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id, code FROM qd_indicator_codes
|
2026-02-01 15:02:14 +08:00
|
|
|
|
WHERE name = %s AND user_id = %s
|
|
|
|
|
|
UNION
|
2026-04-09 14:30:51 +07:00
|
|
|
|
SELECT id, code FROM qd_indicator_codes
|
2026-02-01 15:02:14 +08:00
|
|
|
|
WHERE name = %s AND publish_to_community = 1
|
|
|
|
|
|
LIMIT 1
|
2026-04-09 14:30:51 +07:00
|
|
|
|
""",
|
|
|
|
|
|
(str(indicator_ref), self.user_id, str(indicator_ref)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
row = cursor.fetchone()
|
|
|
|
|
|
cursor.close()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
if row:
|
2026-04-09 14:30:51 +07:00
|
|
|
|
return row["code"], row["id"]
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return None, None
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error fetching indicator code: {e}")
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_indicator_params(indicator_id: int) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""
|
2026-04-08 07:27:26 +07:00
|
|
|
|
Get the indicator parameter declarations for API usage
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Args:
|
2026-04-08 07:27:26 +07:00
|
|
|
|
indicator_id: Indicator ID
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
2026-02-01 15:02:14 +08:00
|
|
|
|
Returns:
|
2026-04-08 07:27:26 +07:00
|
|
|
|
A list of parameter declarations
|
2026-02-01 15:02:14 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db_connection() as db:
|
|
|
|
|
|
cursor = db.cursor()
|
|
|
|
|
|
cursor.execute("SELECT code FROM qd_indicator_codes WHERE id = %s", (indicator_id,))
|
|
|
|
|
|
row = cursor.fetchone()
|
|
|
|
|
|
cursor.close()
|
2026-04-09 14:30:51 +07:00
|
|
|
|
|
|
|
|
|
|
if row and row["code"]:
|
|
|
|
|
|
return IndicatorParamsParser.parse_params(row["code"])
|
2026-02-01 15:02:14 +08:00
|
|
|
|
return []
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error getting indicator params: {e}")
|
|
|
|
|
|
return []
|