Files
tick-stock-panel/backend/tests/test_custom_signals_ai.py
T
shy3130 46635074e8 fix(ai): PR #193 新增 AI 调用放开 max_tokens 限制
合并 #193 引入的 3 处显式 max_tokens(信号生成 2000/策略代码 3000×2)
重新踩中 0ee3aa8 修过的问题: 推理模型思考 token 计入 max_tokens 预算,
显式限制挤占正文导致 JSON/代码截断。统一改为 None(请求不传该参数,
输出上限交给服务端默认)。四个分析器本就是 None, 标题生成 max_tokens=8
为刻意保留的小任务限制, 均不动。
2026-08-24 14:21:55 +08:00

239 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自定义信号 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 计入 max_tokens 预算,
# 显式限制会挤占正文导致 JSON 截断 (对齐分析器的放开策略)
assert captured["max_tokens"] is None
@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