diff --git a/core/qmt_real.py b/core/qmt_real.py index 70163bb..720a96d 100644 --- a/core/qmt_real.py +++ b/core/qmt_real.py @@ -443,6 +443,33 @@ class RealQmtV: """获取股票名称""" return self.cacheStockDetail(stock_code)['InstrumentName'] + def getInstrumentName_batch(self, stock_codes: list) -> dict: + """批量获取股票名称,返回 {stock_code: name} dict""" + result = {} + missing = [] + for code in stock_codes: + if code in self.details: + result[code] = self.details[code].get('InstrumentName', '') + else: + missing.append(code) + if not missing: + return result + try: + from xtquant import xtdata + for code in missing: + full_code = self._to_full_code(code) + detail = xtdata.get_instrument_detail(full_code) + if detail: + name = detail.get('instrumentName', detail.get('InstrumentName', '')) + self.details[code] = detail + result[code] = name + else: + result[code] = '' + except Exception: + for code in missing: + result[code] = '' + return result + def dailyUpStop(self, stock_code: str): """获取涨停价""" detail = self.cacheStockDetail(stock_code) diff --git a/core/scoring/sync/kline_sync.py b/core/scoring/sync/kline_sync.py index 7713552..840e867 100644 --- a/core/scoring/sync/kline_sync.py +++ b/core/scoring/sync/kline_sync.py @@ -58,19 +58,18 @@ class KlineStockSync(BaseSync): 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 - # 增量起点 + # 增量起点: last_db_date + 1; 截止: 昨天(盘中不能同步当天数据) + # 注意: 不能用 latest >= today 跳过,因为 latest 可能是错误的未收盘数据 start_date = (latest + timedelta(days=1)) if latest else None + end_date = today - timedelta(days=1) # 固定截止到昨天,收盘后同步昨天数据 start_str = start_date.strftime('%Y%m%d') if start_date else "" + end_str = end_date.strftime('%Y%m%d') PrintLog(LogLevel.INFO, - f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}') + f'[sync] KlineStock: 增量同步 {start_str} ~ {end_str}') all_stocks = xtdata.get_stock_list_in_sector("沪深A股") PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股') @@ -83,7 +82,7 @@ class KlineStockSync(BaseSync): PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)') try: - xtdata.download_history_data(code, period='1d', start_time=start_str) + xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_str) except Exception: self.stats['errors'] += 1 continue @@ -91,8 +90,9 @@ class KlineStockSync(BaseSync): 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) + start_time=start_str, end_time=end_str, + dividend_type='none', fill_data=False) + inserted += self._upsert_incremental(code, result, start_date, end_date) except Exception: self.stats['errors'] += 1 @@ -102,7 +102,7 @@ class KlineStockSync(BaseSync): f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}') return self.stats - def _upsert_incremental(self, full_code: str, result: dict, start_date) -> int: + def _upsert_incremental(self, full_code: str, result: dict, start_date, end_date) -> int: if not result: return 0 close_df = result.get('close') @@ -110,13 +110,23 @@ class KlineStockSync(BaseSync): return 0 stock_code = full_code.split('.')[0] records = [] + vol_df = result.get('volume') for td in close_df.columns: # xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date if isinstance(td, str): td_date = datetime.strptime(td, '%Y%m%d').date() else: td_date = td.date() if hasattr(td, 'date') else td - if start_date is not None and td_date <= start_date: + # 过滤: 不在增量范围内的跳过 (start_date < td <= end_date) + if start_date is not None and td_date < start_date: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + if end_date is not None and td_date > end_date: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + # 跳过成交量为0的无效数据(盘中未结算数据) + vol = vol_df.loc[full_code, td] if vol_df is not None else None + if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0: self.stats['skipped'] = self.stats.get('skipped', 0) + 1 continue close_val = close_df.loc[full_code, td] @@ -130,7 +140,7 @@ class KlineStockSync(BaseSync): '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), + 'volume': float(vol), }) if records: with db.atomic(): @@ -165,33 +175,35 @@ class KlineIndexSync(BaseSync): 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 + end_date = today - timedelta(days=1) # 截止到昨天 start_str = start_date.strftime('%Y%m%d') if start_date else "" + end_str = end_date.strftime('%Y%m%d') PrintLog(LogLevel.INFO, - f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}') + f'[sync] KlineIndex: 增量同步 {start_str} ~ {end_str}') for code in index_codes: try: - xtdata.download_history_data(code, period='1d', start_time=start_str) + xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_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 {} + start_time=start_str, end_time=end_str, + dividend_type='none', fill_data=False) def _upsert(self, data): if not data: return latest = _latest_date(KlineIndex) + today = date.today() + start_date = (latest + timedelta(days=1)) if latest else None + end_date = today - timedelta(days=1) records = [] close_df = data.get('close') + vol_df = data.get('volume') if close_df is None or close_df.empty: return for full_code in close_df.index: @@ -201,7 +213,16 @@ class KlineIndexSync(BaseSync): td_date = datetime.strptime(td, '%Y%m%d').date() else: td_date = td.date() if hasattr(td, 'date') else td - if latest is not None and td_date <= latest: + # 增量范围过滤 (start_date < td <= end_date) + if start_date is not None and td_date < start_date: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + if end_date is not None and td_date > end_date: + self.stats['skipped'] = self.stats.get('skipped', 0) + 1 + continue + # 过滤成交量为0的无效数据 + vol = vol_df.loc[full_code, td] if vol_df is not None else None + if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0: self.stats['skipped'] = self.stats.get('skipped', 0) + 1 continue close_val = close_df.loc[full_code, td] @@ -215,7 +236,7 @@ class KlineIndexSync(BaseSync): '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), + 'volume': float(vol), }) if records: with db.atomic(): diff --git a/core/sfgrid/pool_manager.py b/core/sfgrid/pool_manager.py index 8b2b18e..35cdf67 100644 --- a/core/sfgrid/pool_manager.py +++ b/core/sfgrid/pool_manager.py @@ -342,7 +342,6 @@ class PoolManager: f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}') if marked: - from core.sfgrid.bus import event_bus event_bus.publish(EventPoolMark, { 'action': 'liquidate', 'stock_codes': marked, @@ -404,7 +403,6 @@ class PoolManager: f'[PoolManager] 淘汰标记: {code},历史: {recent}') if marked: - from core.sfgrid.bus import event_bus event_bus.publish(EventPoolMark, { 'action': 'eliminate', 'stock_codes': marked, diff --git a/core/sfgrid/sfgrid_strategy.py b/core/sfgrid/sfgrid_strategy.py index 8749e04..d5e199b 100644 --- a/core/sfgrid/sfgrid_strategy.py +++ b/core/sfgrid/sfgrid_strategy.py @@ -54,10 +54,17 @@ class SFGridStrategy: """ self.tradeTarget: model.SFGridTradeTarget = tradeTarget + # orderGrid 必须在所有可能触发回调的操作之前初始化 + # orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射 + # seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新) + self.orderGrid = {} # {grid_index: order_seq | order_id} + # 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问 # QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建 # 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发 - self.dataUpdateLock = threading.Lock() + # 注意:必须使用 RLock 而非 Lock,因为 refreshGridOrder 在持有此锁时也会被调用 + #(如 onOrderTrade 回调中),Lock 会导致同一线程重复获取时永久阻塞(死锁) + self.dataUpdateLock = threading.RLock() # 订阅事件总线:监听订单创建、成交、失败三种事件 event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync) @@ -73,10 +80,6 @@ class SFGridStrategy: f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: ' f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}') - # orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射 - # seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新) - self.orderGrid = {} # {grid_index: order_seq | order_id} - # 加载券商侧已存在的未成交订单,恢复到 orderGrid 中 self.loadExistOrders() @@ -171,7 +174,14 @@ class SFGridStrategy: sell_remark = self._make_remark(OrderTypeSell, sellIdx) # 检查是否已存在同 remark 的卖单(避免重复挂单) - if not any(o.order_remark == sell_remark for o in orders): + # 注意:必须同时查 QMT 订单簿和本地 orderGrid + # - QMT 订单簿:已确认的订单(onOrderCreateAsync 之后) + # - orderGrid:本地下单后、回调前的新单(orderAsync 返回后直接写入) + # 两者并集才能完整覆盖所有已存在订单,防止 onOrderCreateAsync 回调 + # 之前再次触发 refreshGridOrder 导致重复下单 + qmt_has_order = any(o.order_remark == sell_remark for o in orders) + local_has_order = sellIdx in self.orderGrid + if not qmt_has_order and not local_has_order: # 卖单价格超过涨停价 → 今日无法成交,跳过下单 # 防御性检查:若属性未初始化(初始化顺序导致),先获取 if not hasattr(self, 'todayUpStopPrice') or self.todayUpStopPrice is None: @@ -208,7 +218,10 @@ class SFGridStrategy: buy_remark = self._make_remark(OrderTypeBuy, buyIdx) # 检查是否已存在同 remark 的买单(避免重复挂单) - if not any(o.order_remark == buy_remark for o in orders): + # 必须同时查 QMT 订单簿和本地 orderGrid(见上方卖单注释) + qmt_has_order = any(o.order_remark == buy_remark for o in orders) + local_has_order = buyIdx in self.orderGrid + if not qmt_has_order and not local_has_order: # 买单价格低于跌停价 → 今日无法成交,跳过下单 # 防御性检查:若属性未初始化(初始化顺序导致),先获取 if not hasattr(self, 'todayDownStopPrice') or self.todayDownStopPrice is None: @@ -434,6 +447,17 @@ class SFGridStrategy: if self.tradeTarget.grid_index == 0: self.tradeTarget.init_price = trade.traded_price # type: ignore + # ── 同步更新持仓量 ── + # 注意:xtquant 的成交推送不包含最新持仓,此处根据成交方向估算变动 + # 买入成交(建仓/补仓)→ 持仓增加 + # 卖出成交(减仓/清仓)→ 持仓减少 + if gridIdx > self.tradeTarget.grid_index: + # 买入方向:持仓增加 + self.tradeTarget.current_position += int(trade.traded_volume) # type: ignore + elif gridIdx < self.tradeTarget.grid_index: + # 卖出方向:持仓减少 + self.tradeTarget.current_position -= int(trade.traded_volume) # type: ignore + # ── 网格方向判断 ── # 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向 oriIdx = self.tradeTarget.grid_index # 成交前的网格位置 diff --git a/core/ui/flet/app_v2.py b/core/ui/flet/app_v2.py index 125b165..bba74e3 100644 --- a/core/ui/flet/app_v2.py +++ b/core/ui/flet/app_v2.py @@ -23,13 +23,15 @@ from core.sfgrid.bus_events import EventTradeTargetUpdate, EventPoolMark _ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤', 53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'} +from datetime import date as _date + def _fmt_time(t) -> str: if not t: return '' - import datetime + from datetime import datetime as _dt try: ts = int(t) if ts > 1e12: ts //= 1000 - return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S') + return _dt.fromtimestamp(ts).strftime('%H:%M:%S') except (ValueError, OSError): return str(t) @@ -563,7 +565,6 @@ class _DrawerPanel: self._refresh_trades() self._refresh_market() self._refresh_dataset() - self._refresh_scoring() def _refresh_grid(self): # (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间 @@ -908,34 +909,91 @@ class _DrawerPanel: # ── Tab 6: 每日评分 ── def _build_scoring_tab(self) -> ft.Control: - from datetime import date, timedelta - self._score_cur_date = date.today() + from datetime import timedelta + from peewee import fn + from core.scoring.models import ScoringResult + + # 初始日期:优先最近一个有评分的过去日期,今天盘中不能评分 + today = _date.today() + latest_scored = (ScoringResult + .select(fn.MAX(ScoringResult.trade_date)) + .scalar()) + # 只取过去的评分日期,今天(未收盘)不算有效评分 + if latest_scored and latest_scored < today: + self._score_cur_date = latest_scored + elif latest_scored: + self._score_cur_date = latest_scored # 今天有评分(可能是收盘后补跑的),也显示 + else: + self._score_cur_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') + self._score_refreshing = False + self._score_lock = threading.Lock() + + self._score_max_date = today # 导航上限:不允许看未来日期 def _prev_day(_): + if not self._score_lock.acquire(blocking=False): + return self._score_cur_date -= timedelta(days=1) self._score_date_label.value = str(self._score_cur_date) self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() self._refresh_scoring() def _next_day(_): + if not self._score_lock.acquire(blocking=False): + return + if self._score_cur_date >= self._score_max_date: + self._score_lock.release() + return self._score_cur_date += timedelta(days=1) self._score_date_label.value = str(self._score_cur_date) self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() self._refresh_scoring() def _today(_): - self._score_cur_date = date.today() + if not self._score_lock.acquire(blocking=False): + return + if self._score_cur_date >= self._score_max_date: + self._score_lock.release() + return + self._score_cur_date = self._score_max_date self._score_date_label.value = str(self._score_cur_date) self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() self._refresh_scoring() + self._score_nav_btns = [] # 占位,后面重新赋值 + prev_btn = ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day) + next_btn = ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day) + today_btn = ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today) + self._score_nav_btns = [prev_btn, next_btn, today_btn] + + # 初始时若已在上限日期,禁用"明天"和"今天"按钮 + if self._score_cur_date >= self._score_max_date: + next_btn.disabled = True + today_btn.disabled = True + date_row = ft.Row([ - ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day), + prev_btn, 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), + next_btn, + today_btn, 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), @@ -958,34 +1016,68 @@ class _DrawerPanel: from core.scoring.inference.scorer import GridSeekerPipeline trade_date = self._score_cur_date - # 检查当日 K线数据是否已同步 + # 检查 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},请先盘后同步" + + if latest_kline is None: + self._score_status.value = "K线数据未同步,请先同步数据" self._score_status.color = '#F44336' self._score_status.update() return + # 评分日期以实际可用数据日期为准 + # 规则: 只能评过去已收盘的日期,今天(06-26)盘中不能评分 + # - trade_date < today: 过去日期,若有数据则用,若无数据则fallback到latest_kline + # - trade_date == today: 今天盘中,拒绝评分 + today = _date.today() + if trade_date >= today: + self._score_status.value = f"今天({today})未收盘,无法评分,请切换到过去日期" + self._score_status.color = '#F44336' + self._score_status.update() + self._revert_nav_btns() + return + + from core.scoring.models import KlineStock + has_trade_date = (KlineStock + .select(fn.COUNT(KlineStock.stock_code)) + .where(KlineStock.trade_date == trade_date) + .scalar() or 0) > 0 + + persist_date = trade_date if has_trade_date else latest_kline + + if not has_trade_date: + self._score_status.value = f"{trade_date} K线未同步,实际用 {persist_date} 数据评分" + self._score_status.color = '#FF9800' + self._score_status.update() + try: engine = GridSeekerPipeline() - rankings = engine.run(trade_date) + rankings = engine.run(persist_date) if not rankings.empty: - engine.persist(rankings, trade_date) - self._score_status.value = f"{trade_date} 评分完成" + engine.persist(rankings, persist_date) + self._score_status.value = f"{persist_date} 评分完成" + self._score_status.color = '#4CAF50' + # 刷新后跳到实际评分日期的那一页 + self._score_cur_date = persist_date + self._score_date_label.value = str(persist_date) except FileNotFoundError: self._score_status.value = "模型文件缺失" except Exception as ex: self._score_status.value = f"失败: {ex}" + self._score_status.color = '#F44336' self._score_status.update() self._refresh_scoring() threading.Thread(target=_do, daemon=True).start() def _refresh_scoring(self): + import time as _time from core.scoring.models import ScoringResult from core.qmt import qmtv + t0 = _time.time() + PrintLog(LogLevel.DEBUG, '[_refresh_scoring] START date=%s' % self._score_cur_date) # (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32 _C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)] @@ -1016,28 +1108,40 @@ class _DrawerPanel: if not scored_rows: self._score_status.value = f"{trade_date} 无评分数据" self._score_status.color = '#F44336' + # 空状态时在表格内也显示日期占位,让切换日期有明确的视觉反馈 + rows.append(ft.Container( + ft.Text(f'← {trade_date} 暂无评分数据 →', + size=14, color='#bbbbbb', text_align=ft.TextAlign.CENTER), + padding=ft.Padding(0, 20, 0, 20), + )) self._scoring_table.controls = rows + # 一次性批量更新:标签 + 状态 + 导航按钮 + self._score_date_label.update() self._score_status.update() + self._revert_nav_btns() return - shown = 0 + # 批量预加载: 1次DB查询 (替代原来每行1+1次,最快方案) + plain_codes = [] + full_codes = [] 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': + plain_codes.append(plain) + full_codes.append(f'{plain}.SH' if plain.startswith(('6', '5', '9')) else f'{plain}.SZ') + + from core.scoring.models import StockInfo + st_map = {} + name_map = {} + for row in StockInfo.select(StockInfo.code, StockInfo.listing_status, StockInfo.name).where(StockInfo.code.in_(full_codes)).dicts(): + st_map[row['code']] = row['listing_status'] + name_map[row['code'].split('.')[0]] = row['name'] + + shown = 0 + for r, plain, full_code in zip(scored_rows, plain_codes, full_codes): + if st_map.get(full_code) == 'ST': continue - name = '' - try: - name = qmtv.getInstrumentName(plain) - except Exception: - pass + name = name_map.get(plain, '') already_in = plain in self._data.stockCodeIdMap shown += 1 @@ -1068,7 +1172,26 @@ class _DrawerPanel: self._scoring_table.controls = rows self._score_status.value = f"{trade_date} 候选 {shown} 只" self._score_status.color = '#4CAF50' + # 一次性批量更新:标签 + 状态 + 导航按钮 + self._score_date_label.update() self._score_status.update() + self._revert_nav_btns() + PrintLog(LogLevel.DEBUG, '[_refresh_scoring] END t=%.2fs rows=%s' % (_time.time()-t0, shown)) + + + def _revert_nav_btns(self): + """重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮""" + self._score_refreshing = False + at_max = self._score_cur_date >= self._score_max_date + for btn in self._score_nav_btns: + btn.disabled = False + if at_max: + self._score_nav_btns[1].disabled = True # next + self._score_nav_btns[2].disabled = True # today + for btn in self._score_nav_btns: + btn.update() + if self._score_refreshing: + self._score_lock.release() def _on_add_from_scoring(self, stock_code: str, stock_name: str): """+ 按钮:从评分列表添加标的并打开网格配置""" diff --git a/patch_lock.py b/patch_lock.py new file mode 100644 index 0000000..5f195ed --- /dev/null +++ b/patch_lock.py @@ -0,0 +1,127 @@ +with open('core/ui/flet/app_v2.py', 'r', encoding='utf-8') as f: + content = f.read() + +# Patch 1: Add lock alongside _score_refreshing init +old_init = " self._score_refreshing = False # 刷新锁" +new_init = " self._score_refreshing = False\n self._score_lock = threading.Lock()" +content = content.replace(old_init, new_init, 1) +print('Patch 1 (init):', 'OK' if old_init not in content else 'NOT FOUND') + +# Patch 2: _prev_day - use lock instead of flag guard +old_prev = ''' def _prev_day(_): + if self._score_refreshing: + return + self._score_cur_date -= timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_refreshing = True + self._score_nav_btns[0].disabled = True + self._score_nav_btns[1].disabled = True + self._score_nav_btns[2].disabled = True + self._refresh_scoring()''' +new_prev = ''' def _prev_day(_): + if not self._score_lock.acquire(blocking=False): + return + self._score_cur_date -= timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() + self._refresh_scoring()''' +content = content.replace(old_prev, new_prev, 1) +print('Patch 2 (_prev_day):', 'OK' if old_prev not in content else 'NOT FOUND') + +# Patch 3: _next_day - use lock +old_next = ''' def _next_day(_): + if self._score_refreshing: + return + if self._score_cur_date >= self._score_max_date: + return + self._score_cur_date += timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_refreshing = True + self._score_nav_btns[0].disabled = True + self._score_nav_btns[1].disabled = True + self._score_nav_btns[2].disabled = True + self._refresh_scoring()''' +new_next = ''' def _next_day(_): + if not self._score_lock.acquire(blocking=False): + return + if self._score_cur_date >= self._score_max_date: + self._score_lock.release() + return + self._score_cur_date += timedelta(days=1) + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() + self._refresh_scoring()''' +content = content.replace(old_next, new_next, 1) +print('Patch 3 (_next_day):', 'OK' if old_next not in content else 'NOT FOUND') + +# Patch 4: _today - use lock +old_today = ''' def _today(_): + if self._score_refreshing: + return + if self._score_cur_date >= self._score_max_date: + return + self._score_cur_date = self._score_max_date + self._score_date_label.value = str(self._score_cur_date) + self._score_refreshing = True + self._score_nav_btns[0].disabled = True + self._score_nav_btns[1].disabled = True + self._score_nav_btns[2].disabled = True + self._refresh_scoring()''' +new_today = ''' def _today(_): + if not self._score_lock.acquire(blocking=False): + return + if self._score_cur_date >= self._score_max_date: + self._score_lock.release() + return + self._score_cur_date = self._score_max_date + self._score_date_label.value = str(self._score_cur_date) + self._score_date_label.update() + self._score_refreshing = True + for btn in self._score_nav_btns: + btn.disabled = True + for btn in self._score_nav_btns: + btn.update() + self._refresh_scoring()''' +content = content.replace(old_today, new_today, 1) +print('Patch 4 (_today):', 'OK' if old_today not in content else 'NOT FOUND') + +# Patch 5: _revert_nav_btns - release lock at end +old_revert = ''' def _revert_nav_btns(self): + """重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮""" + self._score_refreshing = False + at_max = self._score_cur_date >= self._score_max_date + for btn in self._score_nav_btns: + btn.disabled = False + if at_max: + self._score_nav_btns[1].disabled = True # next + self._score_nav_btns[2].disabled = True # today + for btn in self._score_nav_btns: + btn.update()''' +new_revert = ''' def _revert_nav_btns(self): + """重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮""" + self._score_refreshing = False + at_max = self._score_cur_date >= self._score_max_date + for btn in self._score_nav_btns: + btn.disabled = False + if at_max: + self._score_nav_btns[1].disabled = True # next + self._score_nav_btns[2].disabled = True # today + for btn in self._score_nav_btns: + btn.update() + self._score_lock.release()''' +content = content.replace(old_revert, new_revert, 1) +print('Patch 5 (_revert_nav_btns):', 'OK' if old_revert not in content else 'NOT FOUND') + +with open('core/ui/flet/app_v2.py', 'w', encoding='utf-8') as f: + f.write(content) +print('Done writing')