Files
xiaobaifupan/app/backend/features/screener/formula.py
T

147 lines
6.8 KiB
Python

from __future__ import annotations
import copy
from typing import Any
from backend.features.screener.catalog import (
ALLOWED_OPERATORS,
BUILTIN_STRATEGIES,
FACTOR_FIELDS,
REGIMES,
)
from backend.features.screener.indicators import _matches, _percentile_map, _risk_flags
class FormulaEvaluator:
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
if not isinstance(formula, dict):
raise ValueError("选股公式必须是 JSON 对象。")
result = copy.deepcopy(formula)
universe = result.setdefault("universe", {})
universe["exclude_st"] = bool(universe.get("exclude_st", True))
universe["listed_days_min"] = max(0, min(5000, int(universe.get("listed_days_min", 120))))
filters = result.setdefault("filters", [])
if not isinstance(filters, list) or len(filters) > 20:
raise ValueError("筛选条件必须是列表,且不能超过 20 条。")
for condition in filters:
field = condition.get("field")
operator = condition.get("op")
if field not in FACTOR_FIELDS:
raise ValueError(f"不支持的选股因子:{field}")
if operator not in ALLOWED_OPERATORS:
raise ValueError(f"不支持的运算符:{operator}")
if "value" not in condition:
raise ValueError(f"因子 {field} 缺少比较值。")
scores = result.setdefault("score", [])
if not isinstance(scores, list) or not scores or len(scores) > 12:
raise ValueError("评分因子应为 1 至 12 条。")
for item in scores:
if item.get("field") not in FACTOR_FIELDS:
raise ValueError(f"不支持的评分因子:{item.get('field')}")
item["weight"] = float(item.get("weight", 0))
if item["weight"] <= 0 or item["weight"] > 1:
raise ValueError("评分权重必须大于 0 且不超过 1。")
if item.get("direction", "desc") not in {"asc", "desc"}:
raise ValueError("评分方向只能是 asc 或 desc。")
item["direction"] = item.get("direction", "desc")
result["limit"] = max(1, min(50, int(result.get("limit", 15))))
result["min_score"] = max(0, min(1, float(result.get("min_score", 0))))
return result
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
universe = formula["universe"]
eligible = []
score_fields = [item["field"] for item in formula["score"]]
for row in rows:
name = str(row.get("name") or "")
if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name):
continue
if row.get("listed_days", 0) < universe.get("listed_days_min", 0):
continue
if any(row.get(field) is None for field in score_fields):
continue
if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]):
eligible.append(row)
if not eligible:
return []
percentiles = {
item["field"]: _percentile_map(eligible, item["field"], item["direction"])
for item in formula["score"]
}
weight_total = sum(item["weight"] for item in formula["score"])
results = []
for row in eligible:
contributions = []
score = 0.0
for item in formula["score"]:
percentile = percentiles[item["field"]].get(row["ts_code"], 0.5)
points = percentile * item["weight"] / weight_total
score += points
contributions.append(
{
"field": item["field"],
"label": FACTOR_FIELDS[item["field"]],
"value": row.get(item["field"], 0),
"points": round(points * 100, 1),
}
)
if score < formula["min_score"]:
continue
contributions.sort(key=lambda item: item["points"], reverse=True)
item = dict(row)
item["score"] = round(score, 4)
item["score_display"] = round(score * 100, 1)
item["contributions"] = contributions
item["reason"] = "、".join(entry["label"] for entry in contributions[:3])
include_regime_risk = formula.get("meta", {}).get("library") != "curated"
item["risk_flags"] = _risk_flags(row, regime, include_regime_risk)
results.append(item)
results.sort(key=lambda item: item["score"], reverse=True)
return results[: formula["limit"]]
def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]:
base = next((item for item in BUILTIN_STRATEGIES if regime in item["regimes"]), BUILTIN_STRATEGIES[1])
formula = copy.deepcopy(base["formula"])
description = prompt.strip() or base["description"]
lowered = description.lower()
if "低吸" in description:
formula["filters"] = [item for item in formula["filters"] if item["field"] != "pct_chg"]
formula["filters"].append({"field": "pct_chg", "op": "between", "value": [-3, 3]})
if "放量" in description:
formula["filters"].append({"field": "volume_ratio_5d", "op": ">=", "value": 1.2})
if "强势" in description or "突破" in description:
formula["filters"].append({"field": "return_5d", "op": ">=", "value": 5})
if "低波" in description or "稳健" in description:
formula["score"].append({"field": "volatility_10d", "weight": 0.18, "direction": "asc"})
if "资金" in description or "主力" in description:
formula["score"].append({"field": "net_flow_million", "weight": 0.18, "direction": "desc"})
if "小市值" in description or "小盘" in description:
formula["score"].append({"field": "circ_mv_billion", "weight": 0.15, "direction": "asc"})
if "竞价" in description:
formula["filters"].extend(
[
{"field": "auction_change", "op": "between", "value": [0.5, 8]},
{"field": "auction_amount_million", "op": ">=", "value": 2},
]
)
formula["score"].extend(
[
{"field": "auction_volume_ratio", "weight": 0.20, "direction": "desc"},
{"field": "auction_amount_million", "weight": 0.18, "direction": "desc"},
]
)
if "少量" in description or "精选" in description:
formula["limit"] = min(formula["limit"], 8)
formula["score"] = formula["score"][:12]
return {
"name": f"{REGIMES.get(regime, regime)}自定义策略",
"description": description,
"regimes": [regime],
"formula": formula,
"compiler": "local_template",
}