refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
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
|
||||
Reference in New Issue
Block a user