from __future__ import annotations from statistics import fmean, pstdev from typing import Any from backend.features.screener.factor_math import ( calculate_earnings_quality, change, macd, mean, number, ratio, rounded, rsi, weekly_series, ) from backend.features.screener.technical_support import ( broken_metrics, dividend_years, ending_streak, group, large_flow, latest_by_code, max_streak, point_in_time, ) from backend.features.screener.technical_support import ( limit_events as map_limit_events, ) from backend.features.screener.technical_support import ( listed_days as calculate_listed_days, ) def build_technical_rows( trade_date: str, inputs: dict[str, Any], dataset_ready: dict[str, bool], ) -> list[dict[str, Any]]: daily = group(inputs.get("daily") or (), "ts_code") basics = latest_by_code(inputs.get("daily_basic") or (), trade_date) basic_history = group(inputs.get("daily_basic") or (), "ts_code") flows = group(inputs.get("moneyflow") or (), "ts_code") fundamentals = point_in_time(inputs.get("fundamentals") or (), trade_date) dividends = group(inputs.get("dividends") or (), "ts_code") auctions = latest_by_code(inputs.get("auction") or (), trade_date) earnings = point_in_time(inputs.get("earnings") or (), trade_date) popularity = {str(row["ts_code"]): row for row in inputs.get("popularity") or ()} institutions = {str(row["ts_code"]): row for row in inputs.get("institutions") or ()} directory = {str(row["ts_code"]): row for row in inputs.get("directory") or ()} industries = { str(row["ts_code"]): str(row.get("l2_name") or "") for row in inputs.get("industry") or () if row.get("ts_code") } benchmark = { str(row["trade_date"]): float(row["close"]) for row in inputs.get("benchmark") or () if number(row.get("close")) is not None } limit_event_map = map_limit_events(inputs.get("limit_events") or ()) rows = [] for identifier, bars in daily.items(): bars.sort(key=lambda row: str(row.get("trade_date") or "")) if not bars or str(bars[-1].get("trade_date") or "") != trade_date: continue info = directory.get(identifier) if info is None: continue closes = [number(row.get("close")) for row in bars] if any(value is None for value in closes) or not closes: continue close_values = [float(value) for value in closes if value is not None] row = _stock_row( trade_date=trade_date, identifier=identifier, info=info, bars=bars, closes=close_values, basic=basics.get(identifier, {}), basic_history=basic_history.get(identifier, []), flows=flows.get(identifier, []), fundamental=fundamentals.get(identifier, {}), dividends=dividends.get(identifier, []), auction=auctions.get(identifier, {}), earnings=earnings.get(identifier, {}), popularity=popularity.get(identifier), institution=institutions.get(identifier), sector=industries.get(identifier) if dataset_ready.get("industry") else None, benchmark=benchmark, limit_events=limit_event_map, dataset_ready=dataset_ready, ) rows.append(row) return rows def _stock_row( *, trade_date: str, identifier: str, info: dict[str, Any], bars: list[dict[str, Any]], closes: list[float], basic: dict[str, Any], basic_history: list[dict[str, Any]], flows: list[dict[str, Any]], fundamental: dict[str, Any], dividends: list[dict[str, Any]], auction: dict[str, Any], earnings: dict[str, Any], popularity: dict[str, Any] | None, institution: dict[str, Any] | None, sector: str | None, benchmark: dict[str, float], limit_events: dict[str, dict[str, str]], dataset_ready: dict[str, bool], ) -> dict[str, Any]: current = bars[-1] previous = bars[-2] if len(bars) >= 2 else {} highs = [number(item.get("high")) for item in bars] lows = [number(item.get("low")) for item in bars] volumes = [number(item.get("vol")) for item in bars] changes = [number(item.get("pct_chg")) for item in bars] open_price = number(current.get("open")) close_price = closes[-1] code = str(info.get("symbol") or info.get("code") or identifier.split(".")[0]) name = str(info.get("name") or "") is_st = "ST" in name.upper() or "退" in name listed_days = calculate_listed_days(info.get("list_date"), trade_date) ma20 = mean(closes[-20:]) if len(closes) >= 20 else None ma60 = mean(closes[-60:]) if len(closes) >= 60 else None prior_ma20 = mean(closes[-25:-5]) if len(closes) >= 25 else None prior_ma60 = mean(closes[-65:-5]) if len(closes) >= 65 else None ma_values = [ mean(closes[-window:]) if len(closes) >= window else None for window in (5, 10, 20, 60) ] high_values = [float(value) for value in highs if value is not None] low_values = [float(value) for value in lows if value is not None] event_flags = [ limit_events.get(str(item.get("trade_date") or ""), {}).get(identifier) for item in bars ] up_flags = [value == "U" for value in event_flags] down_flags = [value == "D" for value in event_flags] event_known = dataset_ready.get("limit_events", False) benchmark_60 = [benchmark.get(str(item.get("trade_date") or "")) for item in bars[-61:]] rs_values = [ float(item["close"]) / benchmark[str(item["trade_date"])] for item in bars[-120:] if number(item.get("close")) is not None and benchmark.get(str(item.get("trade_date"))) ] weekly_closes, weekly_amounts = weekly_series(bars) weekly_dif, weekly_dea = macd(weekly_closes) daily_dif, daily_dea = macd(closes) daily_cross = ( len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] and daily_dif[-2] <= daily_dea[-2] ) pullback = ( ma20 is not None and open_price is not None and close_price >= ma20 and open_price <= ma20 * 1.02 and close_price > open_price ) turnover_history = sorted(basic_history, key=lambda item: str(item.get("trade_date") or "")) flow_history = sorted(flows, key=lambda item: str(item.get("trade_date") or ""))[-5:] net_flows = [number(item.get("net_mf_amount")) for item in flow_history] current_flow = flow_history[-1] if flow_history else {} circ_mv = number(basic.get("circ_mv")) net_5d_raw = sum(value for value in net_flows if value is not None) if net_flows else None broken = broken_metrics(bars, up_flags) previous_signal = event_flags[-2] if len(event_flags) >= 2 else None prior_three = event_flags[max(0, len(event_flags) - 4) : -2] previous_streak = ending_streak(up_flags, len(up_flags) - 2) if event_known else None current_streak = ending_streak(up_flags) if event_known else None current_low = number(current.get("low")) previous_close = number(previous.get("close")) body = abs(close_price - open_price) if open_price is not None else None lower_shadow = ( max(0.0, min(open_price, close_price) - current_low) if open_price is not None and current_low is not None else None ) lower_shadow_ratio = ( lower_shadow / body if lower_shadow is not None and body not in (None, 0) else 10.0 if lower_shadow and body == 0 else None ) earnings_date = str(earnings.get("ann_date") or "") earnings_days = ( sum(earnings_date < str(item.get("trade_date") or "") <= trade_date for item in bars) if earnings_date else None ) earnings_ready = dataset_ready.get("earnings", False) earnings_quality = ( calculate_earnings_quality(bars, earnings_date) if earnings_date else True if earnings_ready else None ) netprofit = number(fundamental.get("netprofit_yoy")) financial_risk = ( True if is_st or (netprofit is not None and netprofit <= -100) else False if dataset_ready.get("financial") else None ) row = { "identifier": identifier, "code": code, "name": name, "sector": sector, "listed_days": listed_days, "is_st": is_st, "close": rounded(close_price, 2), "pct_chg": rounded(number(current.get("pct_chg")), 2), "return_5d": rounded(change(close_price, closes[-6]), 2) if len(closes) >= 6 else None, "return_10d": rounded(change(close_price, closes[-11]), 2) if len(closes) >= 11 else None, "return_20d": rounded(change(close_price, closes[-21]), 2) if len(closes) >= 21 else None, "return_60d": rounded(change(close_price, closes[-61]), 2) if len(closes) >= 61 else None, "momentum_60_5": rounded(change(closes[-6], closes[-61]), 2) if len(closes) >= 61 else None, "above_ma20": close_price > ma20 if ma20 is not None else None, "rsi_6": rounded(rsi(closes, 6), 2), "ma60_slope": rounded(change(ma60, prior_ma60), 3), "ma20_slope_5d": rounded(change(ma20, prior_ma20), 3), "ma_bull_alignment": ( bool(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]) if all(value is not None for value in ma_values) else None ), "drawdown_from_high_250": ( rounded((1 - close_price / max(high_values[-250:])) * 100, 2) if len(high_values) >= 250 and max(high_values[-250:]) > 0 else None ), "donchian_breakout_pct": ( rounded(change(close_price, max(high_values[-21:-1])), 2) if len(high_values) >= 21 else None ), "range_20d": ( rounded(change(max(high_values[-21:-1]), min(low_values[-21:-1])), 2) if len(high_values) >= 21 and len(low_values) >= 21 else None ), "rs_high_120": len(rs_values) >= 120 and rs_values[-1] >= max(rs_values) if benchmark else None, "excess_return_60d": ( rounded( float(change(close_price, closes[-61]) or 0) - float(change(benchmark_60[-1], benchmark_60[0]) or 0), 2, ) if len(closes) >= 61 and len(benchmark_60) == 61 and all(benchmark_60) else None ), "weekly_trend_signal": ( weekly_dif[-1] > 0 and weekly_dea[-1] > 0 if len(weekly_closes) >= 30 else None ), "daily_buy_trigger": daily_cross or pullback if len(closes) >= 26 else None, "weekly_amount_trend": ( weekly_amounts[-1] >= fmean(weekly_amounts[-5:-1]) if len(weekly_amounts) >= 5 else None ), "volume_ratio_5d": ( rounded(ratio(number(current.get("vol")), mean(volumes[-6:-1])), 2) if len(volumes) >= 6 else None ), "turnover_5d": ( rounded( sum( float(number(item.get("turnover_rate")) or 0) for item in turnover_history[-5:] ), 2, ) if dataset_ready.get("valuation") and len(turnover_history) >= 5 else None ), "volatility_10d": ( rounded(pstdev(float(value) for value in changes[-10:] if value is not None), 2) if len(changes) >= 10 and all(value is not None for value in changes[-10:]) else None ), "amount_billion": rounded((number(current.get("amount")) or 0) / 100000, 2), "turnover_rate": rounded(number(basic.get("turnover_rate")), 2), "circ_mv_billion": rounded(circ_mv / 10000, 2) if circ_mv is not None else None, "total_mv_billion": rounded((number(basic.get("total_mv")) or 0) / 10000, 2) if number(basic.get("total_mv")) is not None else None, "pe_ttm": rounded(number(basic.get("pe_ttm")), 2), "pb": rounded(number(basic.get("pb")), 2), "ps_ttm": rounded(number(basic.get("ps_ttm")), 2), "dividend_yield_ttm": rounded(number(basic.get("dv_ttm")), 2), "dividend_years": dividend_years(dividends, trade_date) if dataset_ready.get("financial") else None, "roe": rounded(number(fundamental.get("roe")), 2), "roa": rounded(number(fundamental.get("roa")), 2), "roic": rounded(number(fundamental.get("roic")), 2), "gross_margin": rounded(number(fundamental.get("grossprofit_margin")), 2), "netprofit_yoy": rounded(netprofit, 2), "revenue_yoy": rounded(number(fundamental.get("or_yoy")), 2), "ocf_to_opincome": rounded(number(fundamental.get("ocf_to_or")), 2), "earnings_surprise_pct": ( rounded(number(earnings.get("surprise_pct")), 2) if earnings else 0.0 if earnings_ready else None ), "earnings_days_since_announce": ( earnings_days if earnings_days is not None else 999 if earnings_ready else None ), "earnings_event_quality": earnings_quality, "popularity_score": ( rounded(number((popularity or {}).get("combined_score")), 2) if popularity else 0.0 if dataset_ready.get("popularity") else None ), "popularity_rank_change": ( number((popularity or {}).get("rank_change")) if popularity else 0.0 if dataset_ready.get("popularity") else None ), "popularity_dual_source": ( bool((popularity or {}).get("dual_source")) if popularity else False if dataset_ready.get("popularity") else None ), "institution_net_buy_million": ( rounded(number((institution or {}).get("net_buy_million")), 2) if institution else 0.0 if dataset_ready.get("institutions") else None ), "institution_seat_count": ( number((institution or {}).get("seat_count")) if institution else 0 if dataset_ready.get("institutions") else None ), "net_flow_million": rounded((number(current_flow.get("net_mf_amount")) or 0) / 100, 2) if current_flow else None, "large_flow_million": large_flow(current_flow), "net_flow_5d_million": rounded(net_5d_raw / 100, 2) if net_5d_raw is not None and len(flow_history) >= 5 else None, "flow_to_circ_mv_5d": rounded(net_5d_raw / circ_mv * 100, 4) if net_5d_raw is not None and circ_mv else None, "limit_streak": current_streak, "previous_limit_streak": previous_streak, "previous_first_limit": previous_signal == "U" and "U" not in prior_three if event_known else None, "previous_limit_signal": previous_signal in {"U", "Z"} and not any(value in {"U", "Z"} for value in prior_three) if event_known else None, "is_limit_up_today": up_flags[-1] if event_known else None, "is_limit_down_today": down_flags[-1] if event_known else None, "no_limit_30d": not any(up_flags[-30:]) if event_known and len(up_flags) >= 30 else None, "had_limit_80d": any(up_flags[-80:-30]) if event_known and len(up_flags) >= 80 else None, "no_limit_down_20d": not any(down_flags[-20:]) if event_known and len(down_flags) >= 20 else None, "financial_risk": financial_risk, "max_continuous_board_10d": max_streak(up_flags[-10:]) if event_known and len(up_flags) >= 10 else None, "dragon_first_yin": ( previous_streak is not None and previous_streak >= 3 and not up_flags[-1] and open_price is not None and close_price < open_price ) if event_known else None, "yin_day_pct": rounded(number(current.get("pct_chg")), 2) if event_known and previous_streak and previous_streak >= 3 and not up_flags[-1] else None, "broken_reversal": broken["signal"] if event_known else None, "days_since_broken": broken["days"] if event_known else None, "close_above_broken_high": broken["recovered"] if event_known else None, "vol_vs_broken_day": broken["volume_ratio"] if event_known else None, "recent_limit_up_5d": sum(up_flags[-5:]) if event_known and len(up_flags) >= 5 else None, "intraday_min_pct": rounded(change(current_low, previous_close), 2), "lower_shadow_ratio": rounded(lower_shadow_ratio, 2), "vol_vs_previous": rounded( ratio(number(current.get("vol")), number(previous.get("vol"))), 3 ), "previous_amount_billion": rounded((number(previous.get("amount")) or 0) / 100000, 2) if previous else None, "auction_change": rounded(number(auction.get("change")), 2), "auction_amount_million": rounded(number(auction.get("amount_million")), 2), "auction_turnover_rate": rounded(number(auction.get("turnover_rate")), 4), "auction_volume_ratio": rounded(number(auction.get("volume_ratio")), 2), "relative_position_60": ( rounded( (close_price - min(low_values[-60:])) / (max(high_values[-60:]) - min(low_values[-60:])), 4, ) if len(high_values) >= 60 and max(high_values[-60:]) > min(low_values[-60:]) else None ), "max_abs_change_15d": max(abs(float(value)) for value in changes[-15:] if value is not None) if len(changes) >= 15 else None, "close_to_high_15d": rounded(ratio(close_price, max(high_values[-15:])), 4) if len(high_values) >= 15 else None, "close_to_high_60d": rounded(ratio(close_price, max(high_values[-60:])), 4) if len(high_values) >= 60 else None, } return row