rebuild(stage-9): deliver deterministic intelligent screening

This commit is contained in:
leefer
2026-07-30 05:15:17 +08:00
parent 6cb52e864a
commit 158257ebb8
46 changed files with 7322 additions and 34 deletions
@@ -0,0 +1,288 @@
from __future__ import annotations
from collections import defaultdict
from statistics import fmean, median
from typing import Any
from backend.features.screener.catalog import factor_catalog
from backend.features.screener.factor_math import mean, number, pearson, percentile_map, rounded
def finalize_factor_rows(
rows: list[dict[str, Any]], dataset_ready: dict[str, bool]
) -> list[dict[str, Any]]:
market_returns = [number(row.get("return_5d")) for row in rows]
valid_market = [value for value in market_returns if value is not None]
market_mean = fmean(valid_market) if valid_market else None
for row in rows:
stock_return = number(row.get("return_5d"))
row["relative_strength"] = (
rounded(stock_return - market_mean, 2)
if stock_return is not None and market_mean is not None
else None
)
_rank(rows, "return_5d", "return_5d_rank", "desc")
_rank(rows, "momentum_60_5", "momentum_60_5_rank", "desc")
_sector_factors(rows, dataset_ready)
_composite_factors(rows)
_style_factors(rows)
_market_height(rows)
known = factor_catalog()["factors"]
for row in rows:
for field in known:
row.setdefault(field, None)
return rows
def _sector_factors(rows: list[dict[str, Any]], dataset_ready: dict[str, bool]) -> None:
fields = (
"sector_strength",
"sector_return_5d",
"sector_return_20d",
"sector_momentum_rank",
"sector_stock_momentum_rank",
"sector_net_flow_5d_million",
"sector_flow_rank",
"sector_prosperity_rank",
"sector_trend_rank",
"sector_crowding_rank",
"sector_composite_score",
"sector_limit_count",
"sector_up_count",
"sector_breadth_ma20",
)
if not dataset_ready.get("industry"):
for row in rows:
row.update(dict.fromkeys(fields))
return
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
if row.get("sector"):
sectors[str(row["sector"])].append(row)
market_amount = sum(float(number(row.get("amount_billion")) or 0) for row in rows)
metrics = []
for name, members in sectors.items():
returns_5 = [number(row.get("return_5d")) for row in members]
returns_20 = [number(row.get("return_20d")) for row in members]
average_5 = mean(returns_5)
average_20 = mean(returns_20)
flows = [number(row.get("net_flow_5d_million")) for row in members]
sector_flow = (
sum(float(value) for value in flows)
if all(value is not None for value in flows)
else None
)
limit_values = [row.get("is_limit_up_today") for row in members]
limit_count = (
sum(bool(value) for value in limit_values)
if all(value is not None for value in limit_values)
else None
)
changes = [number(row.get("pct_chg")) for row in members]
up_count = (
sum(float(value) >= 5 for value in changes if value is not None)
if all(value is not None for value in changes)
else None
)
above = [row.get("above_ma20") for row in members]
breadth = (
sum(bool(value) for value in above) / len(above) * 100
if above and all(value is not None for value in above)
else None
)
growth = [
mean([number(row.get("revenue_yoy")), number(row.get("netprofit_yoy"))])
for row in members
]
valid_growth = [value for value in growth if value is not None]
prosperity = median(valid_growth) if valid_growth else None
turnovers = [number(row.get("turnover_rate")) for row in members]
average_turnover = mean(turnovers)
amount_share = (
sum(float(number(row.get("amount_billion")) or 0) for row in members)
/ market_amount
* 100
if market_amount
else None
)
crowding = (
average_turnover + amount_share
if average_turnover is not None and amount_share is not None
else None
)
trend = (
average_20 + breadth / 10 if average_20 is not None and breadth is not None else None
)
strength = (
min(
100,
max(
0,
50 + average_5 * 4 + (limit_count or 0) * 3 + (up_count or 0) * 0.6,
),
)
if average_5 is not None
else None
)
metrics.append(
{
"identifier": name,
"sector": name,
"return_20": average_20,
"flow": sector_flow,
"prosperity": prosperity,
"trend": trend,
"crowding": crowding,
}
)
stock_ranks = percentile_map(members, "return_20d", "desc")
for row in members:
row.update(
{
"sector_strength": rounded(strength, 1),
"sector_return_5d": rounded(average_5, 2),
"sector_return_20d": rounded(average_20, 2),
"sector_stock_momentum_rank": rounded(
stock_ranks.get(str(row["identifier"])), 4
),
"sector_net_flow_5d_million": rounded(sector_flow, 2),
"sector_limit_count": limit_count,
"sector_up_count": up_count,
"sector_breadth_ma20": rounded(breadth, 1),
}
)
rank_specs = {
"sector_momentum_rank": ("return_20", "desc"),
"sector_flow_rank": ("flow", "desc"),
"sector_prosperity_rank": ("prosperity", "desc"),
"sector_trend_rank": ("trend", "desc"),
"sector_crowding_rank": ("crowding", "desc"),
}
maps = {
output: percentile_map(metrics, source, direction)
for output, (source, direction) in rank_specs.items()
}
for name, members in sectors.items():
values = {field: mapping.get(name) for field, mapping in maps.items()}
composite = (
values["sector_prosperity_rank"] * 0.4
+ values["sector_trend_rank"] * 0.3
+ (1 - values["sector_crowding_rank"]) * 0.3
if all(value is not None for value in values.values())
else None
)
for row in members:
row.update({field: rounded(value, 4) for field, value in values.items()})
row["sector_composite_score"] = rounded(composite, 4)
for row in rows:
if not row.get("sector"):
row.update(dict.fromkeys(fields))
def _composite_factors(rows: list[dict[str, Any]]) -> None:
specs = {
"factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")),
"factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")),
"factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")),
"factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")),
"factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")),
}
for output, factor_specs in specs.items():
maps = [percentile_map(rows, field, direction) for field, direction in factor_specs]
for row in rows:
values = [mapping.get(str(row["identifier"])) for mapping in maps]
row[output] = rounded(mean(values), 4)
future_rank = percentile_map(rows, "return_20d", "desc")
weights = {}
for output in specs:
pairs = [(number(row.get(output)), future_rank.get(str(row["identifier"]))) for row in rows]
valid = [(left, right) for left, right in pairs if left is not None and right is not None]
correlation = pearson(
[float(left) for left, _ in valid],
[float(right) for _, right in valid],
)
weights[output] = max(0.05, correlation)
for row in rows:
available = [
(number(row.get(field)), weight)
for field, weight in weights.items()
if number(row.get(field)) is not None
]
row["multi_factor_composite"] = (
rounded(
sum(float(value) * weight for value, weight in available)
/ sum(weight for _, weight in available),
4,
)
if available
else None
)
def _style_factors(rows: list[dict[str, Any]]) -> None:
size = percentile_map(rows, "total_mv_billion", "desc")
large = [row for row in rows if (size.get(str(row["identifier"])) or 0) >= 0.7]
small = [row for row in rows if (size.get(str(row["identifier"])) or 1) <= 0.3]
large_return = mean([number(row.get("return_20d")) for row in large])
small_return = mean([number(row.get("return_20d")) for row in small])
prefer_large = (
large_return >= small_return
if large_return is not None and small_return is not None
else None
)
growth = [row for row in rows if (number(row.get("factor_growth_score")) or 0) >= 0.7]
value = [row for row in rows if (number(row.get("factor_value_score")) or 0) >= 0.7]
growth_return = mean([number(row.get("return_20d")) for row in growth])
value_return = mean([number(row.get("return_20d")) for row in value])
prefer_growth = (
growth_return >= value_return
if growth_return is not None and value_return is not None
else None
)
for row in rows:
size_rank = size.get(str(row["identifier"]))
row["style_size_fit"] = (
rounded(size_rank if prefer_large else 1 - size_rank, 4)
if size_rank is not None and prefer_large is not None
else None
)
row["style_growth_fit"] = (
row.get("factor_growth_score")
if prefer_growth
else row.get("factor_value_score")
if prefer_growth is not None
else None
)
row["style_fit_score"] = rounded(
mean([number(row.get("style_size_fit")), number(row.get("style_growth_fit"))]),
4,
)
def _market_height(rows: list[dict[str, Any]]) -> None:
current = [int(row["limit_streak"]) for row in rows if row.get("limit_streak") is not None]
previous = [
int(row["previous_limit_streak"])
for row in rows
if row.get("previous_limit_streak") is not None
]
current_height = max(current, default=0)
previous_height = max(previous, default=0)
for row in rows:
streak = row.get("limit_streak")
prior = row.get("previous_limit_streak")
if streak is None or prior is None:
row["is_market_height"] = None
row["new_space_board"] = None
continue
is_height = current_height >= 2 and int(streak) == current_height
row["is_market_height"] = is_height
row["new_space_board"] = is_height and not (
previous_height >= 2 and int(prior) == previous_height
)
def _rank(rows: list[dict[str, Any]], source: str, target: str, direction: str) -> None:
mapping = percentile_map(rows, source, direction)
for row in rows:
row[target] = rounded(mapping.get(str(row["identifier"])), 4)