Files
xiaobaifupan/next/backend/features/screener/engine.py
T

261 lines
7.7 KiB
Python

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