feat: complete strategy and market data improvements

This commit is contained in:
leefer
2026-07-29 16:50:40 +08:00
parent c30d2107b3
commit 0030bb8cc1
18 changed files with 1622 additions and 162 deletions
+420
View File
@@ -63,6 +63,10 @@ FACTOR_FIELDS = {
"sector_stock_momentum_rank": "行业内个股动量排名",
"sector_net_flow_5d_million": "行业5日主力净流入",
"sector_flow_rank": "行业资金流排名",
"sector_prosperity_rank": "行业景气度排名",
"sector_trend_rank": "行业趋势排名",
"sector_crowding_rank": "行业拥挤度排名",
"sector_composite_score": "行业三维综合分",
"sector_limit_count": "板块涨停数",
"sector_up_count": "板块强势股数",
"relative_strength": "相对强度",
@@ -84,6 +88,23 @@ FACTOR_FIELDS = {
"netprofit_yoy": "净利润同比",
"revenue_yoy": "营业收入同比",
"ocf_to_opincome": "经营现金流质量",
"earnings_surprise_pct": "业绩超预期幅度",
"earnings_days_since_announce": "业绩公告后天数",
"earnings_event_quality": "业绩事件质量",
"popularity_score": "人气榜热度",
"popularity_rank_change": "人气排名跃升",
"popularity_dual_source": "双榜共识",
"institution_net_buy_million": "机构席位净买入",
"institution_seat_count": "机构席位数",
"style_size_fit": "大小盘风格匹配",
"style_growth_fit": "成长价值风格匹配",
"style_fit_score": "当前风格匹配度",
"factor_value_score": "价值因子分",
"factor_growth_score": "成长因子分",
"factor_quality_score": "质量因子分",
"factor_momentum_score": "动量因子分",
"factor_sentiment_score": "交易情绪因子分",
"multi_factor_composite": "动态多因子综合分",
"relative_position_60": "60日相对位置",
"max_abs_change_15d": "15日最大波动",
"close_to_high_15d": "距15日高点",
@@ -133,6 +154,8 @@ FACTOR_GROUPS = {
"板块结构": [
"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",
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
"is_limit_up_today", "is_limit_down_today",
@@ -151,6 +174,14 @@ FACTOR_GROUPS = {
"财务质量": [
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
"ocf_to_opincome", "financial_risk",
"earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality",
],
"特色数据": [
"popularity_score", "popularity_rank_change", "popularity_dual_source",
"institution_net_buy_million", "institution_seat_count",
"style_size_fit", "style_growth_fit", "style_fit_score",
"factor_value_score", "factor_growth_score", "factor_quality_score",
"factor_momentum_score", "factor_sentiment_score", "multi_factor_composite",
],
}
@@ -649,6 +680,30 @@ STRATEGY_ENVIRONMENT_NOTES = {
"板块轮动初期、资金先于价格形成连续净流入的阶段",
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
),
"景气-趋势-拥挤三维行业打分": (
"行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市",
"财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢",
),
"大小盘/成长价值风格切换(元策略)": (
"大小盘或成长价值风格形成持续相对强弱的阶段",
"风格快速往返切换时,近20日相对表现容易产生滞后信号",
),
"业绩超预期漂移(SUE/PEAD)": (
"业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时",
"预告与快报口径可能不同,公告后高开兑现会削弱漂移效应",
),
"多因子综合打分(IC动态加权)": (
"因子表现具备一定延续性、市场并非由单一极端主题主导时",
"近期有效因子可能快速失效,动态权重不能消除风格突变风险",
),
"热度突增潜伏(另类数据)": (
"人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期",
"榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高",
),
"机构榜溢价": (
"机构专用席位在相对低位形成明确净买入、且成交承载正常时",
"高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓",
),
}
for strategy in CURATED_STRATEGIES:
@@ -679,6 +734,106 @@ def _quarter_periods(trade_date: str, count: int) -> list[str]:
return sorted(periods)
def _earnings_event_rows(
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str,
) -> list[dict[str, Any]]:
forecast_map: dict[tuple[str, str], dict[str, Any]] = {}
for row in forecasts:
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
ann_date = str(row.get("ann_date") or "")
if not all(key) or not ann_date or ann_date > trade_date:
continue
previous = forecast_map.get(key)
if previous is None or ann_date > str(previous.get("ann_date") or ""):
forecast_map[key] = row
result = []
for row in expresses:
ts_code = str(row.get("ts_code") or "")
end_date = str(row.get("end_date") or "")
ann_date = str(row.get("ann_date") or "")
forecast = forecast_map.get((ts_code, end_date))
if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date:
continue
lower = _optional_number(forecast.get("net_profit_min"))
upper = _optional_number(forecast.get("net_profit_max"))
forecast_profit = statistics.fmean(
value for value in (lower, upper) if value is not None
) if lower is not None or upper is not None else None
actual_profit = _optional_number(row.get("n_income"))
if forecast_profit in (None, 0) or actual_profit is None:
continue
# forecast is reported in ten-thousand yuan while express uses yuan.
if abs(actual_profit) > max(abs(forecast_profit), 1) * 100:
actual_profit /= 10000
surprise_pct = (actual_profit / forecast_profit - 1) * 100
result.append(
{
"end_date": end_date,
"ann_date": ann_date,
"ts_code": ts_code,
"forecast_profit": forecast_profit,
"actual_profit": actual_profit,
"surprise_pct": surprise_pct,
"revenue_yoy": _optional_number(row.get("yoy_sales")),
"netprofit_yoy": _optional_number(row.get("yoy_net_profit")),
"source": "forecast+express",
}
)
return result
def _popularity_factor_rows(
trade_date: str,
ths_rows: list[dict[str, Any]],
dc_rows: list[dict[str, Any]],
previous_ths: list[dict[str, Any]],
previous_dc: list[dict[str, Any]],
) -> list[dict[str, Any]]:
def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]:
result = {}
for row in rows:
if data_type and str(row.get("data_type") or "") != data_type:
continue
ts_code = str(row.get("ts_code") or "")
rank = int(_number(row.get("rank")))
if ts_code and rank > 0:
result[ts_code] = rank
return result
ths = ranks(ths_rows, "热股")
dc = ranks(dc_rows, "A股市场")
previous_ths_map = ranks(previous_ths, "热股")
previous_dc_map = ranks(previous_dc, "A股市场")
result = []
for ts_code in set(ths) | set(dc):
ths_rank = ths.get(ts_code)
dc_rank = dc.get(ts_code)
current_best = min(value for value in (ths_rank, dc_rank) if value is not None)
previous_candidates = [
value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code))
if value is not None
]
previous_best = min(previous_candidates) if previous_candidates else None
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
result.append(
{
"trade_date": trade_date,
"ts_code": ts_code,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"combined_score": round(score, 2),
"rank_change": (
previous_best - current_best
if previous_best is not None
else min(30, max(0, 31 - current_best))
if previous_ths_map or previous_dc_map else 0
),
"dual_source": bool(ths_rank and dc_rank),
}
)
return result
class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
self.database = database
@@ -714,12 +869,15 @@ class FactorDataService:
"cal_date,is_open",
)
last_open_by_year: dict[str, str] = {}
last_open_by_month: dict[str, str] = {}
for row in long_calendar:
if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value)
valuation_dates = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values())
valuation_dates.update(last_open_by_month.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted(
value for value in valuation_dates if value not in existing_indicators or value == trade_date
@@ -814,6 +972,63 @@ class FactorDataService:
notices.append(f"资金流接口不可用:{exc}")
break
earnings_count = 0
forecasts: list[dict[str, Any]] = []
expresses: list[dict[str, Any]] = []
for period in _quarter_periods(trade_date, 5):
try:
forecast_rows = self.client.query(
"forecast_vip",
{"period": period},
"ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max",
)
express_rows = self.client.query(
"express_vip",
{"period": period},
"ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales",
)
except TushareError as exc:
notices.append(f"业绩事件接口不可用:{exc}")
break
forecasts.extend(forecast_rows)
expresses.extend(express_rows)
if forecasts and expresses:
earnings_count = self.database.upsert_earnings_events(
_earnings_event_rows(forecasts, expresses, trade_date)
)
popularity_count = 0
previous_trade_date = dates[-2] if len(dates) >= 2 else ""
try:
ths_rows = self.client.query("ths_hot", {"trade_date": trade_date})
dc_rows = self.client.query("dc_hot", {"trade_date": trade_date})
previous_ths = (
self.client.query("ths_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
previous_dc = (
self.client.query("dc_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
popularity_count = self.database.upsert_popularity_factors(
_popularity_factor_rows(
trade_date, ths_rows, dc_rows, previous_ths, previous_dc
)
)
except TushareError as exc:
notices.append(f"人气榜因子不可用:{exc}")
institution_count = 0
try:
institution_rows = self.client.query(
"top_inst",
{"trade_date": trade_date},
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
)
institution_count = self.database.upsert_lhb_institutions(institution_rows)
except TushareError as exc:
notices.append(f"机构席位明细不可用:{exc}")
return {
"trade_date": trade_date,
"calendar_dates": len(dates),
@@ -828,6 +1043,9 @@ class FactorDataService:
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count,
"auction_dates": auction_dates,
"earnings_events": earnings_count,
"popularity_rows": popularity_count,
"institution_rows": institution_count,
"notice": "".join(notices),
}
@@ -1061,6 +1279,23 @@ class ScreenerEngine:
for row in data.get("auction", [])
if str(row.get("trade_date") or "") == actual_date
}
earnings_events: dict[str, dict[str, Any]] = {}
for row in data.get("earnings_events", []):
ts_code = str(row.get("ts_code") or "")
ann_date = str(row.get("ann_date") or "")
if ann_date <= actual_date and (
ts_code not in earnings_events
or ann_date > str(earnings_events[ts_code].get("ann_date") or "")
):
earnings_events[ts_code] = row
popularity = {
str(row.get("ts_code") or ""): row
for row in data.get("popularity", [])
}
institutions = {
str(row.get("ts_code") or ""): row
for row in data.get("institutions", [])
}
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data["bars"]:
if row["trade_date"] <= history_date:
@@ -1206,6 +1441,33 @@ class ScreenerEngine:
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
earnings_event = earnings_events.get(ts_code, {})
announcement_date = str(earnings_event.get("ann_date") or "")
earnings_days = (
sum(1 for value in dates if announcement_date < value <= actual_date)
if announcement_date and announcement_date <= actual_date
else None
)
announcement_bar = next(
(item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date),
None,
)
announcement_bad = False
if announcement_bar is not None:
bar_index = shape_rows.index(announcement_bar)
prior_volumes = [
_number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index]
if _number(item.get("vol")) > 0
]
volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0
announcement_bad = (
_number(announcement_bar.get("close")) < _number(announcement_bar.get("open"))
and _number(announcement_bar.get("pct_chg")) < 0
and volume_baseline > 0
and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8
)
popularity_row = popularity.get(ts_code)
institution_row = institutions.get(ts_code)
factors.append(
{
"code": code,
@@ -1263,6 +1525,26 @@ class ScreenerEngine:
"netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
"revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2),
"earnings_days_since_announce": earnings_days,
"earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None,
"popularity_score": _rounded_optional(
popularity_row.get("combined_score") if popularity_row else None, 2
),
"popularity_rank_change": (
int(popularity_row["rank_change"])
if popularity_row and popularity_row.get("rank_change") is not None else None
),
"popularity_dual_source": (
int(bool(popularity_row.get("dual_source"))) if popularity_row else None
),
"institution_net_buy_million": (
round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2)
if institution_row else None
),
"institution_seat_count": (
int(institution_row.get("seat_count") or 0) if institution_row else None
),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"net_flow_5d_million": round(
@@ -1322,6 +1604,7 @@ class ScreenerEngine:
for row in factors:
sectors[row["sector"]].append(row)
sector_metrics = []
market_amount = sum(max(0.0, row["amount_billion"]) for row in factors)
for sector_name, sector_rows in sectors.items():
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
@@ -1329,12 +1612,31 @@ class ScreenerEngine:
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
sector_growth = [
statistics.fmean(values)
for row in sector_rows
if (values := [
value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy"))
if value is not None
])
]
prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0
average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows)
amount_share = (
sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100
if market_amount else 0.0
)
crowding_raw = average_turnover + amount_share
trend_raw = average_return_20d + breadth_ma20 / 10
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
sector_metrics.append(
{
"ts_code": sector_name,
"sector_return_20d": average_return_20d,
"sector_net_flow_5d_million": sector_net_flow,
"sector_prosperity_raw": prosperity_raw,
"sector_trend_raw": trend_raw,
"sector_crowding_raw": crowding_raw,
}
)
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
@@ -1356,7 +1658,22 @@ class ScreenerEngine:
sector_flow_ranks = _percentile_map(
sector_metrics, "sector_net_flow_5d_million", "desc"
)
sector_prosperity_ranks = _percentile_map(
sector_metrics, "sector_prosperity_raw", "desc"
)
sector_trend_ranks = _percentile_map(
sector_metrics, "sector_trend_raw", "desc"
)
sector_crowding_ranks = _percentile_map(
sector_metrics, "sector_crowding_raw", "desc"
)
for sector_name, sector_rows in sectors.items():
prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0)
trend_rank = sector_trend_ranks.get(sector_name, 0.0)
crowding_rank = sector_crowding_ranks.get(sector_name, 0.0)
composite_score = (
prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30
)
for row in sector_rows:
row["sector_momentum_rank"] = round(
sector_momentum_ranks.get(sector_name, 0.0), 4
@@ -1364,6 +1681,75 @@ class ScreenerEngine:
row["sector_flow_rank"] = round(
sector_flow_ranks.get(sector_name, 0.0), 4
)
row["sector_prosperity_rank"] = round(prosperity_rank, 4)
row["sector_trend_rank"] = round(trend_rank, 4)
row["sector_crowding_rank"] = round(crowding_rank, 4)
row["sector_composite_score"] = round(composite_score, 4)
factor_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_field, specs in factor_specs.items():
maps = [_available_percentile_map(factors, field, direction) for field, direction in specs]
for row in factors:
values = [mapping.get(row["ts_code"]) for mapping in maps]
available = [value for value in values if value is not None]
row[output_field] = round(statistics.fmean(available), 4) if available else None
return_rank_map = _available_percentile_map(factors, "return_20d", "desc")
factor_weights = {}
for output_field in factor_specs:
pairs = [
(row.get(output_field), return_rank_map.get(row["ts_code"]))
for row in factors
if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None
]
correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs])
factor_weights[output_field] = max(0.05, correlation)
factor_weight_total = sum(factor_weights.values()) or 1
for row in factors:
weighted = [
(row.get(field), weight)
for field, weight in factor_weights.items()
if row.get(field) is not None
]
row["multi_factor_composite"] = round(
sum(value * weight for value, weight in weighted)
/ (sum(weight for _, weight in weighted) or factor_weight_total),
4,
) if weighted else None
size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc")
large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70]
small_rows = [
row for row in factors
if size_ranks.get(row["ts_code"]) is not None
and size_ranks[row["ts_code"]] <= 0.30
]
large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0
small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0
prefer_large = large_return >= small_return
growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70]
value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70]
growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0
value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0
prefer_growth = growth_return >= value_return
for row in factors:
size_rank = size_ranks.get(row["ts_code"])
row["style_size_fit"] = round(
size_rank if prefer_large else 1 - size_rank, 4
) if size_rank is not None else None
style_factor = "factor_growth_score" if prefer_growth else "factor_value_score"
row["style_growth_fit"] = row.get(style_factor)
style_values = [
value for value in (row.get("style_size_fit"), row.get("style_growth_fit"))
if value is not None
]
row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
return_ranks = _percentile_map(factors, "return_5d", "desc")
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
@@ -1753,6 +2139,40 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
return result
def _available_percentile_map(
rows: list[dict[str, Any]], field: str, direction: str,
) -> dict[str, float | None]:
available = [row for row in rows if row.get(field) is not None]
result: dict[str, float | None] = {
str(row.get("ts_code") or ""): None for row in rows
}
if not available:
return result
ordered = sorted(available, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
for index, row in enumerate(ordered):
percentile = 0.5 if len(ordered) == 1 else index / denominator
result[str(row.get("ts_code") or "")] = (
1 - percentile if direction == "asc" else percentile
)
return result
def _pearson(first: list[float], second: list[float]) -> float:
if len(first) != len(second) or len(first) < 20:
return 0.0
first_mean = statistics.fmean(first)
second_mean = statistics.fmean(second)
numerator = sum(
(left - first_mean) * (right - second_mean)
for left, right in zip(first, second)
)
left_sum = sum((value - first_mean) ** 2 for value in first)
right_sum = sum((value - second_mean) ** 2 for value in second)
denominator = math.sqrt(left_sum * right_sum)
return numerator / denominator if denominator else 0.0
def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]: