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", }