模型,评分,修复网格策略市场状态监听

This commit is contained in:
2026-06-22 10:48:40 +08:00
parent 2e3202968d
commit f938c453e1
42 changed files with 3803 additions and 108 deletions
+18 -2
View File
@@ -242,6 +242,18 @@ class DummyQmtV:
"""停止市场数据订阅"""
PrintLog(LogLevel.INFO, '- 停止市场数据订阅 (模拟)')
def _is_trading_time(self) -> bool:
import zoneinfo
beijing_tz = zoneinfo.ZoneInfo('Asia/Shanghai')
now = datetime.datetime.now(beijing_tz)
if now.weekday() >= 5:
return False
t = now.time()
return (
datetime.time(9, 30) <= t <= datetime.time(11, 30) or
datetime.time(13, 0) <= t <= datetime.time(15, 0)
)
def _generate_market_data(self):
"""生成模拟市场数据"""
stocks = ['600519', '000858', '600036', '000001', '000002', '600000']
@@ -263,8 +275,12 @@ class DummyQmtV:
base_prices[i] = data['last_price']
self.lastMarketDataUpdateTimestamp = time.time()
self.isMarketActive = True
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True)
if self._is_trading_time():
self.isMarketActive = True
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True)
else:
self.isMarketActive = False
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False)
time.sleep(3)
+19 -3
View File
@@ -552,16 +552,32 @@ class RealQmtV:
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True)
eBus.event_bus.publish(eBus.MarketDataUpdate, datas)
def _is_trading_time(self) -> bool:
"""判断当前是否在交易时间内(工作日 09:30-11:30 / 13:00-15:00,北京时间 UTC+8"""
import zoneinfo
beijing_tz = zoneinfo.ZoneInfo("Asia/Shanghai")
now = datetime.datetime.now(beijing_tz)
if now.weekday() >= 5: # 周六、周日
return False
t = now.time()
morning_start = datetime.time(9, 30)
morning_end = datetime.time(11, 30)
afternoon_start = datetime.time(13, 0)
afternoon_end = datetime.time(15, 0)
return (morning_start <= t <= morning_end) or (afternoon_start <= t <= afternoon_end)
def _market_data_watchdog(self):
"""行情活跃监控 — 超过 120 秒无行情则标记市场不活跃"""
"""行情活跃监控 — 超过 120 秒无行情 且 非交易时间 则标记市场不活跃"""
while True:
time.sleep(15)
if self.isMarketActive:
elapsed = time.time() - self.lastMarketDataUpdateTimestamp
if elapsed > 120:
# 非交易时间直接标记休市;交易时间内才用超时判断
if not self._is_trading_time() or elapsed > 120:
self.isMarketActive = False
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False)
PrintLog(LogLevel.WARNING, f'- [行情] 超过 {elapsed:.0f} 秒无更新,市场标记为不活跃')
if not self._is_trading_time():
PrintLog(LogLevel.INFO, '- [行情] 非交易时间,市场标记为不活跃')
def stopMarketDataSubscription(self):
"""停止市场数据订阅"""
+1
View File
@@ -0,0 +1 @@
# grid_seeker v6.4 评分模块
+165
View File
@@ -0,0 +1,165 @@
"""
grid_seeker v6.4 CLI 入口
Usage:
python -m core.scoring.cli sync all # 按依赖顺序执行全部同步
python -m core.scoring.cli sync kline # 仅同步个股+指数K线
python -m core.scoring.cli sync stocks # 仅同步股票基础信息
python -m core.scoring.cli sync industry # 仅同步行业映射
python -m core.scoring.cli sync market # 仅计算市场状态
python -m core.scoring.cli sync sector # 仅计算行业指数
python -m core.scoring.cli score # 完整评分管道 (最新交易日)
python -m core.scoring.cli score --date 20260615 # 指定日期
python -m core.scoring.cli score --dry-run # 试运行 (不写库)
python -m core.scoring.cli check 000001 # 检查单只股票数据充分性
python -m core.scoring.cli list-candidates # 列出全部候选股
"""
import sys
from datetime import date, datetime
def main():
if len(sys.argv) < 2:
_usage()
return
cmd = sys.argv[1]
if cmd == 'sync':
_cmd_sync()
elif cmd == 'score':
_cmd_score()
elif cmd == 'check':
_cmd_check()
elif cmd == 'list-candidates':
_cmd_list_candidates()
else:
print(f'未知命令: {cmd}')
_usage()
def _usage():
print(__doc__)
# ============================================================
# sync 命令
# ============================================================
def _cmd_sync():
target = sys.argv[2] if len(sys.argv) > 2 else 'all'
from core.scoring.sync import (
KlineStockSync, KlineIndexSync,
StocksSync, IndustrySync,
MarketRegimeSync, SectorFeaturesSync,
)
syncs = {
'kline': [KlineStockSync, KlineIndexSync],
'stocks': [StocksSync],
'industry': [IndustrySync],
'market': [MarketRegimeSync],
'sector': [SectorFeaturesSync],
}
if target == 'all':
# 按依赖顺序执行
order = [
('K线(个股)', KlineStockSync(count=300)),
('K线(指数)', KlineIndexSync(count=300)),
('股票信息', StocksSync()),
('行业映射', IndustrySync()),
('市场状态', MarketRegimeSync()),
('行业指数', SectorFeaturesSync()),
]
for label, sync in order:
print(f'\n===== {label} =====')
sync.run()
print('\n===== 全部同步完成 =====')
elif target in syncs:
for cls in syncs[target]:
cls().run()
else:
print(f'未知同步目标: {target}')
print(f'可用: all, {", ".join(syncs.keys())}')
# ============================================================
# score 命令
# ============================================================
def _cmd_score():
dry_run = '--dry-run' in sys.argv
date_str = None
for i, arg in enumerate(sys.argv):
if arg == '--date' and i + 1 < len(sys.argv):
date_str = sys.argv[i + 1]
break
if date_str:
trade_date = datetime.strptime(date_str, '%Y%m%d').date()
else:
trade_date = date.today()
print(f'评分日期: {trade_date}')
from core.scoring.inference.scorer import GridSeekerPipeline
engine = GridSeekerPipeline()
rankings = engine.run(trade_date)
if rankings.empty:
print('无评分结果')
return
if not dry_run:
engine.persist(rankings, trade_date)
print(f'结果已写入 ScoringResult ({len(rankings)} 条)')
# 打印 Top-20
print('\n===== Top-20 =====')
print(f'{"Rank":<6} {"Code":<10} {"Profit":>10} {"Rounds":>10} {"Prob":>10}')
print('-' * 50)
for code, row in rankings.head(20).iterrows():
print(f'{int(row["score_rank"]):<6} {code:<10} '
f'{row["stacking_probability"]:>10.4f} '
f'{row.get("rank_predicted_rounds", 0):>10.2f} '
f'{row.get("stacking_probability", 0):>10.4f}')
# ============================================================
# check 命令
# ============================================================
def _cmd_check():
if len(sys.argv) < 3:
print('Usage: python -m core.scoring.cli check <stock_code>')
return
stock_code = sys.argv[2]
from core.scoring.features.validator import _check_kline_sufficiency
ok, reason, close = _check_kline_sufficiency(stock_code, date.today())
if ok:
print(f'{stock_code}: ✅ 通过 (close={close:.2f})')
else:
print(f'{stock_code}: ❌ {reason}')
# ============================================================
# list-candidates 命令
# ============================================================
def _cmd_list_candidates():
from core.scoring.features.validator import load_candidates
ctx = load_candidates(date.today())
print(f'候选股总数: {len(ctx.candidates)}')
print(f'排除: {len(ctx.excluded)}')
print(f'\n候选股 (前100):')
for i, code in enumerate(ctx.candidates[:100]):
print(f' {i + 1}. {code}')
if ctx.excluded:
print(f'\n排除原因 (前20):')
for i, (code, reason) in enumerate(list(ctx.excluded.items())[:20]):
print(f' {code}: {reason}')
if __name__ == '__main__':
main()
+54
View File
@@ -0,0 +1,54 @@
"""
grid_seeker v6.6 评分配置常量
"""
from pathlib import Path
import config as app_config
# ---- 网格交易参数 ----
GRID_LOW = 1 # 网格下限
GRID_HIGH = 11 # 网格上限
GRID_STEP = 1 # 网格间距(整数格)
# ---- 候选股过滤 ----
FILTER_MIN_CLOSE = 8 # 最低收盘价
FILTER_MAX_CLOSE = 13 # 最高收盘价
REQUIRE_DAYS = 120 # 最少交易日数
# ---- 窗口参数 ----
WINDOW_180D = 180 # 长窗口(情绪特征)
WINDOW_60D = 60 # 中窗口
WINDOW_20D = 20 # 短窗口(独立性特征)
ATR_PERIOD = 14 # ATR 周期
# ---- 指数 ----
HS300_CODE = '000300' # 沪深300基准指数
TRACKED_INDICES = [
'000001', # 上证指数
'000300', # 沪深300
'000852', # 中证1000
'000905', # 中证500
'399001', # 深证成指
'399006', # 创业板指
]
# ---- 市场状态 ----
PANIC_ADVANCE_RATIO = 0.20 # 恐慌日涨跌比阈值
GREED_ADVANCE_RATIO = 0.55 # 贪婪日涨跌比阈值
PANIC_TURNOVER_RATIO = 1.5 # 恐慌日量比阈值
# ---- 模型文件 ----
def model_dir() -> Path:
"""模型文件目录"""
return app_config.app_dir() / 'models'
def get_model_path(name: str) -> Path:
"""获取指定模型文件路径"""
return model_dir() / f'{name}.pkl'
# 3 级模型文件名 (v6.6)
RANK_MODEL = 'rank'
TOP_MODEL = 'top'
STACKING_MODEL = 'stacking'
# Stacking 选股阈值
STACKING_THRESHOLD = 0.35
+3
View File
@@ -0,0 +1,3 @@
# 特征工程子包
from core.scoring.features.validator import load_candidates, DataContext
from core.scoring.features.pipeline import FeaturePipeline
+147
View File
@@ -0,0 +1,147 @@
"""
情绪弹性特征 (4维) — calculate_relaxed_emotion_features(df, market_regime)
180 日窗口,筛选 advance_ratio < 0.20 恐慌日 + advance_ratio > 0.55 贪婪日。
"""
import numpy as np
import pandas as pd
from core.scoring.config import PANIC_ADVANCE_RATIO, GREED_ADVANCE_RATIO, WINDOW_180D
def calculate_relaxed_emotion_features(ctx) -> pd.DataFrame:
"""计算情绪弹性特征 (4维)"""
kline = ctx.kline.copy()
mkt = ctx.market_regime.copy() if ctx.market_regime is not None else pd.DataFrame()
if kline.empty or mkt.empty:
return pd.DataFrame()
# 筛选恐慌日和贪婪日
panic_dates = set()
greed_dates = set()
for _, row in mkt.iterrows():
td = row['trade_date']
ar = row.get('advance_ratio', 0.5)
if ar < PANIC_ADVANCE_RATIO:
panic_dates.add(td.date() if hasattr(td, 'date') else td)
if ar > GREED_ADVANCE_RATIO:
greed_dates.add(td.date() if hasattr(td, 'date') else td)
kline = kline.sort_values(['stock_code', 'trade_date'])
kline['td'] = (kline['trade_date'].dt.date
if hasattr(kline['trade_date'], 'dt')
else pd.to_datetime(kline['trade_date']).dt.date)
candidates = ctx.candidates
# 计算全市场平均振幅 (用于恐慌/贪婪 ratio)
kline['amp'] = (kline['high'] - kline['low']) / np.where(
kline['open'] > 0, kline['open'], 1
)
market_amp = kline.groupby('td')['amp'].mean().to_dict()
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
if len(group) < 20:
continue
g = group.sort_values('trade_date').tail(WINDOW_180D)
closes = g['close'].values
opens = g['open'].values
highs = g['high'].values
lows = g['low'].values
dates = g['td'].values
amps = (highs - lows) / np.where(opens > 0, opens, 1)
feat = {}
# 恐慌日分析
panic_indices = [i for i, d in enumerate(dates) if d in panic_dates]
if panic_indices:
panic_amp_ratios = []
panic_drop_ratios = []
panic_rebounds = []
for pi in panic_indices:
td = dates[pi]
mkt_amp_val = market_amp.get(td, amps[pi])
if mkt_amp_val > 0:
panic_amp_ratios.append(amps[pi] / mkt_amp_val)
# 恐慌日跌幅
if pi > 0 and closes[pi - 1] > 0:
drop = (closes[pi] - closes[pi - 1]) / closes[pi - 1]
# 全市场跌幅: 用 advance_ratio 估算
ar = _get_advance_ratio(mkt, td)
market_drop = -0.02 if ar < PANIC_ADVANCE_RATIO else -0.005
if market_drop != 0:
panic_drop_ratios.append(drop / market_drop)
# 恐慌次日反弹
if pi + 1 < len(g) and closes[pi] > 0:
panic_rebounds.append(highs[pi + 1] / closes[pi] - 1)
# 64. relaxed_panic_amplitude_ratio
feat['relaxed_panic_amplitude_ratio'] = (
np.median(panic_amp_ratios) if panic_amp_ratios else 1.0
)
# 65. relaxed_panic_rebound_strength
feat['relaxed_panic_rebound_strength'] = (
np.median(panic_rebounds) if panic_rebounds else 0.0
)
else:
feat['relaxed_panic_amplitude_ratio'] = 1.0
feat['relaxed_panic_rebound_strength'] = 0.0
# 贪婪日分析
greed_indices = [i for i, d in enumerate(dates) if d in greed_dates]
if greed_indices:
greed_gains = []
greed_amp_ratios = []
for gi in greed_indices:
td = dates[gi]
if gi > 0 and closes[gi - 1] > 0:
gain = (closes[gi] - closes[gi - 1]) / closes[gi - 1]
# 全市场收益
ar = _get_advance_ratio(mkt, td)
market_gain = 0.01 if ar > GREED_ADVANCE_RATIO else 0.003
if market_gain > 0:
greed_gains.append(gain / market_gain)
mkt_amp_val = market_amp.get(td, amps[gi])
if mkt_amp_val > 0:
greed_amp_ratios.append(amps[gi] / mkt_amp_val)
# 66. relaxed_greed_relative_gain
feat['relaxed_greed_relative_gain'] = (
np.median(greed_gains) if greed_gains else 0.0
)
# 67. relaxed_greed_amplitude_ratio
feat['relaxed_greed_amplitude_ratio'] = (
np.median(greed_amp_ratios) if greed_amp_ratios else 1.0
)
else:
feat['relaxed_greed_relative_gain'] = 0.0
feat['relaxed_greed_amplitude_ratio'] = 1.0
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
def _get_advance_ratio(mkt_df, trade_date):
"""获取指定日期的 advance_ratio"""
if mkt_df is None or mkt_df.empty:
return 0.5
td = trade_date
match = mkt_df[mkt_df['trade_date'] == td]
if not match.empty:
return match.iloc[0].get('advance_ratio', 0.5)
return 0.5
@@ -0,0 +1,97 @@
"""
大盘独立性特征 (3维) — calculate_independence_features(df)
使用 HS300(000300) 作为 benchmark,最近 20 个交易日 OLS 回归。
"""
import numpy as np
import pandas as pd
from core.scoring.config import WINDOW_20D
from core.scoring.features.v3_2_features import _ols_slope
def calculate_independence_features(ctx) -> pd.DataFrame:
"""计算大盘独立性特征 (3维)"""
kline = ctx.kline.copy()
hs300 = ctx.hs300_kline.copy() if ctx.hs300_kline is not None else pd.DataFrame()
if kline.empty or hs300.empty:
return pd.DataFrame()
# 准备 HS300 收益率序列
hs300 = hs300.sort_values('trade_date')
hs300['market_return'] = hs300['close'].pct_change()
hs300['market_amplitude'] = (hs300['high'] - hs300['low']) / hs300['open']
hs300 = hs300.dropna(subset=['market_return', 'market_amplitude'])
# 对齐日期
hs300_dates = set(hs300['trade_date'].dt.date
if hasattr(hs300['trade_date'], 'dt') else hs300['trade_date'])
kline = kline.sort_values(['stock_code', 'trade_date'])
kline['trade_date_dt'] = (kline['trade_date'].dt.date
if hasattr(kline['trade_date'], 'dt')
else pd.to_datetime(kline['trade_date']).dt.date)
candidates = ctx.candidates
# 计算 HS300 最近20日平均振幅
hs300_tail = hs300.tail(WINDOW_20D)
hs300_avg_amp = hs300_tail['market_amplitude'].mean() if len(hs300_tail) > 0 else 0
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
if len(group) < 20:
continue
g = group.sort_values('trade_date').tail(120)
closes = g['close'].values
# 计算个股日收益率
stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1)
# 对齐 HS300 收益率 (取对应日期)
# 简化: 取最近 N 个交易日的数据点
n = min(WINDOW_20D, len(stock_rets))
# 获取 HS300 最近 n 天的 market_return
market_rets = hs300['market_return'].tail(n + 1).values
if len(market_rets) < n:
market_rets = hs300['market_return'].values[-n - 1:]
# 对齐长度
min_len = min(n, len(market_rets) - 1, len(stock_rets))
if min_len < 5: # 至少需要5个数据点做回归
continue
stock_ret_window = stock_rets[-min_len:]
market_ret_window = market_rets[-min_len:]
feat = {}
# OLS 回归: stock_ret ~ market_return
try:
slope, r_value = _ols_slope(market_ret_window, stock_ret_window)
residuals = stock_ret_window - slope * market_ret_window
# 58. market_residual_volatility_20d: std(residuals) × √252
feat['market_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252)
# 59. market_independence_ratio_20d: 1 - R²
feat['market_independence_ratio_20d'] = 1 - r_value ** 2
except Exception:
feat['market_residual_volatility_20d'] = 0
feat['market_independence_ratio_20d'] = 1
# 60. market_amplitude_deviation_20d: 个股平均振幅 - HS300 平均振幅
g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where(
g['open'].values[-20:] > 0, g['open'].values[-20:], 1
)
feat['market_amplitude_deviation_20d'] = np.mean(g_amps) - hs300_avg_amp
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
@@ -0,0 +1,84 @@
"""
负向指标 (5维) — calculate_negative_features(df)
注意: #53 trend_consistency_20d 与 #39 同名不同义,更新字典时会覆盖 #39
"""
import numpy as np
import pandas as pd
def calculate_negative_features(ctx) -> pd.DataFrame:
"""计算负向指标 (5维)"""
kline = ctx.kline.copy()
if kline.empty:
return pd.DataFrame()
kline = kline.sort_values(['stock_code', 'trade_date'])
candidates = ctx.candidates
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
g = group.tail(120)
if len(g) < 20:
continue
closes = g['close'].values
opens = g['open'].values
highs = g['high'].values
lows = g['low'].values
volumes = g['volume'].values
feat = {}
# 53. trend_consistency_20d (负向版本): |mean(return>0) - 0.5| × 100
# 衡量偏离均衡的程度,越接近50%越弱
rets_20 = np.diff(closes[-21:]) / np.where(closes[-21:-1] > 0, closes[-21:-1], 1)
feat['trend_consistency_20d'] = abs(np.mean(rets_20 > 0) - 0.5) * 100
# 54. max_consecutive_direction_20d: 最大连续同向天数
rets_sign = np.sign(np.diff(closes[-21:]))
max_consec = 0
curr_consec = 0
curr_sign = 0
for s in rets_sign:
if s != 0 and s == curr_sign:
curr_consec += 1
elif s != 0:
curr_sign = s
curr_consec = 1
else:
curr_consec = 0
max_consec = max(max_consec, curr_consec)
feat['max_consecutive_direction_20d'] = max_consec
# 55. gap_risk_20d: mean(|open_t - close_{t-1}|/close_{t-1} > 0.02) × 100
gap_count = 0
n = 0
for i in range(max(0, len(g) - 20), len(g)):
if i > 0 and closes[i - 1] > 0:
gap_pct = abs(opens[i] - closes[i - 1]) / closes[i - 1]
if gap_pct > 0.02:
gap_count += 1
n += 1
feat['gap_risk_20d'] = gap_count / n * 100 if n > 0 else 0
# 56. liquidity_drying_up_20d: min(vol_20d)/mean(vol_60d)
vol_20 = volumes[-20:]
vol_60 = volumes[-60:] if len(volumes) >= 60 else volumes
feat['liquidity_drying_up_20d'] = (
np.min(vol_20) / np.mean(vol_60) if np.mean(vol_60) > 0 else 1
)
# 57. price_stagnation_20d: (max(high_20d)-min(low_20d))/close × 100
h20 = np.max(highs[-20:])
l20 = np.min(lows[-20:])
feat['price_stagnation_20d'] = (
(h20 - l20) / closes[-1] * 100 if closes[-1] > 0 else 0
)
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
+106
View File
@@ -0,0 +1,106 @@
"""
特征工程编排器 — 串联全部特征组,输出完整特征 DataFrame
"""
import pandas as pd
from datetime import date
from core.scoring.features.validator import load_candidates, DataContext
from core.scoring.features.v3_2_features import calculate_features_v3_2
from core.scoring.features.v3_3_features import calculate_features_v3_3
from core.scoring.features.v3_4_features import calculate_features_v3_4
from core.scoring.features.negative_features import calculate_negative_features
from core.scoring.features.independence_features import calculate_independence_features
from core.scoring.features.sector_features import calculate_sector_independence_features
from core.scoring.features.emotion_features import calculate_relaxed_emotion_features
from core.logger import LogLevel, PrintLog
class FeaturePipeline:
"""
特征工程管道 — 串联 8 组特征计算,输出完整特征矩阵。
Usage:
pipeline = FeaturePipeline(trade_date=date.today())
feature_df = pipeline.run() # DataFrame indexed by stock_code
"""
def __init__(self, trade_date: date):
self.trade_date = trade_date
self.ctx: DataContext = None
def run(self) -> pd.DataFrame:
"""
执行完整特征工程管道。
返回: DataFrame indexed by stock_code, columns = 全部特征
"""
# Stage 0: 加载候选股和数据
PrintLog(LogLevel.INFO, '[pipeline] Stage 0: 加载候选股...')
self.ctx = load_candidates(self.trade_date)
if not self.ctx.candidates:
PrintLog(LogLevel.WARNING, '[pipeline] 无候选股通过过滤')
return pd.DataFrame()
PrintLog(LogLevel.INFO, f'[pipeline] 候选股: {len(self.ctx.candidates)}, '
f'排除: {len(self.ctx.excluded)}')
# Stage 1: v3.2 基础特征 (20维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 1: v3.2 基础特征 (20维)...')
df = calculate_features_v3_2(self.ctx)
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 2: v3.3 扩展特征 (16维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 2: v3.3 扩展特征 (16维)...')
df_v33 = calculate_features_v3_3(self.ctx)
df = df.join(df_v33, how='inner', rsuffix='_v33')
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 3: v3.4 扩展特征 (16维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 3: v3.4 扩展特征 (16维)...')
df_v34 = calculate_features_v3_4(self.ctx)
df = df.join(df_v34, how='inner', rsuffix='_v34')
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 4: 负向指标 (5维) — 注意 #53 覆盖 #39
PrintLog(LogLevel.INFO, '[pipeline] Stage 4: 负向指标 (5维)...')
df_neg = calculate_negative_features(self.ctx)
# 使用 update 模式: 负向指标的 trend_consistency_20d 覆盖 v3.4 版本
common_cols = set(df.columns) & set(df_neg.columns)
for col in common_cols:
df[col] = df_neg[col] # 覆盖
new_cols = set(df_neg.columns) - common_cols
for col in new_cols:
df[col] = df_neg[col]
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 5: 大盘独立性 (3维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 5: 大盘独立性 (3维)...')
df_ind = calculate_independence_features(self.ctx)
df = df.join(df_ind, how='left')
df[df_ind.columns] = df[df_ind.columns].fillna(0)
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 6: 行业独立性 (3维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 6: 行业独立性 (3维)...')
df_sec = calculate_sector_independence_features(self.ctx)
df = df.join(df_sec, how='left')
df[df_sec.columns] = df[df_sec.columns].fillna(0)
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# Stage 7: 情绪弹性 (4维)
PrintLog(LogLevel.INFO, '[pipeline] Stage 7: 情绪弹性 (4维)...')
df_emo = calculate_relaxed_emotion_features(self.ctx)
df = df.join(df_emo, how='left')
df[df_emo.columns] = df[df_emo.columns].fillna(1.0)
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
# 添加 latest_close 列 (Meta Ranker 输入)
df['latest_close'] = 0.0
for code in df.index:
g = self.ctx.kline[self.ctx.kline['stock_code'] == code]
if not g.empty:
g_sorted = g.sort_values('trade_date')
df.at[code, 'latest_close'] = float(g_sorted['close'].iloc[-1])
PrintLog(LogLevel.INFO,
f'[pipeline] 完成: {len(df)} 只股票, {len(df.columns)} 维特征')
return df
+88
View File
@@ -0,0 +1,88 @@
"""
行业独立性特征 (3维) — calculate_sector_independence_features(df)
匹配 sector_features_daily,最近 20 个交易日 OLS 回归。
"""
import numpy as np
import pandas as pd
from core.scoring.config import WINDOW_20D
from core.scoring.features.v3_2_features import _ols_slope
def calculate_sector_independence_features(ctx) -> pd.DataFrame:
"""计算行业独立性特征 (3维)"""
kline = ctx.kline.copy()
sector_df = ctx.sector_features.copy() if ctx.sector_features is not None else pd.DataFrame()
industry_map = ctx.industry_map
if kline.empty or sector_df.empty or not industry_map:
return pd.DataFrame()
sector_df = sector_df.sort_values(['sector_name', 'trade_date'])
kline = kline.sort_values(['stock_code', 'trade_date'])
candidates = ctx.candidates
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
if len(group) < 20:
continue
# 获取行业名称
sector_name = industry_map.get(str(code), '')
if not sector_name:
continue
# 获取该行业的指数数据
sector_data = sector_df[sector_df['sector_name'] == sector_name]
if sector_data.empty or len(sector_data) < 5:
continue
sector_data = sector_data.sort_values('trade_date').tail(120)
sector_rets = sector_data['sector_ret'].values / 100 # 转为小数
sector_amps = sector_data['sector_amplitude'].values / 100
g = group.sort_values('trade_date').tail(120)
closes = g['close'].values
# 个股日收益率
stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1)
# 对齐长度
n = min(WINDOW_20D, len(stock_rets), len(sector_rets) - 1)
if n < 5:
continue
stock_ret_window = stock_rets[-n:]
sector_ret_window = sector_rets[-n:]
feat = {}
# OLS: stock_ret ~ sector_ret
try:
slope, r_value = _ols_slope(sector_ret_window, stock_ret_window)
residuals = stock_ret_window - slope * sector_ret_window
# 61. sector_residual_volatility_20d
feat['sector_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252)
# 62. sector_independence_ratio_20d: 1 - R²
feat['sector_independence_ratio_20d'] = 1 - r_value ** 2
except Exception:
feat['sector_residual_volatility_20d'] = 0
feat['sector_independence_ratio_20d'] = 1
# 63. sector_amplitude_deviation_20d: 个股振幅 - 行业平均振幅
g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where(
g['open'].values[-20:] > 0, g['open'].values[-20:], 1
)
sector_avg_amp = np.mean(sector_amps[-20:]) if len(sector_amps) >= 20 else np.mean(sector_amps)
feat['sector_amplitude_deviation_20d'] = np.mean(g_amps) - sector_avg_amp
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
+203
View File
@@ -0,0 +1,203 @@
"""
基础特征 v3.2 (20维) — calculate_features(df, "v3.2")
"""
import numpy as np
import pandas as pd
import numpy as np
import pandas as pd
from core.scoring.config import GRID_LOW, GRID_HIGH
def calculate_features_v3_2(ctx) -> pd.DataFrame:
"""
计算 v3.2 基础特征 (20维)。
返回 DataFrame indexed by stock_code。
"""
kline = ctx.kline.copy()
if kline.empty:
return pd.DataFrame()
kline = kline.sort_values(['stock_code', 'trade_date'])
candidates = ctx.candidates
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
g = group.tail(120) # 取最近120日用于大部分窗口
if len(g) < 20:
continue
closes = g['close'].values
opens = g['open'].values
highs = g['high'].values
lows = g['low'].values
volumes = g['volume'].values
feat = {}
# 1. rolling_grid_ratio_20d: mean(close∈[1,11]) × 100
recent_20_close = closes[-20:]
feat['rolling_grid_ratio_20d'] = (
np.mean((recent_20_close >= GRID_LOW) & (recent_20_close <= GRID_HIGH)) * 100
)
# 2. cross_freq_20d: 日高低区间穿越≥1条整数网格线的天数比例
cross_count = 0
for i in range(max(0, len(g) - 20), len(g)):
h, l = highs[i], lows[i]
if h > l:
grid_low = int(np.ceil(l))
grid_high = int(np.floor(h))
if grid_high >= grid_low:
cross_count += 1
feat['cross_freq_20d'] = cross_count / min(20, len(g)) * 100
# 3. avg_daily_amp: mean((high-low)/open × 100)
amps = (highs - lows) / np.where(opens > 0, opens, 1) * 100
feat['avg_daily_amp'] = np.mean(amps[-20:])
# 4. high_amp_days: mean(振幅>2%) × 100
feat['high_amp_days'] = np.mean(amps[-20:] > 2) * 100
# 5. atr_pct: mean(ATR_14)/close × 100
atr = _compute_atr(highs, lows, closes, 14)
feat['atr_pct'] = np.mean(atr[-14:]) / closes[-1] * 100 if closes[-1] > 0 else 0
# 6. volatility_20d: std(log_return) × √252 × 100
log_rets = np.diff(np.log(np.maximum(closes, 1e-10)))
vol_20 = np.std(log_rets[-20:], ddof=1) if len(log_rets) >= 20 else 0
feat['volatility_20d'] = vol_20 * np.sqrt(252) * 100
# 7. price_cv: std(close)/mean(close) × 100
feat['price_cv'] = np.std(closes) / np.mean(closes) * 100 if np.mean(closes) > 0 else 0
# 8. bb_width: (MA20+2σ - (MA20-2σ))/MA20 × 100
ma20 = np.mean(closes[-20:])
std20 = np.std(closes[-20:], ddof=1)
feat['bb_width'] = (4 * std20) / ma20 * 100 if ma20 > 0 else 0
# 9. volume_ratio: mean(vol_20d)/mean(vol_60d)
vol_20m = np.mean(volumes[-20:])
vol_60m = np.mean(volumes[-60:]) if len(volumes) >= 60 else vol_20m
feat['volume_ratio'] = vol_20m / vol_60m if vol_60m > 0 else 1
# 10. obv_slope: OBV序列最近20日线性回归斜率
obv = _compute_obv(closes, volumes)
feat['obv_slope'] = _ols_slope(np.arange(20), obv[-20:])[0] if len(obv) >= 20 else 0
# 11. amplitude_cv: std(振幅)/mean(振幅)
feat['amplitude_cv'] = (np.std(amps[-20:]) / np.mean(amps[-20:])
if np.mean(amps[-20:]) > 0 else 0)
# 12. price_entropy: Shannon熵 (10 bins)
feat['price_entropy'] = _shannon_entropy(closes[-60:], bins=10)
# 13. intraday_trend_strength: mean(|close-open|/open) × 100
intraday = np.abs(closes[-20:] - opens[-20:]) / np.where(opens[-20:] > 0, opens[-20:], 1) * 100
feat['intraday_trend_strength'] = np.mean(intraday)
# 14-17. 交叉特征 (依赖前序特征)
feat['amp_x_grid'] = feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100
feat['amp_x_grid_vol'] = (feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100 *
feat['volume_ratio'] / 10000)
feat['amp_cv_x_entropy'] = feat['amplitude_cv'] * feat['price_entropy']
feat['cross_freq_x_bb'] = feat['cross_freq_20d'] * feat['bb_width'] / 100
# 18. ln_float_mv: ln(close × total_share + 1)
total_share = _get_total_share(ctx, code)
float_mv = closes[-1] * total_share if total_share else closes[-1] * 1e8
feat['ln_float_mv'] = np.log(float_mv + 1)
# 19. mv_vol_interact: ln_float_mv × volatility_20d/100
feat['mv_vol_interact'] = feat['ln_float_mv'] * feat['volatility_20d'] / 100
# 20. small_cap_premium: 1/(浮动市值 + 1)
feat['small_cap_premium'] = 1.0 / (float_mv + 1)
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
# ---- 辅助函数 ----
def _compute_atr(highs, lows, closes, period=14):
"""计算 ATR"""
n = len(closes)
tr = np.zeros(n)
for i in range(1, n):
h_l = highs[i] - lows[i]
h_c = abs(highs[i] - closes[i - 1])
l_c = abs(lows[i] - closes[i - 1])
tr[i] = max(h_l, h_c, l_c)
atr = np.zeros(n)
atr[:period] = np.mean(tr[:period])
for i in range(period, n):
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period
return atr
def _compute_obv(closes, volumes):
"""计算 OBV"""
obv = np.zeros(len(closes))
obv[0] = volumes[0]
for i in range(1, len(closes)):
if closes[i] > closes[i - 1]:
obv[i] = obv[i - 1] + volumes[i]
elif closes[i] < closes[i - 1]:
obv[i] = obv[i - 1] - volumes[i]
else:
obv[i] = obv[i - 1]
return obv
def _shannon_entropy(values, bins=10):
"""Shannon 熵"""
if len(values) < bins:
return 0.0
hist, _ = np.histogram(values, bins=bins)
hist = hist / hist.sum()
hist = hist[hist > 0]
return -np.sum(hist * np.log2(hist))
def _get_total_share(ctx, code):
"""从 StockInfo 获取总股本"""
# 尝试各种前缀
for prefix in ['', 'SH', 'SZ', 'BJ']:
key = f'{code}.{prefix}' if prefix else code
info = ctx.stock_info.get(key, {})
ts = info.get('total_share', None)
if ts and ts > 0:
return ts
return None
def _ols_slope(x, y):
"""
纯 numpy OLS 线性回归。
等价于 scipy.stats.linregress(x, y),返回 (slope, r_value)。
slope=0 且 r_value=0 表示计算失败(数据不足或方差为零)。
"""
if len(x) < 2 or len(y) < 2 or len(x) != len(y):
return 0.0, 0.0
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
x_mean = x.mean()
y_mean = y.mean()
num = np.sum((x - x_mean) * (y - y_mean))
den = np.sum((x - x_mean) ** 2)
if den < 1e-12:
return 0.0, 0.0
slope = num / den
ss_xy = num
ss_xx = np.sum((x - x_mean) ** 2)
ss_yy = np.sum((y - y_mean) ** 2)
if ss_xx < 1e-12 or ss_yy < 1e-12:
r_value = 0.0
else:
r_value = ss_xy / (np.sqrt(ss_xx) * np.sqrt(ss_yy))
return slope, r_value
+130
View File
@@ -0,0 +1,130 @@
"""
扩展特征 v3.3 (16维)
"""
import numpy as np
import pandas as pd
from core.scoring.features.v3_2_features import _ols_slope
def calculate_features_v3_3(ctx) -> pd.DataFrame:
"""计算 v3.3 扩展特征 (16维)"""
kline = ctx.kline.copy()
if kline.empty:
return pd.DataFrame()
kline = kline.sort_values(['stock_code', 'trade_date'])
candidates = ctx.candidates
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
g = group.tail(120)
if len(g) < 20:
continue
closes = g['close'].values
highs = g['high'].values
lows = g['low'].values
volumes = g['volume'].values
feat = {}
# 21. grid_touch_count_20d: 高低区间触碰网格线总次数
feat['grid_touch_count_20d'] = _grid_touch_count(highs[-20:], lows[-20:])
# 22. grid_touch_count_60d
h60 = highs[-60:] if len(highs) >= 60 else highs
l60 = lows[-60:] if len(lows) >= 60 else lows
feat['grid_touch_count_60d'] = _grid_touch_count(h60, l60)
# 23. grid_cross_density_20d
cross_count = 0
w = min(20, len(g))
for i in range(len(g) - w, len(g)):
h, l = highs[i], lows[i]
if h > l and int(np.floor(h)) >= int(np.ceil(l)):
cross_count += 1
feat['grid_cross_density_20d'] = cross_count / w
# 24. near_grid_line_ratio_20d
recent_closes = closes[-20:]
dist_to_int = np.abs(recent_closes - np.round(recent_closes))
feat['near_grid_line_ratio_20d'] = np.mean(dist_to_int <= 0.15) * 100
# 25. trend_slope_20d
feat['trend_slope_20d'] = _ols_slope(np.arange(20), closes[-20:])[0]
# 26. trend_slope_60d
c60 = closes[-60:] if len(closes) >= 60 else closes
feat['trend_slope_60d'] = _ols_slope(np.arange(len(c60)), c60)[0]
# 27. trend_abs_slope_20d
feat['trend_abs_slope_20d'] = abs(feat['trend_slope_20d'])
# 28. range_position_60d
h60_mx = np.max(highs[-60:]) if len(highs) >= 60 else np.max(highs)
l60_mn = np.min(lows[-60:]) if len(lows) >= 60 else np.min(lows)
feat['range_position_60d'] = (closes[-1] - l60_mn) / (h60_mx - l60_mn) * 100 \
if h60_mx > l60_mn else 50
# 29. drawdown_60d
c60_arr = closes[-60:] if len(closes) >= 60 else closes
cummax = np.maximum.accumulate(c60_arr)
dd = (1 - c60_arr / cummax) * 100
feat['drawdown_60d'] = np.max(dd)
# 30. rebound_from_low_60d
feat['rebound_from_low_60d'] = (closes[-1] - l60_mn) / l60_mn * 100 if l60_mn > 0 else 0
# 31. dist_to_grid_upper
from core.scoring.config import GRID_HIGH
feat['dist_to_grid_upper'] = max(0, (GRID_HIGH - closes[-1]) / 10 * 100)
# 32. dist_to_grid_lower
from core.scoring.config import GRID_LOW
feat['dist_to_grid_lower'] = max(0, (closes[-1] - GRID_LOW) / 10 * 100)
# 33. grid_room_balance
upper = feat['dist_to_grid_upper']
lower = feat['dist_to_grid_lower']
feat['grid_room_balance'] = min(upper, lower) / (upper + lower) * 100 \
if (upper + lower) > 0 else 50
# 34. amount_mean_20d
amounts = closes[-20:] * volumes[-20:]
feat['amount_mean_20d'] = np.mean(amounts)
# 35. amount_cv_20d
feat['amount_cv_20d'] = np.std(amounts) / np.mean(amounts) * 100 \
if np.mean(amounts) > 0 else 0
# 36. turnover_proxy_20d
total_share = _get_total_share(ctx, code) or 1e8
feat['turnover_proxy_20d'] = np.mean(volumes[-20:]) / (total_share * 1e8) * 100
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
def _get_total_share(ctx, code):
for prefix in ['', 'SH', 'SZ', 'BJ']:
key = f'{code}.{prefix}' if prefix else code
info = ctx.stock_info.get(key, {})
ts = info.get('total_share', None)
if ts and ts > 0:
return ts
return None
def _grid_touch_count(highs, lows):
count = 0
for h, l in zip(highs, lows):
if h > l:
grid_low = int(np.ceil(l))
grid_high = int(np.floor(h))
count += max(0, grid_high - grid_low + 1)
return count
+109
View File
@@ -0,0 +1,109 @@
"""
扩展特征 v3.4 (16维)
"""
import numpy as np
import pandas as pd
from core.scoring.features.v3_2_features import _ols_slope
from core.scoring.config import GRID_LOW, GRID_HIGH
def calculate_features_v3_4(ctx) -> pd.DataFrame:
"""计算 v3.4 扩展特征 (16维)"""
kline = ctx.kline.copy()
if kline.empty:
return pd.DataFrame()
kline = kline.sort_values(['stock_code', 'trade_date'])
candidates = ctx.candidates
features = {}
grouped = kline.groupby('stock_code')
for code, group in grouped:
if code not in candidates:
continue
g = group.tail(120)
if len(g) < 20:
continue
closes = g['close'].values
opens = g['open'].values
highs = g['high'].values
lows = g['low'].values
volumes = g['volume'].values
feat = {}
# 37. down_days_20d
rets_20 = np.diff(closes[-21:]) / closes[-21:-1]
feat['down_days_20d'] = np.mean(rets_20 < 0) * 100
# 38. up_days_20d
feat['up_days_20d'] = np.mean(rets_20 > 0) * 100
# 39. trend_consistency_20d
feat['trend_consistency_20d'] = max(feat['up_days_20d'], feat['down_days_20d'])
# 40. ma20_deviation_pct
ma20 = np.mean(closes[-20:])
feat['ma20_deviation_pct'] = (closes[-1] - ma20) / ma20 * 100 if ma20 > 0 else 0
# 41. ma60_deviation_pct
ma60 = np.mean(closes[-60:]) if len(closes) >= 60 else np.mean(closes)
feat['ma60_deviation_pct'] = (closes[-1] - ma60) / ma60 * 100 if ma60 > 0 else 0
# 42. ma20_ma60_gap_pct
feat['ma20_ma60_gap_pct'] = (ma20 - ma60) / ma60 * 100 if ma60 > 0 else 0
# 43. usable_grid_count_upper
feat['usable_grid_count_upper'] = sum(
1 for g in range(GRID_LOW, GRID_HIGH + 1) if g > closes[-1])
# 44. usable_grid_count_lower
feat['usable_grid_count_lower'] = sum(
1 for g in range(GRID_LOW, GRID_HIGH + 1) if g < closes[-1])
# 45. near_upper_boundary_risk
feat['near_upper_boundary_risk'] = max(0, min(100, (closes[-1] - 9) / 2 * 100))
# 46. near_lower_boundary_risk
feat['near_lower_boundary_risk'] = max(0, min(100, (3 - closes[-1]) / 2 * 100))
# 47. volume_cv_20d
vol_20 = volumes[-20:]
feat['volume_cv_20d'] = np.std(vol_20) / np.mean(vol_20) * 100 \
if np.mean(vol_20) > 0 else 0
# 48. amount_trend_20d
amounts = closes[-20:] * volumes[-20:]
feat['amount_trend_20d'] = _ols_slope(np.arange(len(amounts)), amounts)[0]
# 49. low_volume_days_20d
mean_vol = np.mean(vol_20)
feat['low_volume_days_20d'] = np.mean(vol_20 < 0.5 * mean_vol) * 100
# 50. close_reversal_count_20d
rets_sign = np.sign(np.diff(closes[-21:]))
reversals = sum(
1 for i in range(1, len(rets_sign))
if rets_sign[i] != 0 and rets_sign[i - 1] != 0 and rets_sign[i] != rets_sign[i - 1])
feat['close_reversal_count_20d'] = reversals
# 51. range_compression_20d
amps = (highs - lows) / np.where(closes > 0, closes, 1) * 100
amp_20_mean = np.mean(amps[-20:])
amp_60_mean = np.mean(amps[-60:]) if len(amps) >= 60 else amp_20_mean
feat['range_compression_20d'] = amp_20_mean / amp_60_mean * 100 if amp_60_mean > 0 else 100
# 52. wick_ratio_20d
upper_wick = highs[-20:] - np.maximum(opens[-20:], closes[-20:])
lower_wick = np.minimum(opens[-20:], closes[-20:]) - lows[-20:]
body = np.abs(closes[-20:] - opens[-20:])
total_len = highs[-20:] - lows[-20:]
wick_len = upper_wick + lower_wick
feat['wick_ratio_20d'] = np.mean(
wick_len / np.where(total_len > 0, total_len, 1)) * 100
features[code] = feat
return pd.DataFrame.from_dict(features, orient='index')
+197
View File
@@ -0,0 +1,197 @@
"""
候选股过滤与数据加载
"""
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
+2
View File
@@ -0,0 +1,2 @@
# 模型推理子包
from core.scoring.inference.scorer import GridSeekerPipeline
+217
View File
@@ -0,0 +1,217 @@
"""
grid_seeker v6.6 三级模型推理管道
Rank → Top → Stacking → stacking_probability (最终排序)
"""
import pickle
import numpy as np
import pandas as pd
from pathlib import Path
from datetime import date
from core.scoring.config import (
get_model_path, RANK_MODEL, TOP_MODEL, STACKING_MODEL,
STACKING_THRESHOLD,
)
from core.scoring.features.pipeline import FeaturePipeline
from core.scoring.models import ScoringResult
from core.database import db
from core.logger import LogLevel, PrintLog
# ============================================================
# Rank 模型输入特征 (52维, v3.4, 直接从模型文件的 selected_features 读取)
# ============================================================
def _get_rank_features() -> list:
import pickle
from core.scoring.config import get_model_path
path = get_model_path(RANK_MODEL)
with open(path, 'rb') as f:
obj = pickle.load(f)
if isinstance(obj, dict):
sf = obj.get('selected_features', [])
if sf:
return sf
raise RuntimeError("无法从 rank.pkl 读取 selected_features")
RANK_FEATURE_COLS = _get_rank_features()
class GridSeekerPipeline:
"""
grid_seeker v6.6 三级模型评分管道。
Usage:
engine = GridSeekerPipeline()
rankings = engine.run(trade_date=date.today())
# 返回 DataFrame: stock_code, stacking_probability, rank 等
"""
def __init__(self, model_dir: Path = None):
self._rank_model = None
self._top_model = None
self._stacking_model = None
# ---- 模型加载 ----
def _load_model(self, name: str):
"""加载单个 .pkl 模型"""
path = get_model_path(name)
if not path.exists():
raise FileNotFoundError(f'模型文件不存在: {path}')
with open(path, 'rb') as f:
obj = pickle.load(f)
# 支持 dict 格式 {"model": lgbm_model, ...} 或直接返回模型对象
if isinstance(obj, dict):
return obj.get('model', obj)
return obj
@property
def rank_model(self):
if self._rank_model is None:
self._rank_model = self._load_model(RANK_MODEL)
return self._rank_model
@property
def top_model(self):
if self._top_model is None:
self._top_model = self._load_model(TOP_MODEL)
return self._top_model
@property
def stacking_model(self):
if self._stacking_model is None:
self._stacking_model = self._load_model(STACKING_MODEL)
return self._stacking_model
# ---- 预测 ----
def _predict_with_model(self, model, X: pd.DataFrame, feature_cols: list) -> np.ndarray:
"""
使用模型预测。自动选择特征子集,兼容 sklearn API (predict/predict_proba)。
"""
available = [c for c in feature_cols if c in X.columns]
missing = set(feature_cols) - set(available)
if missing:
PrintLog(LogLevel.WARNING,
f'[scorer] 缺少特征列 ({len(missing)}): {list(missing)[:5]}...')
X_sub = X[available].fillna(0).values
try:
if hasattr(model, 'predict_proba'):
proba = model.predict_proba(X_sub)
if proba.shape[1] >= 2:
return proba[:, 1]
return proba[:, 0]
elif hasattr(model, 'predict'):
return model.predict(X_sub)
else:
return model.predict(X_sub)
except Exception as e:
PrintLog(LogLevel.ERROR, f'[scorer] 模型预测失败: {e}')
raise
# ---- 主流程 ----
def run(self, trade_date: date) -> pd.DataFrame:
"""
执行完整的 3 级评分管道。
Returns:
DataFrame indexed by stock_code, 含 stacking_probability / rank 等列,
按 stacking_probability 降序排列
"""
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.6 评分开始 ({trade_date}) =====')
# 1. 特征工程
pipeline = FeaturePipeline(trade_date)
feature_df = pipeline.run()
if feature_df.empty:
PrintLog(LogLevel.WARNING, '[scorer] 无股票通过特征工程, 终止')
return pd.DataFrame()
PrintLog(LogLevel.INFO,
f'[scorer] 特征工程完成: {len(feature_df)} stocks, '
f'{len(feature_df.columns)} dims')
# 2. Stage 1: Rank 模型 → rank_predicted_rounds (52维)
PrintLog(LogLevel.INFO, '[scorer] Stage 1/3: Rank 模型...')
feature_df['rank_predicted_rounds'] = self._predict_with_model(
self.rank_model, feature_df, RANK_FEATURE_COLS
)
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52 + rank_predicted_rounds)
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
top_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds']
feature_df['top_elite_prob'] = self._predict_with_model(
self.top_model, feature_df, top_cols
)
# 4. Stage 3: Stacking 模型 → stacking_probability (54维 = 52 + rank + top)
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
stk_cols = RANK_FEATURE_COLS + ['rank_predicted_rounds', 'top_elite_prob']
feature_df['stacking_probability'] = self._predict_with_model(
self.stacking_model, feature_df, stk_cols
)
# 5. 排序(直接用 stacking_probability
feature_df['score_rank'] = feature_df['stacking_probability'].rank(
ascending=False, method='min'
).astype(int)
feature_df['candidate_count'] = len(feature_df)
feature_df = feature_df.sort_values('score_rank')
n_above = (feature_df['stacking_probability'] >= STACKING_THRESHOLD).sum()
PrintLog(LogLevel.INFO,
f'[scorer] 评分完成: {len(feature_df)} 只候选, '
f'{n_above} 只高于阈值 {STACKING_THRESHOLD}')
PrintLog(LogLevel.INFO,
f'[scorer] Top-5: '
f'{feature_df.head(5)[["stacking_probability", "rank_predicted_rounds"]].to_dict("index")}')
return feature_df
def persist(self, rankings: pd.DataFrame, trade_date: date):
"""将评分结果持久化到 ScoringResult 表"""
if rankings.empty:
return
records = []
for code, row in rankings.iterrows():
records.append({
'stock_code': str(code),
'trade_date': trade_date,
'predicted_profit': float(row.get('stacking_probability', 0)),
'rank_predicted_rounds': float(row.get('rank_predicted_rounds', 0))
if 'rank_predicted_rounds' in row else None,
'top_elite_prob': float(row.get('top_elite_prob', 0))
if 'top_elite_prob' in row else None,
'stacking_probability': float(row.get('stacking_probability', 0))
if 'stacking_probability' in row else None,
'score_rank': int(row.get('score_rank', 0)),
'candidate_count': int(row.get('candidate_count', 0)),
})
with db.atomic():
for batch in _chunked(records, 500):
ScoringResult.insert_many(batch).on_conflict_replace().execute()
PrintLog(LogLevel.INFO,
f'[scorer] 评分结果已持久化: {len(records)}')
def get_top_n(self, trade_date: date, n: int = 50) -> list[dict]:
"""查询历史评分 Top-N"""
rows = (ScoringResult
.select()
.where(
(ScoringResult.trade_date == trade_date) &
(ScoringResult.score_rank <= n)
)
.order_by(ScoringResult.score_rank)
.dicts())
return list(rows)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+132
View File
@@ -0,0 +1,132 @@
"""
grid_seeker v6.4 数据库模型 — 6 张数据表 + 1 张评分结果表
所有模型继承 core.database.BaseModel,复用现有 SQLite 连接。
"""
from peewee import (
CharField, DateField, FloatField, IntegerField,
CompositeKey, TextField,
)
from core.database import BaseModel, db
# ============================================================
# 1. kline_stock — 个股日K线
# ============================================================
class KlineStock(BaseModel):
stock_code = CharField(max_length=10) # 纯数字6位
trade_date = DateField()
open = FloatField()
high = FloatField()
low = FloatField()
close = FloatField()
volume = FloatField() # 成交量(股)
class Meta:
primary_key = CompositeKey('stock_code', 'trade_date')
indexes = (
(('stock_code', 'trade_date'), False),
)
# ============================================================
# 2. stocks — 股票基础信息
# ============================================================
class StockInfo(BaseModel):
code = CharField(max_length=12, primary_key=True) # 带 SH/SZ/BJ 前缀
name = CharField(max_length=32)
exchange = CharField(max_length=4) # SH / SZ / BJ
list_date = DateField(null=True)
listing_status = CharField(max_length=16, default='normal') # normal / delisted / ST
total_share = FloatField(null=True) # 总股本(股)
float_share = FloatField(null=True) # 流通股本(股)
share_updated_at = DateField(null=True) # 股本数据同步时间
# ============================================================
# 3. industry — 股票-行业映射
# ============================================================
class IndustryMapping(BaseModel):
code = CharField(max_length=6, primary_key=True) # 纯数字6位
industry_name = CharField(max_length=64, index=True)
industry_classification = CharField(max_length=32) # 行业分类体系名称
update_date = DateField()
# ============================================================
# 4. kline_index — 指数日K线
# ============================================================
class KlineIndex(BaseModel):
index_code = CharField(max_length=10) # 指数代码(6位数字)
trade_date = DateField()
open = FloatField()
high = FloatField()
low = FloatField()
close = FloatField()
volume = FloatField()
class Meta:
primary_key = CompositeKey('index_code', 'trade_date')
indexes = (
(('index_code', 'trade_date'), False),
)
# ============================================================
# 5. market_regime_daily — 市场状态(本地计算)
# ============================================================
class MarketRegimeDaily(BaseModel):
trade_date = DateField(primary_key=True)
advancers = FloatField(default=0) # 当日上涨家数
decliners = FloatField(default=0) # 当日下跌家数
advance_ratio = FloatField(default=0) # 涨跌比 = advancers/(advancers+decliners)
turnover = FloatField(default=0) # 全市场成交额
turnover_avg_5d = FloatField(default=0) # 5日滚动均量
turnover_ratio_5d = FloatField(default=0) # 量比 = turnover/turnover_avg_5d
source = CharField(max_length=32, default='qmt')
is_extreme_panic = IntegerField(default=0) # advance_ratio<0.2 且 turnover_ratio_5d>1.5
# ============================================================
# 6. sector_features_daily — 行业聚合指数(本地计算)
# ============================================================
class SectorFeaturesDaily(BaseModel):
trade_date = DateField()
sector_name = CharField(max_length=64) # 与 industry.industry_name 对应
sector_ret = FloatField(default=0) # 行业日收益率(均值)
sector_amplitude = FloatField(default=0) # 行业平均振幅
close = FloatField(default=100) # 行业指数(基值100
ema10 = FloatField(default=0)
ema20 = FloatField(default=0)
ema200 = FloatField(default=0)
score = IntegerField(default=0) # 趋势评分 0/1/2
class Meta:
primary_key = CompositeKey('trade_date', 'sector_name')
# ============================================================
# 7. ScoringResult — 评分结果(模型输出写入表)
# ============================================================
class ScoringResult(BaseModel):
stock_code = CharField(max_length=6) # 纯数字6位
trade_date = DateField() # 评分日
predicted_profit = FloatField(default=0) # 最终预测利润(=stacking_probability
rank_predicted_rounds = FloatField(null=True) # Rank 模型输出
top_elite_prob = FloatField(null=True) # Top 模型输出
stacking_probability = FloatField(null=True) # Stacking 模型输出
score_rank = IntegerField(default=0) # 排名
candidate_count = IntegerField(default=0) # 候选股总数
class Meta:
primary_key = CompositeKey('stock_code', 'trade_date')
# ============================================================
# 建表
# ============================================================
ALL_SCORING_TABLES = [
KlineStock, StockInfo, IndustryMapping, KlineIndex,
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
]
db.create_tables(ALL_SCORING_TABLES)
+7
View File
@@ -0,0 +1,7 @@
# 数据同步子包
from core.scoring.sync.base import BaseSync
from core.scoring.sync.kline_sync import KlineStockSync, KlineIndexSync
from core.scoring.sync.stocks_sync import StocksSync
from core.scoring.sync.industry_sync import IndustrySync
from core.scoring.sync.market_regime import MarketRegimeSync
from core.scoring.sync.sector_features import SectorFeaturesSync
+41
View File
@@ -0,0 +1,41 @@
"""
数据同步抽象基类
"""
import abc
from datetime import datetime
from core.logger import LogLevel, PrintLog
class BaseSync(abc.ABC):
"""数据同步抽象基类,所有同步操作遵循 _fetch → _upsert 模式"""
def __init__(self):
self.stats = {'inserted': 0, 'updated': 0, 'skipped': 0, 'errors': 0}
def run(self, **kwargs) -> dict:
"""同步入口: 拉取数据 → 写入数据库 → 返回统计"""
name = self.__class__.__name__
PrintLog(LogLevel.INFO, f'[sync] {name} 开始同步...')
t0 = datetime.now()
try:
data = self._fetch(**kwargs)
self._upsert(data)
elapsed = (datetime.now() - t0).total_seconds()
PrintLog(
LogLevel.INFO,
f'[sync] {name} 完成 ({elapsed:.1f}s) — '
f'insert={self.stats["inserted"]} update={self.stats["updated"]} '
f'skip={self.stats["skipped"]} err={self.stats["errors"]}'
)
except Exception as e:
PrintLog(LogLevel.ERROR, f'[sync] {name} 失败: {e}')
raise
return self.stats
@abc.abstractmethod
def _fetch(self, **kwargs):
"""从数据源拉取原始数据。子类实现。"""
@abc.abstractmethod
def _upsert(self, data):
"""将数据写入数据库。子类实现。"""
+84
View File
@@ -0,0 +1,84 @@
"""
行业映射同步 — 从 QMT get_sector_list + get_stock_list_in_sector 获取
"""
from datetime import date
from core.scoring.sync.base import BaseSync
from core.scoring.models import IndustryMapping
from core.database import db
from core.logger import LogLevel, PrintLog
class IndustrySync(BaseSync):
"""行业映射同步 — QMT 行业板块 → IndustryMapping 表(全量替换)"""
def _fetch(self, **kwargs):
"""从 QMT 拉取全部行业板块的成份股映射"""
from xtquant import xtdata
all_sectors = xtdata.get_sector_list()
PrintLog(LogLevel.INFO, f'[sync] Industry: 共 {len(all_sectors)} 个板块')
# 尝试使用 get_sector_info 过滤行业板块
industry_sectors = []
try:
sector_info = xtdata.get_sector_info()
if sector_info is not None and not sector_info.empty:
for _, row in sector_info.iterrows():
cat = row.get('category', '')
if '行业' in str(cat):
industry_sectors.append(row['sector'])
except Exception:
pass
# 如果 get_sector_info 无效,回退到名称过滤
if not industry_sectors:
for s in all_sectors:
# 排除明显非行业的板块
skip_markers = ['概念', '风格', '地域', '地区', '指数', '自定义',
'ETF', 'LOF', '债券', '基金', '期货', '期权']
if any(m in s for m in skip_markers):
continue
industry_sectors.append(s)
PrintLog(LogLevel.INFO, f'[sync] Industry: 筛选出 {len(industry_sectors)} 个行业板块')
# 构建 code → {industry_name, classification} 映射
mapping = {} # code → (industry_name, classification)
today = date.today()
for sector_name in industry_sectors:
try:
stocks = xtdata.get_stock_list_in_sector(sector_name)
for full_code in stocks:
code = full_code.split('.')[0]
if code not in mapping:
mapping[code] = {
'code': code,
'industry_name': sector_name,
'industry_classification': 'QMT',
'update_date': today,
}
except Exception:
self.stats['errors'] += 1
self.stats['inserted'] = len(mapping)
return list(mapping.values())
def _upsert(self, data: list):
"""全量替换: 清空旧数据 → 批量插入新数据"""
if not data:
PrintLog(LogLevel.WARNING, '[sync] Industry: 无数据, 跳过')
return
with db.atomic():
IndustryMapping.delete().execute()
for batch in _chunked(data, 500):
IndustryMapping.insert_many(batch).execute()
PrintLog(LogLevel.INFO,
f'[sync] Industry: 全量替换完成, {len(data)} 条映射')
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+222
View File
@@ -0,0 +1,222 @@
"""
K线数据同步 — 个股日K + 指数日K
数据源: QMT xtdata
增量同步: 只拉 max(trade_date) 之后的增量数据
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
"""
import pandas as pd
from datetime import date, timedelta
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, KlineIndex
from core.scoring.config import TRACKED_INDICES
from core.database import db
from core.logger import LogLevel, PrintLog
BATCH_SIZE = 50
DEFAULT_COUNT = 300
# 全局同步状态标记(字典引用传递,可被外部轮询)
_sync_state = {"kline": False, "index": False, "stocks": False,
"industry": False, "market": False, "sector": False}
def is_syncing(key="kline") -> bool:
return _sync_state.get(key, False)
def _latest_date(model_cls) -> date | None:
from peewee import fn
row = model_cls.select(fn.MAX(model_cls.trade_date)).scalar()
if isinstance(row, date):
return row
return None
def _safe_get(df_dict, code, dt, default=0.0) -> float:
"""安全获取 DataFrame 值"""
if df_dict is None:
return default
df = df_dict.get(code)
if df is None or code not in df.index:
return default
try:
val = df.loc[code, dt]
if pd.isna(val):
return default
return float(val)
except Exception:
return default
class KlineStockSync(BaseSync):
"""个股日K线同步 — 增量:只拉 max(trade_date) 之后的增量数据"""
def __init__(self, count: int = DEFAULT_COUNT):
super().__init__()
self.count = count
def _fetch(self, **kwargs):
from xtquant import xtdata
# 增量判断
latest = _latest_date(KlineStock)
today = date.today()
if latest is not None and latest >= today:
PrintLog(LogLevel.INFO, f'[sync] KlineStock: 已最新 ({latest}),跳过')
self.stats['skipped'] = 0
return self.stats
# 增量起点
start_date = (latest + timedelta(days=1)) if latest else None
start_str = start_date.strftime('%Y%m%d') if start_date else ""
PrintLog(LogLevel.INFO,
f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}')
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股')
field_list = ['open', 'high', 'low', 'close', 'volume']
total = len(all_stocks)
inserted = 0
for i, code in enumerate(all_stocks):
if i > 0 and i % 50 == 0:
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
try:
xtdata.download_history_data(code, period='1d', start_time=start_str)
except Exception:
self.stats['errors'] += 1
continue
try:
result = xtdata.get_market_data(
field_list=field_list, stock_list=[code], period='1d',
count=self.count, dividend_type='none', fill_data=False)
inserted += self._upsert_incremental(code, result, start_date)
except Exception:
self.stats['errors'] += 1
self.stats['inserted'] = inserted
PrintLog(LogLevel.INFO,
f'[sync] KlineStock 完成: 新增={inserted} '
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
return self.stats
def _upsert_incremental(self, full_code: str, result: dict, start_date) -> int:
if not result:
return 0
close_df = result.get('close')
if close_df is None or close_df.empty:
return 0
stock_code = full_code.split('.')[0]
records = []
for td in close_df.columns:
td_date = td.date() if hasattr(td, 'date') else td
if start_date is not None and td_date <= start_date:
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
close_val = close_df.loc[full_code, td]
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
records.append({
'stock_code': stock_code,
'trade_date': td_date,
'open': _safe_get(result.get('open'), full_code, td),
'high': _safe_get(result.get('high'), full_code, td),
'low': _safe_get(result.get('low'), full_code, td),
'close': float(close_val),
'volume': _safe_get(result.get('volume'), full_code, td),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
KlineStock.insert_many(batch).on_conflict_replace().execute()
return len(records)
return 0
def _upsert(self, data):
pass
class KlineIndexSync(BaseSync):
"""指数日K线同步 — 增量同步"""
def __init__(self, indices: list = None, count: int = DEFAULT_COUNT):
super().__init__()
self.indices = indices or TRACKED_INDICES
self.count = count
def _fetch(self, **kwargs):
from xtquant import xtdata
index_codes = []
for code in self.indices:
if code.startswith(('000', '001')):
index_codes.append(f'{code}.SH')
elif code.startswith('399'):
index_codes.append(f'{code}.SZ')
else:
index_codes.append(f'{code}.SH')
latest = _latest_date(KlineIndex)
today = date.today()
if latest is not None and latest >= today:
PrintLog(LogLevel.INFO, f'[sync] KlineIndex: 已最新 ({latest}),跳过')
return {}
start_date = (latest + timedelta(days=1)) if latest else None
start_str = start_date.strftime('%Y%m%d') if start_date else ""
PrintLog(LogLevel.INFO,
f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}')
for code in index_codes:
try:
xtdata.download_history_data(code, period='1d', start_time=start_str)
except Exception:
self.stats['errors'] += 1
field_list = ['open', 'high', 'low', 'close', 'volume']
result = xtdata.get_market_data(
field_list=field_list, stock_list=index_codes, period='1d',
count=self.count, dividend_type='none', fill_data=False)
return result or {}
def _upsert(self, data):
if not data:
return
latest = _latest_date(KlineIndex)
records = []
close_df = data.get('close')
if close_df is None or close_df.empty:
return
for full_code in close_df.index:
index_code = full_code.split('.')[0]
for td in close_df.columns:
td_date = td.date() if hasattr(td, 'date') else td
if latest is not None and td_date <= latest:
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
close_val = close_df.loc[full_code, td]
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
records.append({
'index_code': index_code,
'trade_date': td_date,
'open': _safe_get(data.get('open'), full_code, td),
'high': _safe_get(data.get('high'), full_code, td),
'low': _safe_get(data.get('low'), full_code, td),
'close': float(close_val),
'volume': _safe_get(data.get('volume'), full_code, td),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
KlineIndex.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] = self.stats.get('inserted', 0) + len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+98
View File
@@ -0,0 +1,98 @@
"""
市场状态计算 — 从 kline_stock 聚合生成 market_regime_daily
纯本地计算,不依赖外部数据源。
"""
import pandas as pd
from peewee import fn, Case
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, MarketRegimeDaily
from core.scoring.config import PANIC_ADVANCE_RATIO, PANIC_TURNOVER_RATIO
from core.database import db
from core.logger import LogLevel, PrintLog
class MarketRegimeSync(BaseSync):
"""市场状态同步 — kline_stock 聚合 → market_regime_daily"""
def _fetch(self, **kwargs):
"""从 KlineStock 逐日聚合涨跌家数和成交额"""
PrintLog(LogLevel.INFO, '[sync] MarketRegime: 开始聚合全市场数据...')
# peewee 聚合查询: 逐日统计 advancers / decliners / turnover
query = (KlineStock
.select(
KlineStock.trade_date,
fn.SUM(
Case(None, [(KlineStock.close > KlineStock.open, 1)], 0)
).alias('advancers'),
fn.SUM(
Case(None, [(KlineStock.close < KlineStock.open, 1)], 0)
).alias('decliners'),
fn.SUM(KlineStock.close * KlineStock.volume).alias('turnover'),
)
.group_by(KlineStock.trade_date)
.order_by(KlineStock.trade_date))
rows = list(query.dicts())
if not rows:
PrintLog(LogLevel.WARNING, '[sync] MarketRegime: KlineStock 表为空')
return None
df = pd.DataFrame(rows)
df['trade_date'] = pd.to_datetime(df['trade_date'])
df = df.sort_values('trade_date').reset_index(drop=True)
# 计算涨跌比
total = df['advancers'] + df['decliners']
df['advance_ratio'] = (df['advancers'] / total.replace(0, 1)).round(4)
# 5日滚动均量
df['turnover_avg_5d'] = (df['turnover']
.rolling(window=5, min_periods=1)
.mean()
.round(2))
# 量比
df['turnover_ratio_5d'] = (df['turnover'] /
df['turnover_avg_5d'].replace(0, 1)).round(4)
# 极端恐慌标记
df['is_extreme_panic'] = (
(df['advance_ratio'] < PANIC_ADVANCE_RATIO) &
(df['turnover_ratio_5d'] > PANIC_TURNOVER_RATIO)
).astype(int)
PrintLog(LogLevel.INFO,
f'[sync] MarketRegime: 聚合完成, {len(df)} 个交易日')
return df
def _upsert(self, df):
"""写入 MarketRegimeDaily 表"""
if df is None or df.empty:
return
records = []
for _, row in df.iterrows():
records.append({
'trade_date': row['trade_date'].date(),
'advancers': int(row['advancers']),
'decliners': int(row['decliners']),
'advance_ratio': float(row['advance_ratio']),
'turnover': float(row['turnover']),
'turnover_avg_5d': float(row['turnover_avg_5d']),
'turnover_ratio_5d': float(row['turnover_ratio_5d']),
'source': 'qmt',
'is_extreme_panic': int(row['is_extreme_panic']),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
MarketRegimeDaily.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+129
View File
@@ -0,0 +1,129 @@
"""
行业聚合指数计算 — 从 kline_stock + industry 生成 sector_features_daily
纯本地计算,不依赖外部数据源。
"""
import pandas as pd
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, IndustryMapping, SectorFeaturesDaily
from core.database import db
from core.logger import LogLevel, PrintLog
class SectorFeaturesSync(BaseSync):
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
def _fetch(self, **kwargs):
"""从数据库加载原始数据, 计算行业指数特征"""
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
# 1. 加载行业映射: code → industry_name
industries = list(IndustryMapping.select().dicts())
if not industries:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: IndustryMapping 表为空, 请先执行 industry sync')
return None
code_to_industry = {row['code']: row['industry_name'] for row in industries}
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射')
# 2. 加载 K 线数据
kline_rows = (KlineStock
.select(
KlineStock.stock_code,
KlineStock.trade_date,
KlineStock.open,
KlineStock.high,
KlineStock.low,
KlineStock.close,
)
.order_by(KlineStock.stock_code, KlineStock.trade_date)
.dicts())
if not kline_rows:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空')
return None
df = pd.DataFrame(kline_rows)
df['trade_date'] = pd.to_datetime(df['trade_date'])
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(df)} 条K线数据')
# 3. 映射行业
df['sector_name'] = df['stock_code'].map(code_to_industry)
df = df.dropna(subset=['sector_name'])
# 4. 逐股计算日收益率和振幅
df = df.sort_values(['stock_code', 'trade_date'])
df['prev_close'] = df.groupby('stock_code')['close'].shift(1)
df['pct_chg'] = (df['close'] - df['prev_close']) / df['prev_close'] * 100
df['amplitude'] = (df['high'] - df['low']) / df['open'] * 100
# 清理无效值
df = df.dropna(subset=['pct_chg', 'amplitude'])
# 5. 按行业+日期聚合
agg = (df.groupby(['trade_date', 'sector_name'])
.agg(
sector_ret=('pct_chg', 'mean'),
sector_amplitude=('amplitude', 'mean'),
)
.reset_index())
# 6. 构建行业指数 (基值=100)
agg = agg.sort_values(['sector_name', 'trade_date'])
agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform(
lambda x: (1 + x / 100).cumprod() * 100
)
# 重新基值=100 (每行业独立)
for name, group in agg.groupby('sector_name'):
idx = group.index
first_val = group['sector_index'].iloc[0]
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
# 7. 计算 EMA 均线
agg['ema10'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=10, min_periods=1).mean()))
agg['ema20'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=20, min_periods=1).mean()))
agg['ema200'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=200, min_periods=1).mean()))
# 8. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
agg['score'] = (
(agg['sector_index'] > agg['ema200']).astype(int) +
(agg['ema10'] > agg['ema20']).astype(int)
)
PrintLog(LogLevel.INFO,
f'[sync] SectorFeatures: 计算完成, {len(agg)} 行, '
f'{agg["sector_name"].nunique()} 个行业')
return agg
def _upsert(self, agg):
"""写入 SectorFeaturesDaily 表"""
if agg is None or agg.empty:
return
records = []
for _, row in agg.iterrows():
records.append({
'trade_date': row['trade_date'].date()
if hasattr(row['trade_date'], 'date') else row['trade_date'],
'sector_name': str(row['sector_name']),
'sector_ret': round(float(row['sector_ret']), 4),
'sector_amplitude': round(float(row['sector_amplitude']), 4),
'close': round(float(row['sector_index']), 4),
'ema10': round(float(row['ema10']), 4),
'ema20': round(float(row['ema20']), 4),
'ema200': round(float(row['ema200']), 4),
'score': int(row['score']),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
SectorFeaturesDaily.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+100
View File
@@ -0,0 +1,100 @@
"""
股票基础信息同步 — 从 QMT get_instrument_detail 获取
"""
from datetime import date
from core.scoring.sync.base import BaseSync
from core.scoring.models import StockInfo
from core.database import db
from core.logger import LogLevel, PrintLog
BATCH_SIZE = 200
class StocksSync(BaseSync):
"""股票基础信息同步 — QMT get_instrument_detail_list"""
def _fetch(self, **kwargs):
"""从 QMT 拉取全部A股基础信息"""
from xtquant import xtdata
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
PrintLog(LogLevel.INFO, f'[sync] Stocks: 获取到 {len(all_stocks)} 只A股')
result = {}
total = len(all_stocks)
for i in range(0, total, BATCH_SIZE):
batch = all_stocks[i:i + BATCH_SIZE]
try:
details = xtdata.get_instrument_detail_list(batch)
result.update(details)
except Exception as e:
PrintLog(LogLevel.ERROR,
f'[sync] Stocks batch {i}-{min(i + BATCH_SIZE, total)} failed: {e}')
self.stats['errors'] += len(batch)
return result
def _upsert(self, data: dict):
"""写入 StockInfo 表"""
records = []
today = date.today()
for full_code, inst in data.items():
if not inst:
self.stats['skipped'] += 1
continue
code_num = full_code.split('.')[0]
# 判断交易所
exchange = inst.get('ExchangeID', '')
if not exchange:
if '.SH' in full_code:
exchange = 'SH'
elif '.SZ' in full_code:
exchange = 'SZ'
elif '.BJ' in full_code:
exchange = 'BJ'
# OpenDate 格式: 'YYYYMMDD' 或 int
open_date = inst.get('OpenDate', '')
if open_date and len(str(open_date)) == 8:
list_date_val = f'{str(open_date)[:4]}-{str(open_date)[4:6]}-{str(open_date)[6:8]}'
else:
list_date_val = None
# InstrumentStatus 含义:
# 0/1 = 正常股票(含已退市但仍在QMT列表的)
# -1 = ST / *ST
# 30/31 = *ST
# 已退市股(名字含XD/退)K线不足120日,会在候选股过滤时被排除
status = inst.get('InstrumentStatus', -1)
if status in (-1, 30, 31):
listing_status = 'ST'
else:
listing_status = 'normal'
total_share = inst.get('TotalVolume', None)
float_share = inst.get('FloatVolume', None)
records.append({
'code': full_code,
'name': str(inst.get('InstrumentName', '')),
'exchange': exchange,
'list_date': list_date_val,
'listing_status': listing_status,
'total_share': float(total_share) if total_share else None,
'float_share': float(float_share) if float_share else None,
'share_updated_at': today,
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
StockInfo.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+4
View File
@@ -1,3 +1,7 @@
# 删除交易标的事件
EventTradeTargetUpdate = "trade_target_update"
EventTradeTargetDeleted = "trade_target_deleted"
# 评分系统事件
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
+91 -86
View File
@@ -58,6 +58,7 @@ class SFGridStrategy:
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
event_bus.subscribe(eBus.MarketOrderTraded, self.onOrderTrade)
event_bus.subscribe(eBus.MarketOrderError, self.onOrderError)
event_bus.subscribe(eBus.EventMarketActiveSwitch, self.onMarketActiveSwitch)
# 获取当日涨跌停价格(用于价格边界校验)
self.todayUpStopPrice = qmtv.dailyUpStop(tradeTarget.stock_code) # type: ignore
@@ -139,93 +140,97 @@ class SFGridStrategy:
每个方向都先检查是否已存在同价位订单,避免重复下单
"""
# ── 前置检查:市场和策略状态 ──
if not qmtv.isMarketActive or not self.tradeTarget.enabled:
PrintLog(LogLevel.INFO,
f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} '
f'{self.tradeTarget.enabled}, 不下单')
return
# 获取当前该标的所有未成交订单
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
# ── 统一网格逻辑 ──
# grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖
# grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1]
if self.tradeTarget.grid_index >= 0:
currentIdx = self.tradeTarget.grid_index # type: ignore
# --- 上方挂卖出单(空单)---
# 条件: grid_index > 0,即当前位置不是价格最低点,还有向下(卖出)空间
if currentIdx > 0:
sellIdx = currentIdx - 1 # 向上一个网格
sellPrice = self.tradeTarget.getPriceGrid()[sellIdx]
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
# 检查是否已存在同 remark 的卖单(避免重复挂单)
if not any(o.order_remark == sell_remark for o in orders):
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
if sellPrice > self.todayUpStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
f'上方网格[{sellIdx}]卖价 {sellPrice:.3f} > 涨停价 {self.todayUpStopPrice:.3f}'
f'今日无法下卖单 (当前网格基准 grid-{currentIdx})')
else:
tmpOrderSeq = qmtv.orderAsync(
str(self.tradeTarget.stock_code),
self.tradeTarget.grid_volume,
xtconstant.STOCK_SELL, # 卖出
sellPrice,
xtconstant.FIX_PRICE,
sell_remark,
self.getName(),
)
self.orderGrid[sellIdx] = tmpOrderSeq
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'下空单,价格: {sellPrice:.3f}')
else:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已存在同价位空单,跳过下单')
# --- 下方挂买入单(多单)---
# 条件: grid_index < 价格网格长度-1,即当前位置不是价格最高点,还有向上(买入)空间
if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1:
buyIdx = currentIdx + 1 # 向下一个网格
buyPrice = self.tradeTarget.getPriceGrid()[buyIdx]
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
# 检查是否已存在同 remark 的买单(避免重复挂单)
if not any(o.order_remark == buy_remark for o in orders):
# 买单价格低于跌停价 → 今日无法成交,跳过下单
if buyPrice < self.todayDownStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
f'下方网格[{buyIdx}]买价 {buyPrice:.3f} < 跌停价 {self.todayDownStopPrice:.3f}'
f'今日无法下买单 (当前网格基准 grid-{currentIdx})')
else:
tmpOrderSeq = qmtv.orderAsync(
str(self.tradeTarget.stock_code),
self.tradeTarget.grid_volume,
xtconstant.STOCK_BUY, # 买入
buyPrice,
xtconstant.FIX_PRICE,
buy_remark,
self.getName(),
)
self.orderGrid[buyIdx] = tmpOrderSeq
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'下多单,价格: {buyPrice:.3f}')
else:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已存在同价位多单,跳过下单')
else:
# grid_index 已到达价格网格上边界,无法再挂买入单(价格已经到顶)
# 注意:这里用 dataUpdateLock 包裹检查和下单操作,防止竞态条件:
# 主线程在检查 isMarketActive 时,另一线程的行情回调可能同时将其设为 True,
# 导致部分标的通过检查下单,部分被拦截(表现为一票有单、一票无单)
with self.dataUpdateLock:
if not qmtv.isMarketActive or not self.tradeTarget.enabled:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已过下边界,停止多单交易')
f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} '
f'{self.tradeTarget.enabled}, 不下单')
return
# 获取当前该标的所有未成交订单
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
# ── 统一网格逻辑 ──
# grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖
# grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1]
if self.tradeTarget.grid_index >= 0:
currentIdx = self.tradeTarget.grid_index # type: ignore
# --- 上方挂卖出单(空单)---
# 条件: grid_index > 0,即当前位置不是价格最低点,还有向下(卖出)空间
if currentIdx > 0:
sellIdx = currentIdx - 1 # 向上一个网格
sellPrice = self.tradeTarget.getPriceGrid()[sellIdx]
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
# 检查是否已存在同 remark 的卖单(避免重复挂单)
if not any(o.order_remark == sell_remark for o in orders):
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
if sellPrice > self.todayUpStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
f'上方网格[{sellIdx}]卖价 {sellPrice:.3f} > 涨停价 {self.todayUpStopPrice:.3f}'
f'今日无法下卖单 (当前网格基准 grid-{currentIdx})')
else:
tmpOrderSeq = qmtv.orderAsync(
str(self.tradeTarget.stock_code),
self.tradeTarget.grid_volume,
xtconstant.STOCK_SELL, # 卖出
sellPrice,
xtconstant.FIX_PRICE,
sell_remark,
self.getName(),
)
self.orderGrid[sellIdx] = tmpOrderSeq
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'下空单,价格: {sellPrice:.3f}')
else:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已存在同价位空单,跳过下单')
# --- 下方挂买入单(多单)---
# 条件: grid_index < 价格网格长度-1,即当前位置不是价格最高点,还有向上(买入)空间
if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1:
buyIdx = currentIdx + 1 # 向下一个网格
buyPrice = self.tradeTarget.getPriceGrid()[buyIdx]
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
# 检查是否已存在同 remark 的买单(避免重复挂单)
if not any(o.order_remark == buy_remark for o in orders):
# 买单价格低于跌停价 → 今日无法成交,跳过下单
if buyPrice < self.todayDownStopPrice:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] '
f'下方网格[{buyIdx}]买价 {buyPrice:.3f} < 跌停价 {self.todayDownStopPrice:.3f}'
f'今日无法下买单 (当前网格基准 grid-{currentIdx})')
else:
tmpOrderSeq = qmtv.orderAsync(
str(self.tradeTarget.stock_code),
self.tradeTarget.grid_volume,
xtconstant.STOCK_BUY, # 买入
buyPrice,
xtconstant.FIX_PRICE,
buy_remark,
self.getName(),
)
self.orderGrid[buyIdx] = tmpOrderSeq
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'下多单,价格: {buyPrice:.3f}')
else:
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已存在同价位多单,跳过下单')
else:
# grid_index 已到达价格网格上边界,无法再挂买入单(价格已经到顶)
PrintLog(LogLevel.INFO,
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
f'已过下边界,停止多单交易')
# ── 标的管理 ──────────────────────────────────────────────
+328 -6
View File
@@ -414,15 +414,21 @@ class _GridPanel:
class _DrawerPanel:
"""右侧叠加抽屉:市场监控 + 委托 + 成交 + 未分类"""
def __init__(self, data: _DataStore, dialogs=None):
def __init__(self, data: _DataStore, dialogs=None, page=None):
self._data = data
self._dialogs = dialogs
self._tab_orders = ft.Tab(label="当前委托")
self._tab_trades = ft.Tab(label="当日成交")
self._tab_dataset = ft.Tab(label="数据集管理")
self._tab_scoring = ft.Tab(label="每日评分")
self._order_table = None
self._trade_table = None
self._uncl_col = None
self._market_table = None
self._dataset_table = None
self._scoring_table = None
self._sync_lock = False # 同步线程锁
self._sync_btns = [] # 同步按钮引用列表
def build(self) -> ft.Control:
"""返回 overlay Container"""
@@ -430,25 +436,32 @@ class _DrawerPanel:
self._order_table = ft.ListView(expand=True)
self._trade_table = ft.ListView(expand=True)
self._market_table = ft.ListView(expand=True)
self._dataset_table = ft.ListView(expand=True)
self._dataset_col = ft.Column(scroll=ft.ScrollMode.AUTO, expand=True)
self._scoring_table = ft.ListView(expand=True)
bar = ft.TabBar(tabs=[
ft.Tab(label="实时价格监控"),
self._tab_scoring,
self._tab_orders,
self._tab_trades,
self._tab_dataset,
ft.Tab(label="未分类持仓"),
])
view = ft.TabBarView(controls=[
self._build_market_tab(),
self._build_scoring_tab(),
self._order_table,
self._trade_table,
self._build_dataset_tab(),
self._uncl_col,
], expand=True)
panel = ft.Container(
ft.Column([
ft.Tabs(ft.Column([bar, view], expand=True), length=4, expand=True),
ft.Tabs(ft.Column([bar, view], expand=True), length=6, expand=True),
], expand=True),
width=700, bgcolor=ft.Colors.SURFACE,
width=750, bgcolor=ft.Colors.SURFACE,
)
backdrop = ft.Container(bgcolor='#44000000', expand=True)
@@ -477,6 +490,8 @@ class _DrawerPanel:
self._refresh_orders()
self._refresh_trades()
self._refresh_market()
self._refresh_dataset()
self._refresh_scoring()
def _refresh_grid(self):
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
@@ -551,11 +566,16 @@ class _DrawerPanel:
_data_cell(f"{d['last_price']:.3f}", *_C[2]),
]
if already_in_pool:
cells.append(_data_cell("已在池", *_C[3], color='#AAAAAA', size=11))
op_w, _ = _C[3]
cells.append(ft.Container(
ft.Row([ft.Text("已在池", color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)],
alignment=ft.MainAxisAlignment.CENTER),
width=op_w, padding=0))
else:
cells.append(ft.Container(
ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓",
on_click=lambda e, code=sc: self._on_add_from_market(code)),
ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip="添加持仓",
on_click=lambda e, code=sc: self._on_add_from_market(code))],
alignment=ft.MainAxisAlignment.CENTER),
width=_C[3][0], padding=0))
row = ft.Row(cells, spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
@@ -563,6 +583,307 @@ class _DrawerPanel:
self._market_table.controls = [ft.Column(rows, spacing=0)]
# ── Tab 5: 数据集管理 ──
# 按依赖顺序: kline → stocks → industry → market → sector
_SYNC_DEPS = {
'kline': ['kline'],
'stocks': ['stocks'],
'industry':['industry'],
'market': ['market'],
'sector': ['sector'],
'all': ['kline', 'stocks', 'industry', 'market', 'sector'],
}
_SYNC_LABELS = {
'kline': '同步K线',
'stocks': '同步股票信息',
'industry':'同步行业映射',
'market': '计算市场状态',
'sector': '计算行业指数',
}
def _build_dataset_tab(self) -> ft.Control:
self._dataset_status = ft.Text("就绪", size=12, color='#888888')
return ft.Column([
self._dataset_status,
self._dataset_col,
], expand=True, spacing=6)
def _run_sync(self, targets: list):
# 线程锁
if self._sync_lock:
self._dataset_status.value = "同步中,跳过重复点击"
self._dataset_status.update()
return
self._sync_lock = True
self._set_sync_btns_disabled(True)
self._dataset_status.value = "同步中..."
self._dataset_status.update()
import threading
def _do():
from core.scoring.sync import (
KlineStockSync, KlineIndexSync, StocksSync,
IndustrySync, MarketRegimeSync, SectorFeaturesSync,
)
syncs = {
'kline': [KlineStockSync(count=300), KlineIndexSync(count=300)],
'stocks': [StocksSync()],
'industry':[IndustrySync()],
'market': [MarketRegimeSync()],
'sector': [SectorFeaturesSync()],
}
order = []
for t in targets:
for key in self._SYNC_DEPS.get(t, [t]):
order.extend(syncs.get(key, []))
for s in order:
try:
s.run()
except Exception:
pass
self._sync_lock = False
self._set_sync_btns_disabled(False)
self._dataset_status.value = "同步完成"
self._dataset_status.update()
self._refresh_dataset()
threading.Thread(target=_do, daemon=True).start()
def _set_sync_btns_disabled(self, disabled: bool):
for btn in self._sync_btns:
btn.disabled = disabled
btn.update()
def _refresh_dataset(self):
from core.scoring.models import (
KlineStock, KlineIndex, StockInfo, IndustryMapping,
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
)
tables = [
("kline_stock", KlineStock, "kline", "trade_date"),
("kline_index", KlineIndex, "kline", "trade_date"),
("stocks", StockInfo, "stocks", "share_updated_at"),
("industry", IndustryMapping, "industry","update_date"),
("market_regime", MarketRegimeDaily, "market", "trade_date"),
("sector_features", SectorFeaturesDaily, "sector", "trade_date"),
("ScoringResult", ScoringResult, "all", "trade_date"),
]
# 与市场监控Tab一致的列宽布局: (width, expand)
_C = [(150, 0), (80, 0), (100, 0)]
H = ["表名", "记录数", "最新日期"]
def _hcell(text, w, e):
if e > 0:
return ft.Container(
ft.Text(text, weight=ft.FontWeight.BOLD), padding=4, expand=e)
return ft.Container(
ft.Text(text, weight=ft.FontWeight.BOLD), width=w, padding=4)
header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0)
self._sync_btns.clear()
if self._sync_lock:
for btn in self._sync_btns:
btn.disabled = True
rows = [header, ft.Divider(height=1, color='#e0e0e0')]
for name, model_cls, dep_key, date_col in tables:
try:
cnt = model_cls.select().count()
df = getattr(model_cls, date_col, None)
if df is not None:
last = (model_cls.select(df).order_by(df.desc()).first())
date_str = str(getattr(last, date_col, "")) if last else ""
else:
date_str = ""
except Exception:
cnt, date_str = "ERR", ""
def _dcell(text, w, e=0):
if e > 0:
return ft.Container(ft.Text(text), padding=4, expand=e)
return ft.Container(ft.Text(text), width=w, padding=4)
cells = [
_dcell(name, *_C[0]),
_dcell(str(cnt), *_C[1]),
_dcell(date_str, *_C[2]),
]
row = ft.Row(cells, spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
rows.append(ft.Divider(height=1, color='#f0f0f0'))
self._dataset_col.controls = rows
# ── Tab 6: 每日评分 ──
def _build_scoring_tab(self) -> ft.Control:
from datetime import date, timedelta
self._score_cur_date = date.today()
self._score_date_label = ft.Text(str(self._score_cur_date), size=14, weight=ft.FontWeight.BOLD)
self._score_status = ft.Text("无评分数据", size=12, color='#888888')
def _prev_day(_):
self._score_cur_date -= timedelta(days=1)
self._score_date_label.value = str(self._score_cur_date)
self._score_date_label.update()
self._refresh_scoring()
def _next_day(_):
self._score_cur_date += timedelta(days=1)
self._score_date_label.value = str(self._score_cur_date)
self._score_date_label.update()
self._refresh_scoring()
def _today(_):
self._score_cur_date = date.today()
self._score_date_label.value = str(self._score_cur_date)
self._score_date_label.update()
self._refresh_scoring()
date_row = ft.Row([
ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day),
self._score_date_label,
ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day),
ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today),
ft.Container(ft.Divider(height=20), width=2),
ft.ElevatedButton("同步数据", on_click=lambda e: self._run_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32),
ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), height=32),
self._score_status,
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column([
date_row,
self._scoring_table,
], expand=True, spacing=6)
def _run_scoring(self):
self._score_status.value = "评分中..."
self._score_status.update()
import threading
def _do():
from core.scoring.inference.scorer import GridSeekerPipeline
trade_date = self._score_cur_date
# 检查当日 K线数据是否已同步
from peewee import fn
from core.scoring.models import KlineStock
latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar()
if latest_kline is None or latest_kline < trade_date:
self._score_status.value = f"K线未同步至 {trade_date},请先盘后同步"
self._score_status.color = '#F44336'
self._score_status.update()
return
try:
engine = GridSeekerPipeline()
rankings = engine.run(trade_date)
if not rankings.empty:
engine.persist(rankings, trade_date)
self._score_status.value = f"{trade_date} 评分完成"
except FileNotFoundError:
self._score_status.value = "模型文件缺失"
except Exception as ex:
self._score_status.value = f"失败: {ex}"
self._score_status.update()
self._refresh_scoring()
threading.Thread(target=_do, daemon=True).start()
def _refresh_scoring(self):
from core.scoring.models import ScoringResult
from core.qmt import qmtv
# (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
H = ["排名", "代码 / 名称", "Rank轮数", "Top概率", "Stacking概率", "操作"]
def _hcell(text, w, e):
if e > 0:
return ft.Container(_text(text, bold=True), padding=4, expand=e)
return ft.Container(_text(text, bold=True), width=w, padding=4)
def _dcell(text, w, e, color=None, size=None):
if e > 0:
return ft.Container(_text(text, color=color, size=size), padding=4, expand=e)
return ft.Container(_text(text, color=color, size=size), width=w, padding=4)
header = ft.Row([_hcell(h, w, e) for h, (w, e) in zip(H, _C)], spacing=0)
rows = [header, ft.Divider(height=1, color='#e0e0e0')]
# 用导航日期查询
trade_date = self._score_cur_date
scored_rows = list((ScoringResult
.select()
.where(ScoringResult.trade_date == trade_date)
.order_by(ScoringResult.score_rank)
.limit(200)
.dicts()))
if not scored_rows:
self._score_status.value = f"{trade_date} 无评分数据"
self._score_status.color = '#F44336'
self._scoring_table.controls = rows
self._score_status.update()
return
shown = 0
for r in scored_rows:
code = r['stock_code']
plain = code.split('.')[0] if '.' in code else code
# 过滤 ST
from core.scoring.models import StockInfo
if plain.startswith(('6', '5', '9')):
full_code = f'{plain}.SH'
else:
full_code = f'{plain}.SZ'
st = StockInfo.get_or_none(StockInfo.code == full_code)
if st and st.listing_status == 'ST':
continue
name = ''
try:
name = qmtv.getInstrumentName(plain)
except Exception:
pass
already_in = plain in self._data.stockCodeIdMap
shown += 1
cells = [
_dcell(str(r.get("score_rank", "")), _C[0][0], _C[0][1]),
_dcell(f"{plain} {name}", _C[1][0], _C[1][1]),
_dcell(f"{r.get("rank_predicted_rounds", 0) or 0:.4f}", _C[2][0], _C[2][1]),
_dcell(f"{r.get("top_elite_prob", 0) or 0:.4f}", _C[3][0], _C[3][1]),
_dcell(f"{r.get("stacking_probability", 0) or 0:.4f}", _C[4][0], _C[4][1]),
]
if already_in:
op_w = _C[5][0]
cells.append(ft.Container(
ft.Row([ft.Text('已添加', color='#AAAAAA', size=11, text_align=ft.TextAlign.CENTER)],
alignment=ft.MainAxisAlignment.CENTER),
width=op_w, padding=0))
else:
op_w = _C[5][0]
cells.append(ft.Container(
ft.Row([ft.IconButton(ft.Icons.ADD, icon_size=20, tooltip='加入网格',
on_click=lambda e, c=plain, n=name: self._on_add_from_scoring(c, n))],
alignment=ft.MainAxisAlignment.CENTER),
width=op_w, padding=0))
row = ft.Row(cells, spacing=0)
rows.append(ft.Container(row, padding=ft.Padding(0, 2, 0, 2)))
rows.append(ft.Divider(height=1, color='#f0f0f0'))
self._scoring_table.controls = rows
self._score_status.value = f"{trade_date} 候选 {shown}"
self._score_status.color = '#4CAF50'
self._score_status.update()
def _on_add_from_scoring(self, stock_code: str, stock_name: str):
"""+ 按钮:从评分列表添加标的并打开网格配置"""
tid = self._data.add_from_market(stock_code, stock_name)
target = self._data.tradeTargets.get(tid)
if target is None:
return
self._dialogs.open_config(target)
self._refresh_scoring() # 刷新状态(显示"已添加")
def _on_add_from_market(self, stock_code: str):
"""+ 按钮:从市场监控添加标的并打开网格配置"""
info = self._data.marketLog.get(stock_code)
@@ -797,6 +1118,7 @@ class QmtApp:
def __init__(self, page: ft.Page):
self.page = page
self.page.title = "神之一手"
self.page.theme_mode = ft.ThemeMode.LIGHT
self.page.window.width = 1400
self.page.window.height = 800
self.page.padding = 0
+318 -1
View File
@@ -341,6 +341,19 @@ class TradeTargetUI(ttk.Frame):
self.right_notebook.add(self.trade_tab, text="当日成交")
self._create_trade_table(self.trade_tab)
# Tab 4: 数据集管理
self.dataset_tab = ttk.Frame(self.right_notebook)
self.right_notebook.add(self.dataset_tab, text="数据集管理")
self._create_dataset_tab(self.dataset_tab)
# Tab 5: 每日评分
self.scoring_tab = ttk.Frame(self.right_notebook)
self.right_notebook.add(self.scoring_tab, text="每日评分")
self._create_scoring_tab(self.scoring_tab)
# 评分数据缓存
self._scoring_data: dict = {} # {stock_code: {score, rank, ...}}
# Tab 切换时自动刷新
self.right_notebook.bind("<<NotebookTabChanged>>", self._on_tab_changed)
@@ -506,10 +519,314 @@ class TradeTargetUI(ttk.Frame):
self._refresh_orders()
elif tab_text == "当日成交":
self._refresh_trades()
elif tab_text == "数据集管理":
self._refresh_dataset_status()
elif tab_text == "每日评分":
self._refresh_scoring_table()
except Exception:
pass
def _refresh_orders(self):
# ================================================================
# Tab 4: 数据集管理
# ================================================================
def _create_dataset_tab(self, parent):
"""创建数据集管理 Tab"""
# 顶部按钮栏
btn_frame = ttk.Frame(parent)
btn_frame.pack(fill=tk.X, pady=(0, 5))
syncs = [
("同步K线(个股+指数)", lambda: self._run_dataset_sync(['kline'])),
("同步股票信息", lambda: self._run_dataset_sync(['stocks'])),
("同步行业映射", lambda: self._run_dataset_sync(['industry'])),
("计算市场状态", lambda: self._run_dataset_sync(['market'])),
("计算行业指数", lambda: self._run_dataset_sync(['sector'])),
("同步全部", lambda: self._run_dataset_sync(['all'])),
]
for label, cmd in syncs:
ttk.Button(btn_frame, text=label, command=cmd, width=18).pack(
side=tk.LEFT, padx=2)
# 数据表状态表格
cols = ("表名", "记录数", "最新日期", "数据覆盖")
self.dataset_tree = ttk.Treeview(parent, columns=cols, show='headings', height=12)
widths = {"表名": 180, "记录数": 80, "最新日期": 100, "数据覆盖": 200}
for c in cols:
self.dataset_tree.heading(c, text=c)
self.dataset_tree.column(c, width=widths.get(c, 80), anchor=tk.W)
sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.dataset_tree.yview)
self.dataset_tree.configure(yscrollcommand=sb.set)
self.dataset_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sb.pack(side=tk.RIGHT, fill=tk.Y)
# 状态栏
self.dataset_status = ttk.Label(parent, text='就绪', foreground='gray')
self.dataset_status.pack(fill=tk.X, pady=(5, 0))
def _refresh_dataset_status(self):
"""刷新数据集状态"""
from core.scoring.models import (
KlineStock, KlineIndex, StockInfo, IndustryMapping,
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
)
# (显示名, 模型类, 日期字段名)
tables = [
('kline_stock', KlineStock, 'trade_date'),
('kline_index', KlineIndex, 'trade_date'),
('stocks', StockInfo, 'share_updated_at'),
('industry', IndustryMapping, 'update_date'),
('market_regime', MarketRegimeDaily, 'trade_date'),
('sector_features', SectorFeaturesDaily, 'trade_date'),
('ScoringResult', ScoringResult, 'trade_date'),
]
self.dataset_tree.delete(*self.dataset_tree.get_children())
for name, model, date_col in tables:
try:
count = model.select().count()
df = getattr(model, date_col, None)
if df is not None:
last = (model.select(df)
.order_by(df.desc())
.first())
last_date = str(getattr(last, date_col, '')) if last else ''
else:
last_date = ''
self.dataset_tree.insert('', tk.END, values=(name, count, last_date, ''))
except Exception as e:
self.dataset_tree.insert('', tk.END, values=(name, 'ERR', str(e), ''))
def _run_dataset_sync(self, targets: list):
"""后台线程执行数据同步"""
import threading
self.dataset_status.config(text='同步中...', foreground='orange')
def _do_sync():
from core.scoring.sync import (
KlineStockSync, KlineIndexSync, StocksSync,
IndustrySync, MarketRegimeSync, SectorFeaturesSync,
)
from core.eventbus import event_bus
from core.sfgrid.bus_events import EventSyncProgress
all_targets = {
'kline': [('K线-个股', KlineStockSync(count=300)),
('K线-指数', KlineIndexSync(count=300))],
'stocks': [('股票信息', StocksSync())],
'industry': [('行业映射', IndustrySync())],
'market': [('市场状态', MarketRegimeSync())],
'sector': [('行业指数', SectorFeaturesSync())],
}
if 'all' in targets:
order = (all_targets['kline'] + all_targets['stocks'] +
all_targets['industry'] + all_targets['market'] +
all_targets['sector'])
else:
order = []
for t in targets:
order.extend(all_targets.get(t, []))
for label, sync in order:
self.after(0, lambda l=label: self.dataset_status.config(
text=f'同步中: {l}...', foreground='orange'))
try:
stats = sync.run()
event_bus.publish(EventSyncProgress,
{'source': label, 'status': 'ok', 'stats': stats})
except Exception as e:
event_bus.publish(EventSyncProgress,
{'source': label, 'status': 'error', 'stats': str(e)})
self.after(0, lambda: self.dataset_status.config(
text='同步完成', foreground='green'))
self.after(100, self._refresh_dataset_status)
threading.Thread(target=_do_sync, daemon=True).start()
# ================================================================
# Tab 5: 每日评分
# ================================================================
def _create_scoring_tab(self, parent):
"""创建每日评分 Tab"""
# 顶部控制栏
ctrl = ttk.Frame(parent)
ctrl.pack(fill=tk.X, pady=(0, 5))
ttk.Label(ctrl, text="评分日期:").pack(side=tk.LEFT, padx=(0, 5))
self.score_date_var = tk.StringVar(value='')
self.score_date_entry = ttk.Entry(ctrl, textvariable=self.score_date_var, width=10)
self.score_date_entry.pack(side=tk.LEFT, padx=2)
ttk.Button(ctrl, text="执行评分", command=self._run_scoring, width=10).pack(
side=tk.LEFT, padx=5)
ttk.Button(ctrl, text="刷新", command=self._refresh_scoring_table, width=6).pack(
side=tk.LEFT, padx=2)
ttk.Button(ctrl, text="加入网格",
command=self._add_scored_to_grid, width=10).pack(
side=tk.LEFT, padx=5)
self.scoring_status = ttk.Label(ctrl, text='', foreground='gray')
self.scoring_status.pack(side=tk.LEFT, padx=10)
# 评分结果表格
cols = ("排名", "代码", "名称", "预测利润", "预测轮数", "堆叠概率")
self.scoring_tree = ttk.Treeview(parent, columns=cols, show='headings', height=14)
widths = {"排名": 50, "代码": 70, "名称": 80, "预测利润": 80, "预测轮数": 80, "堆叠概率": 80}
for c in cols:
self.scoring_tree.heading(c, text=c)
self.scoring_tree.column(c, width=widths.get(c, 60), anchor=tk.CENTER)
sb = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.scoring_tree.yview)
self.scoring_tree.configure(yscrollcommand=sb.set)
self.scoring_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sb.pack(side=tk.RIGHT, fill=tk.Y)
# 双击加入网格
self.scoring_tree.bind("<Double-1>", self._on_scoring_double_click)
def _refresh_scoring_table(self):
"""从 ScoringResult 表加载最新评分"""
from core.scoring.models import ScoringResult
from core.qmt import qmtv
self.scoring_tree.delete(*self.scoring_tree.get_children())
self._scoring_data.clear()
try:
# 取最新评分日
latest = (ScoringResult
.select(ScoringResult.trade_date)
.distinct()
.order_by(ScoringResult.trade_date.desc())
.first())
if not latest:
self.scoring_status.config(text='无评分数据', foreground='gray')
return
trade_date = latest.trade_date
self.score_date_var.set(str(trade_date))
rows = (ScoringResult
.select()
.where(ScoringResult.trade_date == trade_date)
.order_by(ScoringResult.score_rank)
.limit(200)
.dicts())
for r in rows:
code = r['stock_code']
# 尝试从 QMT 获取名称
name = ''
try:
name = qmtv.getInstrumentName(code)
except Exception:
pass
values = (
r.get('score_rank', ''),
code,
name,
f'{r.get("predicted_profit", 0):.4f}',
f'{r.get("rank_predicted_rounds", 0) or 0:.2f}',
f'{r.get("stacking_probability", 0) or 0:.4f}',
)
self.scoring_tree.insert('', tk.END, values=values)
self._scoring_data[code] = {'name': name, **r}
self.scoring_status.config(
text=f'{trade_date}{len(rows)} 只候选股',
foreground='green')
except Exception as e:
self.scoring_status.config(text=f'加载失败: {e}', foreground='red')
def _run_scoring(self):
"""后台线程执行评分管道"""
import threading
from datetime import date, datetime
date_str = self.score_date_var.get().strip()
if date_str:
try:
trade_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
self.scoring_status.config(text='日期格式错误 (YYYY-MM-DD)', foreground='red')
return
else:
trade_date = date.today()
self.scoring_status.config(text='评分中...', foreground='orange')
def _do_score():
from core.scoring.inference.scorer import GridSeekerPipeline
from core.eventbus import event_bus
from core.sfgrid.bus_events import EventScoringCompleted
try:
engine = GridSeekerPipeline()
rankings = engine.run(trade_date)
if not rankings.empty:
engine.persist(rankings, trade_date)
event_bus.publish(EventScoringCompleted,
{'date': str(trade_date), 'count': len(rankings)})
except FileNotFoundError as e:
self.after(0, lambda: self.scoring_status.config(
text=f'模型文件缺失: {e}', foreground='red'))
return
except Exception as e:
self.after(0, lambda: self.scoring_status.config(
text=f'评分失败: {e}', foreground='red'))
return
self.after(100, self._refresh_scoring_table)
self.after(0, lambda: self.scoring_status.config(
text=f'{trade_date} — 评分完成, {len(rankings)}', foreground='green'))
threading.Thread(target=_do_score, daemon=True).start()
def _on_scoring_double_click(self, event):
"""双击评分行 → 加入网格策略"""
self._add_scored_to_grid()
def _add_scored_to_grid(self):
"""将选中的评分股票加入网格策略"""
selected = self.scoring_tree.selection()
if not selected:
return
for item in selected:
values = self.scoring_tree.item(item)['values']
if not values:
continue
stock_code = str(values[1])
stock_name = str(values[2]) if values[2] else stock_code
# 检查是否已存在
from core.sfgrid.model import SFGridTradeTarget
existing = SFGridTradeTarget.get_or_none(
SFGridTradeTarget.stock_code == stock_code)
if existing:
continue
# 添加到未分类持仓
from core.qmt import qmtv
pos = qmtv.getStockPosition(stock_code)
position = int(pos.volume) if pos else 0
target = SFGridTradeTarget.create(
stock_code=stock_code,
stock_name=stock_name,
current_position=position,
strategy_type=0, # 未分类
enabled=False,
)
self.tradeTargetData[target.id] = target
self.stockCodeIdMap[stock_code] = target.id
self.refresh_table()
# ---- 原有方法继续 ----
"""从 QMT 读取当日委托并刷新表格"""
from core.qmt import qmtv
try:
View File
+197
View File
@@ -0,0 +1,197 @@
# v6.6 模型发布说明
**版本**: v6.6
**发布日期**: 2026-06-16
**状态**: 生产就绪
---
## 一、模型架构
v6.6 采用 **Stacking Calibrated** 三层融合架构:
```
输入特征 (52维 v3.4)
┌─────────────────┐ ┌─────────────────┐
│ Rank 模型 │ │ Top 模型 │
│ LGBMRegressor │ │ LGBMClassifier │
│ 预测网格轮回次数 │ │ 分类精英股票 │
│ CV MAE: 0.2053 │ │ CV PR-AUC: 0.53│
│ CV R²: 0.2258 │ │ │
└────────┬────────┘ └────────┬────────┘
│ │
└──────────┬───────────┘
┌─────────────────────┐
│ Stacking 模型 │
│ LGBMClassifier │
│ CV PR-AUC: 0.8207 │
│ 最优阈值: 0.35 │
└──────────┬──────────┘
最终 top 概率
```
---
## 二、训练数据
| 指标 | 数值 |
|------|------|
| 特征版本 | v3.4 (52维) |
| 原始股票数 | 4,358 |
| 过滤后股票数 | 1,533 |
| 训练样本数 | 205,491 |
| 观察窗口 | 120天 |
| 预测窗口 | 60天 |
| 零触碰率 | 71.1% |
| Elite率 | 20.5% |
---
## 三、模型性能
### Rank模型 (回归)
| 指标 | 数值 |
|------|------|
| CV MAE | 0.2053 |
| CV R² | 0.2258 |
| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=7 |
### Top模型 (分类)
| 指标 | 数值 |
|------|------|
| CV PR-AUC | 0.5333 |
| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=-1 |
### Stacking融合模型
| 指标 | 数值 |
|------|------|
| CV PR-AUC | **0.8207** |
| 最优阈值 | 0.35 |
| 最优参数 | num_leaves=31, min_child_samples=20, max_depth=-1 |
---
## 四、Top 10 重要特征
### Rank模型
| 排名 | 特征 | 重要性% |
|------|------|---------|
| 1 | dist_to_grid_upper | 4.38% |
| 2 | dist_to_grid_lower | 4.26% |
| 3 | price_cv | 4.09% |
| 4 | amount_mean_20d | 3.98% |
| 5 | range_compression_20d | 3.48% |
### Stacking融合
| 排名 | 特征 | 重要性% |
|------|------|---------|
| 1 | rank_predicted_rounds | 20.87% |
| 2 | top_elite_prob | 19.38% |
| 3 | ma20_deviation_pct | 5.35% |
| 4 | atr_pct | 4.93% |
| 5 | dist_to_grid_lower | 3.75% |
---
## 五、周评分回测结果 (2023-04 ~ 2026-04)
| 指标 | v6.6 数值 |
|------|-----------|
| **总收益率** | **+36.79%** |
| 年化夏普 | 0.8494 |
| 最大回撤 | -12.70% |
| 胜率(周) | 48.7% |
| 交易次数 | 277 (147买, 130卖) |
| 最大持仓 | 10只 |
### 年度收益
| 年度 | 收益 |
|------|------|
| 2023 | +2.09% |
| 2024 | +28.34% |
| 2025 | +7.32% |
| 2026 | +0.52% |
---
## 六、与v6.5对比
| 指标 | v6.5 | v6.6 | 变化 |
|------|------|------|------|
| Stacking PR-AUC | 0.5234 | **0.8207** | +57% |
| 特征数 | 37维 | **52维** | +15维 |
| Top PR-AUC | 0.3923 | 0.5333 | +36% |
| 回测收益率 | 59.82% | 36.79%* | - |
| 回测最大回撤 | -33.47% | **-12.70%** | -62% |
\* v6.6使用周评分回测(157周),v6.5使用日评分回测(723天),粒度不同
---
## 七、模型文件
| 文件 | 说明 |
|------|------|
| `rank.pkl` | Rank模型 (预测网格轮回次数) |
| `top.pkl` | Top模型 (分类精英股票) |
| `stacking.pkl` | Stacking融合模型 |
| `rank_feature_importance.csv` | Rank特征重要性 |
| `top_feature_importance.csv` | Top特征重要性 |
| `stacking_feature_importance.csv` | Stacking特征重要性 |
| `training_summary.json` | 训练摘要 |
---
## 八、使用方式
```python
import pickle
import numpy as np
# 加载模型
with open("rank.pkl", "rb") as f:
rank_md = pickle.load(f)
with open("top.pkl", "rb") as f:
top_md = pickle.load(f)
with open("stacking.pkl", "rb") as f:
stacking_md = pickle.load(f)
# 预测
rank_pred = rank_md["model"].predict(features)
top_prob = top_md["model"].predict_proba(features)[:, 1]
stacking_prob = stacking_md["model"].predict_proba(features)[:, 1]
# 选股
threshold = 0.35 # stacking最优阈值
top_picks = scores[scores["stacking_prob"] >= threshold]
```
---
## 九、Registry配置
```yaml
# grid_seeker/registry.yaml
production:
version: v6.6
architecture: stacking_calibrated
models:
stacking: versions/6.6/output/stacking.pkl
rank: versions/6.6/output/rank.pkl
top: versions/6.6/output/top.pkl
feature_version: v3.4
training_samples: 205491
```
---
## 十、注意事项
1. **特征版本**: 必须使用 v3.4 特征(52维),与v6.5/v6.4不兼容
2. **Stacking阈值**: 推荐使用 0.35 作为选股阈值
3. **持股上限**: 建议不超过10只
4. **价格区间**: 适合7-10元区间股票
+38
View File
@@ -0,0 +1,38 @@
date,cash,stock_value,total_value,positions,total_shares,month
2023-05-31,60000.0,0.0,60000.0,0,0,2023-05
2023-06-30,60000.0,0.0,60000.0,0,0,2023-06
2023-07-31,60000.0,0.0,60000.0,0,0,2023-07
2023-08-31,60000.0,0.0,60000.0,0,0,2023-08
2023-09-30,60000.0,0.0,60000.0,0,0,2023-09
2023-10-31,60000.0,0.0,60000.0,0,0,2023-10
2023-11-30,60000.0,0.0,60000.0,0,0,2023-11
2023-12-31,60000.0,0.0,60000.0,0,0,2023-12
2024-01-31,60000.0,0.0,60000.0,0,0,2024-01
2024-02-29,60000.0,0.0,60000.0,0,0,2024-02
2024-03-31,60000.0,0.0,60000.0,0,0,2024-03
2024-04-30,60000.0,0.0,60000.0,0,0,2024-04
2024-05-31,60000.0,0.0,60000.0,0,0,2024-05
2024-06-30,60000.0,0.0,60000.0,0,0,2024-06
2024-07-31,60000.0,0.0,60000.0,0,0,2024-07
2024-08-31,60000.0,0.0,60000.0,0,0,2024-08
2024-09-30,60000.0,0.0,60000.0,0,0,2024-09
2024-10-31,60000.0,0.0,60000.0,0,0,2024-10
2024-11-30,60000.0,0.0,60000.0,0,0,2024-11
2024-12-31,60000.0,0.0,60000.0,0,0,2024-12
2025-01-31,60000.0,0.0,60000.0,0,0,2025-01
2025-02-28,60000.0,0.0,60000.0,0,0,2025-02
2025-03-31,60000.0,0.0,60000.0,0,0,2025-03
2025-04-30,60000.0,0.0,60000.0,0,0,2025-04
2025-05-31,60000.0,0.0,60000.0,0,0,2025-05
2025-06-30,60000.0,0.0,60000.0,0,0,2025-06
2025-07-31,60000.0,0.0,60000.0,0,0,2025-07
2025-08-31,60000.0,0.0,60000.0,0,0,2025-08
2025-09-30,60000.0,0.0,60000.0,0,0,2025-09
2025-10-31,60000.0,0.0,60000.0,0,0,2025-10
2025-11-30,60000.0,0.0,60000.0,0,0,2025-11
2025-12-31,60000.0,0.0,60000.0,0,0,2025-12
2026-01-31,60000.0,0.0,60000.0,0,0,2026-01
2026-02-28,60000.0,0.0,60000.0,0,0,2026-02
2026-03-31,60000.0,0.0,60000.0,0,0,2026-03
2026-04-30,60000.0,0.0,60000.0,0,0,2026-04
2026-05-27,60000.0,0.0,60000.0,0,0,2026-05
1 date cash stock_value total_value positions total_shares month
2 2023-05-31 60000.0 0.0 60000.0 0 0 2023-05
3 2023-06-30 60000.0 0.0 60000.0 0 0 2023-06
4 2023-07-31 60000.0 0.0 60000.0 0 0 2023-07
5 2023-08-31 60000.0 0.0 60000.0 0 0 2023-08
6 2023-09-30 60000.0 0.0 60000.0 0 0 2023-09
7 2023-10-31 60000.0 0.0 60000.0 0 0 2023-10
8 2023-11-30 60000.0 0.0 60000.0 0 0 2023-11
9 2023-12-31 60000.0 0.0 60000.0 0 0 2023-12
10 2024-01-31 60000.0 0.0 60000.0 0 0 2024-01
11 2024-02-29 60000.0 0.0 60000.0 0 0 2024-02
12 2024-03-31 60000.0 0.0 60000.0 0 0 2024-03
13 2024-04-30 60000.0 0.0 60000.0 0 0 2024-04
14 2024-05-31 60000.0 0.0 60000.0 0 0 2024-05
15 2024-06-30 60000.0 0.0 60000.0 0 0 2024-06
16 2024-07-31 60000.0 0.0 60000.0 0 0 2024-07
17 2024-08-31 60000.0 0.0 60000.0 0 0 2024-08
18 2024-09-30 60000.0 0.0 60000.0 0 0 2024-09
19 2024-10-31 60000.0 0.0 60000.0 0 0 2024-10
20 2024-11-30 60000.0 0.0 60000.0 0 0 2024-11
21 2024-12-31 60000.0 0.0 60000.0 0 0 2024-12
22 2025-01-31 60000.0 0.0 60000.0 0 0 2025-01
23 2025-02-28 60000.0 0.0 60000.0 0 0 2025-02
24 2025-03-31 60000.0 0.0 60000.0 0 0 2025-03
25 2025-04-30 60000.0 0.0 60000.0 0 0 2025-04
26 2025-05-31 60000.0 0.0 60000.0 0 0 2025-05
27 2025-06-30 60000.0 0.0 60000.0 0 0 2025-06
28 2025-07-31 60000.0 0.0 60000.0 0 0 2025-07
29 2025-08-31 60000.0 0.0 60000.0 0 0 2025-08
30 2025-09-30 60000.0 0.0 60000.0 0 0 2025-09
31 2025-10-31 60000.0 0.0 60000.0 0 0 2025-10
32 2025-11-30 60000.0 0.0 60000.0 0 0 2025-11
33 2025-12-31 60000.0 0.0 60000.0 0 0 2025-12
34 2026-01-31 60000.0 0.0 60000.0 0 0 2026-01
35 2026-02-28 60000.0 0.0 60000.0 0 0 2026-02
36 2026-03-31 60000.0 0.0 60000.0 0 0 2026-03
37 2026-04-30 60000.0 0.0 60000.0 0 0 2026-04
38 2026-05-27 60000.0 0.0 60000.0 0 0 2026-05
+1
View File
@@ -0,0 +1 @@
+158
View File
@@ -0,0 +1,158 @@
date,cash,stock_value,total_value,positions,year
2023-04-28,60000.0,0.0,60000.0,10,2023
2023-05-05,58400.0,1618.0,60018.0,10,2023
2023-05-12,55400.0,4570.0,59970.0,10,2023
2023-05-19,52200.0,7428.0,59628.0,10,2023
2023-05-26,45200.0,14102.0,59302.0,10,2023
2023-06-02,49800.0,11204.0,61004.0,10,2023
2023-06-09,55600.0,5996.0,61596.0,10,2023
2023-06-16,55600.0,5988.0,61588.0,10,2023
2023-06-23,53800.0,7522.0,61322.0,10,2023
2023-06-30,49200.0,11602.0,60802.0,10,2023
2023-07-07,51200.0,9542.0,60742.0,10,2023
2023-07-14,48400.0,12576.0,60976.0,10,2023
2023-07-21,50200.0,10406.0,60606.0,10,2023
2023-07-28,50200.0,10268.0,60468.0,10,2023
2023-08-04,50200.0,10622.0,60822.0,10,2023
2023-08-11,50200.0,10078.0,60278.0,10,2023
2023-08-18,47400.0,12614.0,60014.0,10,2023
2023-08-25,46200.0,13572.0,59772.0,10,2023
2023-09-01,46200.0,14042.0,60242.0,10,2023
2023-09-08,46800.0,12942.0,59742.0,10,2023
2023-09-15,45200.0,14398.0,59598.0,10,2023
2023-09-22,42600.0,17276.0,59876.0,10,2023
2023-09-29,42600.0,17430.0,60030.0,10,2023
2023-10-06,42600.0,17430.0,60030.0,10,2023
2023-10-13,42600.0,16750.0,59350.0,10,2023
2023-10-20,41000.0,17366.0,58366.0,10,2023
2023-10-27,39600.0,18870.0,58470.0,10,2023
2023-11-03,42600.0,17234.0,59834.0,10,2023
2023-11-10,45400.0,15998.0,61398.0,10,2023
2023-11-17,45400.0,16098.0,61498.0,10,2023
2023-11-24,47000.0,14652.0,61652.0,10,2023
2023-12-01,45800.0,16434.0,62234.0,10,2023
2023-12-08,48800.0,13898.0,62698.0,10,2023
2023-12-15,50800.0,12312.0,63112.0,10,2023
2023-12-22,45400.0,15928.0,61328.0,10,2023
2023-12-29,41000.0,20256.0,61256.0,10,2023
2024-01-05,42800.0,17966.0,60766.0,10,2024
2024-01-12,40000.0,20254.0,60254.0,10,2024
2024-01-19,37600.0,22196.0,59796.0,10,2024
2024-01-26,35000.0,25716.0,60716.0,10,2024
2024-02-02,25600.0,31044.0,56644.0,10,2024
2024-02-09,19400.0,36784.0,56184.0,10,2024
2024-02-16,19400.0,36784.0,56184.0,10,2024
2024-02-23,29400.0,33418.0,62818.0,10,2024
2024-03-01,35200.0,28470.0,63670.0,10,2024
2024-03-08,36400.0,26802.0,63202.0,10,2024
2024-03-15,40800.0,23672.0,64472.0,10,2024
2024-03-22,48600.0,19494.0,68094.0,10,2024
2024-03-29,48800.0,17936.0,66736.0,10,2024
2024-04-05,44800.0,21374.0,66174.0,10,2024
2024-04-12,42200.0,22450.0,64650.0,10,2024
2024-04-19,35000.0,27966.0,62966.0,10,2024
2024-04-26,36600.0,28114.0,64714.0,10,2024
2024-05-03,39000.0,27576.0,66576.0,10,2024
2024-05-10,42200.0,24400.0,66600.0,10,2024
2024-05-17,39800.0,26702.0,66502.0,10,2024
2024-05-24,38200.0,27704.0,65904.0,10,2024
2024-05-31,38400.0,26486.0,64886.0,10,2024
2024-06-07,32200.0,30776.0,62976.0,10,2024
2024-06-14,32200.0,31802.0,64002.0,10,2024
2024-06-21,31000.0,31084.0,62084.0,10,2024
2024-06-28,29000.0,32180.0,61180.0,10,2024
2024-07-05,29000.0,32292.0,61292.0,10,2024
2024-07-12,27200.0,33578.0,60778.0,10,2024
2024-07-19,27200.0,33172.0,60372.0,10,2024
2024-07-26,24200.0,35970.0,60170.0,10,2024
2024-08-02,24200.0,36992.0,61192.0,10,2024
2024-08-09,26000.0,34954.0,60954.0,10,2024
2024-08-16,26000.0,34780.0,60780.0,10,2024
2024-08-23,24400.0,35044.0,59444.0,10,2024
2024-08-30,23400.0,37774.0,61174.0,10,2024
2024-09-06,23400.0,36744.0,60144.0,10,2024
2024-09-13,23400.0,36430.0,59830.0,10,2024
2024-09-20,25200.0,35478.0,60678.0,10,2024
2024-09-27,30200.0,35280.0,65480.0,10,2024
2024-10-04,37000.0,32120.0,69120.0,10,2024
2024-10-11,40600.0,26824.0,67424.0,10,2024
2024-10-18,40600.0,28430.0,69030.0,10,2024
2024-10-25,43600.0,27342.0,70942.0,10,2024
2024-11-01,49600.0,23190.0,72790.0,10,2024
2024-11-08,53200.0,21184.0,74384.0,10,2024
2024-11-15,56000.0,19820.0,75820.0,10,2024
2024-11-22,59400.0,16790.0,76190.0,10,2024
2024-11-29,63600.0,14610.0,78210.0,10,2024
2024-12-06,63800.0,15148.0,78948.0,10,2024
2024-12-13,70600.0,9146.0,79746.0,10,2024
2024-12-20,67800.0,11830.0,79630.0,10,2024
2024-12-27,63400.0,14588.0,77988.0,10,2024
2025-01-03,55800.0,20000.0,75800.0,10,2025
2025-01-10,54400.0,21344.0,75744.0,10,2025
2025-01-17,57400.0,20314.0,77714.0,10,2025
2025-01-24,57400.0,20416.0,77816.0,10,2025
2025-01-31,57400.0,20178.0,77578.0,10,2025
2025-02-07,57400.0,21214.0,78614.0,10,2025
2025-02-14,63600.0,16530.0,80130.0,10,2025
2025-02-21,65000.0,14804.0,79804.0,10,2025
2025-02-28,62400.0,16588.0,78988.0,10,2025
2025-03-07,62400.0,16784.0,79184.0,10,2025
2025-03-14,62400.0,17590.0,79990.0,10,2025
2025-03-21,62400.0,16742.0,79142.0,10,2025
2025-03-28,62400.0,16480.0,78880.0,10,2025
2025-04-04,59800.0,18538.0,78338.0,10,2025
2025-04-11,55200.0,22474.0,77674.0,10,2025
2025-04-18,58600.0,19992.0,78592.0,10,2025
2025-04-25,58800.0,20036.0,78836.0,10,2025
2025-05-02,57200.0,21798.0,78998.0,10,2025
2025-05-09,59000.0,20540.0,79540.0,10,2025
2025-05-16,59000.0,20316.0,79316.0,10,2025
2025-05-23,60800.0,18556.0,79356.0,10,2025
2025-05-30,60800.0,18834.0,79634.0,10,2025
2025-06-06,60800.0,19254.0,80054.0,10,2025
2025-06-13,62000.0,18528.0,80528.0,10,2025
2025-06-20,63200.0,16976.0,80176.0,10,2025
2025-06-27,63200.0,17596.0,80796.0,10,2025
2025-07-04,63200.0,17758.0,80958.0,10,2025
2025-07-11,63200.0,18198.0,81398.0,10,2025
2025-07-18,64800.0,16488.0,81288.0,10,2025
2025-07-25,64800.0,16934.0,81734.0,10,2025
2025-08-01,66800.0,15168.0,81968.0,10,2025
2025-08-08,66800.0,14908.0,81708.0,10,2025
2025-08-15,66800.0,14766.0,81566.0,10,2025
2025-08-22,71200.0,11406.0,82606.0,10,2025
2025-08-29,71200.0,11004.0,82204.0,10,2025
2025-09-05,68400.0,13804.0,82204.0,10,2025
2025-09-12,70200.0,12362.0,82562.0,10,2025
2025-09-19,68400.0,13864.0,82264.0,10,2025
2025-09-26,67400.0,14434.0,81834.0,10,2025
2025-10-03,67400.0,14374.0,81774.0,10,2025
2025-10-10,65800.0,15922.0,81722.0,10,2025
2025-10-17,65800.0,15226.0,81026.0,10,2025
2025-10-24,67800.0,13844.0,81644.0,10,2025
2025-10-31,67800.0,13968.0,81768.0,10,2025
2025-11-07,67800.0,14122.0,81922.0,10,2025
2025-11-14,67800.0,14130.0,81930.0,10,2025
2025-11-21,66000.0,15954.0,81954.0,10,2025
2025-11-28,66000.0,16312.0,82312.0,10,2025
2025-12-05,66000.0,15682.0,81682.0,10,2025
2025-12-12,66000.0,15122.0,81122.0,10,2025
2025-12-19,66000.0,15268.0,81268.0,10,2025
2025-12-26,66000.0,15350.0,81350.0,10,2025
2026-01-02,67400.0,14250.0,81650.0,10,2026
2026-01-09,69200.0,13998.0,83198.0,10,2026
2026-01-16,73400.0,10432.0,83832.0,10,2026
2026-01-23,73400.0,10660.0,84060.0,10,2026
2026-01-30,72400.0,11408.0,83808.0,10,2026
2026-02-06,71000.0,12648.0,83648.0,10,2026
2026-02-13,71000.0,12800.0,83800.0,10,2026
2026-02-20,71000.0,12800.0,83800.0,10,2026
2026-02-27,69800.0,13764.0,83564.0,10,2026
2026-03-06,68200.0,14498.0,82698.0,10,2026
2026-03-13,68200.0,14434.0,82634.0,10,2026
2026-03-20,66600.0,15290.0,81890.0,10,2026
2026-03-27,63000.0,18856.0,81856.0,10,2026
2026-04-03,62200.0,18540.0,80740.0,10,2026
2026-04-10,60600.0,21340.0,81940.0,10,2026
2026-04-17,60600.0,21260.0,81860.0,10,2026
2026-04-24,62200.0,19876.0,82076.0,10,2026
1 date cash stock_value total_value positions year
2 2023-04-28 60000.0 0.0 60000.0 10 2023
3 2023-05-05 58400.0 1618.0 60018.0 10 2023
4 2023-05-12 55400.0 4570.0 59970.0 10 2023
5 2023-05-19 52200.0 7428.0 59628.0 10 2023
6 2023-05-26 45200.0 14102.0 59302.0 10 2023
7 2023-06-02 49800.0 11204.0 61004.0 10 2023
8 2023-06-09 55600.0 5996.0 61596.0 10 2023
9 2023-06-16 55600.0 5988.0 61588.0 10 2023
10 2023-06-23 53800.0 7522.0 61322.0 10 2023
11 2023-06-30 49200.0 11602.0 60802.0 10 2023
12 2023-07-07 51200.0 9542.0 60742.0 10 2023
13 2023-07-14 48400.0 12576.0 60976.0 10 2023
14 2023-07-21 50200.0 10406.0 60606.0 10 2023
15 2023-07-28 50200.0 10268.0 60468.0 10 2023
16 2023-08-04 50200.0 10622.0 60822.0 10 2023
17 2023-08-11 50200.0 10078.0 60278.0 10 2023
18 2023-08-18 47400.0 12614.0 60014.0 10 2023
19 2023-08-25 46200.0 13572.0 59772.0 10 2023
20 2023-09-01 46200.0 14042.0 60242.0 10 2023
21 2023-09-08 46800.0 12942.0 59742.0 10 2023
22 2023-09-15 45200.0 14398.0 59598.0 10 2023
23 2023-09-22 42600.0 17276.0 59876.0 10 2023
24 2023-09-29 42600.0 17430.0 60030.0 10 2023
25 2023-10-06 42600.0 17430.0 60030.0 10 2023
26 2023-10-13 42600.0 16750.0 59350.0 10 2023
27 2023-10-20 41000.0 17366.0 58366.0 10 2023
28 2023-10-27 39600.0 18870.0 58470.0 10 2023
29 2023-11-03 42600.0 17234.0 59834.0 10 2023
30 2023-11-10 45400.0 15998.0 61398.0 10 2023
31 2023-11-17 45400.0 16098.0 61498.0 10 2023
32 2023-11-24 47000.0 14652.0 61652.0 10 2023
33 2023-12-01 45800.0 16434.0 62234.0 10 2023
34 2023-12-08 48800.0 13898.0 62698.0 10 2023
35 2023-12-15 50800.0 12312.0 63112.0 10 2023
36 2023-12-22 45400.0 15928.0 61328.0 10 2023
37 2023-12-29 41000.0 20256.0 61256.0 10 2023
38 2024-01-05 42800.0 17966.0 60766.0 10 2024
39 2024-01-12 40000.0 20254.0 60254.0 10 2024
40 2024-01-19 37600.0 22196.0 59796.0 10 2024
41 2024-01-26 35000.0 25716.0 60716.0 10 2024
42 2024-02-02 25600.0 31044.0 56644.0 10 2024
43 2024-02-09 19400.0 36784.0 56184.0 10 2024
44 2024-02-16 19400.0 36784.0 56184.0 10 2024
45 2024-02-23 29400.0 33418.0 62818.0 10 2024
46 2024-03-01 35200.0 28470.0 63670.0 10 2024
47 2024-03-08 36400.0 26802.0 63202.0 10 2024
48 2024-03-15 40800.0 23672.0 64472.0 10 2024
49 2024-03-22 48600.0 19494.0 68094.0 10 2024
50 2024-03-29 48800.0 17936.0 66736.0 10 2024
51 2024-04-05 44800.0 21374.0 66174.0 10 2024
52 2024-04-12 42200.0 22450.0 64650.0 10 2024
53 2024-04-19 35000.0 27966.0 62966.0 10 2024
54 2024-04-26 36600.0 28114.0 64714.0 10 2024
55 2024-05-03 39000.0 27576.0 66576.0 10 2024
56 2024-05-10 42200.0 24400.0 66600.0 10 2024
57 2024-05-17 39800.0 26702.0 66502.0 10 2024
58 2024-05-24 38200.0 27704.0 65904.0 10 2024
59 2024-05-31 38400.0 26486.0 64886.0 10 2024
60 2024-06-07 32200.0 30776.0 62976.0 10 2024
61 2024-06-14 32200.0 31802.0 64002.0 10 2024
62 2024-06-21 31000.0 31084.0 62084.0 10 2024
63 2024-06-28 29000.0 32180.0 61180.0 10 2024
64 2024-07-05 29000.0 32292.0 61292.0 10 2024
65 2024-07-12 27200.0 33578.0 60778.0 10 2024
66 2024-07-19 27200.0 33172.0 60372.0 10 2024
67 2024-07-26 24200.0 35970.0 60170.0 10 2024
68 2024-08-02 24200.0 36992.0 61192.0 10 2024
69 2024-08-09 26000.0 34954.0 60954.0 10 2024
70 2024-08-16 26000.0 34780.0 60780.0 10 2024
71 2024-08-23 24400.0 35044.0 59444.0 10 2024
72 2024-08-30 23400.0 37774.0 61174.0 10 2024
73 2024-09-06 23400.0 36744.0 60144.0 10 2024
74 2024-09-13 23400.0 36430.0 59830.0 10 2024
75 2024-09-20 25200.0 35478.0 60678.0 10 2024
76 2024-09-27 30200.0 35280.0 65480.0 10 2024
77 2024-10-04 37000.0 32120.0 69120.0 10 2024
78 2024-10-11 40600.0 26824.0 67424.0 10 2024
79 2024-10-18 40600.0 28430.0 69030.0 10 2024
80 2024-10-25 43600.0 27342.0 70942.0 10 2024
81 2024-11-01 49600.0 23190.0 72790.0 10 2024
82 2024-11-08 53200.0 21184.0 74384.0 10 2024
83 2024-11-15 56000.0 19820.0 75820.0 10 2024
84 2024-11-22 59400.0 16790.0 76190.0 10 2024
85 2024-11-29 63600.0 14610.0 78210.0 10 2024
86 2024-12-06 63800.0 15148.0 78948.0 10 2024
87 2024-12-13 70600.0 9146.0 79746.0 10 2024
88 2024-12-20 67800.0 11830.0 79630.0 10 2024
89 2024-12-27 63400.0 14588.0 77988.0 10 2024
90 2025-01-03 55800.0 20000.0 75800.0 10 2025
91 2025-01-10 54400.0 21344.0 75744.0 10 2025
92 2025-01-17 57400.0 20314.0 77714.0 10 2025
93 2025-01-24 57400.0 20416.0 77816.0 10 2025
94 2025-01-31 57400.0 20178.0 77578.0 10 2025
95 2025-02-07 57400.0 21214.0 78614.0 10 2025
96 2025-02-14 63600.0 16530.0 80130.0 10 2025
97 2025-02-21 65000.0 14804.0 79804.0 10 2025
98 2025-02-28 62400.0 16588.0 78988.0 10 2025
99 2025-03-07 62400.0 16784.0 79184.0 10 2025
100 2025-03-14 62400.0 17590.0 79990.0 10 2025
101 2025-03-21 62400.0 16742.0 79142.0 10 2025
102 2025-03-28 62400.0 16480.0 78880.0 10 2025
103 2025-04-04 59800.0 18538.0 78338.0 10 2025
104 2025-04-11 55200.0 22474.0 77674.0 10 2025
105 2025-04-18 58600.0 19992.0 78592.0 10 2025
106 2025-04-25 58800.0 20036.0 78836.0 10 2025
107 2025-05-02 57200.0 21798.0 78998.0 10 2025
108 2025-05-09 59000.0 20540.0 79540.0 10 2025
109 2025-05-16 59000.0 20316.0 79316.0 10 2025
110 2025-05-23 60800.0 18556.0 79356.0 10 2025
111 2025-05-30 60800.0 18834.0 79634.0 10 2025
112 2025-06-06 60800.0 19254.0 80054.0 10 2025
113 2025-06-13 62000.0 18528.0 80528.0 10 2025
114 2025-06-20 63200.0 16976.0 80176.0 10 2025
115 2025-06-27 63200.0 17596.0 80796.0 10 2025
116 2025-07-04 63200.0 17758.0 80958.0 10 2025
117 2025-07-11 63200.0 18198.0 81398.0 10 2025
118 2025-07-18 64800.0 16488.0 81288.0 10 2025
119 2025-07-25 64800.0 16934.0 81734.0 10 2025
120 2025-08-01 66800.0 15168.0 81968.0 10 2025
121 2025-08-08 66800.0 14908.0 81708.0 10 2025
122 2025-08-15 66800.0 14766.0 81566.0 10 2025
123 2025-08-22 71200.0 11406.0 82606.0 10 2025
124 2025-08-29 71200.0 11004.0 82204.0 10 2025
125 2025-09-05 68400.0 13804.0 82204.0 10 2025
126 2025-09-12 70200.0 12362.0 82562.0 10 2025
127 2025-09-19 68400.0 13864.0 82264.0 10 2025
128 2025-09-26 67400.0 14434.0 81834.0 10 2025
129 2025-10-03 67400.0 14374.0 81774.0 10 2025
130 2025-10-10 65800.0 15922.0 81722.0 10 2025
131 2025-10-17 65800.0 15226.0 81026.0 10 2025
132 2025-10-24 67800.0 13844.0 81644.0 10 2025
133 2025-10-31 67800.0 13968.0 81768.0 10 2025
134 2025-11-07 67800.0 14122.0 81922.0 10 2025
135 2025-11-14 67800.0 14130.0 81930.0 10 2025
136 2025-11-21 66000.0 15954.0 81954.0 10 2025
137 2025-11-28 66000.0 16312.0 82312.0 10 2025
138 2025-12-05 66000.0 15682.0 81682.0 10 2025
139 2025-12-12 66000.0 15122.0 81122.0 10 2025
140 2025-12-19 66000.0 15268.0 81268.0 10 2025
141 2025-12-26 66000.0 15350.0 81350.0 10 2025
142 2026-01-02 67400.0 14250.0 81650.0 10 2026
143 2026-01-09 69200.0 13998.0 83198.0 10 2026
144 2026-01-16 73400.0 10432.0 83832.0 10 2026
145 2026-01-23 73400.0 10660.0 84060.0 10 2026
146 2026-01-30 72400.0 11408.0 83808.0 10 2026
147 2026-02-06 71000.0 12648.0 83648.0 10 2026
148 2026-02-13 71000.0 12800.0 83800.0 10 2026
149 2026-02-20 71000.0 12800.0 83800.0 10 2026
150 2026-02-27 69800.0 13764.0 83564.0 10 2026
151 2026-03-06 68200.0 14498.0 82698.0 10 2026
152 2026-03-13 68200.0 14434.0 82634.0 10 2026
153 2026-03-20 66600.0 15290.0 81890.0 10 2026
154 2026-03-27 63000.0 18856.0 81856.0 10 2026
155 2026-04-03 62200.0 18540.0 80740.0 10 2026
156 2026-04-10 60600.0 21340.0 81940.0 10 2026
157 2026-04-17 60600.0 21260.0 81860.0 10 2026
158 2026-04-24 62200.0 19876.0 82076.0 10 2026
BIN
View File
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
feature,importance,importance_pct
dist_to_grid_upper,1319,4.38
dist_to_grid_lower,1282,4.26
price_cv,1231,4.09
amount_mean_20d,1198,3.98
range_compression_20d,1047,3.48
rebound_from_low_60d,1019,3.38
drawdown_60d,981,3.26
ma20_deviation_pct,915,3.04
grid_touch_count_60d,907,3.01
volume_ratio,903,3.0
price_entropy,877,2.91
avg_daily_amp,857,2.85
atr_pct,781,2.59
wick_ratio_20d,780,2.59
cross_freq_x_bb,743,2.47
trend_slope_60d,743,2.47
amount_trend_20d,741,2.46
range_position_60d,735,2.44
obv_slope,731,2.43
volatility_20d,707,2.35
volume_cv_20d,697,2.32
amplitude_cv,673,2.24
intraday_trend_strength,669,2.22
amp_cv_x_entropy,661,2.2
ma60_deviation_pct,655,2.18
ma20_ma60_gap_pct,650,2.16
grid_room_balance,630,2.09
bb_width,629,2.09
amount_cv_20d,598,1.99
amp_x_grid_vol,588,1.95
trend_slope_20d,568,1.89
amp_x_grid,499,1.66
trend_abs_slope_20d,448,1.49
close_reversal_count_20d,395,1.31
near_upper_boundary_risk,366,1.22
grid_touch_count_20d,335,1.11
near_grid_line_ratio_20d,293,0.97
low_volume_days_20d,274,0.91
turnover_proxy_20d,250,0.83
rolling_grid_ratio_20d,248,0.82
cross_freq_20d,206,0.68
mv_vol_interact,190,0.63
down_days_20d,180,0.6
high_amp_days,172,0.57
up_days_20d,165,0.55
small_cap_premium,164,0.54
ln_float_mv,162,0.54
near_lower_boundary_risk,114,0.38
trend_consistency_20d,103,0.34
usable_grid_count_lower,13,0.04
usable_grid_count_upper,12,0.04
grid_cross_density_20d,0,0.0
1 feature importance importance_pct
2 dist_to_grid_upper 1319 4.38
3 dist_to_grid_lower 1282 4.26
4 price_cv 1231 4.09
5 amount_mean_20d 1198 3.98
6 range_compression_20d 1047 3.48
7 rebound_from_low_60d 1019 3.38
8 drawdown_60d 981 3.26
9 ma20_deviation_pct 915 3.04
10 grid_touch_count_60d 907 3.01
11 volume_ratio 903 3.0
12 price_entropy 877 2.91
13 avg_daily_amp 857 2.85
14 atr_pct 781 2.59
15 wick_ratio_20d 780 2.59
16 cross_freq_x_bb 743 2.47
17 trend_slope_60d 743 2.47
18 amount_trend_20d 741 2.46
19 range_position_60d 735 2.44
20 obv_slope 731 2.43
21 volatility_20d 707 2.35
22 volume_cv_20d 697 2.32
23 amplitude_cv 673 2.24
24 intraday_trend_strength 669 2.22
25 amp_cv_x_entropy 661 2.2
26 ma60_deviation_pct 655 2.18
27 ma20_ma60_gap_pct 650 2.16
28 grid_room_balance 630 2.09
29 bb_width 629 2.09
30 amount_cv_20d 598 1.99
31 amp_x_grid_vol 588 1.95
32 trend_slope_20d 568 1.89
33 amp_x_grid 499 1.66
34 trend_abs_slope_20d 448 1.49
35 close_reversal_count_20d 395 1.31
36 near_upper_boundary_risk 366 1.22
37 grid_touch_count_20d 335 1.11
38 near_grid_line_ratio_20d 293 0.97
39 low_volume_days_20d 274 0.91
40 turnover_proxy_20d 250 0.83
41 rolling_grid_ratio_20d 248 0.82
42 cross_freq_20d 206 0.68
43 mv_vol_interact 190 0.63
44 down_days_20d 180 0.6
45 high_amp_days 172 0.57
46 up_days_20d 165 0.55
47 small_cap_premium 164 0.54
48 ln_float_mv 162 0.54
49 near_lower_boundary_risk 114 0.38
50 trend_consistency_20d 103 0.34
51 usable_grid_count_lower 13 0.04
52 usable_grid_count_upper 12 0.04
53 grid_cross_density_20d 0 0.0
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
feature,importance,importance_pct
rank_predicted_rounds,1252,20.87
top_elite_prob,1163,19.38
ma20_deviation_pct,321,5.35
atr_pct,296,4.93
dist_to_grid_lower,225,3.75
price_cv,190,3.17
amount_mean_20d,183,3.05
drawdown_60d,158,2.63
obv_slope,145,2.42
dist_to_grid_upper,142,2.37
ma20_ma60_gap_pct,141,2.35
range_position_60d,124,2.07
trend_slope_60d,123,2.05
rebound_from_low_60d,114,1.9
intraday_trend_strength,98,1.63
volume_ratio,98,1.63
volume_cv_20d,86,1.43
ma60_deviation_pct,81,1.35
grid_room_balance,69,1.15
range_compression_20d,69,1.15
volatility_20d,64,1.07
amount_trend_20d,56,0.93
near_upper_boundary_risk,54,0.9
up_days_20d,50,0.83
avg_daily_amp,48,0.8
amplitude_cv,48,0.8
amount_cv_20d,44,0.73
small_cap_premium,42,0.7
near_lower_boundary_risk,42,0.7
high_amp_days,40,0.67
cross_freq_x_bb,37,0.62
amp_x_grid,37,0.62
bb_width,36,0.6
amp_x_grid_vol,36,0.6
ln_float_mv,33,0.55
price_entropy,28,0.47
turnover_proxy_20d,26,0.43
trend_slope_20d,25,0.42
mv_vol_interact,25,0.42
wick_ratio_20d,22,0.37
grid_touch_count_60d,21,0.35
amp_cv_x_entropy,21,0.35
grid_touch_count_20d,20,0.33
rolling_grid_ratio_20d,14,0.23
near_grid_line_ratio_20d,12,0.2
usable_grid_count_upper,9,0.15
down_days_20d,9,0.15
trend_abs_slope_20d,7,0.12
cross_freq_20d,6,0.1
usable_grid_count_lower,5,0.08
low_volume_days_20d,3,0.05
close_reversal_count_20d,1,0.02
trend_consistency_20d,1,0.02
grid_cross_density_20d,0,0.0
1 feature importance importance_pct
2 rank_predicted_rounds 1252 20.87
3 top_elite_prob 1163 19.38
4 ma20_deviation_pct 321 5.35
5 atr_pct 296 4.93
6 dist_to_grid_lower 225 3.75
7 price_cv 190 3.17
8 amount_mean_20d 183 3.05
9 drawdown_60d 158 2.63
10 obv_slope 145 2.42
11 dist_to_grid_upper 142 2.37
12 ma20_ma60_gap_pct 141 2.35
13 range_position_60d 124 2.07
14 trend_slope_60d 123 2.05
15 rebound_from_low_60d 114 1.9
16 intraday_trend_strength 98 1.63
17 volume_ratio 98 1.63
18 volume_cv_20d 86 1.43
19 ma60_deviation_pct 81 1.35
20 grid_room_balance 69 1.15
21 range_compression_20d 69 1.15
22 volatility_20d 64 1.07
23 amount_trend_20d 56 0.93
24 near_upper_boundary_risk 54 0.9
25 up_days_20d 50 0.83
26 avg_daily_amp 48 0.8
27 amplitude_cv 48 0.8
28 amount_cv_20d 44 0.73
29 small_cap_premium 42 0.7
30 near_lower_boundary_risk 42 0.7
31 high_amp_days 40 0.67
32 cross_freq_x_bb 37 0.62
33 amp_x_grid 37 0.62
34 bb_width 36 0.6
35 amp_x_grid_vol 36 0.6
36 ln_float_mv 33 0.55
37 price_entropy 28 0.47
38 turnover_proxy_20d 26 0.43
39 trend_slope_20d 25 0.42
40 mv_vol_interact 25 0.42
41 wick_ratio_20d 22 0.37
42 grid_touch_count_60d 21 0.35
43 amp_cv_x_entropy 21 0.35
44 grid_touch_count_20d 20 0.33
45 rolling_grid_ratio_20d 14 0.23
46 near_grid_line_ratio_20d 12 0.2
47 usable_grid_count_upper 9 0.15
48 down_days_20d 9 0.15
49 trend_abs_slope_20d 7 0.12
50 cross_freq_20d 6 0.1
51 usable_grid_count_lower 5 0.08
52 low_volume_days_20d 3 0.05
53 close_reversal_count_20d 1 0.02
54 trend_consistency_20d 1 0.02
55 grid_cross_density_20d 0 0.0
BIN
View File
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
feature,importance,importance_pct
rank_predicted_rounds,2126,6.86
amount_mean_20d,1476,4.76
price_cv,1376,4.44
range_compression_20d,1165,3.76
atr_pct,1105,3.56
drawdown_60d,1090,3.52
volume_ratio,1030,3.32
amount_trend_20d,998,3.22
price_entropy,888,2.86
rebound_from_low_60d,872,2.81
ma20_deviation_pct,868,2.8
obv_slope,842,2.72
trend_slope_60d,827,2.67
wick_ratio_20d,800,2.58
ma20_ma60_gap_pct,768,2.48
amplitude_cv,767,2.47
dist_to_grid_lower,757,2.44
range_position_60d,747,2.41
amp_cv_x_entropy,714,2.3
bb_width,699,2.25
ma60_deviation_pct,691,2.23
volatility_20d,680,2.19
volume_cv_20d,657,2.12
intraday_trend_strength,650,2.1
avg_daily_amp,643,2.07
dist_to_grid_upper,629,2.03
trend_slope_20d,626,2.02
amp_x_grid_vol,551,1.78
amount_cv_20d,550,1.77
grid_room_balance,537,1.73
trend_abs_slope_20d,504,1.63
grid_touch_count_60d,480,1.55
cross_freq_x_bb,399,1.29
amp_x_grid,383,1.24
near_grid_line_ratio_20d,277,0.89
close_reversal_count_20d,267,0.86
high_amp_days,250,0.81
turnover_proxy_20d,243,0.78
ln_float_mv,235,0.76
low_volume_days_20d,228,0.74
mv_vol_interact,223,0.72
small_cap_premium,218,0.7
grid_touch_count_20d,218,0.7
up_days_20d,218,0.7
down_days_20d,209,0.67
near_upper_boundary_risk,165,0.53
trend_consistency_20d,105,0.34
near_lower_boundary_risk,87,0.28
cross_freq_20d,86,0.28
rolling_grid_ratio_20d,64,0.21
usable_grid_count_lower,6,0.02
usable_grid_count_upper,6,0.02
grid_cross_density_20d,0,0.0
1 feature importance importance_pct
2 rank_predicted_rounds 2126 6.86
3 amount_mean_20d 1476 4.76
4 price_cv 1376 4.44
5 range_compression_20d 1165 3.76
6 atr_pct 1105 3.56
7 drawdown_60d 1090 3.52
8 volume_ratio 1030 3.32
9 amount_trend_20d 998 3.22
10 price_entropy 888 2.86
11 rebound_from_low_60d 872 2.81
12 ma20_deviation_pct 868 2.8
13 obv_slope 842 2.72
14 trend_slope_60d 827 2.67
15 wick_ratio_20d 800 2.58
16 ma20_ma60_gap_pct 768 2.48
17 amplitude_cv 767 2.47
18 dist_to_grid_lower 757 2.44
19 range_position_60d 747 2.41
20 amp_cv_x_entropy 714 2.3
21 bb_width 699 2.25
22 ma60_deviation_pct 691 2.23
23 volatility_20d 680 2.19
24 volume_cv_20d 657 2.12
25 intraday_trend_strength 650 2.1
26 avg_daily_amp 643 2.07
27 dist_to_grid_upper 629 2.03
28 trend_slope_20d 626 2.02
29 amp_x_grid_vol 551 1.78
30 amount_cv_20d 550 1.77
31 grid_room_balance 537 1.73
32 trend_abs_slope_20d 504 1.63
33 grid_touch_count_60d 480 1.55
34 cross_freq_x_bb 399 1.29
35 amp_x_grid 383 1.24
36 near_grid_line_ratio_20d 277 0.89
37 close_reversal_count_20d 267 0.86
38 high_amp_days 250 0.81
39 turnover_proxy_20d 243 0.78
40 ln_float_mv 235 0.76
41 low_volume_days_20d 228 0.74
42 mv_vol_interact 223 0.72
43 small_cap_premium 218 0.7
44 grid_touch_count_20d 218 0.7
45 up_days_20d 218 0.7
46 down_days_20d 209 0.67
47 near_upper_boundary_risk 165 0.53
48 trend_consistency_20d 105 0.34
49 near_lower_boundary_risk 87 0.28
50 cross_freq_20d 86 0.28
51 rolling_grid_ratio_20d 64 0.21
52 usable_grid_count_lower 6 0.02
53 usable_grid_count_upper 6 0.02
54 grid_cross_density_20d 0 0.0
+46
View File
@@ -0,0 +1,46 @@
{
"version": "v6.6",
"feature_version": "v3.4",
"architecture": "stacking_calibrated",
"data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base",
"training_date": "2026-05-28T14:23:23.662118",
"n_stocks_total": 4358,
"n_stocks_after_filter": 1533,
"n_training_samples": 205491,
"window_days": 120,
"future_days": 60,
"step_days": 20,
"y_rounds_mean": 0.1998384357465777,
"y_rounds_median": 0.0,
"y_rounds_zero_rate": 0.7108340511263267,
"elite_rate": 20.52109338121864,
"rank": {
"cv_mae": 0.2053,
"cv_r2": 0.2258,
"best_params": {
"num_leaves": 63,
"min_child_samples": 30,
"max_depth": 7
},
"n_features": 52
},
"top": {
"cv_pr_auc": 0.5333,
"best_params": {
"num_leaves": 63,
"min_child_samples": 30,
"max_depth": -1
},
"n_features": 53
},
"stacking": {
"cv_pr_auc": 0.8207,
"best_params": {
"num_leaves": 31,
"min_child_samples": 20,
"max_depth": -1
},
"n_features": 54,
"optimal_threshold": 0.35
}
}
+7 -10
View File
@@ -1,23 +1,20 @@
# coding:utf-8
"""
启动入口 — 自动探测 QMT 环境
默认使用 Tkinter UI使用 --flet 参数切换到 Flet (Flutter) UI。
启动入口 — 默认使用 Flet2 UI
使用 --tk 参数切换到 Tkinter UI。
"""
import sys
if __name__ == '__main__':
if '--flet2' in sys.argv:
from core.ui.flet.app_v2 import run
run()
elif '--flet' in sys.argv:
from core.ui.flet.app import run
run()
else:
from tkinter import messagebox
if '--tk' in sys.argv:
from core.ui.tkinter.splash import SplashWindow
from tkinter import messagebox
try:
window = SplashWindow().run()
if window:
window.run()
except Exception as e:
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
else:
from core.ui.flet.app_v2 import run
run()