mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
merge: 试验性合并 PR #193 (feat/ai-generate-signal) 到最新 main
This commit is contained in:
@@ -63,6 +63,8 @@ def get_settings() -> dict:
|
||||
current_codex_reasoning_effort,
|
||||
current_openai_model,
|
||||
current_openai_reasoning_effort,
|
||||
current_ai_context_window,
|
||||
current_ai_max_output_tokens,
|
||||
)
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
@@ -91,6 +93,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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +255,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")
|
||||
@@ -267,6 +273,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
current_codex_reasoning_effort,
|
||||
current_openai_model,
|
||||
current_openai_reasoning_effort,
|
||||
current_ai_context_window,
|
||||
current_ai_max_output_tokens,
|
||||
normalize_codex_command,
|
||||
normalize_codex_model,
|
||||
normalize_codex_reasoning_effort,
|
||||
@@ -307,6 +315,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)
|
||||
|
||||
@@ -321,6 +341,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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +364,7 @@ def clear_ai_settings() -> dict:
|
||||
"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 = ""
|
||||
@@ -349,6 +372,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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -73,6 +73,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
|
||||
@@ -726,6 +742,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()
|
||||
|
||||
@@ -96,6 +96,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"
|
||||
@@ -131,6 +136,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -133,6 +133,57 @@ def current_openai_reasoning_effort() -> str:
|
||||
return str(stored.get("ai_reasoning_effort") or "").strip()
|
||||
|
||||
|
||||
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 | None:
|
||||
"""显式传入的 max_tokens 钳制到配置输出上限; None 保持 None。
|
||||
|
||||
None = 不传该参数(推理型模型的思考 token 计入 max_tokens 预算,
|
||||
显式限制会挤占正文, 语义见 generate_ai_text), 不能映射成配置上限。
|
||||
"""
|
||||
if max_tokens is None:
|
||||
return None
|
||||
cap = current_ai_max_output_tokens()
|
||||
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) -> None:
|
||||
"""输入估算超出上下文窗口时给出明确报错, 避免上游 400 或静默截断。
|
||||
|
||||
token 计数不精确, 仅作安全网: 用中/英文混合估算, 只有明显超窗才拒绝;
|
||||
用户可在 AI 设置里调大『上下文窗口』。max_tokens=None(输出放开)时用
|
||||
配置上限作输出预留估计。
|
||||
"""
|
||||
context_window = current_ai_context_window()
|
||||
if context_window <= 0:
|
||||
return
|
||||
output_reserve = max_tokens if max_tokens is not None else current_ai_max_output_tokens()
|
||||
est = _estimate_input_tokens(messages)
|
||||
if est + output_reserve > 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),
|
||||
@@ -224,7 +275,10 @@ async def generate_ai_text(
|
||||
max_tokens=None 表示不传该参数(输出上限交给服务端默认) — 推理型模型
|
||||
(如 deepseek reasoner 系)的思考 token 计入 max_tokens 预算, 显式限制
|
||||
会挤占正文甚至全部吃光(正文 0 字 + finish=length), 长分析类调用应放开。
|
||||
显式传入的数值会被钳制到配置的输出上限 (AI 设置可调)。
|
||||
"""
|
||||
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(
|
||||
@@ -249,6 +303,8 @@ async def stream_ai_text(
|
||||
|
||||
max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -121,7 +121,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):
|
||||
@@ -135,7 +142,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:
|
||||
|
||||
@@ -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:]
|
||||
@@ -688,7 +688,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(
|
||||
@@ -882,6 +895,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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import tomllib
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
|
||||
from app import secrets_store
|
||||
from app.api import settings as settings_api
|
||||
@@ -277,6 +278,120 @@ def test_ai_settings_keep_provider_models_separate(monkeypatch):
|
||||
assert stored["ai_model"] == "new-custom-model"
|
||||
assert stored["ai_reasoning_effort"] == "vendor-high"
|
||||
assert stored["ai_codex_model"] == "gpt-5.6-sol"
|
||||
# ── 输出上限 / 上下文窗口配置 ─────────────────────────────────
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -136,3 +136,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 个交易日前的值)。例:收盘价(最新) > 收盘价(前1日) = 上涨。带偏移的条件仅盘后/回测生效, 盘中实时跳过。
|
||||
</p>
|
||||
|
||||
+15
-2
@@ -710,6 +710,11 @@ export interface CustomSignalOptions {
|
||||
kinds: { key: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface CustomSignalAIGenerateResult {
|
||||
name: string
|
||||
conditions: CustomSignalCondition[]
|
||||
}
|
||||
|
||||
// ===== Monitor (监控规则 + 触发记录) =====
|
||||
export interface MonitorCondition {
|
||||
field: string
|
||||
@@ -1337,6 +1342,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 的响应(先探后存) */
|
||||
@@ -1553,8 +1560,8 @@ export const api = {
|
||||
),
|
||||
|
||||
/** 保存 AI 配置 */
|
||||
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; reasoning_effort?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) =>
|
||||
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_openai_model?: string; ai_reasoning_effort?: string; ai_codex_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; reasoning_effort?: 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_openai_model?: string; ai_reasoning_effort?: string; ai_codex_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),
|
||||
}),
|
||||
@@ -2828,6 +2835,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 }),
|
||||
}),
|
||||
|
||||
// ===== Abnormal Moves (异动边缘) =====
|
||||
abnormalOverview: (minCloseness = 0.5, limit = 200) =>
|
||||
request<AbnormalOverview>(
|
||||
|
||||
@@ -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'
|
||||
const OPENAI_COMPAT_PROVIDER = 'openai_compat'
|
||||
@@ -79,6 +85,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)
|
||||
@@ -149,6 +157,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 = () => ({
|
||||
@@ -160,6 +170,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({
|
||||
@@ -178,6 +190,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)}`,
|
||||
@@ -205,6 +219,8 @@ export function SettingsAIPanel() {
|
||||
custom: { baseUrl: '', model: '' },
|
||||
openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL },
|
||||
}
|
||||
setMaxOutputTokens('8192')
|
||||
setContextWindow('64000')
|
||||
setTestResult(null)
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
...prev,
|
||||
@@ -216,6 +232,8 @@ export function SettingsAIPanel() {
|
||||
ai_codex_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: '',
|
||||
@@ -445,6 +463,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user