from __future__ import annotations import statistics from collections import defaultdict from datetime import datetime from typing import Any from backend.data.numbers import finite_number as _number from backend.features.screener.indicators import ( _available_percentile_map, _broken_reversal_metrics, _ending_streak, _is_limit_bar, _limit_threshold, _macd_last, _macd_series, _max_streak, _optional_number, _pearson, _percentile_map, _rounded_optional, _rsi, _touched_limit_bar, _weekly_series, ) from database import ReviewDatabase class FactorBuilder: def __init__(self, database: ReviewDatabase) -> None: self.database = database def build_factors( self, trade_date: str, realtime_snapshot: dict[str, Any] | None = None, history_days: int = 80, ) -> tuple[list[dict[str, Any]], str]: history_days = max(21, min(260, int(history_days))) data = self.database.load_factor_data(trade_date, history_days) dates = [value for value in data["dates"] if value <= trade_date] if len(dates) < 21: raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。") history_date = dates[-1] realtime_map = { str(row.get("ts_code") or ""): row for row in (realtime_snapshot or {}).get("rows") or [] } realtime_date = str((realtime_snapshot or {}).get("trade_date") or "") use_realtime = bool(realtime_map and realtime_date == trade_date and history_date < trade_date) actual_date = trade_date if use_realtime else history_date master = {row["ts_code"]: row for row in data["master"]} indicators = {row["ts_code"]: row for row in data["indicators"]} fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])} indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in data.get("indicator_history", []): indicator_history[str(row.get("ts_code") or "")].append(row) indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in data.get("indicator_series", []): indicator_series[str(row.get("ts_code") or "")].append(row) benchmark_by_date = { str(row.get("trade_date") or ""): _number(row.get("close")) for row in data.get("benchmarks", []) if _number(row.get("close")) > 0 } moneyflow = {row["ts_code"]: row for row in data["moneyflow"]} moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in data.get("moneyflow_history", []): moneyflow_history[str(row.get("ts_code") or "")].append(row) auction = { row["ts_code"]: row 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: grouped[row["ts_code"]].append(row) snapshot = self.database.get_snapshot(actual_date) or {} limit_map: dict[str, tuple[str, int]] = {} for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")): for row in snapshot.get(key) or []: limit_map[str(row.get("code"))] = (status, int(row.get("streak") or 0)) factors = [] current_day = datetime.strptime(actual_date, "%Y%m%d") for ts_code, bars in grouped.items(): bars.sort(key=lambda item: item["trade_date"]) if len(bars) < 21 or bars[-1]["trade_date"] != history_date: continue info = master.get(ts_code) if not info: continue historical_closes = [_number(item["close"]) for item in bars] historical_volumes = [_number(item["vol"]) for item in bars] realtime = realtime_map.get(ts_code) if use_realtime else None current = realtime or bars[-1] closes = historical_closes + ([_number(realtime["close"])] if realtime else []) volumes = historical_volumes + ([_number(realtime["vol"])] if realtime else []) if closes[-1] <= 0: continue returns_10 = [_number(item["pct_chg"]) for item in bars[-10:]] if realtime: returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))] previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0 indicator = indicators.get(ts_code, {}) fundamental = fundamentals.get(ts_code, {}) flow = moneyflow.get(ts_code, {}) flow_history = moneyflow_history.get(ts_code, []) auction_row = auction.get(ts_code, {}) list_date = str(info.get("list_date") or "") try: listed_days = (current_day - datetime.strptime(list_date, "%Y%m%d")).days except ValueError: listed_days = 9999 code = str(info.get("code") or ts_code.split(".")[0]) status, streak = limit_map.get(code, ("", 0)) name = str(info.get("name") or "--") shape_rows = bars + ([realtime] if realtime else []) shape_close = [_number(item.get("close")) for item in shape_rows] shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows] shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows] shape_changes = [_number(item.get("pct_chg")) for item in shape_rows] position_rows = shape_rows[-60:] position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0) position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0) relative_position = ( (closes[-1] - position_low) / (position_high - position_low) if position_high > position_low else 0.5 ) previous_index = len(bars) - 1 if realtime else len(bars) - 2 previous_bar = bars[previous_index] if previous_index >= 0 else {} previous_limit = _is_limit_bar(bars, previous_index, code, name) previous_touched = _touched_limit_bar(bars, previous_index, code, name) recent_prior_signal = any( _is_limit_bar(bars, index, code, name) or _touched_limit_bar(bars, index, code, name) for index in range(max(0, previous_index - 2), previous_index) ) previous_streak = 0 streak_index = previous_index while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name): previous_streak += 1 streak_index -= 1 limit_flags = [ _is_limit_bar(shape_rows, index, code, name) for index in range(len(shape_rows)) ] annual_dividend_rows = indicator_history.get(ts_code, []) dividend_years = sum( 1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0) ) current_streak = _ending_streak(limit_flags) prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2) streak = max(streak, current_streak) return_60d = ( (closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 ) momentum_60_5 = ( (closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 ) ma20 = statistics.fmean(closes[-20:]) ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20 prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20 prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60 ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0 ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0 ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)] high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high) drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100 prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0 breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0 prior_lows_20 = shape_low[-21:-1] range_20d = ( (prior_high_20 / min(prior_lows_20) - 1) * 100 if prior_lows_20 and min(prior_lows_20) > 0 else 100 ) turnover_rows = sorted( indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "") ) turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]] if realtime and _number(realtime.get("turnover_rate")): turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))] turnover_5d = sum(turnover_values) rs_values = [ _number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))] for item in shape_rows[-120:] if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0 ] benchmark_60 = [ benchmark_by_date.get(str(item.get("trade_date"))) for item in shape_rows[-61:] if benchmark_by_date.get(str(item.get("trade_date"))) ] benchmark_return_60 = ( (benchmark_60[-1] / benchmark_60[0] - 1) * 100 if len(benchmark_60) >= 61 and benchmark_60[0] else 0 ) weekly_closes, weekly_amounts = _weekly_series(shape_rows) weekly_dif, weekly_dea = _macd_last(weekly_closes) daily_dif, daily_dea = _macd_series(closes) daily_cross = ( len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] and daily_dif[-2] <= daily_dea[-2] ) current_open = _number(current.get("open")) daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open previous_close = closes[-2] if len(closes) >= 2 else closes[-1] intraday_min = ( (_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0 ) body = abs(closes[-1] - current_open) lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low"))) lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0) previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0 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, "ts_code": ts_code, "name": name, "sector": info.get("industry") or "其他", "market": info.get("market") or "--", "listed_days": listed_days, "close": round(closes[-1], 2), "price": round(closes[-1], 2), "pct_chg": round(_number(current["pct_chg"]), 2), "return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2), "return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2), "return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2), "return_60d": round(return_60d, 2), "momentum_60_5": round(momentum_60_5, 2), "above_ma20": int(closes[-1] > ma20), "rsi_6": round(_rsi(closes, 6), 2), "ma60_slope": round(ma60_slope, 3), "ma20_slope_5d": round(ma20_slope, 3), "ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]), "drawdown_from_high_250": round(drawdown_250, 2), "donchian_breakout_pct": round(breakout_pct, 2), "range_20d": round(range_20d, 2), "rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)), "excess_return_60d": round(return_60d - benchmark_return_60, 2), "weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0), "daily_buy_trigger": int(daily_cross or daily_pullback), "weekly_amount_trend": int( len(weekly_amounts) >= 5 and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1]) ), "volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0, "turnover_5d": round(turnover_5d, 2), "volatility_10d": round(statistics.pstdev(returns_10), 2), "amount_billion": round( _number(current["amount"]) / (100000000 if realtime else 100000), 2 ), "turnover_rate": round( _number(realtime.get("turnover_rate")) if realtime else _number(indicator.get("turnover_rate")), 2, ), "circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2), "total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2), "pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2), "pb": _rounded_optional(indicator.get("pb"), 2), "ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2), "dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2), "dividend_years": dividend_years, "roe": _rounded_optional(fundamental.get("roe"), 2), "roa": _rounded_optional(fundamental.get("roa"), 2), "roic": _rounded_optional(fundamental.get("roic"), 2), "gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2), "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( sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100, 2, ), "flow_to_circ_mv_5d": round( sum(_number(item.get("net_mf_amount")) for item in flow_history) / _number(indicator.get("circ_mv")) * 100, 4, ) if _number(indicator.get("circ_mv")) else 0, "limit_status": status, "limit_streak": streak, "is_limit_up_today": int(limit_flags[-1]), "is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)), "auction_change": round(_number(auction_row.get("change")), 2), "auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2), "auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4), "auction_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2), "relative_position_60": round(relative_position, 4), "max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2), "close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) else 0, "close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0, "no_limit_30d": int(not any(limit_flags[-30:])), "had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])), "no_limit_down_20d": int(not any( _number(item.get("pct_chg")) <= -_limit_threshold(code, name) for item in shape_rows[-20:] )), "financial_risk": int( "ST" in name.upper() or "退" in name or (netprofit_yoy is not None and netprofit_yoy <= -100) ), "prior_limit_streak": prior_streak, "max_continuous_board_10d": _max_streak(limit_flags[-10:]), "dragon_first_yin": int( prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open ), "yin_day_pct": round(_number(current.get("pct_chg")), 2), "vol_vs_previous": round(vol_vs_previous, 3), "broken_reversal": broken["signal"], "days_since_broken": broken["days"], "close_above_broken_high": broken["recovered"], "vol_vs_broken_day": broken["volume_ratio"], "recent_limit_up_5d": sum(limit_flags[-5:]), "intraday_min_pct": round(intraday_min, 2), "lower_shadow_ratio": round(lower_shadow_ratio, 2), "previous_first_limit": int(previous_limit and not recent_prior_signal), "previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal), "previous_limit_streak": previous_streak, "previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2), } ) market_return = statistics.fmean(row["return_5d"] for row in factors) if factors else 0 sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) 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) sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows) 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") for row in sector_rows: row["sector_strength"] = round(strength, 1) row["sector_return_5d"] = round(average_return, 2) row["sector_return_20d"] = round(average_return_20d, 2) row["sector_net_flow_5d_million"] = round(sector_net_flow, 2) row["sector_stock_momentum_rank"] = round( stock_momentum_ranks.get(row["ts_code"], 0.0), 4 ) row["sector_limit_count"] = limit_count row["sector_up_count"] = up_count row["sector_breadth_ma20"] = round(breadth_ma20, 1) row["relative_strength"] = round(row["return_5d"] - market_return, 2) sector_momentum_ranks = _percentile_map( sector_metrics, "sector_return_20d", "desc" ) 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 ) 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) prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0) for row in factors: row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4) row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4) is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height row["is_market_height"] = int(is_height) row["new_space_board"] = int( is_height and not ( prior_market_height >= 2 and int(row.get("prior_limit_streak") or 0) == prior_market_height ) ) return factors, actual_date