rebuild(screener): add controlled formulas and rolling backtests
This commit is contained in:
@@ -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
|
||||||
@@ -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小数且总和等于1;direction仅限asc或desc;"
|
||||||
|
"limit为1至50,min_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,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 Policy;HTTPS增加HSTS。
|
- 生产响应统一设置CSP、`nosniff`、拒绝Frame、Permissions Policy、Referrer Policy;HTTPS增加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:通过。
|
||||||
- pytest:104项通过。
|
- pytest:106项通过。
|
||||||
- Vue TypeScript:通过。
|
- Vue TypeScript:通过。
|
||||||
- Vitest:3个文件、7项通过。
|
- Vitest:3个文件、7项通过。
|
||||||
- Vite生产构建:通过。
|
- Vite生产构建:通过。
|
||||||
|
|||||||
@@ -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)" />
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user