261 lines
7.7 KiB
Python
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
|