135 lines
5.6 KiB
Python
135 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CONFIG_ROOT = Path(__file__).resolve().parents[3] / "config"
|
|
ALLOWED_OPERATORS = frozenset({">", ">=", "<", "<=", "==", "!=", "between", "in"})
|
|
|
|
|
|
class CatalogError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def factor_catalog() -> dict[str, Any]:
|
|
payload = _read("screener-factors.json")
|
|
factors = payload.get("factors")
|
|
groups = payload.get("groups")
|
|
if payload.get("schema_version") != 1 or not isinstance(factors, dict):
|
|
raise CatalogError("选股因子目录无效")
|
|
grouped = [field for values in (groups or {}).values() for field in values]
|
|
if len(grouped) != len(set(grouped)) or set(grouped) != set(factors):
|
|
raise CatalogError("选股因子分组与目录不一致")
|
|
return payload
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def strategy_catalog() -> tuple[dict[str, Any], ...]:
|
|
payload = _read("screener-strategies.json")
|
|
items = payload.get("strategies")
|
|
if payload.get("schema_version") != 1 or not isinstance(items, list):
|
|
raise CatalogError("选股策略目录无效")
|
|
identifiers: set[str] = set()
|
|
names: set[str] = set()
|
|
factors = set(factor_catalog()["factors"])
|
|
for item in items:
|
|
identifier = str(item.get("id") or "")
|
|
name = str(item.get("name") or "")
|
|
if not identifier or identifier in identifiers or not name or name in names:
|
|
raise CatalogError("选股策略标识或名称重复")
|
|
identifiers.add(identifier)
|
|
names.add(name)
|
|
validate_formula(item.get("formula"), factors)
|
|
if sum(item.get("kind") == "stage" for item in items) != 7:
|
|
raise CatalogError("阶段策略必须为7套")
|
|
if sum(item.get("kind") == "curated" for item in items) != 29:
|
|
raise CatalogError("精选策略必须为29套")
|
|
return tuple(items)
|
|
|
|
|
|
def strategy_by_id(identifier: str) -> dict[str, Any] | None:
|
|
return next((item for item in strategy_catalog() if item["id"] == identifier), None)
|
|
|
|
|
|
def validate_formula(formula: Any, factors: set[str] | None = None) -> dict[str, Any]:
|
|
if not isinstance(formula, dict):
|
|
raise CatalogError("选股公式必须是对象")
|
|
known = factors or set(factor_catalog()["factors"])
|
|
universe = formula.get("universe") or {}
|
|
listed_days = universe.get("listed_days_min", 120)
|
|
if not isinstance(listed_days, int) or not 0 <= listed_days <= 5000:
|
|
raise CatalogError("上市天数范围无效")
|
|
filters = formula.get("filters")
|
|
scores = formula.get("score")
|
|
if not isinstance(filters, list) or len(filters) > 20:
|
|
raise CatalogError("筛选条件必须为不超过20项的列表")
|
|
if not isinstance(scores, list) or not 1 <= len(scores) <= 12:
|
|
raise CatalogError("评分因子必须为1至12项")
|
|
for condition in filters:
|
|
if condition.get("field") not in known:
|
|
raise CatalogError(f"未知筛选因子:{condition.get('field')}")
|
|
if condition.get("op") not in ALLOWED_OPERATORS or "value" not in condition:
|
|
raise CatalogError("筛选运算符或比较值无效")
|
|
_validate_comparison(condition["op"], condition["value"])
|
|
total = 0.0
|
|
score_fields: set[str] = set()
|
|
for score in scores:
|
|
if score.get("field") not in known:
|
|
raise CatalogError(f"未知评分因子:{score.get('field')}")
|
|
field = str(score["field"])
|
|
if field in score_fields:
|
|
raise CatalogError("评分因子不能重复")
|
|
score_fields.add(field)
|
|
raw_weight = score.get("weight")
|
|
if not isinstance(raw_weight, (int, float)) or isinstance(raw_weight, bool):
|
|
raise CatalogError("评分权重必须是数值")
|
|
weight = float(raw_weight)
|
|
if weight <= 0 or score.get("direction", "desc") not in {"asc", "desc"}:
|
|
raise CatalogError("评分权重或方向无效")
|
|
total += weight
|
|
if abs(total - 1) > 0.000001:
|
|
raise CatalogError("评分权重总和必须为100%")
|
|
limit = formula.get("limit")
|
|
minimum = formula.get("min_score")
|
|
if not isinstance(limit, int) or not 1 <= limit <= 50:
|
|
raise CatalogError("输出数量必须为1至50")
|
|
if not isinstance(minimum, (int, float)) or not 0 <= minimum <= 1:
|
|
raise CatalogError("最低综合分必须在0至1之间")
|
|
return formula
|
|
|
|
|
|
def _validate_comparison(operator: str, value: Any) -> None:
|
|
if operator == "between":
|
|
if not isinstance(value, list) or len(value) != 2:
|
|
raise CatalogError("区间条件必须包含两个边界")
|
|
if not all(_comparable(item) for item in value) or value[0] > value[1]:
|
|
raise CatalogError("区间条件边界无效")
|
|
return
|
|
if operator == "in":
|
|
if not isinstance(value, list) or not 1 <= len(value) <= 20:
|
|
raise CatalogError("集合条件必须包含1至20个值")
|
|
if not all(_comparable(item) for item in value):
|
|
raise CatalogError("集合条件包含无效值")
|
|
return
|
|
if not _comparable(value):
|
|
raise CatalogError("比较值必须是有限数值或布尔值")
|
|
|
|
|
|
def _comparable(value: Any) -> bool:
|
|
return isinstance(value, bool) or (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
and math.isfinite(float(value))
|
|
)
|
|
|
|
|
|
def _read(filename: str) -> dict[str, Any]:
|
|
try:
|
|
return json.loads((CONFIG_ROOT / filename).read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise CatalogError(f"无法读取{filename}") from exc
|