rebuild(screener): add controlled formulas and rolling backtests
This commit is contained in:
@@ -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,),
|
||||
).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]]:
|
||||
return [
|
||||
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(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
|
||||
@@ -15,6 +15,8 @@ from backend.features.accounts.auth import (
|
||||
)
|
||||
from backend.features.screener.schemas import (
|
||||
CustomStrategyInput,
|
||||
FormulaCompileInput,
|
||||
FormulaCompileResponse,
|
||||
IdentifierResponse,
|
||||
MessageResponse,
|
||||
ScreenerCatalogResponse,
|
||||
@@ -24,6 +26,7 @@ from backend.features.screener.schemas import (
|
||||
)
|
||||
from backend.features.screener.service import ScreenerError
|
||||
from backend.http.errors import AppError
|
||||
from backend.llm.gateway import LLMGatewayError
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
def delete_custom(
|
||||
request: Request,
|
||||
@@ -110,3 +122,6 @@ def _call(request: Request, method: str, *args):
|
||||
return getattr(request.app.state.container.screener, method)(*args)
|
||||
except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as 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]
|
||||
|
||||
|
||||
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):
|
||||
run_id: int = Field(gt=0)
|
||||
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.repository import MarketRepository
|
||||
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 (
|
||||
CatalogError,
|
||||
factor_catalog,
|
||||
strategy_catalog,
|
||||
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.factors import build_factor_snapshot
|
||||
from backend.features.screener.repository import (
|
||||
@@ -23,6 +31,7 @@ from backend.features.screener.repository import (
|
||||
decode_run,
|
||||
decode_track,
|
||||
)
|
||||
from backend.llm.gateway import LLMGateway
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
PHASE_REGIMES = {
|
||||
@@ -47,11 +56,13 @@ class ScreenerService:
|
||||
repository: ScreenerRepository,
|
||||
market_repository: MarketRepository,
|
||||
gateway: DataGateway,
|
||||
llm: LLMGateway,
|
||||
) -> None:
|
||||
self._database = database
|
||||
self._repository = repository
|
||||
self._market_repository = market_repository
|
||||
self._gateway = gateway
|
||||
self._llm = llm
|
||||
|
||||
def catalog(self) -> dict[str, Any]:
|
||||
factors = factor_catalog()
|
||||
@@ -89,10 +100,16 @@ class ScreenerService:
|
||||
decode_run(row)
|
||||
for row in self._repository.latest_runs(connection, "curated", through)
|
||||
]
|
||||
custom_runs = [
|
||||
decode_run(row)
|
||||
for row in self._repository.latest_runs(connection, "custom", through, user_id)
|
||||
]
|
||||
custom_runs = []
|
||||
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 {
|
||||
"trade_date": through,
|
||||
"message": "",
|
||||
@@ -161,6 +178,28 @@ class ScreenerService:
|
||||
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:
|
||||
with self._database.transaction() as connection:
|
||||
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)
|
||||
if result is None:
|
||||
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]]:
|
||||
with self._database.read() as connection:
|
||||
@@ -274,6 +313,32 @@ class ScreenerService:
|
||||
).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]:
|
||||
with self._database.read() as connection:
|
||||
row = self._market_repository.latest_summary(connection, trade_date)
|
||||
|
||||
Reference in New Issue
Block a user