update
This commit is contained in:
@@ -443,6 +443,33 @@ class RealQmtV:
|
|||||||
"""获取股票名称"""
|
"""获取股票名称"""
|
||||||
return self.cacheStockDetail(stock_code)['InstrumentName']
|
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):
|
def dailyUpStop(self, stock_code: str):
|
||||||
"""获取涨停价"""
|
"""获取涨停价"""
|
||||||
detail = self.cacheStockDetail(stock_code)
|
detail = self.cacheStockDetail(stock_code)
|
||||||
|
|||||||
@@ -58,19 +58,18 @@ class KlineStockSync(BaseSync):
|
|||||||
def _fetch(self, **kwargs):
|
def _fetch(self, **kwargs):
|
||||||
from xtquant import xtdata
|
from xtquant import xtdata
|
||||||
|
|
||||||
# 增量判断
|
# 增量判断: 以数据库最新一条记录为准
|
||||||
latest = _latest_date(KlineStock)
|
latest = _latest_date(KlineStock)
|
||||||
today = date.today()
|
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
|
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 ""
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
PrintLog(LogLevel.INFO,
|
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股")
|
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
|
||||||
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只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}%)')
|
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
continue
|
continue
|
||||||
@@ -91,8 +90,9 @@ class KlineStockSync(BaseSync):
|
|||||||
try:
|
try:
|
||||||
result = xtdata.get_market_data(
|
result = xtdata.get_market_data(
|
||||||
field_list=field_list, stock_list=[code], period='1d',
|
field_list=field_list, stock_list=[code], period='1d',
|
||||||
count=self.count, dividend_type='none', fill_data=False)
|
start_time=start_str, end_time=end_str,
|
||||||
inserted += self._upsert_incremental(code, result, start_date)
|
dividend_type='none', fill_data=False)
|
||||||
|
inserted += self._upsert_incremental(code, result, start_date, end_date)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class KlineStockSync(BaseSync):
|
|||||||
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
|
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
|
||||||
return self.stats
|
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:
|
if not result:
|
||||||
return 0
|
return 0
|
||||||
close_df = result.get('close')
|
close_df = result.get('close')
|
||||||
@@ -110,13 +110,23 @@ class KlineStockSync(BaseSync):
|
|||||||
return 0
|
return 0
|
||||||
stock_code = full_code.split('.')[0]
|
stock_code = full_code.split('.')[0]
|
||||||
records = []
|
records = []
|
||||||
|
vol_df = result.get('volume')
|
||||||
for td in close_df.columns:
|
for td in close_df.columns:
|
||||||
# xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date
|
# xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date
|
||||||
if isinstance(td, str):
|
if isinstance(td, str):
|
||||||
td_date = datetime.strptime(td, '%Y%m%d').date()
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
else:
|
else:
|
||||||
td_date = td.date() if hasattr(td, 'date') else td
|
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
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
continue
|
continue
|
||||||
close_val = close_df.loc[full_code, td]
|
close_val = close_df.loc[full_code, td]
|
||||||
@@ -130,7 +140,7 @@ class KlineStockSync(BaseSync):
|
|||||||
'high': _safe_get(result.get('high'), full_code, td),
|
'high': _safe_get(result.get('high'), full_code, td),
|
||||||
'low': _safe_get(result.get('low'), full_code, td),
|
'low': _safe_get(result.get('low'), full_code, td),
|
||||||
'close': float(close_val),
|
'close': float(close_val),
|
||||||
'volume': _safe_get(result.get('volume'), full_code, td),
|
'volume': float(vol),
|
||||||
})
|
})
|
||||||
if records:
|
if records:
|
||||||
with db.atomic():
|
with db.atomic():
|
||||||
@@ -165,33 +175,35 @@ class KlineIndexSync(BaseSync):
|
|||||||
|
|
||||||
latest = _latest_date(KlineIndex)
|
latest = _latest_date(KlineIndex)
|
||||||
today = date.today()
|
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_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 ""
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
PrintLog(LogLevel.INFO,
|
PrintLog(LogLevel.INFO,
|
||||||
f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}')
|
f'[sync] KlineIndex: 增量同步 {start_str} ~ {end_str}')
|
||||||
|
|
||||||
for code in index_codes:
|
for code in index_codes:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
field_list = ['open', 'high', 'low', 'close', 'volume']
|
field_list = ['open', 'high', 'low', 'close', 'volume']
|
||||||
result = xtdata.get_market_data(
|
result = xtdata.get_market_data(
|
||||||
field_list=field_list, stock_list=index_codes, period='1d',
|
field_list=field_list, stock_list=index_codes, period='1d',
|
||||||
count=self.count, dividend_type='none', fill_data=False)
|
start_time=start_str, end_time=end_str,
|
||||||
return result or {}
|
dividend_type='none', fill_data=False)
|
||||||
|
|
||||||
def _upsert(self, data):
|
def _upsert(self, data):
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
latest = _latest_date(KlineIndex)
|
latest = _latest_date(KlineIndex)
|
||||||
|
today = date.today()
|
||||||
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1)
|
||||||
records = []
|
records = []
|
||||||
close_df = data.get('close')
|
close_df = data.get('close')
|
||||||
|
vol_df = data.get('volume')
|
||||||
if close_df is None or close_df.empty:
|
if close_df is None or close_df.empty:
|
||||||
return
|
return
|
||||||
for full_code in close_df.index:
|
for full_code in close_df.index:
|
||||||
@@ -201,7 +213,16 @@ class KlineIndexSync(BaseSync):
|
|||||||
td_date = datetime.strptime(td, '%Y%m%d').date()
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
else:
|
else:
|
||||||
td_date = td.date() if hasattr(td, 'date') else td
|
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
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
continue
|
continue
|
||||||
close_val = close_df.loc[full_code, td]
|
close_val = close_df.loc[full_code, td]
|
||||||
@@ -215,7 +236,7 @@ class KlineIndexSync(BaseSync):
|
|||||||
'high': _safe_get(data.get('high'), full_code, td),
|
'high': _safe_get(data.get('high'), full_code, td),
|
||||||
'low': _safe_get(data.get('low'), full_code, td),
|
'low': _safe_get(data.get('low'), full_code, td),
|
||||||
'close': float(close_val),
|
'close': float(close_val),
|
||||||
'volume': _safe_get(data.get('volume'), full_code, td),
|
'volume': float(vol),
|
||||||
})
|
})
|
||||||
if records:
|
if records:
|
||||||
with db.atomic():
|
with db.atomic():
|
||||||
|
|||||||
@@ -342,7 +342,6 @@ class PoolManager:
|
|||||||
f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}')
|
f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}')
|
||||||
|
|
||||||
if marked:
|
if marked:
|
||||||
from core.sfgrid.bus import event_bus
|
|
||||||
event_bus.publish(EventPoolMark, {
|
event_bus.publish(EventPoolMark, {
|
||||||
'action': 'liquidate',
|
'action': 'liquidate',
|
||||||
'stock_codes': marked,
|
'stock_codes': marked,
|
||||||
@@ -404,7 +403,6 @@ class PoolManager:
|
|||||||
f'[PoolManager] 淘汰标记: {code},历史: {recent}')
|
f'[PoolManager] 淘汰标记: {code},历史: {recent}')
|
||||||
|
|
||||||
if marked:
|
if marked:
|
||||||
from core.sfgrid.bus import event_bus
|
|
||||||
event_bus.publish(EventPoolMark, {
|
event_bus.publish(EventPoolMark, {
|
||||||
'action': 'eliminate',
|
'action': 'eliminate',
|
||||||
'stock_codes': marked,
|
'stock_codes': marked,
|
||||||
|
|||||||
@@ -54,10 +54,17 @@ class SFGridStrategy:
|
|||||||
"""
|
"""
|
||||||
self.tradeTarget: model.SFGridTradeTarget = tradeTarget
|
self.tradeTarget: model.SFGridTradeTarget = tradeTarget
|
||||||
|
|
||||||
|
# orderGrid 必须在所有可能触发回调的操作之前初始化
|
||||||
|
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
|
||||||
|
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
||||||
|
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
||||||
|
|
||||||
# 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问
|
# 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问
|
||||||
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
||||||
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
||||||
self.dataUpdateLock = threading.Lock()
|
# 注意:必须使用 RLock 而非 Lock,因为 refreshGridOrder 在持有此锁时也会被调用
|
||||||
|
#(如 onOrderTrade 回调中),Lock 会导致同一线程重复获取时永久阻塞(死锁)
|
||||||
|
self.dataUpdateLock = threading.RLock()
|
||||||
|
|
||||||
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
||||||
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
||||||
@@ -73,10 +80,6 @@ class SFGridStrategy:
|
|||||||
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
|
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
|
||||||
f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
|
f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
|
||||||
|
|
||||||
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
|
|
||||||
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
|
||||||
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
|
||||||
|
|
||||||
# 加载券商侧已存在的未成交订单,恢复到 orderGrid 中
|
# 加载券商侧已存在的未成交订单,恢复到 orderGrid 中
|
||||||
self.loadExistOrders()
|
self.loadExistOrders()
|
||||||
|
|
||||||
@@ -171,7 +174,14 @@ class SFGridStrategy:
|
|||||||
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
|
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
|
||||||
|
|
||||||
# 检查是否已存在同 remark 的卖单(避免重复挂单)
|
# 检查是否已存在同 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:
|
if not hasattr(self, 'todayUpStopPrice') or self.todayUpStopPrice is None:
|
||||||
@@ -208,7 +218,10 @@ class SFGridStrategy:
|
|||||||
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
|
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
|
||||||
|
|
||||||
# 检查是否已存在同 remark 的买单(避免重复挂单)
|
# 检查是否已存在同 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:
|
if not hasattr(self, 'todayDownStopPrice') or self.todayDownStopPrice is None:
|
||||||
@@ -434,6 +447,17 @@ class SFGridStrategy:
|
|||||||
if self.tradeTarget.grid_index == 0:
|
if self.tradeTarget.grid_index == 0:
|
||||||
self.tradeTarget.init_price = trade.traded_price # type: ignore
|
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 当前网格索引,判断价格移动方向
|
# 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
|
||||||
oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
|
oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
|
||||||
|
|||||||
+151
-28
@@ -23,13 +23,15 @@ from core.sfgrid.bus_events import EventTradeTargetUpdate, EventPoolMark
|
|||||||
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||||
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
|
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
|
||||||
|
|
||||||
|
from datetime import date as _date
|
||||||
|
|
||||||
def _fmt_time(t) -> str:
|
def _fmt_time(t) -> str:
|
||||||
if not t: return ''
|
if not t: return ''
|
||||||
import datetime
|
from datetime import datetime as _dt
|
||||||
try:
|
try:
|
||||||
ts = int(t)
|
ts = int(t)
|
||||||
if ts > 1e12: ts //= 1000
|
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):
|
except (ValueError, OSError):
|
||||||
return str(t)
|
return str(t)
|
||||||
|
|
||||||
@@ -563,7 +565,6 @@ class _DrawerPanel:
|
|||||||
self._refresh_trades()
|
self._refresh_trades()
|
||||||
self._refresh_market()
|
self._refresh_market()
|
||||||
self._refresh_dataset()
|
self._refresh_dataset()
|
||||||
self._refresh_scoring()
|
|
||||||
|
|
||||||
def _refresh_grid(self):
|
def _refresh_grid(self):
|
||||||
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
|
# (width, expand): 0=固定宽, >0=弹性比重; 股票列 expand 自动填充剩余空间
|
||||||
@@ -908,34 +909,91 @@ class _DrawerPanel:
|
|||||||
|
|
||||||
# ── Tab 6: 每日评分 ──
|
# ── Tab 6: 每日评分 ──
|
||||||
def _build_scoring_tab(self) -> ft.Control:
|
def _build_scoring_tab(self) -> ft.Control:
|
||||||
from datetime import date, timedelta
|
from datetime import timedelta
|
||||||
self._score_cur_date = date.today()
|
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_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_status = ft.Text("无评分数据", size=12, color='#888888')
|
||||||
|
self._score_refreshing = False
|
||||||
|
self._score_lock = threading.Lock()
|
||||||
|
|
||||||
|
self._score_max_date = today # 导航上限:不允许看未来日期
|
||||||
|
|
||||||
def _prev_day(_):
|
def _prev_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
self._score_cur_date -= timedelta(days=1)
|
self._score_cur_date -= timedelta(days=1)
|
||||||
self._score_date_label.value = str(self._score_cur_date)
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
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._refresh_scoring()
|
||||||
|
|
||||||
def _next_day(_):
|
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_cur_date += timedelta(days=1)
|
||||||
self._score_date_label.value = str(self._score_cur_date)
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
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._refresh_scoring()
|
||||||
|
|
||||||
def _today(_):
|
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.value = str(self._score_cur_date)
|
||||||
self._score_date_label.update()
|
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._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([
|
date_row = ft.Row([
|
||||||
ft.IconButton(ft.Icons.CHEVRON_LEFT, icon_size=22, tooltip="前一天", on_click=_prev_day),
|
prev_btn,
|
||||||
self._score_date_label,
|
self._score_date_label,
|
||||||
ft.IconButton(ft.Icons.CHEVRON_RIGHT, icon_size=22, tooltip="后一天", on_click=_next_day),
|
next_btn,
|
||||||
ft.IconButton(ft.Icons.TODAY, icon_size=20, tooltip="回到今天", on_click=_today),
|
today_btn,
|
||||||
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),
|
||||||
@@ -958,34 +1016,68 @@ class _DrawerPanel:
|
|||||||
from core.scoring.inference.scorer import GridSeekerPipeline
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
trade_date = self._score_cur_date
|
trade_date = self._score_cur_date
|
||||||
|
|
||||||
# 检查当日 K线数据是否已同步
|
# 检查 K线数据同步到哪一天
|
||||||
from peewee import fn
|
from peewee import fn
|
||||||
from core.scoring.models import KlineStock
|
from core.scoring.models import KlineStock
|
||||||
latest_kline = KlineStock.select(fn.MAX(KlineStock.trade_date)).scalar()
|
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.color = '#F44336'
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
return
|
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:
|
try:
|
||||||
engine = GridSeekerPipeline()
|
engine = GridSeekerPipeline()
|
||||||
rankings = engine.run(trade_date)
|
rankings = engine.run(persist_date)
|
||||||
if not rankings.empty:
|
if not rankings.empty:
|
||||||
engine.persist(rankings, trade_date)
|
engine.persist(rankings, persist_date)
|
||||||
self._score_status.value = f"{trade_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:
|
except FileNotFoundError:
|
||||||
self._score_status.value = "模型文件缺失"
|
self._score_status.value = "模型文件缺失"
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._score_status.value = f"失败: {ex}"
|
self._score_status.value = f"失败: {ex}"
|
||||||
|
self._score_status.color = '#F44336'
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
self._refresh_scoring()
|
self._refresh_scoring()
|
||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
def _refresh_scoring(self):
|
def _refresh_scoring(self):
|
||||||
|
import time as _time
|
||||||
from core.scoring.models import ScoringResult
|
from core.scoring.models import ScoringResult
|
||||||
from core.qmt import qmtv
|
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
|
# (width, expand): 排名60 + 名称弹性 + 概率65 + 轮数58 + 堆叠概率65 + 操作32
|
||||||
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
|
_C = [(60, 0), (0, 1), (65, 1), (58, 1), (65, 1), (32, 1)]
|
||||||
@@ -1016,28 +1108,40 @@ class _DrawerPanel:
|
|||||||
if not scored_rows:
|
if not scored_rows:
|
||||||
self._score_status.value = f"{trade_date} 无评分数据"
|
self._score_status.value = f"{trade_date} 无评分数据"
|
||||||
self._score_status.color = '#F44336'
|
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._scoring_table.controls = rows
|
||||||
|
# 一次性批量更新:标签 + 状态 + 导航按钮
|
||||||
|
self._score_date_label.update()
|
||||||
self._score_status.update()
|
self._score_status.update()
|
||||||
|
self._revert_nav_btns()
|
||||||
return
|
return
|
||||||
|
|
||||||
shown = 0
|
# 批量预加载: 1次DB查询 (替代原来每行1+1次,最快方案)
|
||||||
|
plain_codes = []
|
||||||
|
full_codes = []
|
||||||
for r in scored_rows:
|
for r in scored_rows:
|
||||||
code = r['stock_code']
|
code = r['stock_code']
|
||||||
plain = code.split('.')[0] if '.' in code else code
|
plain = code.split('.')[0] if '.' in code else code
|
||||||
# 过滤 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
|
from core.scoring.models import StockInfo
|
||||||
if plain.startswith(('6', '5', '9')):
|
st_map = {}
|
||||||
full_code = f'{plain}.SH'
|
name_map = {}
|
||||||
else:
|
for row in StockInfo.select(StockInfo.code, StockInfo.listing_status, StockInfo.name).where(StockInfo.code.in_(full_codes)).dicts():
|
||||||
full_code = f'{plain}.SZ'
|
st_map[row['code']] = row['listing_status']
|
||||||
st = StockInfo.get_or_none(StockInfo.code == full_code)
|
name_map[row['code'].split('.')[0]] = row['name']
|
||||||
if st and st.listing_status == 'ST':
|
|
||||||
|
shown = 0
|
||||||
|
for r, plain, full_code in zip(scored_rows, plain_codes, full_codes):
|
||||||
|
if st_map.get(full_code) == 'ST':
|
||||||
continue
|
continue
|
||||||
name = ''
|
name = name_map.get(plain, '')
|
||||||
try:
|
|
||||||
name = qmtv.getInstrumentName(plain)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
already_in = plain in self._data.stockCodeIdMap
|
already_in = plain in self._data.stockCodeIdMap
|
||||||
shown += 1
|
shown += 1
|
||||||
|
|
||||||
@@ -1068,7 +1172,26 @@ class _DrawerPanel:
|
|||||||
self._scoring_table.controls = rows
|
self._scoring_table.controls = rows
|
||||||
self._score_status.value = f"{trade_date} 候选 {shown} 只"
|
self._score_status.value = f"{trade_date} 候选 {shown} 只"
|
||||||
self._score_status.color = '#4CAF50'
|
self._score_status.color = '#4CAF50'
|
||||||
|
# 一次性批量更新:标签 + 状态 + 导航按钮
|
||||||
|
self._score_date_label.update()
|
||||||
self._score_status.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):
|
def _on_add_from_scoring(self, stock_code: str, stock_name: str):
|
||||||
"""+ 按钮:从评分列表添加标的并打开网格配置"""
|
"""+ 按钮:从评分列表添加标的并打开网格配置"""
|
||||||
|
|||||||
+127
@@ -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')
|
||||||
Reference in New Issue
Block a user