diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6528466..abc7e70 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -59,6 +59,8 @@ def get_settings() -> dict: current_ai_model, current_codex_command, current_codex_reasoning_effort, + current_ai_context_window, + current_ai_max_output_tokens, ) key = secrets_store.get_tickflow_key() @@ -84,6 +86,8 @@ def get_settings() -> dict: "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent), + "ai_max_output_tokens": current_ai_max_output_tokens(), + "ai_context_window": current_ai_context_window(), } @@ -243,6 +247,8 @@ class AiSettingsIn(BaseModel): codex_command: str = "" codex_reasoning_effort: str = "" user_agent: str = "" + max_output_tokens: int | None = None # 输出上限, 钳制所有任务的 max_tokens + context_window: int | None = None # 输入上下文窗口上限 (约 token) @router.post("/ai") @@ -255,6 +261,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict: current_ai_provider, current_codex_command, current_codex_reasoning_effort, + current_ai_context_window, + current_ai_max_output_tokens, normalize_codex_command, normalize_codex_reasoning_effort, ) @@ -293,6 +301,18 @@ def save_ai_settings(req: AiSettingsIn) -> dict: updates["ai_user_agent"] = req.user_agent settings.ai_user_agent = req.user_agent + # 输出上限 / 输入上下文窗口 (数值配置, 缺省保持原值) + if req.max_output_tokens is not None: + if req.max_output_tokens <= 0: + raise HTTPException(status_code=400, detail="输出上限必须为正整数") + updates["ai_max_output_tokens"] = req.max_output_tokens + settings.ai_max_output_tokens = req.max_output_tokens + if req.context_window is not None: + if req.context_window <= 0: + raise HTTPException(status_code=400, detail="上下文窗口必须为正整数") + updates["ai_context_window"] = req.context_window + settings.ai_context_window = req.context_window + if updates: secrets_store.save(updates) @@ -304,6 +324,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict: "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_configured": ai_configured(provider), + "ai_max_output_tokens": current_ai_max_output_tokens(), + "ai_context_window": current_ai_context_window(), } @@ -315,7 +337,7 @@ def clear_ai_settings() -> dict: """ from app.config import settings - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort") + secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort", "ai_max_output_tokens", "ai_context_window") # 同步重置运行时内存(provider 回默认值,其余置空) settings.ai_provider = "openai_compat" settings.ai_base_url = "" @@ -323,6 +345,8 @@ def clear_ai_settings() -> dict: settings.ai_model = "" settings.ai_codex_command = "codex" settings.ai_codex_reasoning_effort = "" + settings.ai_max_output_tokens = 8192 + settings.ai_context_window = 64000 return {"ok": True} diff --git a/backend/app/api/signals.py b/backend/app/api/signals.py index 334ba88..4e6bd58 100644 --- a/backend/app/api/signals.py +++ b/backend/app/api/signals.py @@ -18,10 +18,20 @@ def _data_dir(request: Request) -> Path: return request.app.state.repo.store.data_dir -def _invalidate() -> None: - """失效 pipeline 的自定义信号缓存,下次计算重新加载。""" +def _invalidate(request: Request) -> None: + """失效自定义信号表达式缓存, 并清掉含旧信号列的计算缓存。 + + 信号增删会改变注入列集合: 只清表达式缓存不够, repo 内存缓存 / + strategy 磁盘缓存里算好的历史窗口仍不含新 csg_ 列 (或仍含已删列), + 需要一并清除, 否则创建信号后立即运行策略仍会报缺列。 + """ from app.indicators.pipeline import invalidate_custom_signals invalidate_custom_signals() + from app.services import strategy_cache + strategy_cache.clear_cache(_data_dir(request)) + repo = request.app.state.repo + if hasattr(repo, "clear_cache"): + repo.clear_cache() class ConditionModel(BaseModel): @@ -40,6 +50,10 @@ class SignalModel(BaseModel): enabled: bool = True +class AIGenerateRequest(BaseModel): + description: str + + # ── 字段选项 / 运算符 ─────────────────────────────────── @@ -106,10 +120,44 @@ def save_signal(req: SignalModel, request: Request): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) custom_signals.save_one(_data_dir(request), sig) - _invalidate() + _invalidate(request) return {"ok": True, "signal": sig} +# ── AI 生成 ───────────────────────────────────────────── + + +@router.post("/ai/generate") +async def ai_generate_signal(req: AIGenerateRequest): + """AI 根据自然语言描述生成自定义信号条件。 + + 不落盘:只返回 {name, conditions} 供前端回填表单,由用户确认后走 + 常规 save 流程。校验复用 custom_signals.validate()(白名单安全闸门)。 + """ + from app.services.ai_provider import generate_ai_text + from app.strategy import custom_signals_ai + + description = req.description.strip() + if not description: + raise HTTPException(status_code=400, detail="请先描述信号思路") + if len(description) > 500: + raise HTTPException(status_code=400, detail="描述过长(最多 500 字)") + + messages = custom_signals_ai.build_messages(description) + try: + # max_tokens 给足 8 个条件的 JSON 余量 (1000 会被复杂描述截断, 导致返回非法 JSON) + text = await generate_ai_text(messages, temperature=0.2, max_tokens=2000) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=400, detail=f"AI 生成失败: {e}") from e + + try: + return custom_signals_ai.parse_and_validate(text) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + # ── 删除 ─────────────────────────────────────────────── @@ -120,5 +168,5 @@ def delete_signal(signal_id: str, request: Request): deleted = custom_signals.delete_one(_data_dir(request), signal_id) if not deleted: raise HTTPException(status_code=404, detail="信号不存在") - _invalidate() + _invalidate(request) return {"ok": True} diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 6e40c1c..ca367ed 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -58,6 +58,22 @@ def _invalidate_strategy_runtime(request: Request) -> None: monitor_engine.invalidate_strategy_state() +def _missing_custom_signals(data_dir: Path, required_features) -> list[str]: + """required_features 中 csg_ 列对应信号未定义的部分 (保存策略前校验)。 + + 自定义信号列 (csg_ 前缀) 只有在 data/user_data/custom_signals/*.json + 有对应定义时才会被注入; 引用不存在的信号运行必报缺列错, 保存时早失败。 + """ + from app.strategy import custom_signals + + defined = {s.get("id") for s in custom_signals.load_all(data_dir)} + return [ + name for name in (required_features or ()) + if isinstance(name, str) and name.startswith(custom_signals.PREFIX) + and name[len(custom_signals.PREFIX):] not in defined + ] + + def _cleanup_deleted_strategy(request: Request, strategy_id: str) -> list[str]: """尽力清理删除后的派生状态, 清理失败不应把已成功的源文件删除变成 500。""" from app.services import preferences @@ -669,6 +685,14 @@ def _save_strategy_code(req: StrategyCodeSaveRequest, request: Request, *, legac raise ValueError("策略加载到了非预期文件,请检查是否存在重复 strategy_id") if loaded.source != expected_source: raise ValueError(f"策略来源异常: 期望 {expected_source}, 实际 {loaded.source}") + # 自定义信号存在性校验: REQUIRED_FEATURES 里 csg_ 列必须已有定义, + # 否则运行必报缺列错。早失败并恢复文件, 提示用户先创建信号。 + missing = _missing_custom_signals(data_dir, loaded.required_features) + if missing: + raise ValueError( + "策略引用了未定义的自定义信号: " + ", ".join(sorted(missing)) + + " — 请先在「自定义信号」管理中创建对应信号后再保存" + ) except Exception as e: _restore_strategy_file(path, previous_code) engine.reload() diff --git a/backend/app/config.py b/backend/app/config.py index b57f305..e34ea5b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -89,6 +89,11 @@ class Settings(BaseSettings): "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36" ) + # AI 输出上限 (max_tokens) 与输入上下文窗口上限 (约 token)。 + # 任务级 max_tokens 会被钳制到 ai_max_output_tokens; 输入估算超出上下文窗口时给出明确报错。 + # 默认 8192 高于所有现有任务 (最多 4500), 避免默认配置反而截断长报告; 可在 AI 设置里调整。 + ai_max_output_tokens: int = 8192 + ai_context_window: int = 64000 # Server host: str = "0.0.0.0" @@ -124,6 +129,10 @@ class Settings(BaseSettings): raise ValueError("backtest_matrix_cache_max_mb must be positive") if self.backtest_matrix_cache_prewarm_years <= 0: raise ValueError("backtest_matrix_cache_prewarm_years must be positive") + if self.ai_max_output_tokens <= 0: + raise ValueError("ai_max_output_tokens must be positive") + if self.ai_context_window <= 0: + raise ValueError("ai_context_window must be positive") return self @property diff --git a/backend/app/data_providers/custom/loader.py b/backend/app/data_providers/custom/loader.py index 1e73848..e610cc4 100644 --- a/backend/app/data_providers/custom/loader.py +++ b/backend/app/data_providers/custom/loader.py @@ -131,8 +131,14 @@ def install_plugin(name: str) -> tuple[bool, str]: timeout=300, ) elif runtime == "python": + import sys # Python 型插件: 优先用 uv pip install (uv 管理的 venv 无 pip 模块), # 回退 python -m pip。都装进当前后端虚拟环境。 + # 关键: uv 分支必须显式传 --python sys.executable。dev.ps1 直接跑 + # .venv/Scripts/python.exe 而不 activate, 后端进程 VIRTUAL_ENV 为空, + # uv pip 会默认选 PATH 上的基础解释器 (如 conda base, 常为只读) → + # 装错环境并 exit 2 (访问拒绝)。--python 锁定后端自身 venv, 与 pip + # 回退路径 (sys.executable -m pip) 的目标一致。 # uv 容错: 用户全局 uv.toml 配置错误时 exit 2, 回退 --no-config 重试。 # UV_HTTP_TIMEOUT=300: akshare 等含大包(如 mini-racer 14MB), 默认 30s 不够。 req = pdir / "requirements.txt" @@ -141,7 +147,7 @@ def install_plugin(name: str) -> tuple[bool, str]: uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( - [uv_bin, "pip", "install", "-r", str(req)], + [uv_bin, "pip", "install", "--python", sys.executable, "-r", str(req)], capture_output=True, text=True, timeout=300, env={**__import__("os").environ, "UV_HTTP_TIMEOUT": "300"}, ) @@ -151,12 +157,12 @@ def install_plugin(name: str) -> tuple[bool, str]: result = subprocess.run( [uv_bin, "pip", "install", "--no-config", "--index-url", "https://pypi.tuna.tsinghua.edu.cn/simple", + "--python", sys.executable, "-r", str(req)], capture_output=True, text=True, timeout=300, env={**__import__("os").environ, "UV_HTTP_TIMEOUT": "300"}, ) else: - import sys result = subprocess.run( [sys.executable, "-m", "pip", "install", "-r", str(req)], capture_output=True, text=True, timeout=300, @@ -217,11 +223,12 @@ def uninstall_plugin(name: str) -> tuple[bool, str]: if l.strip() and not l.startswith("#")] if not pkgs: return True, "requirements.txt 无有效包名" + import sys uv_bin = _shutil.which("uv") - cmd = [uv_bin, "pip", "uninstall", *pkgs] if uv_bin else None - if cmd is None: - import sys - cmd = [sys.executable, "-m", "pip", "uninstall", "-y", *pkgs] + # 与 install 一致: uv 分支显式 --python 锁定后端 venv (无 VIRTUAL_ENV 时 + # 会默认落到 PATH 基础解释器), 与 pip 回退 (sys.executable -m pip) 目标一致。 + cmd = ([uv_bin, "pip", "uninstall", "--python", sys.executable, *pkgs] + if uv_bin else [sys.executable, "-m", "pip", "uninstall", "-y", *pkgs]) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: diff --git a/backend/app/secrets_store.py b/backend/app/secrets_store.py index 28d2c1b..8cc4ca8 100644 --- a/backend/app/secrets_store.py +++ b/backend/app/secrets_store.py @@ -87,6 +87,18 @@ def get_ai_config(key: str, default: str = "") -> str: return getattr(settings, key, default) or default +def get_ai_config_int(key: str, default: int) -> int: + """取 AI 数值配置项 (如 ai_max_output_tokens): secrets.json 优先,否则 config。""" + val = load().get(key) + if val is not None: + try: + return int(val) + except (TypeError, ValueError): + logger.warning("ai config %s is not an int: %r", key, val) + from app.config import settings + return int(getattr(settings, key, default) or default) + + def mask(key: str, prefix: int = 4, suffix: int = 4) -> str: """脱敏显示。""" if not key: diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 2886520..14da0e2 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -110,6 +110,51 @@ def current_ai_model() -> str: return secrets_store.get_ai_config("ai_model", settings.ai_model) +def current_ai_max_output_tokens() -> int: + """当前 AI 输出上限 (max_tokens): secrets.json 优先, 否则 config 默认。""" + return secrets_store.get_ai_config_int("ai_max_output_tokens", settings.ai_max_output_tokens) + + +def current_ai_context_window() -> int: + """当前 AI 输入上下文窗口上限 (约 token): secrets.json 优先, 否则 config 默认。""" + return secrets_store.get_ai_config_int("ai_context_window", settings.ai_context_window) + + +def _resolve_max_tokens(max_tokens: int | None) -> int: + """任务请求的 max_tokens 钳制到配置输出上限; 未传时用配置上限。""" + cap = current_ai_max_output_tokens() + if max_tokens is None: + return cap + return max(1, min(int(max_tokens), cap)) + + +def _estimate_input_tokens(messages: Sequence[Message]) -> int: + """粗略估算输入 token 数: 中文按 1 字 1 token, 其余按 4 字符 1 token。""" + total = 0 + for m in messages: + text = str(m.get("content") or "") + cjk = sum(1 for ch in text if "一" <= ch <= "鿿") + total += cjk + (len(text) - cjk) // 4 + 1 + return max(1, total) + + +def _check_input_budget(messages: Sequence[Message], *, max_tokens: int) -> None: + """输入估算超出上下文窗口时给出明确报错, 避免上游 400 或静默截断。 + + token 计数不精确, 仅作安全网: 用中/英文混合估算, 只有明显超窗才拒绝; + 用户可在 AI 设置里调大『上下文窗口』。 + """ + context_window = current_ai_context_window() + if context_window <= 0: + return + est = _estimate_input_tokens(messages) + if est + max_tokens > context_window: + raise ValueError( + f"输入过长: 估算输入约 {est} tokens, 加上输出预算 {max_tokens} tokens, " + f"超过上下文窗口 {context_window}。请缩短输入, 或在 AI 设置中调大『上下文窗口』。" + ) + + def current_codex_command() -> str: return normalize_codex_command( secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command), @@ -193,10 +238,15 @@ async def generate_ai_text( messages: Sequence[Message], *, temperature: float | None = 0.3, - max_tokens: int = 3000, + max_tokens: int | None = None, timeout: float = 180.0, ) -> str: - """Return a complete AI response from the currently configured provider.""" + """Return a complete AI response from the currently configured provider. + + max_tokens 未传时取配置的输出上限; 显式传入也会被钳制到配置上限。 + """ + max_tokens = _resolve_max_tokens(max_tokens) + _check_input_budget(messages, max_tokens=max_tokens) if is_codex_cli_provider(): return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) return await _run_openai_once( @@ -211,7 +261,7 @@ async def stream_ai_text( messages: Sequence[Message], *, temperature: float | None = 0.5, - max_tokens: int = 4000, + max_tokens: int | None = None, timeout: float = 180.0, ) -> AsyncIterator[str]: """Yield text deltas from the configured provider. @@ -219,6 +269,8 @@ async def stream_ai_text( Codex CLI only exposes the final assistant message for this use case, so it yields one complete chunk after the command exits. """ + max_tokens = _resolve_max_tokens(max_tokens) + _check_input_budget(messages, max_tokens=max_tokens) if is_codex_cli_provider(): yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) return diff --git a/backend/app/strategy/custom_signals.py b/backend/app/strategy/custom_signals.py index eb957c4..7ee0a3c 100644 --- a/backend/app/strategy/custom_signals.py +++ b/backend/app/strategy/custom_signals.py @@ -119,7 +119,14 @@ def _parse_days(c: dict, key: str, i: int) -> int: def _parse_right(right: str) -> tuple[str, object]: - """解析右值。返回 ('field', colname) 或 ('const', float)。""" + """解析右值。返回 ('field', colname) 或 ('const', float)。 + + 接受三种形式: + - 数字 (int / float / 数字字符串) → 常量 + - "field:字段名" → 字段引用 + - 裸字段名 (在白名单内) → 自动视为字段引用 + (AI 生成偶尔漏写 field: 前缀; 白名单字段名不可能是数字, 无歧义) + """ if isinstance(right, (int, float)): return ("const", float(right)) if not isinstance(right, str): @@ -133,7 +140,11 @@ def _parse_right(right: str) -> tuple[str, object]: try: return ("const", float(right)) except ValueError: - raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}") + pass + # 裸字段名 — 兜底容错, 仍受白名单约束 + if right in ALLOWED_FIELDS: + return ("field", right) + raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}") def validate(sig: dict) -> None: diff --git a/backend/app/strategy/custom_signals_ai.py b/backend/app/strategy/custom_signals_ai.py new file mode 100644 index 0000000..4b3344f --- /dev/null +++ b/backend/app/strategy/custom_signals_ai.py @@ -0,0 +1,231 @@ +"""AI 生成自定义信号 — 组装提示词 + 解析并校验 AI 返回的结构化条件。 + +职责: + - build_messages(description): 把用户一句描述 + 字段白名单/运算符/格式要求组装成 LLM 消息 + - parse_and_validate(text): 把 LLM 返回的 JSON 解析为 {name, conditions},并复用 + custom_signals.validate() 做白名单/运算符/偏移校验(安全闸门) + +不知道: API、AI 调用、持久化。纯函数,无副作用。 +""" +from __future__ import annotations + +import json +import re + +from app.indicators.pipeline import ENRICHED_COLUMNS, ENRICHED_COLUMNS_BY_CATEGORY +from app.strategy import custom_signals + +# 分类 → 中文标签(与 /api/custom-signals/options 的分组一致) +_GROUP_LABELS = { + "basic": "基础", "ma": "均线 MA", "ema": "指数均线 EMA", + "macd": "MACD", "boll": "布林带 BOLL", "kdj": "KDJ", + "atr": "ATR", "volume": "量价", "extremes": "极值", + "momentum": "动量", "volatility": "波动率", "rsi": "RSI", +} +# 行情类字段不在 ENRICHED_COLUMNS_BY_CATEGORY 里, 单独归一组 +_QUOTE_FIELDS = { + "open", "high", "low", "close", "volume", "amount", "turnover_rate", + "consecutive_limit_ups", "consecutive_limit_downs", +} + +_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*\n?(.*?)```", re.DOTALL) + + +def _format_fields() -> str: + """按类别格式化白名单字段(key(中文标签)),供 LLM 参考。""" + allowed = custom_signals.ALLOWED_FIELDS + lines: list[str] = [] + quote = sorted(f for f in _QUOTE_FIELDS if f in allowed) + lines.append( + "行情: " + ", ".join(f"{f}({ENRICHED_COLUMNS.get(f, f)})" for f in quote) + ) + for cat, label in _GROUP_LABELS.items(): + fields = [f for f in ENRICHED_COLUMNS_BY_CATEGORY.get(cat, []) if f in allowed] + if fields: + lines.append( + f"{label}: " + + ", ".join(f"{f}({ENRICHED_COLUMNS.get(f, f)})" for f in fields) + ) + return "\n".join(lines) + + +_SYSTEM_TEMPLATE = """你是A股量化信号设计专家。用户会描述一个信号思路,你要把它拆解为布尔条件组合(多条件之间是「且」关系,即同时满足),并输出结构化 JSON 供系统编译为选股/回测/监控信号。 + +可用字段(白名单,只能使用以下字段,禁止自造或使用白名单之外的字段): +{fields} + +运算符(op):> >= < <= == != + +右值(right): +- 数字:写字符串形式,如 "2"、"3000"、"0.05" +- 另一字段:必须带 "field:" 前缀,如 "field:ma20";严禁裸写字段名,如 "macd_dea" 应写成 "field:macd_dea" + +日期偏移(leftDays / rightDays):取 N 个交易日前的值,0 = 当日最新;范围 0~{max_days}。只有明确需要「前N日」时才使用偏移。 + +要求: +1. 只输出一个 JSON 对象,禁止 markdown 代码块、禁止任何解释或多余文字。 +2. JSON 结构固定为: +{{"name": "简短中文信号名称(≤12字)", "conditions": [ + {{"left": "字段", "op": "运算符", "right": "数字字符串或field:字段", "leftDays": 0, "rightDays": 0}} +]}} + 示例(右值引用另一字段时必须带 field: 前缀,不能裸写字段名): + {{"name": "MACD金叉", "conditions": [ + {{"left": "macd_dif", "op": ">", "right": "field:macd_dea", "leftDays": 0, "rightDays": 0}} + ]}} +3. conditions 至少 1 个、最多 8 个;优先用最少的条件表达清晰的思路。 +4. 多条件必须能同时满足,不要输出互相矛盾的条件。""" + + +def build_messages(description: str) -> list[dict]: + """组装 LLM 消息:[system 提示词, user 描述]。""" + system = _SYSTEM_TEMPLATE.format( + fields=_format_fields(), + max_days=custom_signals.MAX_DAYS, + ) + return [ + {"role": "system", "content": system}, + {"role": "user", "content": description}, + ] + + +def parse_and_validate(text: str) -> dict: + """解析并校验 AI 返回的 JSON → {"name", "conditions"}。非法时抛 ValueError。 + + 只取 name 与 conditions;id / kind 由用户在表单里填写,不信任 AI。 + """ + raw = _extract_json_object(text) + if not isinstance(raw, dict): + raise ValueError("AI 返回的 JSON 不是对象") + name = raw.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("AI 未返回信号名称 name") + name = name.strip()[:30] + conditions_raw = raw.get("conditions") + if not isinstance(conditions_raw, list) or not conditions_raw: + raise ValueError("AI 未返回任何条件 conditions") + + conditions = [_normalize_condition(c) for c in conditions_raw] + + # 复用现有白名单/运算符/偏移校验作为安全闸门(id/kind 用占位值)。 + probe = { + "id": "aigenerated", + "name": name, + "kind": "both", + "conditions": conditions, + } + custom_signals.validate(probe) + return {"name": name, "conditions": conditions} + + +def _normalize_condition(c: object) -> dict: + if not isinstance(c, dict): + raise ValueError("条件的每一项必须是 JSON 对象") + left = c.get("left") + op = c.get("op") + right = c.get("right") + if right is None: + raise ValueError("条件缺少右值 right") + if isinstance(right, bool): + raise ValueError("右值不能是布尔值") + if isinstance(right, (int, float)): + right = _num_to_str(right) + if not isinstance(right, str) or not right.strip(): + raise ValueError(f"右值非法: {right!r}") + right = right.strip() + # 兜底: AI 偶尔漏写 field: 前缀的裸字段名, 补全为规范形式 + if not right.startswith("field:") and right in custom_signals.ALLOWED_FIELDS: + right = f"field:{right}" + return { + "left": str(left), + "op": str(op), + "right": right, + "leftDays": _norm_days(c.get("leftDays")), + "rightDays": _norm_days(c.get("rightDays")), + } + + +def _norm_days(value: object) -> object: + """归一化日期偏移为 int(缺省 0);无法转换时原样返回,由 validate 报中文错误。""" + if value is None: + return 0 + if isinstance(value, bool): + return value + try: + return int(value) + except (TypeError, ValueError): + return value + + +def _num_to_str(value) -> str: + if isinstance(value, int): + return str(value) + f = float(value) + return str(int(f)) if f.is_integer() else str(f) + + +def _extract_json_object(text: str) -> object: + """从 LLM 文本提取 JSON 对象(多级容错)。 + + 依次尝试: 整段 → markdown 围栏内 → 首个 {...} 平衡块; + 每级再对 尾随垃圾 / 尾逗号 做轻量修复。全部失败才报错。 + """ + source = text or "" + candidates: list[str] = [] + stripped = source.strip() + if stripped: + candidates.append(stripped) + candidates.extend( + match.group(1).strip() for match in _FENCED_JSON_RE.finditer(source) + ) + brace = _first_brace_block(source) + if brace and brace.strip() not in candidates: + candidates.append(brace) + last_error: Exception | None = None + for candidate in candidates: + parsed = _try_parse_json(candidate) + if parsed is not None: + return parsed + try: + json.loads(candidate) + except json.JSONDecodeError as e: + last_error = e + raise ValueError(f"AI 返回的不是合法 JSON: {last_error}") + + +def _try_parse_json(candidate: str) -> object | None: + """尽力解析一段可能带尾随垃圾 / 尾逗号的 JSON;失败返 None。""" + variants = [candidate.strip()] + last = candidate.rfind("}") + if last >= 0 and last < len(candidate) - 1: + variants.append(candidate[:last + 1].strip()) + for v in variants: + if not v: + continue + try: + return json.loads(v) + except json.JSONDecodeError: + pass + # 去掉数组/对象结尾的多余逗号 (AI 常见错误): `,}` / `,]` + cleaned = re.sub(r",\s*([}\]])", r"\1", v) + if cleaned != v: + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + return None + + +def _first_brace_block(text: str) -> str: + """括号配对截取首个 {...} 块(AI 偶尔混入前后解释文字时的兜底)。""" + start = text.find("{") + if start < 0: + return text + depth = 0 + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start:i + 1] + return text[start:] diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1d3d4c2..632a192 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -676,7 +676,20 @@ class StrategyEngine: self.required_history_bars(child_ids, params_map=params_map), ) elif strategy.filter_history_fn: - required = max(required, int(strategy.lookback_days)) + # lookback_days 优先取自解析后的参数(默认值/保存覆盖/本次调用), + # 静态 LOOKBACK_DAYS 兜底。策略可能只把窗口声明为参数 + # (如 AI 生成策略), 此时 strategy.lookback_days 回退到 1, + # 不解析参数会低估历史需求 → build_strategy_context 跳过加载 → 运行时报错。 + params = self.resolve_params( + strategy, + params_map.get(strategy_id), + overrides_map.get(strategy_id), + ) + lookback = int(strategy.lookback_days) + param_lookback = params.get("lookback_days") + if isinstance(param_lookback, (int, float)) and param_lookback > 0: + lookback = max(lookback, int(param_lookback)) + required = max(required, lookback) return required def prepare_realtime_matrix( @@ -852,6 +865,19 @@ class StrategyEngine: strategy_id=strategy_id, exit_signal_hits=exit_signal_hits, ) + # 自定义信号前置校验: REQUIRED_FEATURES 引用的 csg_ 列未注入时, + # 给出明确指引, 而不是让策略代码抛 polars 缺列错 (500)。 + # 盘中单日路径不在此校验 (该路径对带偏移信号本就优雅降级)。 + missing_csg = [ + name for name in s.required_features + if name.startswith("csg_") and name not in df.columns + ] + if missing_csg: + raise ValueError( + "策略引用了未定义的自定义信号: " + + ", ".join(sorted(missing_csg)) + + " — 请先在「自定义信号」管理中创建对应信号后再运行" + ) df = s.filter_history_fn(df, params) if "date" in df.columns: df = df.filter(pl.col("date") == as_of) diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 1b5a89c..b235665 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -4,6 +4,7 @@ import tomllib import httpx import openai +import pytest from app.services import ai_provider from app.services.ai_provider import ( @@ -145,6 +146,122 @@ def test_is_temperature_rejected_false_for_non_400(): assert _is_temperature_rejected(exc) is False +# ── 输出上限 / 上下文窗口配置 ───────────────────────────────── + + +def test_resolve_max_tokens_defaults_to_config_cap(monkeypatch): + monkeypatch.setattr(ai_provider, "current_ai_max_output_tokens", lambda: 8192) + assert ai_provider._resolve_max_tokens(None) == 8192 + + +def test_resolve_max_tokens_clamps_above_cap(monkeypatch): + monkeypatch.setattr(ai_provider, "current_ai_max_output_tokens", lambda: 3000) + assert ai_provider._resolve_max_tokens(9000) == 3000 + + +def test_resolve_max_tokens_keeps_below_cap(monkeypatch): + monkeypatch.setattr(ai_provider, "current_ai_max_output_tokens", lambda: 8192) + assert ai_provider._resolve_max_tokens(2000) == 2000 + + +def test_estimate_input_tokens_counts_cjk_and_ascii(): + # 中文按 1 字 1 token + cjk = [{"role": "user", "content": "中文" * 100}] # 200 字 + assert ai_provider._estimate_input_tokens(cjk) >= 200 + # 英文按 ~4 字符 1 token + ascii_msg = [{"role": "user", "content": "a" * 400}] + assert ai_provider._estimate_input_tokens(ascii_msg) <= 200 + + +def test_check_input_budget_raises_when_over_window(monkeypatch): + monkeypatch.setattr(ai_provider, "current_ai_context_window", lambda: 100) + big = [{"role": "user", "content": "中" * 200}] # 估算输入 ~200 tokens + with pytest.raises(ValueError, match="上下文窗口"): + ai_provider._check_input_budget(big, max_tokens=3000) + + +def test_check_input_budget_passes_within_window(monkeypatch): + monkeypatch.setattr(ai_provider, "current_ai_context_window", lambda: 64000) + small = [{"role": "user", "content": "中" * 100}] + # 不抛异常 + ai_provider._check_input_budget(small, max_tokens=2000) + + +@pytest.mark.asyncio +async def test_generate_ai_text_clamps_max_tokens_to_config_cap(monkeypatch): + captured: dict = {} + monkeypatch.setattr(ai_provider, "is_codex_cli_provider", lambda: False) + monkeypatch.setattr(ai_provider, "current_ai_max_output_tokens", lambda: 3000) + monkeypatch.setattr(ai_provider, "current_ai_context_window", lambda: 64000) + + async def fake_run(messages, *, temperature, max_tokens, timeout): + captured["max_tokens"] = max_tokens + return "ok" + + monkeypatch.setattr(ai_provider, "_run_openai_once", fake_run) + text = await ai_provider.generate_ai_text( + [{"role": "user", "content": "hi"}], max_tokens=9000 + ) + assert text == "ok" + assert captured["max_tokens"] == 3000 + + +@pytest.mark.asyncio +async def test_generate_ai_text_defaults_to_config_cap(monkeypatch): + captured: dict = {} + monkeypatch.setattr(ai_provider, "is_codex_cli_provider", lambda: False) + monkeypatch.setattr(ai_provider, "current_ai_max_output_tokens", lambda: 4000) + monkeypatch.setattr(ai_provider, "current_ai_context_window", lambda: 64000) + + async def fake_run(messages, *, temperature, max_tokens, timeout): + captured["max_tokens"] = max_tokens + return "ok" + + monkeypatch.setattr(ai_provider, "_run_openai_once", fake_run) + await ai_provider.generate_ai_text([{"role": "user", "content": "hi"}]) + assert captured["max_tokens"] == 4000 + + +def test_save_ai_settings_persists_token_sizes(monkeypatch): + from app.api import settings as settings_api + from app.config import settings as app_settings + + saved: dict = {} + monkeypatch.setattr(settings_api.secrets_store, "save", lambda updates: saved.update(updates)) + monkeypatch.setattr(settings_api.secrets_store, "load", lambda: saved) + original_output = app_settings.ai_max_output_tokens + original_window = app_settings.ai_context_window + try: + req = settings_api.AiSettingsIn( + provider="openai_compat", + base_url="https://example.com/v1", + api_key="sk-test", + model="gpt-x", + max_output_tokens=5000, + context_window=128000, + ) + result = settings_api.save_ai_settings(req) + assert saved["ai_max_output_tokens"] == 5000 + assert saved["ai_context_window"] == 128000 + assert result["ai_max_output_tokens"] == 5000 + assert result["ai_context_window"] == 128000 + finally: + app_settings.ai_max_output_tokens = original_output + app_settings.ai_context_window = original_window + + +def test_save_ai_settings_rejects_non_positive(monkeypatch): + from app.api import settings as settings_api + from fastapi import HTTPException + + req = settings_api.AiSettingsIn(provider="openai_compat", max_output_tokens=-1) + with pytest.raises(HTTPException): + settings_api.save_ai_settings(req) + req2 = settings_api.AiSettingsIn(provider="openai_compat", context_window=0) + with pytest.raises(HTTPException): + settings_api.save_ai_settings(req2) + + def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "test-path") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example") diff --git a/backend/tests/test_custom_loader_install.py b/backend/tests/test_custom_loader_install.py new file mode 100644 index 0000000..b037326 --- /dev/null +++ b/backend/tests/test_custom_loader_install.py @@ -0,0 +1,83 @@ +"""loader.install_plugin 单元测试 — uv 安装必须显式指定目标环境。 + +回归场景: dev.ps1 直接跑 backend/.venv/Scripts/python.exe 而不 activate venv, +后端进程的 VIRTUAL_ENV 为空; 此时 `uv pip install -r ...` 不传 --python 会落到 +PATH 上的基础解释器 (如 conda base, 常为只读) → 装错环境 + exit 2 (访问拒绝)。 +修复: uv 命令显式传 `--python sys.executable` 锁定后端自身 venv, 与 pip 回退 +路径 (sys.executable -m pip) 的目标一致。 +""" +from __future__ import annotations + +import subprocess +import sys + +from app.data_providers.custom import loader + + +def _patch_uv_install(monkeypatch, returncodes): + """替换 loader.shutil.which 与 subprocess.run, 捕获 uv 命令行。""" + calls: list[list[str]] = [] + + def fake_which(cmd, *_a, **_k): + return "/fake/uv" if cmd == "uv" else None + + def fake_run(cmd, *_a, **_k): + calls.append(list(cmd)) + code = returncodes.pop(0) if isinstance(returncodes, list) else returncodes + return subprocess.CompletedProcess(cmd, code) + + monkeypatch.setattr(loader.shutil, "which", fake_which) + monkeypatch.setattr(loader.subprocess, "run", fake_run) + return calls + + +def test_install_plugin_uv_targets_running_interpreter(monkeypatch): + """uv 分支必须传 --python sys.executable, 不能留给 uv 自动探测。""" + calls = _patch_uv_install(monkeypatch, [0]) + + ok, msg = loader.install_plugin("baostock") + assert ok, msg + assert calls, "应调用 uv" + uv_cmd = calls[0] + assert uv_cmd[0] == "/fake/uv" + assert "--python" in uv_cmd + idx = uv_cmd.index("--python") + assert uv_cmd[idx + 1] == sys.executable + + +def test_install_plugin_uv_retry_keeps_python_target(monkeypatch): + """exit 2 → --no-config 重试时也必须保持 --python 目标。""" + calls = _patch_uv_install(monkeypatch, [2, 0]) + + ok, msg = loader.install_plugin("baostock") + assert ok, msg + assert len(calls) == 2, f"应触发一次重试, 实际 {len(calls)} 次" + for cmd in calls: + assert "--python" in cmd + idx = cmd.index("--python") + assert cmd[idx + 1] == sys.executable + + +def test_uninstall_plugin_uv_targets_running_interpreter(monkeypatch): + """uv 卸载同样必须传 --python sys.executable, 否则会动到基础解释器。""" + calls: list[list[str]] = [] + + def fake_which(cmd, *_a, **_k): + return "/fake/uv" if cmd == "uv" else None + + def fake_run(cmd, *_a, **_k): + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(loader.shutil, "which", fake_which) + monkeypatch.setattr(loader.subprocess, "run", fake_run) + + ok, msg = loader.uninstall_plugin("baostock") + assert ok, msg + assert calls, "应调用 uv" + uv_cmd = calls[0] + assert uv_cmd[0] == "/fake/uv" + assert "uninstall" in uv_cmd + assert "--python" in uv_cmd + idx = uv_cmd.index("--python") + assert uv_cmd[idx + 1] == sys.executable diff --git a/backend/tests/test_custom_signals_ai.py b/backend/tests/test_custom_signals_ai.py new file mode 100644 index 0000000..730fd4d --- /dev/null +++ b/backend/tests/test_custom_signals_ai.py @@ -0,0 +1,237 @@ +"""自定义信号 AI 生成 — prompt 构建与解析校验测试。 + +覆盖: + - build_messages: prompt 包含字段白名单、运算符、MAX_DAYS + - parse_and_validate: 合法 JSON / 白名单外字段 / 非法 JSON / markdown 围栏 + - API 端点: 成功 / 校验失败 400 / 空描述 400 / AI 运行时错误透出 +""" +from __future__ import annotations + +import json +import re + +import pytest +from fastapi import HTTPException + +from app.api.signals import AIGenerateRequest, ai_generate_signal +from app.strategy.custom_signals_ai import build_messages, parse_and_validate + +VALID_JSON = '''{ + "name": "回踩MA20放量", + "conditions": [ + {"left": "close", "op": "<=", "right": "field:ma20", "leftDays": 0, "rightDays": 0}, + {"left": "vol_ratio_5d", "op": ">=", "right": 2, "leftDays": 0, "rightDays": 0} + ] +}''' + + +# ── build_messages ────────────────────────────────────────── + + +def test_build_messages_contains_whitelist_fields_and_rules(): + messages = build_messages("回踩MA20且放量") + system = messages[0]["content"] + user = messages[1]["content"] + assert "close" in system + assert "vol_ratio_5d" in system + assert ">=" in system + assert "MAX_DAYS" in system or "60" in system + assert "回踩MA20且放量" in user + assert messages[0]["role"] == "system" + assert messages[1]["role"] == "user" + + +def test_build_messages_only_contains_whitelisted_fields(): + from app.strategy.custom_signals import ALLOWED_FIELDS + + system = build_messages("x")[0]["content"] + # 只检查「字段清单」段落(可用字段 … 运算符),排除 JSON 格式示例里的 "name" + field_section = system.split("运算符(op)", 1)[0] + for field in ("ma20", "rsi_14", "boll_upper"): + assert field in field_section + # 字段清单里出现的每个 key( 都必须在白名单内 + keys = set(re.findall(r"([a-z0-9_]+)\(", field_section)) + assert keys and keys <= ALLOWED_FIELDS + + +# ── parse_and_validate ────────────────────────────────────── + + +def test_parse_and_validate_valid(): + result = parse_and_validate(VALID_JSON) + assert result["name"] == "回踩MA20放量" + conds = result["conditions"] + assert len(conds) == 2 + # 数字右值归一化为字符串 + assert conds[1]["right"] == "2" + assert conds[0]["right"] == "field:ma20" + # 缺省偏移补 0 + assert conds[0]["leftDays"] == 0 + assert conds[0]["rightDays"] == 0 + + +def test_parse_and_validate_handles_missing_days(): + raw = json.dumps({ + "name": "新低反转", + "conditions": [ + {"left": "close", "op": "<=", "right": "field:low_60d"} + ], + }) + result = parse_and_validate(raw) + assert result["conditions"][0]["leftDays"] == 0 + assert result["conditions"][0]["rightDays"] == 0 + + +def test_parse_and_validate_handles_markdown_fence(): + wrapped = f"```json\n{VALID_JSON}\n```" + result = parse_and_validate(wrapped) + assert result["name"] == "回踩MA20放量" + + +def test_parse_and_validate_rejects_non_whitelist_field(): + raw = json.dumps({ + "name": "非法字段", + "conditions": [{"left": "not_a_field", "op": ">", "right": "1"}], + }) + with pytest.raises(ValueError, match="not_a_field"): + parse_and_validate(raw) + + +def test_parse_and_validate_rejects_bad_operator(): + raw = json.dumps({ + "name": "非法运算符", + "conditions": [{"left": "close", "op": "=~", "right": "1"}], + }) + with pytest.raises(ValueError): + parse_and_validate(raw) + + +def test_parse_and_validate_rejects_invalid_json(): + with pytest.raises(ValueError, match="JSON"): + parse_and_validate("这不是 JSON") + + +def test_parse_and_validate_rejects_empty_conditions(): + raw = json.dumps({"name": "空条件", "conditions": []}) + with pytest.raises(ValueError): + parse_and_validate(raw) + + +def test_parse_and_validate_accepts_bare_whitelist_field_rhs(): + # AI 漏写 field: 前缀: 右值裸写白名单字段, 应自动补全为 field: 形式 + raw = json.dumps({ + "name": "MACD金叉", + "conditions": [ + {"left": "macd_dif", "op": ">", "right": "macd_dea"} + ], + }) + result = parse_and_validate(raw) + assert result["conditions"][0]["right"] == "field:macd_dea" + + +def test_parse_and_validate_rejects_bare_non_whitelist_field_rhs(): + # 裸写非白名单字段作为右值, 仍应报非法右值 + raw = json.dumps({ + "name": "非法右值", + "conditions": [ + {"left": "close", "op": ">", "right": "not_a_field"} + ], + }) + with pytest.raises(ValueError, match="非法右值"): + parse_and_validate(raw) + + +def test_validate_accepts_bare_field_rhs(): + # 解析器层面: 裸字段右值在白名单内即视为字段引用, 不抛异常 + from app.strategy import custom_signals + + sig = { + "id": "test_bare_rhs", + "name": "测试", + "kind": "entry", + "conditions": [ + {"left": "macd_dif", "op": ">", "right": "macd_dea", + "leftDays": 0, "rightDays": 0}, + ], + } + custom_signals.validate(sig) + + +def test_parse_and_validate_accepts_json_with_trailing_comma(): + # AI 在数组结尾多加逗号 (,]): 应被容错 + raw = '{"name": "测试", "conditions": [{"left": "close", "op": ">", "right": "1", "leftDays": 0, "rightDays": 0},]}' + result = parse_and_validate(raw) + assert result["name"] == "测试" + assert len(result["conditions"]) == 1 + + +def test_parse_and_validate_accepts_json_with_prose_wrap(): + # AI 混入前后解释文字: 应提取首个 {...} 平衡块 + raw = f"好的, 我设计了如下信号:\n{VALID_JSON}\n希望对你有所帮助。" + result = parse_and_validate(raw) + assert result["name"] == "回踩MA20放量" + + +def test_parse_and_validate_accepts_json_with_trailing_garbage(): + # 无围栏 + 尾随垃圾字符: 截到最后一个 } 后仍应解析成功 + raw = '{"name": "测试", "conditions": [{"left": "close", "op": ">", "right": "1", "leftDays": 0, "rightDays": 0}]} 这是额外说明' + result = parse_and_validate(raw) + assert result["name"] == "测试" + + +# ── API 端点 ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_ai_generate_endpoint_success(monkeypatch): + captured: dict = {} + + async def fake_generate(messages, **kwargs): + captured["max_tokens"] = kwargs.get("max_tokens") + return VALID_JSON + + import app.services.ai_provider as ai_provider + + monkeypatch.setattr(ai_provider, "generate_ai_text", fake_generate) + result = await ai_generate_signal(AIGenerateRequest(description="回踩MA20且放量")) + assert result["name"] == "回踩MA20放量" + assert len(result["conditions"]) == 2 + # 复杂描述 (多条件) 需要足够 token, 避免 JSON 被截断 + assert captured["max_tokens"] >= 2000 + + +@pytest.mark.asyncio +async def test_ai_generate_endpoint_400_on_invalid_conditions(monkeypatch): + async def fake_generate(messages, **kwargs): + return json.dumps({ + "name": "非法", + "conditions": [{"left": "close", "op": ">", "right": "field:not_allowed"}], + }) + + import app.services.ai_provider as ai_provider + + monkeypatch.setattr(ai_provider, "generate_ai_text", fake_generate) + with pytest.raises(HTTPException) as exc_info: + await ai_generate_signal(AIGenerateRequest(description="测试")) + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_ai_generate_endpoint_400_on_empty_description(): + with pytest.raises(HTTPException) as exc_info: + await ai_generate_signal(AIGenerateRequest(description=" ")) + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_ai_generate_endpoint_passes_runtime_error(monkeypatch): + async def fake_generate(messages, **kwargs): + raise RuntimeError("AI API Key 未配置, 请在设置页配置") + + import app.services.ai_provider as ai_provider + + monkeypatch.setattr(ai_provider, "generate_ai_text", fake_generate) + with pytest.raises(HTTPException) as exc_info: + await ai_generate_signal(AIGenerateRequest(description="测试")) + assert exc_info.value.status_code == 400 + assert "AI API Key" in exc_info.value.detail diff --git a/backend/tests/test_strategy_code_save.py b/backend/tests/test_strategy_code_save.py index 1816220..51fd9d0 100644 --- a/backend/tests/test_strategy_code_save.py +++ b/backend/tests/test_strategy_code_save.py @@ -137,3 +137,59 @@ def test_save_strategy_code_updates_existing_source_file(tmp_path): assert custom_path.exists() assert not (tmp_path / "strategies" / "ai" / "custom_update.py").exists() assert '"name": "新名称"' in custom_path.read_text(encoding="utf-8") + + +def test_save_strategy_code_rejects_undefined_custom_signal(tmp_path): + """REQUIRED_FEATURES 引用未定义的自定义信号 → 拒绝保存并恢复文件。 + + 回归: 之前保存不校验, 运行期才抛 polars 缺列错 (500)。 + """ + request = _request(tmp_path) + code = _code("custom_missing_sig") + ( + '\nREQUIRED_FEATURES = {"csg_oversold_macd_about_to_golden"}\n' + ) + req = StrategyCodeSaveRequest( + strategy_id="custom_missing_sig", + target_source="custom", + mode="create", + code=code, + name="引用不存在信号的策略", + ) + + with pytest.raises(ValueError, match="csg_oversold_macd_about_to_golden"): + _save_strategy_code(req, request) + + # 校验失败不落盘 + assert not (tmp_path / "strategies" / "custom" / "custom_missing_sig.py").exists() + + +def test_save_strategy_code_ok_when_custom_signal_defined(tmp_path): + """信号已定义时, 引用它的策略可以正常保存。""" + from app.strategy import custom_signals + + custom_signals.save_one(tmp_path, { + "id": "oversold_macd_about_to_golden", + "name": "超跌接近金叉", + "kind": "entry", + "conditions": [ + {"left": "momentum_60d", "op": "<=", "right": "-0.30", + "leftDays": 0, "rightDays": 0}, + ], + "enabled": True, + }) + request = _request(tmp_path) + code = _code("custom_with_sig") + ( + '\nREQUIRED_FEATURES = {"csg_oversold_macd_about_to_golden"}\n' + ) + req = StrategyCodeSaveRequest( + strategy_id="custom_with_sig", + target_source="custom", + mode="create", + code=code, + name="引用已定义信号的策略", + ) + + result = _save_strategy_code(req, request) + assert result["ok"] is True + loaded = request.app.state.strategy_engine.get("custom_with_sig") + assert "csg_oversold_macd_about_to_golden" in loaded.required_features diff --git a/backend/tests/test_strategy_required_history.py b/backend/tests/test_strategy_required_history.py new file mode 100644 index 0000000..aa4813c --- /dev/null +++ b/backend/tests/test_strategy_required_history.py @@ -0,0 +1,171 @@ +"""required_history_bars 对 filter_history 策略的历史窗口判定。 + +回归: 自定义策略只把 lookback_days 声明为参数(未声明模块级 LOOKBACK_DAYS)时, +strategy.lookback_days 回退到 1, required_history_bars 返回 1, +build_strategy_context 因此跳过加载历史 (history_bars > 1 不成立), +engine.run 对 filter_history 策略报 "requires history data"。 +""" +import dataclasses +from datetime import date +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.services.screener import ScreenerService +from app.strategy.engine import StrategyDef, StrategyEngine + + +def _filter_history_strategy( + sid: str, + *, + lookback_days: int = 1, + param_default: int | None = 22, +) -> StrategyDef: + params = [ + {"id": "lookback_days", "label": "回看窗口(天)", "type": "int", "default": param_default}, + {"id": "max_drawdown_thresh", "type": "float", "default": -0.25}, + ] + return StrategyDef( + meta={"id": sid, "params": params, "scoring": {}, "limit": 100}, + basic_filter={"enabled": False}, + entry_signals=[], + exit_signals=[], + stop_loss=None, + trailing_stop=None, + trailing_take_profit_activate=None, + trailing_take_profit_drawdown=None, + max_hold_days=None, + alerts=[], + filter_fn=None, + filter_history_fn=lambda df, params: df, + lookback_days=lookback_days, + source="custom", + ) + + +def _make_engine(*strategies: StrategyDef) -> StrategyEngine: + engine = StrategyEngine(strategy_dirs=[]) + for s in strategies: + engine._strategies[s.meta["id"]] = s + return engine + + +def test_required_history_bars_uses_resolved_lookback_days_param(): + """策略只声明 lookback_days 参数(默认 22)时, 应加载 22 天历史而非静态回退的 1 天。""" + engine = _make_engine( + _filter_history_strategy("custom_window", lookback_days=1, param_default=22) + ) + assert engine.required_history_bars(["custom_window"]) == 22 + + +def test_required_history_bars_respects_saved_param_override(): + """用户保存的 lookback_days 参数应覆盖默认值。""" + engine = _make_engine( + _filter_history_strategy("custom_saved", lookback_days=1, param_default=22) + ) + bars = engine.required_history_bars( + ["custom_saved"], + params_map={"custom_saved": {"lookback_days": 30}}, + overrides_map={"custom_saved": {}}, + ) + assert bars == 30 + + +def test_required_history_bars_falls_back_to_static_lookback_days(): + """无 lookback_days 参数、声明 LOOKBACK_DAYS=8 的策略, 沿用静态值 8。""" + engine = _make_engine( + _filter_history_strategy("custom_static", lookback_days=8, param_default=None) + ) + assert engine.required_history_bars(["custom_static"]) == 8 + + +def test_required_history_bars_takes_max_of_static_and_param(): + """静态 LOOKBACK_DAYS 与参数窗口并存时取较大者, 保证窗口数据充足。""" + engine = _make_engine( + _filter_history_strategy("custom_both", lookback_days=8, param_default=22) + ) + assert engine.required_history_bars(["custom_both"]) == 22 + + +def test_build_strategy_context_loads_history_for_param_lookback(tmp_path, monkeypatch): + """service 层回归: filter_history 策略只声明 lookback_days 参数时, + build_strategy_context 应按参数加载历史, 使 engine.run 不再报 requires history data。""" + engine = _make_engine( + _filter_history_strategy("custom_window", lookback_days=1, param_default=22) + ) + repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + svc = ScreenerService(repo) + + captured: dict[str, int] = {} + + def fake_load(target_date: date, lookback_days: int) -> pl.DataFrame: + captured["lookback_days"] = lookback_days + return pl.DataFrame({"symbol": ["A"], "date": [target_date]}) + + monkeypatch.setattr(svc, "_load_enriched_history", fake_load) + + target = date(2026, 7, 15) + context = svc.build_strategy_context( + engine, + target, + ["custom_window"], + current=pl.DataFrame({"symbol": ["A"], "date": [target]}), + ) + assert captured["lookback_days"] == 22 + assert context.history is not None + + +def test_run_rejects_missing_custom_signal_column(): + """filter_history 策略 REQUIRED_FEATURES 引用未注入的 csg_ 列时, + 引擎给出明确中文报错, 而不是把 polars 缺列错抛成 500。""" + from app.strategy.engine import StrategyDataContext + + sid = "custom_csg_missing" + strategy = _filter_history_strategy(sid) + strategy = dataclasses.replace( + strategy, + required_features=frozenset({"csg_oversold_macd_about_to_golden"}), + ) + engine = _make_engine(strategy) + + target = date(2026, 7, 15) + context = StrategyDataContext( + asset_type="stock", + timeframe="1d", + as_of=target, + current=None, + history=pl.DataFrame({"symbol": ["A", "B"], "date": [target, target]}), + ) + + with pytest.raises(ValueError, match="csg_oversold_macd_about_to_golden"): + engine.run(sid, context) + + +def test_run_ok_when_custom_signal_column_present(): + """csg_ 列已注入时正常运行 (回归: 不误伤)。""" + from app.strategy.engine import StrategyDataContext + + sid = "custom_csg_ok" + strategy = _filter_history_strategy(sid) + strategy = dataclasses.replace( + strategy, + required_features=frozenset({"csg_oversold_macd_about_to_golden"}), + ) + engine = _make_engine(strategy) + + target = date(2026, 7, 15) + context = StrategyDataContext( + asset_type="stock", + timeframe="1d", + as_of=target, + current=None, + history=pl.DataFrame({ + "symbol": ["A", "B"], + "date": [target, target], + "csg_oversold_macd_about_to_golden": [True, False], + }), + ) + + result = engine.run(sid, context) + assert result.strategy_id == sid diff --git a/frontend/src/components/signals/CustomSignalDialog.tsx b/frontend/src/components/signals/CustomSignalDialog.tsx index adfffe5..a501ddb 100644 --- a/frontend/src/components/signals/CustomSignalDialog.tsx +++ b/frontend/src/components/signals/CustomSignalDialog.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { AnimatePresence, motion } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ArrowRight, Plus, Save, Search, X } from 'lucide-react' +import { ArrowRight, Loader2, Plus, Save, Search, Sparkles, X } from 'lucide-react' import { api, type CustomSignal, type CustomSignalCondition, type CustomSignalFieldGroup } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' @@ -28,6 +28,14 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose const [draft, setDraft] = useState(() => emptySignal(defaultKind)) const [error, setError] = useState('') + // AI 生成条件 + const [aiOpen, setAiOpen] = useState(false) + const [aiDesc, setAiDesc] = useState('') + const [aiLoading, setAiLoading] = useState(false) + const [aiError, setAiError] = useState('') + const [aiConfigured, setAiConfigured] = useState(null) + const checkedAi = useRef(false) + const fields = options.data?.fields ?? [] const groups = options.data?.groups const maxDays = options.data?.maxDays ?? 60 @@ -38,8 +46,37 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose if (!open) return setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind)) setError('') + setAiOpen(false); setAiDesc(''); setAiError(''); setAiLoading(false) }, [open, signal, defaultKind]) + // 打开时检查一次 AI 是否已配置(复用策略构建器逻辑) + useEffect(() => { + if (!open || checkedAi.current) return + checkedAi.current = true + api.strategyAiStatus() + .then(s => setAiConfigured(s.configured)) + .catch(() => setAiConfigured(false)) + }, [open]) + + const generateByAI = async () => { + const desc = aiDesc.trim() + if (!desc) { setAiError('请先描述信号思路'); return } + setAiLoading(true) + setAiError('') + try { + const res = await api.customSignalsAiGenerate(desc) + setDraft(d => ({ + ...d, + name: d.name.trim() ? d.name : res.name, + conditions: res.conditions.map(c => ({ ...c, leftDays: c.leftDays ?? 0, rightDays: c.rightDays ?? 0 })), + })) + } catch (err: any) { + setAiError(String(err?.message ?? err)) + } finally { + setAiLoading(false) + } + } + const save = useMutation({ mutationFn: () => { const d = draft @@ -122,11 +159,19 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
-
+
条件(多条件为「且」关系) - +
+ + +
{draft.conditions.map((c, i) => ( @@ -155,6 +200,41 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
))}
+ {aiOpen && ( +
+
+ + 描述信号思路,AI 将生成条件组合 +
+ {aiConfigured === false ? ( +
+ AI 未配置,无法生成信号。{' '} + 去设置页配置 API Key +
+ ) : ( + <> +