This commit is contained in:
2026-06-24 16:13:44 +08:00
parent 363efd6d2d
commit 5b1412eab0
5 changed files with 552 additions and 2 deletions
+15 -1
View File
@@ -121,12 +121,26 @@ class ScoringResult(BaseModel):
primary_key = CompositeKey('stock_code', 'trade_date')
# ============================================================
# 8. PendingPoolAction — 待执行股票池操作(阶段一标记,阶段二执行)
# ============================================================
class PendingPoolAction(BaseModel):
"""pending_pool_actions 表 — T日标记的操作,待 T+1 执行"""
action_date = DateField() # T日日期(标记日期)
action_type = CharField(max_length=20) # 'eliminate' | 'liquidate'
stock_code = CharField(max_length=6) # 股票代码
reason = TextField(null=True) # 淘汰原因描述
class Meta:
primary_key = CompositeKey('action_date', 'action_type', 'stock_code')
# ============================================================
# 建表
# ============================================================
ALL_SCORING_TABLES = [
KlineStock, StockInfo, IndustryMapping, KlineIndex,
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, PendingPoolAction,
]
db.create_tables(ALL_SCORING_TABLES)
+8
View File
@@ -5,3 +5,11 @@ EventTradeTargetDeleted = "trade_target_deleted"
# 评分系统事件
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
# 股票池管理器事件(阶段一)
# T日收盘标记完成, data: {action: 'eliminate'|'liquidate', stock_codes: list[str], count: int}
EventPoolMark = "pool_mark"
# T+1日执行完成(阶段二用)
# data: {action: 'eliminate'|'liquidate'|'refill', stock_codes: list[str], count: int}
EventPoolActionExecute = "pool_action_execute"
+488
View File
@@ -0,0 +1,488 @@
"""
pool_manager.py — 网格自动交易股票池管理器(阶段一:T日标记)
===================================================================
后台 daemon 线程运行,每日定时:
09:25 K线数据同步
09:30 执行评分
15:30 沉寂检测标记
周五15:30 额外执行周度淘汰标记
所有操作只记录到数据库,不执行真实交易(阶段二实现)。
"""
from __future__ import annotations
import threading
import time
from collections import defaultdict
from datetime import date, datetime, timedelta
import numpy as np
import pandas as pd
from core.logger import LogLevel, PrintLog
from core.scoring.models import PendingPoolAction, ScoringResult
from core.sfgrid.model import SFGridTradeTarget
from core.sfgrid.bus_events import EventPoolMark
from core.eventbus import event_bus
# ============================================================
# 配置
# ============================================================
TOP_N = 10 # 最大持仓数
TOP_MODEL_N = 50 # 评分池 Top N
ELIM_WINDOW = 2 # 连续 N 周不在 Top50 则淘汰
SLUMBER_DAYS = 10 # 沉寂触发天数(连续)
SLUMBER_TRIGGERS = 3 # 沉寂触发特征数(5个中触发几个)
# ============================================================
# 沉寂检测 — 直接复用 ref_backtest_strategy.py 的纯函数
# ============================================================
def is_slumbering(df: pd.DataFrame,
lookback_60: int = 60,
lookback_20: int = 20,
min_triggers: int = SLUMBER_TRIGGERS) -> bool:
"""
检测一只股是否陷入'沉寂'(资金离场后长期低位震荡)。
5 特征,>= min_triggers 触发则返回 True。
df 要求:包含 close/high/low/volume 列,index 为日期升序,
至少 60 条记录。
"""
if df is None or len(df) < lookback_60:
return False
# 过滤停牌日期(volume=0 的行会导致 log_ret = NaN
active = df[df["volume"] > 0]
if len(active) < lookback_60:
return False
sub = active.tail(lookback_60)
close = sub["close"].values
high = sub["high"].values
low = sub["low"].values
vol = sub["volume"].values
# 1. 波动率塌陷
log_ret = np.log(close[1:] / close[:-1])
if len(log_ret) < lookback_20:
return False
vol_20d = float(np.std(log_ret[-lookback_20:], ddof=1))
vol_60d = float(np.std(log_ret, ddof=1))
vol_collapse = (vol_60d > 0) and (vol_20d / vol_60d < 0.6)
# 2. 振幅萎缩
amp_20d = float(np.mean((high[-lookback_20:] - low[-lookback_20:]) / close[-lookback_20:]) * 100)
amp_shrink = amp_20d < 2.5
# 3. 成交量枯竭
avg_vol_20 = float(np.mean(vol[-lookback_20:]))
avg_vol_60 = float(np.mean(vol))
vol_dry = (avg_vol_60 > 0) and (avg_vol_20 / avg_vol_60 < 0.5)
# 4. 价格弱势
price_max_60 = float(np.max(close))
price_weak = price_max_60 > 0 and (close[-1] / price_max_60) < 0.85
# 5. 反弹失败
recent_high_30 = float(np.max(high[-30:]))
past_high_60 = float(np.max(high))
rebound_fail = past_high_60 > 0 and (recent_high_30 / past_high_60) < 0.95
triggers = [vol_collapse, amp_shrink, vol_dry, price_weak, rebound_fail]
return sum(triggers) >= min_triggers
# ============================================================
# 工具函数
# ============================================================
def is_trading_day(td: date) -> bool:
"""简单判断是否为交易日(周一~周五)"""
return td.weekday() < 5 # 0=周一, 4=周五
def get_week_id(td: date) -> int:
"""返回年内周序号(周一为起始)"""
return td.isocalendar()[1]
def seconds_to_target(target_hour: int, target_minute: int) -> float:
"""计算从现在到目标时间(当天 target_hour:target_minute)的秒数。"""
now = datetime.now()
today_target = datetime(now.year, now.month, now.day, target_hour, target_minute, 0)
if now >= today_target:
# 今天已过,推到明天
today_target += timedelta(days=1)
return (today_target - now).total_seconds()
# ============================================================
# 股票池管理器
# ============================================================
class PoolManager:
"""
网格股票池自动管理器(阶段一:T日标记)
使用 threading.Timer 递归调度,实现每日 09:25 / 09:30 / 15:30 定时任务。
所有操作只写入数据库,不执行真实交易。
"""
def __init__(self):
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
# 周度淘汰历史:key=股票代码,value=[(week_id, rank), ...]
# rank=0 表示在 Top50rank=-1 表示不在 Top50
self._top50_history: dict[str, list[tuple[int, int]]] = defaultdict(list)
# 调试:手动触发时传入自定义日期(仅供测试用)
self._override_date: date | None = None
# 加载历史排名数据(从 ScoringResult 重建)
self._rebuild_top50_history()
# ---- 对外控制接口 ----
def start(self):
"""启动后台管理线程(幂等)"""
if self._thread is not None and self._thread.is_alive():
PrintLog(LogLevel.WARNING, '[PoolManager] 已启动,忽略重复调用')
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run_loop, daemon=True, name='PoolManager')
self._thread.start()
PrintLog(LogLevel.INFO, '[PoolManager] 已启动')
def stop(self):
"""停止后台管理线程"""
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
PrintLog(LogLevel.INFO, '[PoolManager] 已停止')
# ---- 每日定时任务 ----
def _run_loop(self):
"""后台线程主循环:计算出距下次任务的时间,注册下一个 Timer"""
while not self._stop_event.is_set():
now = datetime.now()
td = self._override_date or now.date()
# 确定当天要执行的任务及距其的秒数
delay, task_name = self._compute_next_delay(now, td)
# 保证 Timer 不会太久(最多 24 小时),处理节假日顺延
if delay <= 0:
delay = 60 # 异常时等 1 分钟重算
PrintLog(LogLevel.INFO,
f'[PoolManager] 计划任务 "{task_name}"{delay:.0f} 秒后执行')
timer = threading.Timer(delay, self._execute_task, args=(task_name, td))
timer.name = f'PoolManager-{task_name}'
timer.start()
# 等待定时器完成或停止信号
timer.join()
if self._stop_event.is_set():
break
def _compute_next_delay(self, now: datetime, td: date) -> tuple[float, str]:
"""
计算距下一个任务的时间和任务名称。
任务顺序:09:25 → 09:30 → 15:30 → (下一天 09:25)
"""
h, m = now.hour, now.minute
if h < 9 or (h == 9 and m < 25):
# 现在在 09:25 之前 → 先执行 09:25
return seconds_to_target(9, 25), '_sync_data'
elif h == 9 and 25 <= m < 30:
# 09:25~09:30 之间 → 立即执行 09:30
return 0.0, '_run_scoring'
elif (h == 9 and m >= 30) or h < 15:
# 09:30 之后、15:30 之前 → 执行 15:30
return seconds_to_target(15, 30), '_mark_slumber'
elif h >= 15:
# 15:30 之后 → 推到下一天 09:25
delay = seconds_to_target(9, 25) + (td.weekday() < 4 and 1 or 3) * 86400 # 工作日+1,周末+3
return delay, '_sync_data'
# 默认兜底
return seconds_to_target(9, 25), '_sync_data'
def _execute_task(self, task_name: str, td: date):
"""根据任务名执行对应任务"""
try:
if task_name == '_sync_data':
self._sync_data()
# 同步完成后自动调度评分
self._run_scoring()
# 调度 15:30
self._schedule_next(target_hour=15, target_minute=30,
task_name='_mark_slumber', td=td)
elif task_name == '_run_scoring':
self._run_scoring()
# 评分完成后调度沉寂检测
self._schedule_next(target_hour=15, target_minute=30,
task_name='_mark_slumber', td=td)
elif task_name == '_mark_slumber':
self._mark_slumber(td)
# 如果是周五,额外调度淘汰标记
if td.weekday() == 4: # 周五
self._mark_weekly_elim(td)
# 调度下一天 09:25
self._schedule_next(target_hour=9, target_minute=25,
task_name='_sync_data', td=td)
except Exception as e:
PrintLog(LogLevel.ERROR, f'[PoolManager] 任务 {task_name} 执行异常: {e}')
def _schedule_next(self, target_hour: int, target_minute: int,
task_name: str, td: date):
"""注册一个 Timer,在指定时间执行 task_name"""
# 计算 delay
delay = seconds_to_target(target_hour, target_minute)
# 如果 target 在过去(如周末顺延),加 1 天
if delay <= 0:
delay += 86400
def wrapper():
now = datetime.now()
# 重新计算真实日期
exec_td = self._override_date or now.date()
self._execute_task(task_name, exec_td)
timer = threading.Timer(delay, wrapper, name=f'PoolManager-sched-{task_name}')
timer.start()
# ---- 任务实现 ----
def _sync_data(self):
"""09:25 — 同步 K 线数据"""
PrintLog(LogLevel.INFO, '[PoolManager] 开始同步K线数据...')
try:
from core.scoring.sync.kline_sync import KlineStockSync
syncer = KlineStockSync()
syncer.run()
PrintLog(LogLevel.INFO, '[PoolManager] K线数据同步完成')
except Exception as e:
PrintLog(LogLevel.ERROR, f'[PoolManager] K线同步失败: {e}')
def _run_scoring(self):
"""09:30 — 执行评分"""
PrintLog(LogLevel.INFO, '[PoolManager] 开始执行评分...')
try:
from core.scoring.inference.scorer import GridSeekerPipeline
pipeline = GridSeekerPipeline()
td = self._override_date or date.today()
result = pipeline.run(trade_date=td)
if not result.empty:
pipeline.persist(result, trade_date=td)
PrintLog(LogLevel.INFO, f'[PoolManager] 评分完成,写入 {len(result)} 条结果')
# 触发 UI 刷新
event_bus.publish(EventPoolMark, {
'action': 'scoring',
'stock_codes': [],
'count': len(result),
})
else:
PrintLog(LogLevel.WARNING, '[PoolManager] 评分无结果')
except Exception as e:
PrintLog(LogLevel.ERROR, f'[PoolManager] 评分失败: {e}')
def _mark_slumber(self, td: date):
"""15:30 — 沉寂检测,标记待卖出股"""
if not is_trading_day(td):
PrintLog(LogLevel.INFO, '[PoolManager] 非交易日,跳过沉寂检测')
return
PrintLog(LogLevel.INFO, '[PoolManager] 开始沉寂检测...')
# 获取所有 enabled=True 的持仓
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
if not targets:
PrintLog(LogLevel.INFO, '[PoolManager] 无持仓,跳过沉寂检测')
return
marked: list[str] = []
today_str = td.strftime('%Y-%m-%d')
for tgt in targets:
try:
# 读取该股 K 线数据(从本地 SQLite KlineStock 表)
from core.scoring.models import KlineStock
klines = list(KlineStock
.select()
.where(KlineStock.stock_code == tgt.stock_code)
.order_by(KlineStock.trade_date)
.dicts())
if not klines or len(klines) < 60:
continue
df = pd.DataFrame(klines)
if 'trade_date' in df.columns and not pd.api.types.is_datetime64_any_dtype(df['trade_date']):
df['trade_date'] = pd.to_datetime(df['trade_date'])
df = df.set_index('trade_date').sort_index()
if is_slumbering(df):
self._write_pending(td, 'liquidate', tgt.stock_code,
reason=f'沉寂检测触发({today_str}')
marked.append(tgt.stock_code)
PrintLog(LogLevel.INFO,
f'[PoolManager] 沉寂标记: {tgt.stock_code}')
except Exception as e:
PrintLog(LogLevel.WARNING,
f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}')
if marked:
from core.sfgrid.bus import event_bus
event_bus.publish(EventPoolMark, {
'action': 'liquidate',
'stock_codes': marked,
'count': len(marked),
})
PrintLog(LogLevel.INFO,
f'[PoolManager] 沉寂检测完成,标记 {len(marked)} 只股')
else:
PrintLog(LogLevel.INFO, '[PoolManager] 沉寂检测完成,无股触发')
def _mark_weekly_elim(self, td: date):
"""周五 15:30 — 周度评分淘汰标记(连续2周不在Top50则卖出)"""
if not is_trading_day(td):
return
PrintLog(LogLevel.INFO, '[PoolManager] 开始周度淘汰检测...')
# 获取最近 N 周的评分排名
week_id = get_week_id(td)
today_str = td.strftime('%Y-%m-%d')
# 查今日 Top50
top50_codes: Set[str] = set()
rows = (ScoringResult
.select(ScoringResult.stock_code)
.where(ScoringResult.trade_date == td)
.where(ScoringResult.score_rank <= TOP_MODEL_N)
.dicts())
for r in rows:
top50_codes.add(r['stock_code'])
if not top50_codes:
PrintLog(LogLevel.WARNING, '[PoolManager] 今日无评分数据,无法进行淘汰检测')
return
# 更新历史排名
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
for tgt in targets:
code = tgt.stock_code
rank_in_top50 = 0 if code in top50_codes else -1
self._top50_history[code].append((week_id, rank_in_top50))
# 只保留近 8 周记录,防止内存膨胀
if len(self._top50_history[code]) > 8:
self._top50_history[code] = self._top50_history[code][-8:]
# 检测连续 N 周不在 Top50 的股
marked: list[str] = []
for tgt in targets:
code = tgt.stock_code
hist = self._top50_history.get(code, [])
if len(hist) < ELIM_WINDOW:
continue
recent = hist[-ELIM_WINDOW:]
if all(rank == -1 for _, rank in recent):
self._write_pending(td, 'eliminate', code,
reason=f'连续{ELIM_WINDOW}周不在Top50{today_str}')
marked.append(code)
PrintLog(LogLevel.INFO,
f'[PoolManager] 淘汰标记: {code},历史: {recent}')
if marked:
from core.sfgrid.bus import event_bus
event_bus.publish(EventPoolMark, {
'action': 'eliminate',
'stock_codes': marked,
'count': len(marked),
})
PrintLog(LogLevel.INFO,
f'[PoolManager] 周度淘汰检测完成,标记 {len(marked)} 只股')
else:
PrintLog(LogLevel.INFO, '[PoolManager] 周度淘汰检测完成,无股触发')
def _write_pending(self, td: date, action_type: str, stock_code: str, reason: str = ''):
"""写入 pending_pool_actions 表(幂等)"""
try:
PendingPoolAction.insert(
action_date=td,
action_type=action_type,
stock_code=stock_code,
reason=reason,
).on_conflict_replace().execute()
except Exception as e:
PrintLog(LogLevel.WARNING,
f'[PoolManager] 写入待处理操作失败: {e}')
def _rebuild_top50_history(self):
"""启动时从 ScoringResult 表重建 _top50_history(用于淘汰判断)"""
try:
rows = (ScoringResult
.select(ScoringResult.stock_code, ScoringResult.trade_date,
ScoringResult.score_rank)
.where(ScoringResult.score_rank <= TOP_MODEL_N)
.order_by(ScoringResult.trade_date)
.dicts())
week_groups: dict = defaultdict(list)
for r in rows:
td = r['trade_date']
if isinstance(td, str):
td = datetime.strptime(td, '%Y-%m-%d').date()
week_id = get_week_id(td)
week_groups[(r['stock_code'], week_id)].append(r['score_rank'])
# 取每周最新一条(排名最靠前的)
for (code, week_id), ranks in week_groups.items():
best_rank = min(ranks)
rank_val = 0 if best_rank <= TOP_MODEL_N else -1
self._top50_history[code].append((week_id, rank_val))
# 去重,每 week_id 只留一条
for code in self._top50_history:
seen = set()
cleaned = []
for w, r in self._top50_history[code]:
if w not in seen:
seen.add(w)
cleaned.append((w, r))
self._top50_history[code] = cleaned
PrintLog(LogLevel.INFO,
f'[PoolManager] 历史排名已重建,{len(self._top50_history)} 只股有历史数据')
except Exception as e:
PrintLog(LogLevel.ERROR, f'[PoolManager] 重建历史排名失败: {e}')
# ---- 手动触发(供 UI 调试按钮调用)----
def trigger_sync(self):
"""手动触发数据同步"""
threading.Thread(target=self._sync_data, daemon=True).start()
def trigger_scoring(self):
"""手动触发评分"""
threading.Thread(target=self._run_scoring, daemon=True).start()
def trigger_slumber(self, td: date | None = None):
"""手动触发沉寂检测"""
td = td or (self._override_date or date.today())
threading.Thread(target=self._mark_slumber, args=(td,), daemon=True).start()
def trigger_weekly_elim(self, td: date | None = None):
"""手动触发周度淘汰检测"""
td = td or (self._override_date or date.today())
threading.Thread(target=self._mark_weekly_elim, args=(td,), daemon=True).start()
+41 -1
View File
@@ -17,7 +17,7 @@ from core.logger import LogLevel, PrintLog
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
from core.sfgrid.sfgrid_strategy import SFGridStrategy
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
from core.sfgrid.bus_events import EventTradeTargetUpdate
from core.sfgrid.bus_events import EventTradeTargetUpdate, EventPoolMark
# ── 工具函数 ──
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
@@ -78,6 +78,7 @@ class _DataStore:
self.monitorPrice: float = 10.0
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
self.listeningStocks: list = []
self.poolLog: list[str] = [] # 股票池管理器日志(list,方便 append)
# ── 初始化 ──
@@ -903,6 +904,9 @@ class _DrawerPanel:
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),
ft.Container(ft.Divider(height=20), width=2),
ft.ElevatedButton("沉寂检测", on_click=lambda e: self._data._pool_manager.trigger_slumber(), height=32, tooltip="手动触发沉寂检测(阶段一标记)"),
ft.ElevatedButton("淘汰检测", on_click=lambda e: self._data._pool_manager.trigger_weekly_elim(), height=32, tooltip="手动触发周度淘汰检测(阶段一标记)"),
self._score_status,
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column([
@@ -1329,8 +1333,14 @@ class QmtApp:
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
event_bus.subscribe(EventTradeTargetUpdate, lambda t: self._refresh_ui())
event_bus.subscribe(EventPoolMark, self._on_pool_mark)
threading.Thread(target=self._refresh_loop, daemon=True).start()
# 启动股票池管理器(阶段一:T日标记)
from core.sfgrid.pool_manager import PoolManager
self._data._pool_manager = PoolManager()
self._data._pool_manager.start()
def _show_error(self, msg: str):
self.page.clean()
self.page.add(ft.Container(
@@ -1434,6 +1444,36 @@ class QmtApp:
self._prices_loaded = True
self._refresh_ui()
def _on_pool_mark(self, data: dict):
"""响应股票池标记事件,在日志区显示"""
action = data.get('action', '')
count = data.get('count', 0)
codes = data.get('stock_codes', [])
if action == 'scoring':
msg = f'[池] 评分完成,共 {count} 只候选'
elif action == 'liquidate':
msg = f'[池] 沉寂检测完成,标记 {count} 只股: {", ".join(codes)}'
elif action == 'eliminate':
msg = f'[池] 周度淘汰检测完成,标记 {count} 只股: {", ".join(codes)}'
else:
msg = f'[池] 未知事件: {data}'
self._data.poolLog.append(msg)
# 追加到 UI 日志
if hasattr(self, '_pool_log_text') and self._pool_log_text:
log = self._pool_log_text
log.value = '\n'.join(self._data.poolLog[-200:]) if self._data.poolLog else ''
log.update()
from core.logger import PrintLog, LogLevel
PrintLog(LogLevel.INFO, msg)
def trigger_pool_slumber(self):
"""供 UI 按钮手动触发沉寂检测"""
self._data._pool_manager.trigger_slumber()
def trigger_pool_elim(self):
"""供 UI 按钮手动触淘汰检测"""
self._data._pool_manager.trigger_weekly_elim()
def _on_market_active(self, is_active: bool):
from core.logger import PrintLog, LogLevel
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')