rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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
|
||||
Reference in New Issue
Block a user