rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
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
|
||||
Reference in New Issue
Block a user