feat: complete strategy and market data improvements
This commit is contained in:
+420
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user