from __future__ import annotations from typing import Any from backend.features.screener.catalog import factor_catalog, validate_formula FACTOR_MINIMUM_COVERAGE = { "valuation": 0.95, "financial": 0.90, "moneyflow": 0.90, "industry": 0.95, "auction": 0.90, "popularity": 0.95, "institutions": 0.95, "earnings": 0.95, "market": 0.98, } FACTOR_DATASET = { **dict.fromkeys( { "pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "circ_mv_billion", "total_mv_billion", "turnover_rate", "turnover_5d", }, "valuation", ), **dict.fromkeys( { "dividend_years", "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome", "financial_risk", "factor_value_score", "factor_growth_score", "factor_quality_score", "multi_factor_composite", }, "financial", ), **dict.fromkeys( { "earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality", }, "earnings", ), **dict.fromkeys( { "net_flow_million", "large_flow_million", "net_flow_5d_million", "flow_to_circ_mv_5d", "sector_net_flow_5d_million", "sector_flow_rank", }, "moneyflow", ), **dict.fromkeys( { "sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank", "sector_stock_momentum_rank", "sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank", "sector_composite_score", "sector_limit_count", "sector_up_count", "sector_breadth_ma20", }, "industry", ), **dict.fromkeys( { "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", }, "auction", ), **dict.fromkeys( {"popularity_score", "popularity_rank_change", "popularity_dual_source"}, "popularity", ), **dict.fromkeys( {"institution_net_buy_million", "institution_seat_count"}, "institutions", ), } def execute_formula( rows: list[dict[str, Any]], formula: dict[str, Any], coverage: dict[str, float], ) -> dict[str, Any]: validate_formula(formula) required = sorted({str(item["field"]) for item in [*formula["filters"], *formula["score"]]}) universe = formula.get("universe") or {} universe_rows = [ row for row in rows if not (universe.get("exclude_st", True) and bool(row.get("is_st"))) and int(row.get("listed_days") or 0) >= int(universe.get("listed_days_min") or 0) ] missing_datasets = sorted( { dataset for field in required if (dataset := FACTOR_DATASET.get(field, "market")) and coverage.get(dataset, 0) < FACTOR_MINIMUM_COVERAGE[dataset] } ) field_coverage = { field: ( sum(row.get(field) is not None for row in universe_rows) / len(universe_rows) if universe_rows else 0.0 ) for field in required } missing_fields = sorted( field for field in required if field_coverage[field] < FACTOR_MINIMUM_COVERAGE[FACTOR_DATASET.get(field, "market")] ) if missing_datasets or missing_fields: return { "status": "data_incomplete", "items": [], "missing_datasets": missing_datasets, "missing_fields": missing_fields, "field_coverage": field_coverage, "eligible_count": 0, } eligible = [] for row in universe_rows: if any(row.get(field) is None for field in required): continue if all( _matches(row[item["field"]], item["op"], item["value"]) for item in formula["filters"] ): eligible.append(row) if not eligible: return { "status": "no_signal", "items": [], "missing_datasets": [], "missing_fields": [], "field_coverage": field_coverage, "eligible_count": 0, } percentiles = { item["field"]: _percentiles(eligible, item["field"], item["direction"]) for item in formula["score"] } labels = factor_catalog()["factors"] results = [] for row in eligible: contributions = [] total = 0.0 identifier = str(row["identifier"]) for item in formula["score"]: points = percentiles[item["field"]][identifier] * float(item["weight"]) total += points contributions.append( { "field": item["field"], "label": labels[item["field"]], "value": row[item["field"]], "points": round(points * 100, 1), } ) if total < float(formula["min_score"]): continue contributions.sort(key=lambda item: (-item["points"], item["field"])) results.append( { "identifier": identifier, "code": row["code"], "name": row["name"], "sector": row.get("sector") or "", "close": row.get("close"), "pct_chg": row.get("pct_chg"), "amount_billion": row.get("amount_billion"), "score": round(total, 6), "score_display": round(total * 100, 1), "contributions": contributions, "reason": "、".join(item["label"] for item in contributions[:3]), "risk_flags": _risk_flags(row), } ) results.sort(key=lambda item: (-item["score"], item["identifier"])) results = results[: int(formula["limit"])] return { "status": "completed" if results else "no_signal", "items": results, "missing_datasets": [], "missing_fields": [], "field_coverage": field_coverage, "eligible_count": len(eligible), } def _percentiles(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]: ordered = sorted( rows, key=lambda row: ( float(row[field]) if direction == "asc" else -float(row[field]), str(row["identifier"]), ), ) if len(ordered) == 1: return {str(ordered[0]["identifier"]): 1.0} return { str(row["identifier"]): 1 - index / (len(ordered) - 1) for index, row in enumerate(ordered) } def _matches(value: Any, operator: str, expected: Any) -> bool: if operator == "between": return expected[0] <= value <= expected[1] if operator == "in": return value in expected return { ">": value > expected, ">=": value >= expected, "<": value < expected, "<=": value <= expected, "==": value == expected, "!=": value != expected, }[operator] def _risk_flags(row: dict[str, Any]) -> list[str]: flags = [] if row.get("financial_risk"): flags.append("存在已确认财务风险") if row.get("volatility_10d") is not None and float(row["volatility_10d"]) >= 6: flags.append("近期波动偏高") if row.get("pct_chg") is not None and float(row["pct_chg"]) >= 9: flags.append("当日涨幅较高") return flags