update
This commit is contained in:
+15
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.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}')
|
||||
|
||||
Reference in New Issue
Block a user