Files
sfgrid/core/scoring/features/validator.py
T

198 lines
7.6 KiB
Python

"""
候选股过滤与数据加载
"""
import pandas as pd
from datetime import date, timedelta
from core.scoring.models import (
KlineStock, StockInfo, IndustryMapping,
KlineIndex, MarketRegimeDaily, SectorFeaturesDaily,
)
from core.scoring.config import (
FILTER_MIN_CLOSE, FILTER_MAX_CLOSE, REQUIRE_DAYS,
WINDOW_180D, HS300_CODE,
)
from core.logger import LogLevel, PrintLog
class DataContext:
"""特征计算所需的全部数据上下文"""
def __init__(self, trade_date: date):
self.trade_date = trade_date
self.require_days = REQUIRE_DAYS
# 原始数据
self.kline: pd.DataFrame = None # 候选股 K线 (180d)
self.stock_info: dict = {} # code → StockInfo dict
self.industry_map: dict = {} # code → industry_name
self.hs300_kline: pd.DataFrame = None # HS300 K线 (180d)
self.market_regime: pd.DataFrame = None # 市场状态 (180d)
self.sector_features: pd.DataFrame = None # 行业指数 (180d)
# 候选股列表
self.candidates: list[str] = []
self.excluded: dict[str, str] = {} # code → reason
def _get_stock_codes_for_date(trade_date: date) -> list:
"""获取评分日所有符合条件的股票代码(非ST/退市)"""
rows = (StockInfo
.select(StockInfo.code, StockInfo.listing_status)
.where(StockInfo.listing_status.not_in(['delisted', 'ST']))
.dicts())
return [row['code'] for row in rows]
def _check_kline_sufficiency(stock_code: str, trade_date: date) -> tuple[bool, str, float]:
"""检查单只股票的K线数据是否满足评分条件"""
# 查询最近 REQUIRE_DAYS + 30 (留缓冲) 个交易日
start = trade_date - timedelta(days=(REQUIRE_DAYS + 60) * 2)
rows = (KlineStock
.select(KlineStock.trade_date, KlineStock.close)
.where(
(KlineStock.stock_code == stock_code) &
(KlineStock.trade_date >= start) &
(KlineStock.trade_date <= trade_date)
)
.order_by(KlineStock.trade_date.desc())
.dicts())
if not rows:
return False, '无K线数据', 0
# 过滤有效收盘价 (close > 0)
valid_rows = [r for r in rows if r['close'] and r['close'] > 0]
if len(valid_rows) < REQUIRE_DAYS:
return False, f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})', 0
latest_close = valid_rows[0]['close']
# 价格区间检查
if latest_close < FILTER_MIN_CLOSE:
return False, f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})', latest_close
if latest_close > FILTER_MAX_CLOSE:
return False, f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})', latest_close
return True, '', latest_close
def load_candidates(trade_date: date) -> DataContext:
"""
加载评分日候选股及全部所需数据。
返回 DataContext,包含候选股列表和预加载的原始数据。
"""
ctx = DataContext(trade_date)
start_180 = trade_date - timedelta(days=365) # 取约1年数据覆盖180个交易日
# 1. 获取非ST/退市的全部股票
all_codes = [r.split('.')[0] for r in _get_stock_codes_for_date(trade_date)]
PrintLog(LogLevel.INFO, f'[validator] 全市场有效股票: {len(all_codes)}')
# 2. 批量加载 KlineStock (180d 窗口)
raw_codes = all_codes # 使用纯数字代码查询
kline_rows = (KlineStock
.select()
.where(
(KlineStock.stock_code.in_(raw_codes)) &
(KlineStock.trade_date >= start_180) &
(KlineStock.trade_date <= trade_date)
)
.order_by(KlineStock.stock_code, KlineStock.trade_date)
.dicts())
kline_df = pd.DataFrame(kline_rows)
if kline_df.empty:
PrintLog(LogLevel.WARNING, '[validator] KlineStock 无数据')
return ctx
kline_df['trade_date'] = pd.to_datetime(kline_df['trade_date'])
PrintLog(LogLevel.INFO, f'[validator] K线原始数据: {len(kline_df)}')
# 3. 逐股过滤
candidates = []
excluded = {}
grouped = kline_df.groupby('stock_code')
for code, group in grouped:
g_sorted = group.sort_values('trade_date', ascending=False)
valid_rows = g_sorted[g_sorted['close'].notna() & (g_sorted['close'] > 0)]
if len(valid_rows) < REQUIRE_DAYS:
excluded[code] = f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})'
continue
latest_close = valid_rows.iloc[0]['close']
if latest_close < FILTER_MIN_CLOSE:
excluded[code] = f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})'
continue
if latest_close > FILTER_MAX_CLOSE:
excluded[code] = f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})'
continue
candidates.append(code)
PrintLog(LogLevel.INFO,
f'[validator] 候选: {len(candidates)} 通过, {len(excluded)} 排除')
# 4. 裁剪K线到只含候选股 (保留最近180日)
kline_df = kline_df[kline_df['stock_code'].isin(candidates)].copy()
cut_date = trade_date - timedelta(days=365)
kline_df = kline_df[kline_df['trade_date'] >= pd.Timestamp(cut_date)]
ctx.kline = kline_df
ctx.candidates = candidates
ctx.excluded = excluded
# 5. 加载 StockInfo
stock_rows = (StockInfo
.select()
.where(StockInfo.code.in_([f'{c}.SH' for c in candidates] +
[f'{c}.SZ' for c in candidates] +
[f'{c}.BJ' for c in candidates]))
.dicts())
ctx.stock_info = {r['code']: r for r in stock_rows}
# 6. 加载行业映射: code → industry_name
ind_rows = (IndustryMapping
.select()
.where(IndustryMapping.code.in_(candidates))
.dicts())
ctx.industry_map = {r['code']: r['industry_name'] for r in ind_rows}
PrintLog(LogLevel.INFO, f'[validator] 行业映射: {len(ctx.industry_map)}')
# 7. 加载 HS300 K线
hs300_rows = (KlineIndex
.select()
.where(
(KlineIndex.index_code == HS300_CODE) &
(KlineIndex.trade_date >= start_180) &
(KlineIndex.trade_date <= trade_date)
)
.order_by(KlineIndex.trade_date)
.dicts())
ctx.hs300_kline = pd.DataFrame(hs300_rows)
if not ctx.hs300_kline.empty:
ctx.hs300_kline['trade_date'] = pd.to_datetime(ctx.hs300_kline['trade_date'])
# 8. 加载市场状态 (180d)
mkt_rows = (MarketRegimeDaily
.select()
.where(
(MarketRegimeDaily.trade_date >= start_180) &
(MarketRegimeDaily.trade_date <= trade_date)
)
.order_by(MarketRegimeDaily.trade_date)
.dicts())
ctx.market_regime = pd.DataFrame(mkt_rows)
if not ctx.market_regime.empty:
ctx.market_regime['trade_date'] = pd.to_datetime(ctx.market_regime['trade_date'])
# 9. 加载行业指数 (180d)
sec_rows = (SectorFeaturesDaily
.select()
.where(
(SectorFeaturesDaily.trade_date >= start_180) &
(SectorFeaturesDaily.trade_date <= trade_date)
)
.order_by(SectorFeaturesDaily.trade_date)
.dicts())
ctx.sector_features = pd.DataFrame(sec_rows)
if not ctx.sector_features.empty:
ctx.sector_features['trade_date'] = pd.to_datetime(ctx.sector_features['trade_date'])
return ctx