update
This commit is contained in:
+15
-1
@@ -121,12 +121,26 @@ class ScoringResult(BaseModel):
|
|||||||
primary_key = CompositeKey('stock_code', 'trade_date')
|
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 = [
|
ALL_SCORING_TABLES = [
|
||||||
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
||||||
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
|
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, PendingPoolAction,
|
||||||
]
|
]
|
||||||
|
|
||||||
db.create_tables(ALL_SCORING_TABLES)
|
db.create_tables(ALL_SCORING_TABLES)
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ EventTradeTargetDeleted = "trade_target_deleted"
|
|||||||
# 评分系统事件
|
# 评分系统事件
|
||||||
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
||||||
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
|
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"
|
||||||
|
|||||||
@@ -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 表示在 Top50,rank=-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
@@ -17,7 +17,7 @@ from core.logger import LogLevel, PrintLog
|
|||||||
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
|
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID
|
||||||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
||||||
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
|
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: '部成待撤',
|
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||||
@@ -78,6 +78,7 @@ class _DataStore:
|
|||||||
self.monitorPrice: float = 10.0
|
self.monitorPrice: float = 10.0
|
||||||
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
|
self.marketLog: dict[str, dict] = {} # stock_code → {name, price, time}
|
||||||
self.listeningStocks: list = []
|
self.listeningStocks: list = []
|
||||||
|
self.poolLog: list[str] = [] # 股票池管理器日志(list,方便 append)
|
||||||
|
|
||||||
# ── 初始化 ──
|
# ── 初始化 ──
|
||||||
|
|
||||||
@@ -903,6 +904,9 @@ class _DrawerPanel:
|
|||||||
ft.Container(ft.Divider(height=20), width=2),
|
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_sync(['kline', 'stocks', 'industry', 'market', 'sector']), height=32),
|
||||||
ft.ElevatedButton("执行评分", on_click=lambda e: self._run_scoring(), 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,
|
self._score_status,
|
||||||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||||
return ft.Column([
|
return ft.Column([
|
||||||
@@ -1329,8 +1333,14 @@ class QmtApp:
|
|||||||
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
||||||
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
|
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active)
|
||||||
event_bus.subscribe(EventTradeTargetUpdate, lambda t: self._refresh_ui())
|
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()
|
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):
|
def _show_error(self, msg: str):
|
||||||
self.page.clean()
|
self.page.clean()
|
||||||
self.page.add(ft.Container(
|
self.page.add(ft.Container(
|
||||||
@@ -1434,6 +1444,36 @@ class QmtApp:
|
|||||||
self._prices_loaded = True
|
self._prices_loaded = True
|
||||||
self._refresh_ui()
|
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):
|
def _on_market_active(self, is_active: bool):
|
||||||
from core.logger import PrintLog, LogLevel
|
from core.logger import PrintLog, LogLevel
|
||||||
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')
|
PrintLog(LogLevel.INFO, f'[UI] 收到市场状态切换事件 is_active={is_active}')
|
||||||
|
|||||||
Reference in New Issue
Block a user