rebuild(screener): add controlled formulas and rolling backtests

This commit is contained in:
leefer
2026-07-30 10:45:40 +08:00
parent 08f69b0641
commit b79b4ba280
21 changed files with 616 additions and 37 deletions
+3 -1
View File
@@ -87,8 +87,10 @@ def build_container(settings: Settings) -> ApplicationContainer:
accounts = AccountService(database, account_repository, PasswordHasher(), cipher) accounts = AccountService(database, account_repository, PasswordHasher(), cipher)
memberships = MembershipService(database, account_repository) memberships = MembershipService(database, account_repository)
model_pool = ModelPoolService(database, model_pool_repository, cipher) model_pool = ModelPoolService(database, model_pool_repository, cipher)
screener = ScreenerService(database, screener_repository, market_repository, gateway)
llm = LLMGateway(database, LLMRepository(), memberships, model_pool) llm = LLMGateway(database, LLMRepository(), memberships, model_pool)
screener = ScreenerService(
database, screener_repository, market_repository, gateway, llm
)
mentor = MentorService( mentor = MentorService(
database, database,
MentorRepository(), MentorRepository(),
@@ -0,0 +1,31 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE screener_run_backtests (
run_id INTEGER PRIMARY KEY
REFERENCES screener_runs(id) ON DELETE CASCADE,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE screener_run_backtests")
MIGRATION = Migration(
version=12,
name="create_screener_backtests",
signature="screener:v2:persistent-rolling-backtests",
upgrade=upgrade,
downgrade=downgrade,
)
@@ -9,6 +9,7 @@ from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
from backend.database.migrations.m0010_review import MIGRATION as REVIEW from backend.database.migrations.m0010_review import MIGRATION as REVIEW
from backend.database.migrations.m0011_operations import MIGRATION as OPERATIONS from backend.database.migrations.m0011_operations import MIGRATION as OPERATIONS
from backend.database.migrations.m0012_screener_backtests import MIGRATION as SCREENER_BACKTESTS
from backend.database.migrations.runner import Migration from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = ( MIGRATIONS: tuple[Migration, ...] = (
@@ -23,4 +24,5 @@ MIGRATIONS: tuple[Migration, ...] = (
HEAVEN, HEAVEN,
REVIEW, REVIEW,
OPERATIONS, OPERATIONS,
SCREENER_BACKTESTS,
) )
@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Any
from backend.features.screener.engine import execute_formula
MINIMUM_STABLE_SAMPLES = 20
FORWARD_TRADING_DAYS = 3
def rolling_backtest(
snapshots: list[dict[str, Any]], formula: dict[str, Any]
) -> dict[str, Any]:
returns: list[float] = []
evaluated_dates: list[str] = []
for index in range(max(0, len(snapshots) - FORWARD_TRADING_DAYS)):
current = snapshots[index]
future = snapshots[index + FORWARD_TRADING_DAYS]
outcome = execute_formula(current["rows"], formula, current["coverage"])
if outcome["status"] not in {"completed", "no_signal"}:
continue
evaluated_dates.append(str(current["trade_date"]))
future_closes = {
str(row.get("identifier")): _positive(row.get("close"))
for row in future["rows"]
}
for candidate in outcome["items"]:
entry = _positive(candidate.get("close"))
future_close = future_closes.get(str(candidate.get("identifier")))
if entry is None or future_close is None:
continue
returns.append((future_close / entry - 1) * 100)
sample_size = len(returns)
stable = sample_size >= MINIMUM_STABLE_SAMPLES
period_start = evaluated_dates[0] if evaluated_dates else None
period_end = evaluated_dates[-1] if evaluated_dates else None
return {
"sample_size": sample_size,
"evaluated_dates": len(evaluated_dates),
"stable": stable,
"win_rate": (
round(sum(value > 0 for value in returns) / sample_size * 100, 1)
if stable
else None
),
"average_return_3d": (
round(sum(returns) / sample_size, 2) if stable else None
),
"period_start": period_start,
"period_end": period_end,
"message": (
"历史估计已达到最低样本要求"
if stable
else (
f"小样本:当前{sample_size}个有效样本,"
f"{MINIMUM_STABLE_SAMPLES}个后显示胜率与平均收益"
)
),
}
def attach_historical_estimate(
run: dict[str, Any], backtest: dict[str, Any] | None
) -> dict[str, Any]:
run["backtest"] = backtest
if not backtest or not backtest.get("stable"):
return run
win_rate = float(backtest["win_rate"])
for item in run.get("items") or []:
score = item.get("score_display")
item["historical_estimate"] = (
round(win_rate * 0.65 + float(score) * 0.35, 1)
if isinstance(score, (int, float))
else None
)
return run
def _positive(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import hashlib
import json
from typing import Any
from backend.features.screener.catalog import CatalogError, factor_catalog, validate_formula
PROMPT_VERSION = "screener:formula-compiler:v1"
def compile_messages(description: str) -> list[dict[str, str]]:
factors = factor_catalog()["factors"]
schema = {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": "amount_billion", "op": ">=", "value": 1}],
"score": [{"field": "return_20d", "weight": 1.0, "direction": "desc"}],
"limit": 30,
"min_score": 0.5,
}
return [
{
"role": "system",
"content": (
"你是受控选股公式编译器。只输出一个JSON对象,不得输出Markdown、解释、股票或代码。"
"只能使用给定因子;filters运算符仅限 >、>=、<、<=、==、!=、between、in"
"score权重必须为0至1小数且总和等于1direction仅限asc或desc"
"limit为1至50min_score为0至1。无法完全表达时选择最接近的已知因子,不得创造字段。"
f"\nJSON结构:{json.dumps(schema, ensure_ascii=False, separators=(',', ':'))}"
f"\n可用因子:{json.dumps(factors, ensure_ascii=False, separators=(',', ':'))}"
),
},
{"role": "user", "content": description},
]
def description_key(description: str) -> str:
digest = hashlib.sha256(description.encode("utf-8")).hexdigest()[:16]
return f"formula:{digest}"
def parse_compiled_formula(content: str) -> dict[str, Any]:
text = content.strip()
if text.startswith("```"):
lines = text.splitlines()
if len(lines) < 3 or lines[-1].strip() != "```":
raise CatalogError("模型返回的公式格式无效")
text = "\n".join(lines[1:-1]).strip()
try:
raw = json.loads(text)
except json.JSONDecodeError as exc:
raise CatalogError("模型未返回有效JSON公式") from exc
if not isinstance(raw, dict):
raise CatalogError("模型返回的公式必须是对象")
return normalize_custom_formula(raw)
def normalize_custom_formula(raw: dict[str, Any]) -> dict[str, Any]:
universe = raw.get("universe")
filters = raw.get("filters")
scores = raw.get("score")
if not isinstance(universe, dict) or not isinstance(filters, list) or not isinstance(
scores, list
):
raise CatalogError("模型返回的公式结构不完整")
exclude_st = universe.get("exclude_st", True)
listed_days = universe.get("listed_days_min", 120)
if not isinstance(exclude_st, bool) or not isinstance(listed_days, int):
raise CatalogError("股票范围设置无效")
normalized_scores = []
for score in scores:
if not isinstance(score, dict):
raise CatalogError("评分因子结构无效")
normalized_scores.append(
{
"field": score.get("field"),
"weight": score.get("weight"),
"direction": score.get("direction", "desc"),
}
)
if any(not isinstance(item, dict) for item in filters):
raise CatalogError("筛选条件结构无效")
numeric_weights = [item["weight"] for item in normalized_scores]
if all(
isinstance(value, (int, float)) and not isinstance(value, bool)
for value in numeric_weights
):
total = sum(float(value) for value in numeric_weights)
if abs(total - 100) <= 0.0001:
for item in normalized_scores:
item["weight"] = float(item["weight"]) / 100
minimum = raw.get("min_score")
if isinstance(minimum, (int, float)) and not isinstance(minimum, bool) and 1 < minimum <= 100:
minimum = float(minimum) / 100
formula = {
"universe": {
"exclude_st": exclude_st,
"listed_days_min": listed_days,
},
"filters": [
{
"field": item.get("field"),
"op": item.get("op"),
"value": item.get("value"),
}
for item in filters
],
"score": normalized_scores,
"limit": raw.get("limit"),
"min_score": minimum,
}
validate_formula(formula)
return formula
@@ -88,6 +88,28 @@ class ScreenerRepository:
(snapshot_id,), (snapshot_id,),
).fetchone() ).fetchone()
def factor_snapshots_through(
self, connection: sqlite3.Connection, through: str, limit: int
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT * FROM (
SELECT snapshots.*,
ROW_NUMBER() OVER (
PARTITION BY trade_date ORDER BY id DESC
) AS revision_rank
FROM screener_factor_snapshots AS snapshots
WHERE trade_date <= ?
)
WHERE revision_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
(through, limit),
).fetchall()
)
def factor_rows(self, connection: sqlite3.Connection, snapshot_id: int) -> list[dict[str, Any]]: def factor_rows(self, connection: sqlite3.Connection, snapshot_id: int) -> list[dict[str, Any]]:
return [ return [
json.loads(str(row["payload_json"])) json.loads(str(row["payload_json"]))
@@ -180,6 +202,30 @@ class ScreenerRepository:
), ),
) )
def save_backtest(
self,
connection: sqlite3.Connection,
run_id: int,
payload: dict[str, Any],
) -> None:
connection.execute(
"""
INSERT OR IGNORE INTO screener_run_backtests (
run_id, payload_json, created_at
) VALUES (?, ?, ?)
""",
(run_id, _json(payload), _now()),
)
def backtest(
self, connection: sqlite3.Connection, run_id: int
) -> dict[str, Any] | None:
row = connection.execute(
"SELECT payload_json FROM screener_run_backtests WHERE run_id = ?",
(run_id,),
).fetchone()
return json.loads(str(row["payload_json"])) if row else None
def latest_runs( def latest_runs(
self, self,
connection: sqlite3.Connection, connection: sqlite3.Connection,
+15
View File
@@ -15,6 +15,8 @@ from backend.features.accounts.auth import (
) )
from backend.features.screener.schemas import ( from backend.features.screener.schemas import (
CustomStrategyInput, CustomStrategyInput,
FormulaCompileInput,
FormulaCompileResponse,
IdentifierResponse, IdentifierResponse,
MessageResponse, MessageResponse,
ScreenerCatalogResponse, ScreenerCatalogResponse,
@@ -24,6 +26,7 @@ from backend.features.screener.schemas import (
) )
from backend.features.screener.service import ScreenerError from backend.features.screener.service import ScreenerError
from backend.http.errors import AppError from backend.http.errors import AppError
from backend.llm.gateway import LLMGatewayError
router = APIRouter(prefix="/screener", tags=["screener"]) router = APIRouter(prefix="/screener", tags=["screener"])
@@ -60,6 +63,15 @@ def save_custom(
return _call(request, "save_custom", principal.user.id, payload.name, payload.formula) return _call(request, "save_custom", principal.user.id, payload.name, payload.formula)
@router.post("/formula/compile", response_model=FormulaCompileResponse)
def compile_formula(
payload: FormulaCompileInput,
request: Request,
principal: SmartWritePrincipal,
) -> dict:
return _call(request, "compile_formula", principal, payload.description)
@router.delete("/custom/{strategy_id}", response_model=MessageResponse) @router.delete("/custom/{strategy_id}", response_model=MessageResponse)
def delete_custom( def delete_custom(
request: Request, request: Request,
@@ -110,3 +122,6 @@ def _call(request: Request, method: str, *args):
return getattr(request.app.state.container.screener, method)(*args) return getattr(request.app.state.container.screener, method)(*args)
except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as exc: except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as exc:
raise AppError("screener_unavailable", str(exc), 409) from exc raise AppError("screener_unavailable", str(exc), 409) from exc
except LLMGatewayError as exc:
status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503
raise AppError(exc.code, str(exc), status) from exc
@@ -38,6 +38,15 @@ class CustomStrategyInput(BaseModel):
formula: dict[str, Any] formula: dict[str, Any]
class FormulaCompileInput(BaseModel):
description: str = Field(min_length=5, max_length=500)
class FormulaCompileResponse(BaseModel):
formula: dict[str, Any]
prompt_version: str
class TrackInput(BaseModel): class TrackInput(BaseModel):
run_id: int = Field(gt=0) run_id: int = Field(gt=0)
identifier: str = Field(min_length=1, max_length=40) identifier: str = Field(min_length=1, max_length=40)
+70 -5
View File
@@ -9,12 +9,20 @@ from backend.data.contracts import SnapshotState
from backend.data.gateway import DataGateway from backend.data.gateway import DataGateway
from backend.data.repository import MarketRepository from backend.data.repository import MarketRepository
from backend.database.connection import Database from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.screener.backtest import attach_historical_estimate, rolling_backtest
from backend.features.screener.catalog import ( from backend.features.screener.catalog import (
CatalogError, CatalogError,
factor_catalog, factor_catalog,
strategy_catalog, strategy_catalog,
validate_formula, validate_formula,
) )
from backend.features.screener.compiler import (
PROMPT_VERSION,
compile_messages,
description_key,
parse_compiled_formula,
)
from backend.features.screener.engine import execute_formula from backend.features.screener.engine import execute_formula
from backend.features.screener.factors import build_factor_snapshot from backend.features.screener.factors import build_factor_snapshot
from backend.features.screener.repository import ( from backend.features.screener.repository import (
@@ -23,6 +31,7 @@ from backend.features.screener.repository import (
decode_run, decode_run,
decode_track, decode_track,
) )
from backend.llm.gateway import LLMGateway
SHANGHAI = ZoneInfo("Asia/Shanghai") SHANGHAI = ZoneInfo("Asia/Shanghai")
PHASE_REGIMES = { PHASE_REGIMES = {
@@ -47,11 +56,13 @@ class ScreenerService:
repository: ScreenerRepository, repository: ScreenerRepository,
market_repository: MarketRepository, market_repository: MarketRepository,
gateway: DataGateway, gateway: DataGateway,
llm: LLMGateway,
) -> None: ) -> None:
self._database = database self._database = database
self._repository = repository self._repository = repository
self._market_repository = market_repository self._market_repository = market_repository
self._gateway = gateway self._gateway = gateway
self._llm = llm
def catalog(self) -> dict[str, Any]: def catalog(self) -> dict[str, Any]:
factors = factor_catalog() factors = factor_catalog()
@@ -89,10 +100,16 @@ class ScreenerService:
decode_run(row) decode_run(row)
for row in self._repository.latest_runs(connection, "curated", through) for row in self._repository.latest_runs(connection, "curated", through)
] ]
custom_runs = [ custom_runs = []
decode_run(row) for row in self._repository.latest_runs(connection, "custom", through, user_id):
for row in self._repository.latest_runs(connection, "custom", through, user_id) decoded = decode_run(row)
] if decoded is not None:
custom_runs.append(
attach_historical_estimate(
decoded,
self._repository.backtest(connection, int(row["id"])),
)
)
return { return {
"trade_date": through, "trade_date": through,
"message": "", "message": "",
@@ -161,6 +178,28 @@ class ScreenerService:
self._repository.save_custom_strategy(connection, user_id, normalized, formula) self._repository.save_custom_strategy(connection, user_id, normalized, formula)
) )
def compile_formula(self, principal: Principal, description: str) -> dict[str, Any]:
normalized = " ".join(description.split())
if not 5 <= len(normalized) <= 500:
raise ScreenerError("自然语言条件应为5至500个字符")
call = self._llm.prepare(
principal,
feature="screener_formula",
prompt_version=PROMPT_VERSION,
business_id=description_key(normalized),
input_chars=len(normalized),
)
content = "".join(
event.content
for event in self._llm.stream(call, compile_messages(normalized))
if event.type == "delta"
)
try:
formula = parse_compiled_formula(content)
except CatalogError as exc:
raise ScreenerError(f"公式转换失败:{exc}") from exc
return {"formula": formula, "prompt_version": PROMPT_VERSION}
def delete_custom(self, user_id: int, strategy_id: int) -> None: def delete_custom(self, user_id: int, strategy_id: int) -> None:
with self._database.transaction() as connection: with self._database.transaction() as connection:
if not self._repository.delete_custom_strategy(connection, user_id, strategy_id): if not self._repository.delete_custom_strategy(connection, user_id, strategy_id):
@@ -184,7 +223,7 @@ class ScreenerService:
result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id) result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id)
if result is None: if result is None:
raise ScreenerError("自定义策略执行失败") raise ScreenerError("自定义策略执行失败")
return result return self._with_backtest(result, strategy["formula"], str(snapshot["trade_date"]))
def tracks(self, user_id: int) -> list[dict[str, Any]]: def tracks(self, user_id: int) -> list[dict[str, Any]]:
with self._database.read() as connection: with self._database.read() as connection:
@@ -274,6 +313,32 @@ class ScreenerService:
).fetchone() ).fetchone()
) )
def _with_backtest(
self,
run: dict[str, Any],
formula: dict[str, Any],
through: str,
) -> dict[str, Any]:
run_id = int(run["id"])
with self._database.read() as connection:
existing = self._repository.backtest(connection, run_id)
if existing is None:
with self._database.read() as connection:
rows = self._repository.factor_snapshots_through(connection, through, 63)
history = [
{
"trade_date": str(row["trade_date"]),
"coverage": json.loads(str(row["coverage_json"])),
"rows": self._repository.factor_rows(connection, int(row["id"])),
}
for row in reversed(rows)
]
calculated = rolling_backtest(history, formula)
with self._database.transaction() as connection:
self._repository.save_backtest(connection, run_id, calculated)
existing = self._repository.backtest(connection, run_id)
return attach_historical_estimate(run, existing)
def _market_snapshot(self, trade_date: str) -> dict[str, Any]: def _market_snapshot(self, trade_date: str) -> dict[str, Any]:
with self._database.read() as connection: with self._database.read() as connection:
row = self._market_repository.latest_summary(connection, trade_date) row = self._market_repository.latest_summary(connection, trade_date)
+5 -5
View File
@@ -8,8 +8,8 @@
## 减法结果 ## 减法结果
- 旧运行时代码:55个文件、61,793行;当前重建运行时代码:216个职责文件、24,677行。 - 旧运行时代码:55个文件、61,793行;当前重建运行时代码:219个职责文件、25,053行。
- 运行时代码净减少37,116行,约60.1%;迁移/备份工具、测试和文档未混入运行时比较。 - 运行时代码净减少36,740行,约59.5%;迁移/备份工具、测试和文档未混入运行时比较。
- 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。 - 唯一浏览器API、数据网关、LLM网关、弹窗Host、设计令牌和移动规则均通过扫描。
- LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。 - LLM网关原有3个账户领域具体类型反向导入已改为最小Protocol,未增加第二套服务。
- 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md` - 超过章程建议行数的算法、Provider和CSS已逐项登记保留原因及拆分触发条件,见`../../final/subtraction-audit.md`
@@ -17,7 +17,7 @@
## 维护与回退演练 ## 维护与回退演练
- 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。 - 已在真实迁移库副本执行schema 10 -> 9 -> 10,完整性及外键检查通过;明确验证数据回退只能在副本执行。
- 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询保持只读;运行治理迁移后当前版本为`schema_version=11` - 演练发现并修复`tools.database status`对已有migration误报未知版本的问题;状态查询保持只读;加入选股回测持久化迁移后当前版本为`schema_version=12`
- 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md` - 人工维护入口、常见改动路径、数据源标准、故障定位与复杂度红线见`../../final/maintenance-guide.md`
- NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。 - NAS切换、观察和回退动作见`../../final/cutover-checklist.md`;正式容器未变更。
@@ -28,12 +28,12 @@
- 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer PolicyHTTPS增加HSTS。 - 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer PolicyHTTPS增加HSTS。
- CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。 - CSP保留Vue动态宽度样式所需的`style-src 'unsafe-inline'`,脚本仍只允许同源。
- 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。 - 静态哈希资源长期缓存,SPA入口不缓存;未知API不被SPA接管。
- 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 296.90KB、CSS 92.08KB(未压缩)。 - 真实迁移库副本市场摘要中位数20.90ms、P95 23.55ms;前端生产JS 298.87KB、CSS 92.40KB(未压缩)。
## 最终门禁 ## 最终门禁
- Ruff:通过。 - Ruff:通过。
- pytest104项通过。 - pytest106项通过。
- Vue TypeScript:通过。 - Vue TypeScript:通过。
- Vitest3个文件、7项通过。 - Vitest3个文件、7项通过。
- Vite生产构建:通过。 - Vite生产构建:通过。
+9 -10
View File
@@ -21,14 +21,7 @@
- 数据库迁移、旧库只读迁移、备份、校验恢复、生产静态文件服务和安全响应头已有自动测试。 - 数据库迁移、旧库只读迁移、备份、校验恢复、生产静态文件服务和安全响应头已有自动测试。
- PC 统一 Shell、设计令牌、日夜主题和独立移动端布局规则已经建立。 - PC 统一 Shell、设计令牌、日夜主题和独立移动端布局规则已经建立。
## 阻断“完整迁移”的产品缺口 ## 阻断“完整迁移”的验收缺口
### P0:用户可见功能缺失
1. **自定义选股完整能力**:缺少自然语言转换受控公式和滚动回测;精选策略详情尚未完整显示评分权重、
执行频率和风险等级。
### P1:验收和交互覆盖不足
1. 固定案例 `C06``C08` 当前为未实现,不能标记通过。 1. 固定案例 `C06``C08` 当前为未实现,不能标记通过。
2. `C05``U01` 的主题无白闪需要首帧/加载态直接断言,不能只依赖夜间截图。 2. `C05``U01` 的主题无白闪需要首帧/加载态直接断言,不能只依赖夜间截图。
@@ -59,7 +52,13 @@
- 状态栏读取真实后台任务与最后成功行情状态,不再显示固定占位文字。 - 状态栏读取真实后台任务与最后成功行情状态,不再显示固定占位文字。
- 模型池中的每个模型可由管理员独立测试;测试复用统一LLM网关并记录模型、耗时、成功或失败, - 模型池中的每个模型可由管理员独立测试;测试复用统一LLM网关并记录模型、耗时、成功或失败,
不返回密钥和上游正文,也不计入会员每日额度。 不返回密钥和上游正文,也不计入会员每日额度。
- 本轮验证为Ruff、104项pytest、Vue类型检查、7项Vitest、生产构建和21项Playwright全部通过。 - 自然语言选股只允许由统一LLM网关生成白名单JSON公式;未知因子直接拒绝,LLM不参与股票候选计算,
用户必须核对生成的因子、权重和过滤条件后再保存。
- 自定义选股滚动回测只读取已归档因子快照,以入选日至第3个后续交易日收盘计算;少于20个有效样本
只提示小样本,达到门槛后才显示胜率和平均3日收益,结果按运行持久化且重复读取不漂移。
- 精选策略详情直接展示配置中已有的质量、风险、执行频率、适用环境、失效风险、适用阶段和完整评分权重,
没有补造无法验证的策略元信息。
- 本轮验证为Ruff、106项pytest、Vue类型检查、7项Vitest、生产构建和21项Playwright全部通过。
## 外部环境阻断项 ## 外部环境阻断项
@@ -71,7 +70,7 @@
只有以下条件全部满足后,才可恢复“重建完成”的表述: 只有以下条件全部满足后,才可恢复“重建完成”的表述:
1. 本文件所有 P0 缺口完成并有直接测试。 1. 本文件登记的用户可见功能缺口全部完成并有直接测试。
2. 85 个固定案例逐项标记为“确定性测试”“浏览器直接断言”或“人工视觉验收”;不得使用宽泛截图代替算法证明。 2. 85 个固定案例逐项标记为“确定性测试”“浏览器直接断言”或“人工视觉验收”;不得使用宽泛截图代替算法证明。
3. 第 26 节页面覆盖矩阵逐格完成,人工验收项由用户确认。 3. 第 26 节页面覆盖矩阵逐格完成,人工验收项由用户确认。
4. Docker 构建、启动、持久化重启、备份恢复和回退在实际容器环境通过。 4. Docker 构建、启动、持久化重启、备份恢复和回退在实际容器环境通过。
@@ -16,6 +16,7 @@ const props = defineProps<{
factorGroups: Record<string, string[]>; factorGroups: Record<string, string[]>;
strategies: CustomStrategy[]; strategies: CustomStrategy[];
runs: ScreenerRun[]; runs: ScreenerRun[];
compiledFormula: ScreenerFormula | null;
locked: boolean; locked: boolean;
busy: boolean; busy: boolean;
}>(); }>();
@@ -24,9 +25,11 @@ const emit = defineEmits<{
run: [strategy: CustomStrategy]; run: [strategy: CustomStrategy];
remove: [strategy: CustomStrategy]; remove: [strategy: CustomStrategy];
track: [run: ScreenerRun, candidate: Candidate]; track: [run: ScreenerRun, candidate: Candidate];
compile: [description: string];
}>(); }>();
const name = ref("我的选股策略"); const name = ref("我的选股策略");
const naturalLanguage = ref("");
const selectedFactor = ref("return_20d"); const selectedFactor = ref("return_20d");
const scores = ref<FormulaScore[]>([ const scores = ref<FormulaScore[]>([
{ field: "return_20d", weight: 60, direction: "desc" }, { field: "return_20d", weight: 60, direction: "desc" },
@@ -55,6 +58,11 @@ watch(
}, },
); );
watch(
() => props.compiledFormula,
(formula) => { if (formula) applyFormula(formula); },
);
function addFactor(): void { function addFactor(): void {
if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) { if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) {
return; return;
@@ -66,18 +74,22 @@ function addFilter(): void {
filters.value.push({ field: "amount_billion", op: ">=", value: 1 }); filters.value.push({ field: "amount_billion", op: ">=", value: 1 });
} }
function edit(strategy: CustomStrategy): void { function applyFormula(formula: ScreenerFormula): void {
selected.value = strategy.id; scores.value = formula.score.map((item) => ({
name.value = strategy.name;
scores.value = strategy.formula.score.map((item) => ({
...item, ...item,
weight: Math.round(item.weight * 100), weight: Math.round(item.weight * 100),
})); }));
filters.value = strategy.formula.filters.map((item) => ({ ...item })); filters.value = formula.filters.map((item) => ({ ...item }));
listedDays.value = strategy.formula.universe.listed_days_min; listedDays.value = formula.universe.listed_days_min;
excludeSt.value = strategy.formula.universe.exclude_st; excludeSt.value = formula.universe.exclude_st;
outputLimit.value = strategy.formula.limit; outputLimit.value = formula.limit;
minimumScore.value = Math.round(strategy.formula.min_score * 100); minimumScore.value = Math.round(formula.min_score * 100);
}
function edit(strategy: CustomStrategy): void {
selected.value = strategy.id;
name.value = strategy.name;
applyFormula(strategy.formula);
} }
function save(): void { function save(): void {
@@ -128,6 +140,10 @@ function save(): void {
</div> </div>
<div class="custom-filters"> <div class="custom-filters">
<header class="card-header"><h2>过滤与输出</h2></header> <header class="card-header"><h2>过滤与输出</h2></header>
<div class="formula-compiler">
<label class="field"><span>自然语言转公式</span><textarea v-model="naturalLanguage" class="input" rows="2" maxlength="500" placeholder="例如:近20日走势较强、成交额不少于3亿元,偏重板块强度"></textarea></label>
<button class="btn" type="button" :disabled="busy || naturalLanguage.trim().length < 5" @click="emit('compile', naturalLanguage)">{{ busy ? "转换中" : "转换" }}</button>
</div>
<div class="filter-list"> <div class="filter-list">
<div v-for="(item, index) in filters" :key="index"> <div v-for="(item, index) in filters" :key="index">
<select v-model="item.field" class="select"> <select v-model="item.field" class="select">
@@ -164,6 +180,10 @@ function save(): void {
<button class="btn" type="button" :disabled="busy" @click="edit(current)">编辑</button> <button class="btn" type="button" :disabled="busy" @click="edit(current)">编辑</button>
<button class="btn" type="button" :disabled="busy" @click="emit('remove', current)">删除</button> <button class="btn" type="button" :disabled="busy" @click="emit('remove', current)">删除</button>
</div> </div>
<div v-if="run?.backtest" class="notice">
<template v-if="run.backtest.stable">历史样本 {{ run.backtest.sample_size }} · 条件胜率 {{ run.backtest.win_rate?.toFixed(1) }}% · 平均3日收益 {{ run.backtest.average_return_3d?.toFixed(2) }}%</template>
<template v-else>{{ run.backtest.message }}</template>
</div>
</section> </section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" /> <CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template> </template>
@@ -21,6 +21,7 @@ const workspace = ref<ScreenerWorkspace | null>(null);
const loading = ref(false); const loading = ref(false);
const busy = ref(false); const busy = ref(false);
const error = ref(""); const error = ref("");
const compiledFormula = ref<ScreenerFormula | null>(null);
const locked = () => !session.account?.smart_access; const locked = () => !session.account?.smart_access;
async function load(): Promise<void> { async function load(): Promise<void> {
@@ -41,6 +42,15 @@ async function saveCustom(name: string, formula: ScreenerFormula): Promise<void>
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); } catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
finally { busy.value = false; } finally { busy.value = false; }
} }
async function compileFormula(description: string): Promise<void> {
busy.value = true;
try {
compiledFormula.value = (await screenerApi.compileFormula(description)).formula;
ui.showToast("已转换为受控公式,请核对后保存");
} catch (reason) {
ui.showToast(reason instanceof Error ? reason.message : "公式转换失败");
} finally { busy.value = false; }
}
async function runCustom(strategy: CustomStrategy): Promise<void> { async function runCustom(strategy: CustomStrategy): Promise<void> {
busy.value = true; busy.value = true;
try { await screenerApi.runCustom(strategy.id, market.selectedDate); ui.showToast("选股计算已完成"); await load(); } try { await screenerApi.runCustom(strategy.id, market.selectedDate); ui.showToast("选股计算已完成"); await load(); }
@@ -74,7 +84,7 @@ watch(() => market.selectedDate, load);
<template v-else-if="catalog"> <template v-else-if="catalog">
<StagePanel v-if="mode === 'stage'" :strategies="catalog.stage" :runs="workspace?.stage_runs ?? []" :locked="locked()" @track="track" /> <StagePanel v-if="mode === 'stage'" :strategies="catalog.stage" :runs="workspace?.stage_runs ?? []" :locked="locked()" @track="track" />
<StrategyPanel v-else-if="mode === 'curated'" :strategies="catalog.curated" :runs="workspace?.curated_runs ?? []" :labels="catalog.factors" :locked="locked()" @track="track" /> <StrategyPanel v-else-if="mode === 'curated'" :strategies="catalog.curated" :runs="workspace?.curated_runs ?? []" :labels="catalog.factors" :locked="locked()" @track="track" />
<CustomPanel v-else :factors="catalog.factors" :factor-groups="catalog.factor_groups" :strategies="workspace?.custom_strategies ?? []" :runs="workspace?.custom_runs ?? []" :locked="locked()" :busy="busy" @save="saveCustom" @run="runCustom" @remove="removeCustom" @track="track" /> <CustomPanel v-else :factors="catalog.factors" :factor-groups="catalog.factor_groups" :strategies="workspace?.custom_strategies ?? []" :runs="workspace?.custom_runs ?? []" :compiled-formula="compiledFormula" :locked="locked()" :busy="busy" @compile="compileFormula" @save="saveCustom" @run="runCustom" @remove="removeCustom" @track="track" />
</template> </template>
</main> </main>
</template> </template>
@@ -49,10 +49,11 @@ function statusLabel(strategyId: string): string {
</div> </div>
</aside> </aside>
<article class="card strategy-detail"> <article class="card strategy-detail">
<header><div><span>{{ strategy?.formula.meta?.category }}</span><h2>{{ strategy?.display_name }}</h2></div><span class="tag">每日盘后</span></header> <header><div><span>{{ strategy?.formula.meta?.category }}</span><h2>{{ strategy?.display_name }}</h2></div><span class="tag">{{ strategy?.formula.meta?.frequency ?? "每日" }}</span></header>
<p>{{ strategy?.description }}</p> <p>{{ strategy?.description }}</p>
<dl><div><dt>适用环境</dt><dd>{{ strategy?.formula.meta?.suitable_environment }}</dd></div><div><dt>主要失效风险</dt><dd>{{ strategy?.formula.meta?.failure_risk }}</dd></div><div><dt>适用阶段</dt><dd>{{ strategy?.regimes.map((item) => regimeLabels[item]).join(" · ") }}</dd></div></dl> <dl><div><dt>策略质量</dt><dd>{{ strategy?.formula.meta?.quality ?? "待评估" }}</dd></div><div><dt>风险等级</dt><dd>{{ strategy?.formula.meta?.risk ?? "未标注" }}</dd></div><div><dt>执行频率</dt><dd>{{ strategy?.formula.meta?.frequency ?? "每日" }}</dd></div><div><dt>适用环境</dt><dd>{{ strategy?.formula.meta?.suitable_environment }}</dd></div><div><dt>主要失效风险</dt><dd>{{ strategy?.formula.meta?.failure_risk }}</dd></div><div><dt>适用阶段</dt><dd>{{ strategy?.regimes.map((item) => regimeLabels[item]).join(" · ") }}</dd></div></dl>
<div class="strategy-conditions"><h3>选股条件</h3><span v-for="condition in strategy?.formula.filters" :key="`${condition.field}-${condition.op}`">{{ labels[condition.field] }} {{ condition.op }} {{ Array.isArray(condition.value) ? condition.value.join(' ') : condition.value }}</span></div> <div class="strategy-conditions"><h3>选股条件</h3><span v-for="condition in strategy?.formula.filters" :key="`${condition.field}-${condition.op}`">{{ labels[condition.field] }} {{ condition.op }} {{ Array.isArray(condition.value) ? condition.value.join(' ') : condition.value }}</span></div>
<div class="strategy-conditions"><h3>评分权重</h3><span v-for="score in strategy?.formula.score" :key="score.field">{{ labels[score.field] }} · {{ Math.round(score.weight * 100) }}% · {{ score.direction === 'desc' ? '高优' : '低优' }}</span></div>
</article> </article>
</section> </section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" /> <CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
+15
View File
@@ -31,6 +31,17 @@ export type Candidate = {
score_display: number; score_display: number;
reason: string; reason: string;
risk_flags: string[]; risk_flags: string[];
historical_estimate?: number | null;
};
export type ScreenerBacktest = {
sample_size: number;
evaluated_dates: number;
stable: boolean;
win_rate: number | null;
average_return_3d: number | null;
period_start: string | null;
period_end: string | null;
message: string;
}; };
export type ScreenerRun = { export type ScreenerRun = {
id: number; id: number;
@@ -43,6 +54,7 @@ export type ScreenerRun = {
missing_fields: string[]; missing_fields: string[];
items: Candidate[]; items: Candidate[];
error_message: string; error_message: string;
backtest?: ScreenerBacktest | null;
}; };
export type CustomStrategy = { export type CustomStrategy = {
id: number; id: number;
@@ -90,6 +102,9 @@ export const screenerApi = {
workspace(date: string): Promise<ScreenerWorkspace> { workspace(date: string): Promise<ScreenerWorkspace> {
return api.get(`/screener?date=${encodeURIComponent(date)}`); return api.get(`/screener?date=${encodeURIComponent(date)}`);
}, },
compileFormula(description: string): Promise<{ formula: ScreenerFormula; prompt_version: string }> {
return api.post("/screener/formula/compile", { description });
},
saveCustom(name: string, formula: ScreenerFormula): Promise<CustomStrategy> { saveCustom(name: string, formula: ScreenerFormula): Promise<CustomStrategy> {
return api.put("/screener/custom", { name, formula }); return api.put("/screener/custom", { name, formula });
}, },
@@ -49,6 +49,7 @@
.strategy-conditions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-8); } .strategy-conditions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-8); }
.strategy-conditions h3 { width: 100%; font-size: var(--font-12); } .strategy-conditions h3 { width: 100%; font-size: var(--font-12); }
.strategy-conditions span { padding: var(--s-6) var(--s-8); border-radius: var(--tag-radius); color: var(--color-text-secondary); background: var(--color-surface-muted); font-size: var(--font-11); } .strategy-conditions span { padding: var(--s-6) var(--s-8); border-radius: var(--tag-radius); color: var(--color-text-secondary); background: var(--color-surface-muted); font-size: var(--font-11); }
.strategy-conditions + .strategy-conditions { margin-top: var(--s-16); }
.screener-results { min-width: 0; overflow: hidden; } .screener-results { min-width: 0; overflow: hidden; }
.screener-results .card-header > div { display: flex; align-items: baseline; gap: var(--s-10); } .screener-results .card-header > div { display: flex; align-items: baseline; gap: var(--s-10); }
.screener-results .card-header .tag { margin-left: auto; } .screener-results .card-header .tag { margin-left: auto; }
@@ -64,6 +65,8 @@
.factor-list .input { min-height: var(--s-32); padding: var(--s-4) var(--s-6); } .factor-list .input { min-height: var(--s-32); padding: var(--s-4) var(--s-6); }
.factor-direction { min-height: var(--s-32); padding: var(--s-4); } .factor-direction { min-height: var(--s-32); padding: var(--s-4); }
.custom-filters { min-width: 0; } .custom-filters { min-width: 0; }
.formula-compiler { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--s-8); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
.formula-compiler textarea { min-height: var(--s-64); resize: vertical; }
.filter-list { display: grid; gap: var(--s-6); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); } .filter-list { display: grid; gap: var(--s-6); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
.filter-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-32); gap: var(--s-6); } .filter-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-32); gap: var(--s-6); }
.filter-list .input, .filter-list .input,
@@ -98,5 +101,6 @@
.factor-list > div { grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-44); } .factor-list > div { grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-44); }
.factor-list > div input[type="range"] { grid-column: 1 / -1; grid-row: 2; } .factor-list > div input[type="range"] { grid-column: 1 / -1; grid-row: 2; }
.custom-options { grid-template-columns: minmax(0, 1fr); } .custom-options { grid-template-columns: minmax(0, 1fr); }
.formula-compiler { grid-template-columns: minmax(0, 1fr); }
.custom-save { grid-template-columns: minmax(0, 1fr); } .custom-save { grid-template-columns: minmax(0, 1fr); }
} }
+35 -1
View File
@@ -111,7 +111,19 @@ const workspace = {
run(3, "curated", "动态多因子(基础版)", "completed", "curated-02", candidateNamed("000022", "多因子样本", "综合因子得分居前")), run(3, "curated", "动态多因子(基础版)", "completed", "curated-02", candidateNamed("000022", "多因子样本", "综合因子得分居前")),
], ],
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }], custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
custom_runs: [run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中"))], custom_runs: [{
...run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中")),
backtest: {
sample_size: 7,
evaluated_dates: 7,
stable: false,
win_rate: null,
average_return_3d: null,
period_start: "2026-07-01",
period_end: "2026-07-09",
message: "小样本:当前7个有效样本,满20个后显示胜率与平均收益",
},
}],
}; };
const track = { const track = {
@@ -138,6 +150,19 @@ async function mockScreener(page) {
const body = route.request().method() === "GET" ? [track] : { id: 9 }; const body = route.request().method() === "GET" ? [track] : { id: 9 };
return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) }); return route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
}); });
await page.route("**/api/screener/formula/compile", (route) => route.fulfill({
contentType: "application/json",
body: JSON.stringify({
prompt_version: "screener:formula-compiler:v1",
formula: {
universe: { exclude_st: true, listed_days_min: 120 },
filters: [{ field: "amount_billion", op: ">=", value: 3 }],
score: [{ field: "return_20d", weight: 1, direction: "desc" }],
limit: 20,
min_score: 0.55,
},
}),
}));
} }
test("stage 9 screening preserves deterministic modes, explicit tracking and responsive layout", async ({ page }) => { test("stage 9 screening preserves deterministic modes, explicit tracking and responsive layout", async ({ page }) => {
@@ -161,6 +186,9 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await page.getByRole("button", { name: "策略选股" }).click(); await page.getByRole("button", { name: "策略选股" }).click();
await expect(page.getByText("策略库")).toBeVisible(); await expect(page.getByText("策略库")).toBeVisible();
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible(); await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
await expect(page.getByText("风险等级", { exact: true })).toBeVisible();
await expect(page.getByText("评分权重", { exact: true })).toBeVisible();
await expect(page.locator(".strategy-detail")).toContainText("20日涨幅 · 60% · 高优");
await expect(page.locator(".screener-results")).toContainText("分红样本"); await expect(page.locator(".screener-results")).toContainText("分红样本");
await page.locator(".strategy-items").getByRole("button", { name: /动态多因子/ }).click(); await page.locator(".strategy-items").getByRole("button", { name: /动态多因子/ }).click();
await expect(page.locator(".screener-results")).toContainText("多因子样本"); await expect(page.locator(".screener-results")).toContainText("多因子样本");
@@ -173,6 +201,12 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible(); await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible();
await expect(page.locator(".screener-results")).toContainText("自定义样本"); await expect(page.locator(".screener-results")).toContainText("自定义样本");
await expect(page.locator(".screener-results")).not.toContainText("多因子样本"); await expect(page.locator(".screener-results")).not.toContainText("多因子样本");
await expect(page.getByText(/小样本:当前7个有效样本/)).toBeVisible();
await page.getByPlaceholder(/近20日走势较强/).fill("成交额至少3亿元,偏重20日走势");
await page.getByRole("button", { name: "转换", exact: true }).click();
await expect(page.getByRole("status")).toContainText("已转换为受控公式");
await expect(page.locator(".factor-list input[type=number]").first()).toHaveValue("100");
await expect(page.locator(".filter-list input[type=number]").first()).toHaveValue("3");
await page.getByRole("button", { name: "夜间" }).click(); await page.getByRole("button", { name: "夜间" }).click();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+1 -1
View File
@@ -25,7 +25,7 @@ def test_status_reads_an_existing_schema_without_mutating_history(
assert main(["status"]) == 0 assert main(["status"]) == 0
assert capsys.readouterr().out.strip() == "available=true schema_version=11" assert capsys.readouterr().out.strip() == "available=true schema_version=12"
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None: def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
+3 -1
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db") database = Database(tmp_path / "app.db")
runner = MigrationRunner(database) runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
assert { assert {
"users", "users",
"memberships", "memberships",
@@ -146,9 +146,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"review_assistant_messages", "review_assistant_messages",
"job_runs", "job_runs",
"market_event_revisions", "market_event_revisions",
"screener_run_backtests",
} <= table_names(database) } <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == ( assert runner.downgrade(MIGRATIONS, target_version=0) == (
12,
11, 11,
10, 10,
9, 9,
+80 -1
View File
@@ -6,12 +6,17 @@ import pytest
from backend.database.connection import Database from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.screener.backtest import (
attach_historical_estimate,
rolling_backtest,
)
from backend.features.screener.catalog import ( from backend.features.screener.catalog import (
CatalogError, CatalogError,
factor_catalog, factor_catalog,
strategy_catalog, strategy_catalog,
validate_formula, validate_formula,
) )
from backend.features.screener.compiler import parse_compiled_formula
from backend.features.screener.engine import execute_formula from backend.features.screener.engine import execute_formula
from backend.features.screener.repository import ScreenerRepository, decode_track from backend.features.screener.repository import ScreenerRepository, decode_track
from backend.features.screener.service import automatic_strategies from backend.features.screener.service import automatic_strategies
@@ -145,6 +150,65 @@ def test_formula_rejects_invalid_comparisons_and_duplicate_scores() -> None:
validate_formula(duplicate) validate_formula(duplicate)
def test_natural_language_output_is_reduced_to_the_controlled_formula_schema() -> None:
compiled = parse_compiled_formula(
"""```json
{
"universe": {"exclude_st": true, "listed_days_min": 120},
"filters": [{"field": "amount_billion", "op": ">=", "value": 3}],
"score": [
{"field": "return_20d", "weight": 60, "direction": "desc"},
{"field": "sector_strength", "weight": 40, "direction": "desc"}
],
"limit": 20,
"min_score": 55,
"invented_instruction": "直接选择某只股票"
}
```"""
)
assert [item["weight"] for item in compiled["score"]] == [0.6, 0.4]
assert compiled["min_score"] == 0.55
assert "invented_instruction" not in compiled
with pytest.raises(CatalogError, match="未知筛选因子"):
parse_compiled_formula(
json.dumps(
{
**compiled,
"filters": [{"field": "future_price", "op": ">", "value": 1}],
}
)
)
def test_rolling_backtest_hides_small_samples_and_uses_t_plus_three() -> None:
formula = _formula()
snapshots = [
{
"trade_date": f"2026-07-{index + 1:02d}",
"coverage": {"market": 1},
"rows": [_row("000001.SZ", 10 + index)],
}
for index in range(23)
]
small = rolling_backtest(snapshots[:10], formula)
assert small["sample_size"] == 7
assert small["stable"] is False
assert small["win_rate"] is None
assert small["average_return_3d"] is None
stable = rolling_backtest(snapshots, formula)
assert stable["sample_size"] == 20
assert stable["stable"] is True
assert stable["win_rate"] == 100
assert stable["average_return_3d"] == pytest.approx(16.99, abs=0.01)
enriched = attach_historical_estimate(
{"items": [{"score_display": 80.0}]}, stable
)
assert enriched["items"][0]["historical_estimate"] == 93.0
def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None: def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
database = Database(tmp_path / "screener.db") database = Database(tmp_path / "screener.db")
MigrationRunner(database).upgrade(MIGRATIONS) MigrationRunner(database).upgrade(MIGRATIONS)
@@ -173,6 +237,16 @@ def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
missing_fields=[], missing_fields=[],
result=[{**_row("000001.SZ", 10), "score": 1}], result=[{**_row("000001.SZ", 10), "score": 1}],
) )
repository.save_backtest(
connection,
int(run["id"]),
{"sample_size": 7, "stable": False, "win_rate": None},
)
repository.save_backtest(
connection,
int(run["id"]),
{"sample_size": 99, "stable": True, "win_rate": 100},
)
repository.add_track( repository.add_track(
connection, connection,
user_id=1, user_id=1,
@@ -185,6 +259,11 @@ def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
assert len(repository.custom_strategies(connection, 2)) == 1 assert len(repository.custom_strategies(connection, 2)) == 1
assert len(repository.tracks(connection, 1)) == 1 assert len(repository.tracks(connection, 1)) == 1
assert repository.tracks(connection, 2) == () assert repository.tracks(connection, 2) == ()
assert repository.backtest(connection, int(run["id"])) == {
"sample_size": 7,
"stable": False,
"win_rate": None,
}
def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None: def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
@@ -219,7 +298,7 @@ def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
stored = connection.execute( stored = connection.execute(
"SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],) "SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],)
).fetchone() ).fetchone()
assert len(json.loads(stored["result_json"])) == 1 assert len(json.loads(stored["result_json"])) == 1
def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None: def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None:
+48
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
import httpx import httpx
from backend.bootstrap.application import create_application from backend.bootstrap.application import create_application
@@ -24,6 +26,23 @@ def _formula() -> dict:
} }
class FormulaProvider:
def stream(self, _profile, _messages):
yield json.dumps(
{
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": "amount_billion", "op": ">=", "value": 3}],
"score": [
{"field": "return_20d", "weight": 60, "direction": "desc"},
{"field": "sector_strength", "weight": 40, "direction": "desc"},
],
"limit": 20,
"min_score": 55,
},
ensure_ascii=False,
)
def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None: def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path)) application = create_application(Settings.for_test(tmp_path))
@@ -45,8 +64,37 @@ def test_screener_catalog_lock_and_custom_strategy_boundary(tmp_path) -> None:
json={"name": "越权策略", "formula": _formula()}, json={"name": "越权策略", "formula": _formula()},
) )
assert rejected.status_code == 403 assert rejected.status_code == 403
denied_compile = await client.post(
"/api/screener/formula/compile",
headers=csrf_headers(regular),
json={"description": "选择成交活跃且趋势较强的股票"},
)
assert denied_compile.status_code == 403
use_session(client, admin) use_session(client, admin)
model = await client.post(
"/api/admin/models",
headers=csrf_headers(admin),
json={
"display_name": "公式模型",
"base_url": "https://model.example.com/v1",
"model_identifier": "formula-model",
"api_key": "formula-secret",
},
)
assert model.status_code == 201
application.state.container.llm._provider = FormulaProvider()
compiled = await client.post(
"/api/screener/formula/compile",
headers=csrf_headers(admin),
json={"description": "成交额至少3亿元,偏重20日走势和板块强度"},
)
assert compiled.status_code == 200
assert [item["weight"] for item in compiled.json()["formula"]["score"]] == [
0.6,
0.4,
]
assert compiled.json()["formula"]["min_score"] == 0.55
workspace = await client.get("/api/screener?date=2026-07-30") workspace = await client.get("/api/screener?date=2026-07-30")
assert workspace.status_code == 200 assert workspace.status_code == 200
assert workspace.json()["trade_date"] is None assert workspace.json()["trade_date"] is None