feat(ai): AI 生成自定义信号条件

- 新增 /api/signals/ai/generate 接口:自然语言描述 → 结构化信号条件
- 新增 custom_signals_ai 模块:组装提示词 + 解析校验 AI 返回的 JSON
- 复用 custom_signals.validate() 白名单安全闸门
- 新增输出 token 上限和上下文窗口设置
- 实现 AI 请求 max_tokens 钳制和输入预算检查
- 自定义信号右值字段名容错处理
- 增强 JSON 解析容错(尾随逗号、垃圾字符)
- 前端自定义信号对话框接入 AI 生成
This commit is contained in:
dev
2026-08-21 09:55:24 +08:00
parent 9b9538a70f
commit 8519a2bd16
18 changed files with 1255 additions and 25 deletions
+25 -1
View File
@@ -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}
+52 -4
View File
@@ -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}
+24
View File
@@ -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()
+9
View File
@@ -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
+13 -6
View File
@@ -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:
+12
View File
@@ -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:
+55 -3
View File
@@ -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
+13 -2
View File
@@ -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:
+231
View File
@@ -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 与 conditionsid / 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:]
+27 -1
View File
@@ -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)
+117
View File
@@ -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")
@@ -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
+237
View File
@@ -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
+56
View File
@@ -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
@@ -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
@@ -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<CustomSignal>(() => 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<boolean | null>(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
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setAiOpen(o => !o)}
className={`inline-flex items-center gap-1 text-[11px] cursor-pointer transition-colors ${aiOpen ? 'text-amber-400' : 'text-amber-400/80 hover:text-amber-400'}`}
>
<Sparkles className="h-3 w-3" />AI
</button>
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
@@ -155,6 +200,41 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose
</div>
))}
</div>
{aiOpen && (
<div className="rounded-card border border-amber-400/30 bg-amber-400/5 p-3 space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="text-[11px] text-amber-300">AI </span>
</div>
{aiConfigured === false ? (
<div className="text-xs text-amber-400/80">
AI {' '}
<a href="/settings?tab=ai" className="underline hover:text-amber-300"> API Key</a>
</div>
) : (
<>
<textarea
value={aiDesc}
onChange={e => setAiDesc(e.target.value)}
placeholder="例如:收盘价回踩20日均线,且量比≥2 放量"
rows={2}
className="w-full rounded-btn border border-border bg-base px-3 py-2 text-xs text-foreground focus:outline-none focus:border-amber-400/50 resize-none"
/>
{aiError && <div className="text-xs text-danger">{aiError}</div>}
<div className="flex justify-end">
<button
onClick={generateByAI}
disabled={aiLoading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50 cursor-pointer"
>
{aiLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{aiLoading ? '生成中…' : '生成条件'}
</button>
</div>
</>
)}
</div>
)}
<p className="text-[10px] text-muted/60 px-1">
<span className="text-foreground/70"></span> N日( N ):收盘价() &gt; (1) = /,
</p>
+15 -2
View File
@@ -586,6 +586,11 @@ export interface CustomSignalOptions {
kinds: { key: string; label: string }[]
}
export interface CustomSignalAIGenerateResult {
name: string
conditions: CustomSignalCondition[]
}
// ===== Monitor (监控规则 + 触发记录) =====
export interface MonitorCondition {
field: string
@@ -898,6 +903,8 @@ export interface SettingsState {
ai_codex_command?: string
ai_codex_reasoning_effort?: string
ai_user_agent: string
ai_max_output_tokens?: number
ai_context_window?: number
}
/** 保存 TickFlow Key 的响应(先探后存) */
@@ -1111,8 +1118,8 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) =>
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', {
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string; max_output_tokens?: number; context_window?: number }) =>
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean; ai_max_output_tokens?: number; ai_context_window?: number }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
}),
@@ -2191,6 +2198,12 @@ export const api = {
customSignalDelete: (id: string) =>
request<{ ok: boolean }>(`/api/custom-signals/${encodeURIComponent(id)}`, { method: 'DELETE' }),
customSignalsAiGenerate: (description: string) =>
request<CustomSignalAIGenerateResult>('/api/custom-signals/ai/generate', {
method: 'POST',
body: JSON.stringify({ description }),
}),
// ===== Monitor Rules (监控规则) =====
monitorRulesList: () =>
request<{ rules: MonitorRule[] }>('/api/monitor-rules'),
+29
View File
@@ -13,6 +13,12 @@ import { QK } from '@/lib/queryKeys'
const INPUT_CLS =
'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow'
// 空/非法输入 → undefined (后端保持原值), 合法正整数 → int
const toPositiveInt = (v: string) => {
const n = parseInt(v, 10)
return Number.isInteger(n) && n > 0 ? n : undefined
}
const CODEX_PROVIDER = 'codex_cli'
const OPENAI_PROVIDER = 'openai_compat'
const CODEX_COMMAND = 'codex'
@@ -65,6 +71,8 @@ export function SettingsAIPanel() {
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [maxOutputTokens, setMaxOutputTokens] = useState('')
const [contextWindow, setContextWindow] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
@@ -114,6 +122,8 @@ export function SettingsAIPanel() {
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
setMaxOutputTokens(String(s?.ai_max_output_tokens ?? 8192))
setContextWindow(String(s?.ai_context_window ?? 64000))
}, [s])
const payload = () => ({
@@ -124,6 +134,8 @@ export function SettingsAIPanel() {
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
codex_reasoning_effort: isCodexProvider ? codexReasoningEffort : '',
user_agent: customUa ? userAgent : '',
max_output_tokens: toPositiveInt(maxOutputTokens),
context_window: toPositiveInt(contextWindow),
})
const save = useMutation({
@@ -139,6 +151,8 @@ export function SettingsAIPanel() {
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
ai_codex_reasoning_effort: result.ai_codex_reasoning_effort ?? (isCodexProvider ? codexReasoningEffort : ''),
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
ai_max_output_tokens: result.ai_max_output_tokens ?? toPositiveInt(maxOutputTokens),
ai_context_window: result.ai_context_window ?? toPositiveInt(contextWindow),
...(apiKey ? {
has_ai_key: true,
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
@@ -159,6 +173,8 @@ export function SettingsAIPanel() {
setModel('')
setCodexReasoningEffort('')
setCodexCommand(CODEX_COMMAND)
setMaxOutputTokens('8192')
setContextWindow('64000')
setTestResult(null)
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
@@ -167,6 +183,8 @@ export function SettingsAIPanel() {
ai_model: '',
ai_codex_command: CODEX_COMMAND,
ai_codex_reasoning_effort: '',
ai_max_output_tokens: 8192,
ai_context_window: 64000,
has_ai_key: false,
ai_configured: false,
ai_api_key_masked: '',
@@ -361,6 +379,17 @@ export function SettingsAIPanel() {
</div>
</>
)}
<div className="border-t border-border/20 pt-4">
<div className="grid grid-cols-2 gap-4">
<Field label="输出上限 max_tokens" hint="所有 AI 任务的输出 token 上限, 任务请求会被钳制到此值; 默认 8192">
<input type="number" min={1} value={maxOutputTokens} onChange={e => setMaxOutputTokens(e.target.value)} placeholder="8192" className={INPUT_CLS} />
</Field>
<Field label="上下文窗口 (输入上限)" hint="输入估算超出此窗口时会报错并提示调大; 默认 64000">
<input type="number" min={1} value={contextWindow} onChange={e => setContextWindow(e.target.value)} placeholder="64000" className={INPUT_CLS} />
</Field>
</div>
</div>
</div>
</Card>