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