通道弃洞自愈+参数列放宽TEXT+运维清标记按钮+iPad浮层teleport与触屏直连+信号计划标
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3e2a243678
commit
a507e7afbc
122
DEVLOG.md
122
DEVLOG.md
|
|
@ -1139,7 +1139,127 @@ tlai4090 同法。无新 env、无 DDL。
|
||||||
存量合计/底行) 与图表单点标记都应出现。
|
存量合计/底行) 与图表单点标记都应出现。
|
||||||
|
|
||||||
**还欠着什么**
|
**还欠着什么**
|
||||||
上节点的三个口径拍板照旧欠着; 图表逐像素对齐照旧按需。
|
1. **样式仍有出入 (未决, 新会话第一件事)**: 隐藏行修复后使用者反馈「表格样式还是不对」,
|
||||||
|
具体差异点没来得及描述。新会话需要: 让使用者指认哪里不对 (截图或把导出的 xlsx 发回),
|
||||||
|
对照基准用仓库根目录的 公司原表-量化数据2026.8.3.xlsx (2026-08-28 已入库), 逐区 diff
|
||||||
|
(候选疑点: O/P 融资列折叠是否符合预期、黄条/粉底跨的列数、字体字号、列宽、边框、
|
||||||
|
图表配色与轴样式、标题行)。改样式动 app/assets/publish_template.xlsx 与
|
||||||
|
publish_export.py, 改完跑 scripts/test_wiring.py + 全套 588 例。
|
||||||
|
2. 部署状态待确认: 本节点三个文件已回写开发机工作区, commit/push 与两台服务器
|
||||||
|
(真机 188 / 模拟机 tlai4090) 的 git pull + make deploy 是否已做, 新会话先问。
|
||||||
|
3. 上节点的三个口径拍板照旧欠着; 图表逐像素对齐照旧按需。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-31 · 查透「通道要人工做一次全量对账」+ 参数列 1406 截断 + iPad 点不开侧栏 + 信号标计划
|
||||||
|
|
||||||
|
**做了什么**
|
||||||
|
使用者报三件事: 页面一直挂「通道要人工做一次全量对账」不知怎么操作; iPad Pro 上点
|
||||||
|
左右窄条 (信号流 / 待办与操作) 展不开; 想在速览「没持仓的票」段标出选股计划里的票。
|
||||||
|
只读排查模拟机 (155), 挖出三个真问题, 全部修掉:
|
||||||
|
|
||||||
|
一、**水位卡死三天** (对账横幅的根源)。2026-08-28 17:21 pms-ws 重启首连时, 对端补发的
|
||||||
|
99 条 pong 全部验签失败被丢 (SIG_INVALID, 一次性, 之后再无), 每条占一个 seq → 水位卡在
|
||||||
|
365003 洞前; `_baselined` 一经置位终生不再对齐, 会话内的洞永远等不来 (ws 是有序流,
|
||||||
|
§6.1 补发只在重连握手时), 暂存涨到 5.3 万条、ack 停摆、重连时对端整段重发 4.9 万条。
|
||||||
|
修法两层: `_session` 每连接重置 `_baselined` (重连即重对齐, 连续时是空操作);
|
||||||
|
新增**弃洞前进** watchdog (`_maybe_skip_gap` + 纯函数 `ws_codec.skip_gap`): 水位在同一
|
||||||
|
位置卡满 PMS_QMT_GAP_SKIP_SEC (默认 300 秒, 参数中心可调, 0=关) 就按 §6.2 认定补不出
|
||||||
|
来 —— 推水位到已到达最高序号、清暂存、置 resync 标记走全量对账。暂存序号全部来自验签
|
||||||
|
通过的消息, 推进目标不受伪造数据影响。另加 sig_invalid / pending_gap / gap_skips 三个
|
||||||
|
stat 计数, ws_smoke status 逐条给人话告警。
|
||||||
|
|
||||||
|
二、**pms_runtime_param.param_value VARCHAR(200) 装不下会长大的 JSON** (每分钟两处
|
||||||
|
1406 截断错的共同病根)。清场名单 PMS_EXIT_CLEANUP_DONE (30 只闭仓票就超长; 存不上时
|
||||||
|
「每次闭仓只清一次」防呆失守, 闭仓后新下的建仓单会被清场误撤) 与回放游标
|
||||||
|
PMS_REPLAY_CURSOR (08-28 改 v2 带 seen 去重字典后必超; 游标推不动, 表回放每分钟报
|
||||||
|
「游标写入失败」)。修法: DDL 放宽为 TEXT, 已有库跑新脚本
|
||||||
|
scripts/migrate_param_value_text.py (幂等, 默认演练, --yes 执行, 显式过 map_tables,
|
||||||
|
batch19 [K2] 白名单同步加上)。
|
||||||
|
|
||||||
|
三、**页面**: ① 运维抽屉补上「清除通道对账标记」按钮 —— 后端接口
|
||||||
|
(POST /api/ws-channel/clear-resync, 管理员) 早就有, 页面一直没入口, 横幅让人「经运维
|
||||||
|
抽屉清除」却无处可点; 按钮带确认弹窗, 只在 resync_required 时出现。② iPad 点不开侧栏:
|
||||||
|
本地台架实测鼠标路径完好, 判为 iPad Safari 对非控件元素点按不合成 click 的老毛病;
|
||||||
|
修法是窄条与蒙层加 touchend 直连 (touchstart 记起点, 位移 >12px 判滑动误触不开,
|
||||||
|
preventDefault 挡合成 click 防双触发), CSS 加 touch-action:manipulation 与
|
||||||
|
@media(pointer:coarse) 命中区加大 (窄条 40→48px)。鼠标端零改动 (无 touch 事件, 走原
|
||||||
|
@click; coarse 媒询不命中)。③ 速览「没持仓的票」段: 行首加蓝色「计划」标 —— 票在
|
||||||
|
今天上游选股计划主榜 (planRows, 登录即随 loadPlan 加载) 就标, 段标题写明含义;
|
||||||
|
「更早的信号」段同样标 (仅非持仓行)。
|
||||||
|
|
||||||
|
顺带查明账实差异现状: 账本与 ws 快照差 5 只 (002436 少 1600 / 002709 少 300 /
|
||||||
|
300308 多 900 / 301308 多 100 / 688676 多 3600), 正是缺口期丢成交所致; 爆炸半径闸
|
||||||
|
判据是「>5 只且 >34%」, 5 只不触发, 15:10 日终结算的全量对账会自动照 ws 修正留痕。
|
||||||
|
另发现 trading_position 表只有 2 只与 ws 快照 (7 只) 不同步, 事实源仲裁已按 ws 为准
|
||||||
|
并告警 —— 「谁在写那张表」要与 QMT 侧核对。
|
||||||
|
|
||||||
|
**动了哪些文件**
|
||||||
|
app/ws/runner.py (每连接重置基线 + 弃洞 watchdog + sig_invalid 计数)、
|
||||||
|
app/core/ws_codec.py (skip_gap)、app/web/static/index.html (清标记按钮 + 触屏直连 +
|
||||||
|
计划标)、ddl_pms_v1.sql (param_value → TEXT)、新增 scripts/migrate_param_value_text.py、
|
||||||
|
scripts/ws_smoke.py (三条新告警)、scripts/test_batch6_units.py (弃洞 3 例, 65→68)、
|
||||||
|
scripts/test_batch19_units.py ([K2] 白名单)、scripts/run_tests.py (共 591 例)、README.md。
|
||||||
|
|
||||||
|
**部署方式**
|
||||||
|
模拟机 155: git pull 后 make deploy (源码打镜像必须重建); 之后跑一次
|
||||||
|
`docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py --yes`
|
||||||
|
(ALTER 秒级, 不用重启, 新库建表自带 TEXT 不用跑)。真机 188 同法, 时点等 QMT 侧联调
|
||||||
|
恢复时一并 (它同样带着 VARCHAR(200) 和旧 runner)。
|
||||||
|
|
||||||
|
**真机判收**
|
||||||
|
开发机: 全部 20 套 591 例 ALL SUITES PASS; 本地台架 1024×1366 实测 —— 鼠标点窄条开浮层、
|
||||||
|
触摸序列开浮层、滑动 380px 误触不开、触摸蒙层关闭、控制台无脚本错误。
|
||||||
|
部署后待判收: ① pms-ws 起来后 5 分钟内日志出现「水位弃洞前进」且 make ws-status 水位
|
||||||
|
追到当前、暂存归零; ② 页面「账本对账」差异清零后点「清除通道对账标记」, 横幅消失且
|
||||||
|
不复发; ③ worker 日志不再刷 1406; ④ iPad 实机点窄条能展开 (模拟器只能模拟到触摸事件
|
||||||
|
层, Safari 真机行为要使用者过手); ⑤ 速览出现蓝「计划」标。
|
||||||
|
|
||||||
|
**还欠着什么**
|
||||||
|
1. 155 部署与迁移待用户批准执行 (容器重建 + ALTER TABLE 各要一次授权)。
|
||||||
|
2. 全量对账清差异 + 清标记按钮点掉横幅, 待部署后走一遍 (15:10 结算大概率已自动修正
|
||||||
|
差异, 部署后核对 make watch 与页面对账结果即可)。
|
||||||
|
3. 与 QMT 模拟侧核对两件: 08-28 17:21 那批 pong 为何验签失败 (重放重签? 密钥轮换?);
|
||||||
|
trading_position 表还该不该有人写。
|
||||||
|
4. iPad 实机判收 (第 ④ 条) 待使用者过手。
|
||||||
|
5. 真机 188 的同款部署与迁移, 等 QMT 侧联调窗口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-01 · iPad 浮层实锅重修: teleport 出栈 + 只用老写法的浮层几何
|
||||||
|
|
||||||
|
**做了什么**
|
||||||
|
使用者发来 iPad Pro 实机截图: 点右窄条浮层**能开但渲染烂了** —— 面板无背景、无遮罩,
|
||||||
|
内容与底下持仓表的浮动操作列穿插, 选股计划抽屉的深色提示块也粘在屏上。与 08-31 的判断
|
||||||
|
修正一处: 实机上点击事件是通的, 坏的是浮层渲染。两个独立机制都补死:
|
||||||
|
一, **层叠上下文困局**: 浮层 aside 在 position:sticky 的 side 容器里, sticky 自成层叠
|
||||||
|
上下文, 固定定位的浮层被困在里面, 与主区 el-table 浮动列 (自带 z-index) 的绘制次序在
|
||||||
|
部分 Safari 上穿插。修法: 两个浮层与遮罩用 Vue 内建 teleport 挂到 body 下
|
||||||
|
(:disabled 绑定「非浮层态」, 常驻停靠时原地不动, 行为与从前逐字节相同)。
|
||||||
|
二, **新式 CSS 整条被丢**: 浮层宽度用 min(560px,88vw)、遮罩用 inset:0, 旧 Safari
|
||||||
|
(min 需 11.1+, inset 需 14.5+) 会把整条声明丢掉 —— 遮罩变 0×0 不可见。改成
|
||||||
|
width+max-width 与 top/left/right/bottom 四边的等价老写法, 面板背景加一条 #fff 兜底
|
||||||
|
再让变量覆盖。顺带: 触屏上 ? 号悬停气泡点按后粘住不消失 (截图那块黑浮块),
|
||||||
|
@media(hover:none) 下不出气泡; 新增 ?uidbg=1 远程排查小牌 (印浏览器 UA / 视口 /
|
||||||
|
停靠态 / 浮层态, 平时不出现), iPad 再有样式问题一张截图即可定位。
|
||||||
|
|
||||||
|
**动了哪些文件**
|
||||||
|
app/web/static/index.html (teleport ×3、浮层几何老写法、背景兜底、气泡抑制、uidbg)。
|
||||||
|
|
||||||
|
**部署方式**
|
||||||
|
随 08-31 那批一起: 155 git pull 后 make deploy, 另跑一次 migrate_param_value_text.py --yes。
|
||||||
|
|
||||||
|
**真机判收**
|
||||||
|
开发机台架两形态实测: 1024×1366 浮层 —— 面板已挂 body 下、position fixed、宽 560、
|
||||||
|
白底、遮罩 display:block、z 2010; 1920×1080 常驻 —— 两栏 parent 仍是 side 容器
|
||||||
|
(teleport 未触发)、窄条隐藏、三栏布局与改前一致; 控制台无脚本错误。
|
||||||
|
部署后待判收: iPad 实机点窄条, 浮层应当白底带阴影、背后有半透明遮罩、点遮罩即关。
|
||||||
|
|
||||||
|
**还欠着什么**
|
||||||
|
1. 随 08-31 节点: 155 部署+迁移待批; iPad 实机判收待使用者过手 (地址后加 ?uidbg=1
|
||||||
|
截图可带上排查小牌)。
|
||||||
|
2. 模拟仓重启方案已给使用者建议 (彻底清盘: 盘中紧急离场卖光 → 收盘后 purge-dead-orders
|
||||||
|
+ reset-ledger → 次日从零自动建仓; 清账前先存档), 待拍板。
|
||||||
|
|
||||||
---
|
---
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ scripts/
|
||||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例
|
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例
|
||||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
|
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
|
||||||
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
|
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 68 例
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
||||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,23 @@ def cold_start_baseline(last_seq: int, first_seq: int) -> tuple:
|
||||||
return int(first_seq) - 1, int(last_seq) > 0
|
return int(first_seq) - 1, int(last_seq) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def skip_gap(last_seq: int, arrived: set) -> tuple:
|
||||||
|
"""§6.2 弃洞前进: 认定 (last_seq, max(arrived)] 里未到的序号永远不会来。
|
||||||
|
返回 (新水位 = 已到达的最高序号, 洞数)。arrived 为空则原地不动。
|
||||||
|
|
||||||
|
只应在「水位卡死已满处置窗口」时调用 (见 runner._maybe_skip_gap 的说明) —— ws 是
|
||||||
|
单连接有序流, 会话内的洞不会自愈; 到点仍未收敛就是对端补不出来, 与
|
||||||
|
cold_start_baseline 处置真缺口同一语义: 推水位、置 resync、走全量对账。
|
||||||
|
arrived 里的序号全部来自验签通过的消息, 推进目标不受未验签数据影响。
|
||||||
|
"""
|
||||||
|
if not arrived:
|
||||||
|
return int(last_seq), 0
|
||||||
|
base = int(last_seq)
|
||||||
|
top = max(int(s) for s in arrived)
|
||||||
|
got = len({int(s) for s in arrived if base < int(s) <= top})
|
||||||
|
return top, max(0, (top - base) - got)
|
||||||
|
|
||||||
|
|
||||||
def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple:
|
def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple:
|
||||||
"""收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。
|
"""收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,10 +261,14 @@ body.dock-r:not(.r-fold) .side-r{width:var(--rail-w-r);}
|
||||||
display:none;flex-direction:column;max-height:calc(100vh - 116px);overflow:hidden;}
|
display:none;flex-direction:column;max-height:calc(100vh - 116px);overflow:hidden;}
|
||||||
body.dock-l:not(.l-fold) #railL{display:flex;}
|
body.dock-l:not(.l-fold) #railL{display:flex;}
|
||||||
body.dock-r:not(.r-fold) #railR{display:flex;}
|
body.dock-r:not(.r-fold) #railR{display:flex;}
|
||||||
.rail.open{display:flex;position:fixed;top:96px;bottom:18px;width:min(560px,88vw);max-height:none;
|
/* 浮层几何刻意只用老写法 (2026-09-01): min()/inset 这类新函数在旧 Safari 上整条声明
|
||||||
|
被丢, 浮层就成了没背景没遮罩的"透明面板" (iPad 实锅)。宽度用 width+max-width 等价
|
||||||
|
表达; 背景先给一条固定色兜底再让变量覆盖 */
|
||||||
|
.rail.open{display:flex;position:fixed;top:96px;bottom:18px;width:560px;max-width:88vw;max-height:none;
|
||||||
|
background:#ffffff;background:var(--surface);
|
||||||
z-index:2010;box-shadow:0 12px 38px rgba(17,17,16,.2),0 2px 8px rgba(17,17,16,.08);}
|
z-index:2010;box-shadow:0 12px 38px rgba(17,17,16,.2),0 2px 8px rgba(17,17,16,.08);}
|
||||||
#railL.open{left:16px;} #railR.open{right:16px;}
|
#railL.open{left:16px;} #railR.open{right:16px;}
|
||||||
.rail-scrim{position:fixed;inset:0;background:rgba(17,17,16,.3);z-index:2005;display:none;}
|
.rail-scrim{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(17,17,16,.3);z-index:2005;display:none;}
|
||||||
.rail-scrim.on{display:block;}
|
.rail-scrim.on{display:block;}
|
||||||
.rail-h{display:flex;align-items:center;gap:7px;padding:9px 11px;border-bottom:1px solid var(--hair);flex:none;}
|
.rail-h{display:flex;align-items:center;gap:7px;padding:9px 11px;border-bottom:1px solid var(--hair);flex:none;}
|
||||||
.rail-h .ttl{font-size:13.5px;font-weight:660;white-space:nowrap;}
|
.rail-h .ttl{font-size:13.5px;font-weight:660;white-space:nowrap;}
|
||||||
|
|
@ -280,8 +284,22 @@ body.dock-r:not(.r-fold) #railR{display:flex;}
|
||||||
/* 窄条: 手动收起和宽度不够共用这一块 */
|
/* 窄条: 手动收起和宽度不够共用这一块 */
|
||||||
.strip{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
|
.strip{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
|
||||||
width:var(--strip-w);min-height:150px;padding:14px 0;background:var(--surface);
|
width:var(--strip-w);min-height:150px;padding:14px 0;background:var(--surface);
|
||||||
border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);cursor:pointer;}
|
border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);cursor:pointer;
|
||||||
|
touch-action:manipulation;user-select:none;-webkit-user-select:none;
|
||||||
|
-webkit-tap-highlight-color:rgba(42,120,214,.18);}
|
||||||
.strip:hover{border-color:var(--accent);}
|
.strip:hover{border-color:var(--accent);}
|
||||||
|
/* 触屏端 (iPad 等): 去掉点按歧义等待、命中区加大。鼠标端 (pointer:fine) 完全不受影响 */
|
||||||
|
.iconbtn,.railtabs button,.lv-chip,.rail-scrim,.rail-h{touch-action:manipulation;}
|
||||||
|
@media (pointer:coarse){
|
||||||
|
:root{ --strip-w:48px; }
|
||||||
|
.strip{min-height:176px;}
|
||||||
|
.iconbtn{width:30px;height:30px;font-size:14px;}
|
||||||
|
}
|
||||||
|
/* 无悬停设备 (触屏): ? 号的悬停气泡会在点按后粘住不消失 (iPad 截图里那块黑浮块),
|
||||||
|
干脆不出气泡 —— 它只是补充说明, 正文都能看到 */
|
||||||
|
@media (hover:none){
|
||||||
|
.help:hover::after{display:none;}
|
||||||
|
}
|
||||||
body.dock-l:not(.l-fold) .side-l .strip{display:none;}
|
body.dock-l:not(.l-fold) .side-l .strip{display:none;}
|
||||||
body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
.strip .vtxt{writing-mode:vertical-rl;font-size:12.5px;font-weight:620;color:var(--ink-2);letter-spacing:2px;white-space:nowrap;}
|
.strip .vtxt{writing-mode:vertical-rl;font-size:12.5px;font-weight:620;color:var(--ink-2);letter-spacing:2px;white-space:nowrap;}
|
||||||
|
|
@ -324,6 +342,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
.sigrow2 .sp{flex:1;}
|
.sigrow2 .sp{flex:1;}
|
||||||
.sigrow2.old{opacity:.5;}
|
.sigrow2.old{opacity:.5;}
|
||||||
.pin{font-size:10px;font-weight:700;color:var(--stop);background:var(--stop-weak);border-radius:4px;padding:0 4px;flex:none;}
|
.pin{font-size:10px;font-weight:700;color:var(--stop);background:var(--stop-weak);border-radius:4px;padding:0 4px;flex:none;}
|
||||||
|
.pin.plan{color:var(--accent);background:var(--accent-weak);}
|
||||||
.srcgroup{border:1px solid var(--hair);border-radius:10px;margin-bottom:7px;overflow:hidden;background:var(--surface-2);}
|
.srcgroup{border:1px solid var(--hair);border-radius:10px;margin-bottom:7px;overflow:hidden;background:var(--surface-2);}
|
||||||
.srch{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;
|
.srch{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;
|
||||||
font-size:12.5px;font-weight:600;color:var(--ink-2);}
|
font-size:12.5px;font-weight:600;color:var(--ink-2);}
|
||||||
|
|
@ -519,17 +538,28 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rail-scrim" :class="{on:!!railOpen}" @click="closeRail"></div>
|
<teleport to="body">
|
||||||
|
<div class="rail-scrim" :class="{on:!!railOpen}" @click="closeRail" @touchend="scrimTE"></div>
|
||||||
|
<!-- 远程排查小牌: 地址栏加 ?uidbg=1 才出现, 显示浏览器与布局关键量, 只读 -->
|
||||||
|
<div v-if="uidbg" class="mono" style="position:fixed;left:8px;bottom:8px;z-index:99990;
|
||||||
|
background:#141412;color:#fff;font-size:11px;line-height:1.6;padding:6px 9px;
|
||||||
|
border-radius:8px;max-width:86vw;opacity:.88;pointer-events:none">{{ dbgText }}</div>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
<!-- ══════════ 三栏 ══════════ -->
|
<!-- ══════════ 三栏 ══════════ -->
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
|
|
||||||
<!-- ─── 左栏: 信号流 ─── -->
|
<!-- ─── 左栏: 信号流 ─── -->
|
||||||
<div class="side side-l">
|
<div class="side side-l">
|
||||||
<div class="strip" @click="openRailSide('l')" title="展开信号流">
|
<div class="strip" @click="openRailSide('l')" @touchstart.passive="railTS" @touchend="railTE($event,'l')" title="展开信号流">
|
||||||
<span class="badge" v-if="sigBriefHeldCount">{{ sigBriefHeldCount }}</span>
|
<span class="badge" v-if="sigBriefHeldCount">{{ sigBriefHeldCount }}</span>
|
||||||
<span class="vtxt">信号流</span><span class="arr">▶</span>
|
<span class="vtxt">信号流</span><span class="arr">▶</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- teleport (2026-09-01): 打开成浮层时把面板挂到 body 下再定位。side 是
|
||||||
|
position:sticky, 自成层叠上下文, 固定定位的浮层被困在里面时, 在部分
|
||||||
|
Safari 上会与主区表格的浮动列互相穿插 (iPad 实锅)。挂到 body 就没有
|
||||||
|
任何祖先能困住它; 常驻停靠时 disabled, 原地不动, 行为与从前逐字节相同 -->
|
||||||
|
<teleport to="body" :disabled="railOpen!=='l'">
|
||||||
<aside class="rail" id="railL" :class="{open:railOpen==='l'}">
|
<aside class="rail" id="railL" :class="{open:railOpen==='l'}">
|
||||||
<div class="rail-h">
|
<div class="rail-h">
|
||||||
<span class="ttl">信号流</span>
|
<span class="ttl">信号流</span>
|
||||||
|
|
@ -567,9 +597,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" v-if="sigBriefOther.length"
|
<div class="muted" v-if="sigBriefOther.length"
|
||||||
style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px">
|
style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px">
|
||||||
没持仓的票 · 只列重要和警告</div>
|
没持仓的票 · 只列重要和警告 · 带「计划」的在今天的选股计划里</div>
|
||||||
<div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}">
|
<div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}">
|
||||||
<span class="t mono">{{ hm(g.time) || '—' }}</span>
|
<span class="t mono">{{ hm(g.time) || '—' }}</span>
|
||||||
|
<span class="pin plan" v-if="g.plan">计划</span>
|
||||||
<span class="nm">{{ nm(g.ts_code) }}</span>
|
<span class="nm">{{ nm(g.ts_code) }}</span>
|
||||||
<span class="cat">{{ g.cat }}</span>
|
<span class="cat">{{ g.cat }}</span>
|
||||||
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
||||||
|
|
@ -586,6 +617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<div v-for="(g,i) in sigOld.slice(0,60)" :key="'d'+i" class="sigrow2 old">
|
<div v-for="(g,i) in sigOld.slice(0,60)" :key="'d'+i" class="sigrow2 old">
|
||||||
<span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span>
|
<span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span>
|
||||||
<span class="pin" v-if="g.held">持仓</span>
|
<span class="pin" v-if="g.held">持仓</span>
|
||||||
|
<span class="pin plan" v-if="g.plan && !g.held">计划</span>
|
||||||
<span class="nm">{{ nm(g.ts_code) }}</span>
|
<span class="nm">{{ nm(g.ts_code) }}</span>
|
||||||
<span class="cat">{{ g.cat }}</span>
|
<span class="cat">{{ g.cat }}</span>
|
||||||
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
|
||||||
|
|
@ -752,6 +784,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── 中区: 一览 / 需要注意 / 我的持仓 / 今日在办 ─── -->
|
<!-- ─── 中区: 一览 / 需要注意 / 我的持仓 / 今日在办 ─── -->
|
||||||
|
|
@ -952,10 +985,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
|
|
||||||
<!-- ─── 右栏: 待办与操作 ─── -->
|
<!-- ─── 右栏: 待办与操作 ─── -->
|
||||||
<div class="side side-r">
|
<div class="side side-r">
|
||||||
<div class="strip" @click="openRailSide('r')" title="展开待办与操作">
|
<div class="strip" @click="openRailSide('r')" @touchstart.passive="railTS" @touchend="railTE($event,'r')" title="展开待办与操作">
|
||||||
<span class="arr">◀</span><span class="vtxt">待办与操作</span>
|
<span class="arr">◀</span><span class="vtxt">待办与操作</span>
|
||||||
<span class="badge" v-if="proposals.length">{{ proposals.length }}</span>
|
<span class="badge" v-if="proposals.length">{{ proposals.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<teleport to="body" :disabled="railOpen!=='r'">
|
||||||
<aside class="rail" id="railR" :class="{open:railOpen==='r'}">
|
<aside class="rail" id="railR" :class="{open:railOpen==='r'}">
|
||||||
<div class="rail-h">
|
<div class="rail-h">
|
||||||
<button class="iconbtn btn-close" title="关闭(或按 Esc)" @click="closeRail">✕</button>
|
<button class="iconbtn btn-close" title="关闭(或按 Esc)" @click="closeRail">✕</button>
|
||||||
|
|
@ -1183,6 +1217,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1748,6 +1783,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
|
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
|
||||||
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
|
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px" v-if="(wsc||{}).resync_required">
|
||||||
|
<el-button type="warning" @click="clearResync" :loading="opsLoading">清除通道对账标记</el-button>
|
||||||
|
<span class="muted" style="flex:1;min-width:240px">通道曾经补发不全(顶部横幅「通道要人工做一次
|
||||||
|
全量对账」就是它)。处理顺序:先点上面的「账本对账」,确认结果里差异为零,再点这里清除提醒。</span>
|
||||||
|
</div>
|
||||||
<div class="muted" style="margin-top:10px">
|
<div class="muted" style="margin-top:10px">
|
||||||
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
|
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
|
||||||
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
|
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
|
||||||
|
|
@ -2658,6 +2698,9 @@ createApp({
|
||||||
// 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ──
|
// 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ──
|
||||||
const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0];
|
const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0];
|
||||||
const heldCodeSet = computed(() => new Set(heldPositions.value.map(p => code6(p.ts_code))));
|
const heldCodeSet = computed(() => new Set(heldPositions.value.map(p => code6(p.ts_code))));
|
||||||
|
// 今天选股计划里的票 (上游 /plan 主榜, 登录时随 loadPlan 加载): 速览里没持仓的
|
||||||
|
// 信号若命中计划, 挂「计划」标 —— 交易员一眼分清哪些噪音其实是候选池的动静 (2026-08-31)
|
||||||
|
const planCodeSet = computed(() => new Set((planRows.value || []).map(r => code6(r.ts_code))));
|
||||||
const sigBrief = computed(() => {
|
const sigBrief = computed(() => {
|
||||||
const held = heldCodeSet.value;
|
const held = heldCodeSet.value;
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|
@ -2679,6 +2722,8 @@ createApp({
|
||||||
for (const r of (s.timing || [])) out.push({
|
for (const r of (s.timing || [])) out.push({
|
||||||
ts_code: r.ts_code, time: r.time || '', cat: '买入时机',
|
ts_code: r.ts_code, time: r.time || '', cat: '买入时机',
|
||||||
direction: '买入', level: 'INFO', held: held.has(code6(r.ts_code)) });
|
direction: '买入', level: 'INFO', held: held.has(code6(r.ts_code)) });
|
||||||
|
const inPlan = planCodeSet.value;
|
||||||
|
for (const g of out) g.plan = inPlan.has(code6(g.ts_code));
|
||||||
out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time)));
|
out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time)));
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
@ -3004,6 +3049,21 @@ createApp({
|
||||||
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
|
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
|
||||||
await loadAll();
|
await loadAll();
|
||||||
}
|
}
|
||||||
|
// 清通道对账标记 (2026-08-31): 后端接口早就有 (POST /api/ws-channel/clear-resync),
|
||||||
|
// 页面一直没有入口, 横幅让人"经运维抽屉清除"却无处可点。语义见后端注释: 标记只能
|
||||||
|
// 人清, 清之前必须先做完全量对账 (上面「账本对账」按钮) 且差异为零。
|
||||||
|
async function clearResync() {
|
||||||
|
try {
|
||||||
|
await ElementPlus.ElMessageBox.confirm(
|
||||||
|
'清除前请确认两件事:一,已点过「账本对账」;二,对账结果 diffs 为空(差异为零)。'
|
||||||
|
+ '清除只是去掉「通道要人工做一次全量对账」的提醒,不改任何账目。',
|
||||||
|
'清除通道对账标记', { type: 'warning', confirmButtonText: '已对账,清除', cancelButtonText: '先不清' });
|
||||||
|
} catch (e) { return; }
|
||||||
|
opsLoading.value = true;
|
||||||
|
const d = await call('post', '/api/ws-channel/clear-resync');
|
||||||
|
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
|
||||||
|
await Promise.all([loadWs(), loadAll()]);
|
||||||
|
}
|
||||||
async function loadSchema() {
|
async function loadSchema() {
|
||||||
opsLoading.value = true;
|
opsLoading.value = true;
|
||||||
const d = await call('get', '/api/ops/downstream-schema');
|
const d = await call('get', '/api/ops/downstream-schema');
|
||||||
|
|
@ -3202,10 +3262,22 @@ createApp({
|
||||||
document.body.classList.toggle('l-fold', lFold.value);
|
document.body.classList.toggle('l-fold', lFold.value);
|
||||||
document.body.classList.toggle('r-fold', rFold.value);
|
document.body.classList.toggle('r-fold', rFold.value);
|
||||||
}
|
}
|
||||||
|
// ?uidbg=1 远程排查 (2026-09-01): iPad 只能靠截图排样式问题, 这块小牌把浏览器
|
||||||
|
// 版本与布局关键量直接印在屏幕上, 一张截图就够定位。平时不带参数, 完全不出现。
|
||||||
|
const uidbg = new URLSearchParams(location.search).has('uidbg');
|
||||||
|
const dbgText = ref('');
|
||||||
|
function _dbgUpdate() {
|
||||||
|
if (!uidbg) return;
|
||||||
|
dbgText.value = navigator.userAgent + ' · 视口 ' + window.innerWidth + '×' + window.innerHeight
|
||||||
|
+ ' · dock-l ' + document.body.classList.contains('dock-l')
|
||||||
|
+ ' · dock-r ' + document.body.classList.contains('dock-r')
|
||||||
|
+ ' · 浮层 ' + (railOpen.value || '关');
|
||||||
|
}
|
||||||
function applyDock() {
|
function applyDock() {
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT);
|
document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT);
|
||||||
document.body.classList.toggle('dock-r', w >= W_DOCK_BOTH);
|
document.body.classList.toggle('dock-r', w >= W_DOCK_BOTH);
|
||||||
|
_dbgUpdate();
|
||||||
// 这一侧已经能常驻了, 就不该还浮在上面
|
// 这一侧已经能常驻了, 就不该还浮在上面
|
||||||
if (railOpen.value === 'l' && w >= W_DOCK_LEFT && !lFold.value) railOpen.value = null;
|
if (railOpen.value === 'l' && w >= W_DOCK_LEFT && !lFold.value) railOpen.value = null;
|
||||||
if (railOpen.value === 'r' && w >= W_DOCK_BOTH && !rFold.value) railOpen.value = null;
|
if (railOpen.value === 'r' && w >= W_DOCK_BOTH && !rFold.value) railOpen.value = null;
|
||||||
|
|
@ -3214,7 +3286,7 @@ createApp({
|
||||||
if (side === 'l') lFold.value = true; else rFold.value = true;
|
if (side === 'l') lFold.value = true; else rFold.value = true;
|
||||||
_railClasses(); _railSave();
|
_railClasses(); _railSave();
|
||||||
}
|
}
|
||||||
function closeRail() { railOpen.value = null; }
|
function closeRail() { railOpen.value = null; _dbgUpdate(); }
|
||||||
function openRailSide(side, forcePull) {
|
function openRailSide(side, forcePull) {
|
||||||
const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r');
|
const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r');
|
||||||
const folded = side === 'l' ? lFold.value : rFold.value;
|
const folded = side === 'l' ? lFold.value : rFold.value;
|
||||||
|
|
@ -3225,7 +3297,24 @@ createApp({
|
||||||
}
|
}
|
||||||
if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层
|
if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层
|
||||||
railOpen.value = side;
|
railOpen.value = side;
|
||||||
|
_dbgUpdate();
|
||||||
}
|
}
|
||||||
|
// 触屏直连 (2026-08-31): iPad 的 Safari 对非控件元素的点按并不总是合成 click 事件,
|
||||||
|
// 窄条在触屏上点不开就是这么来的。touchend 直接开侧栏, preventDefault 挡掉后续可能
|
||||||
|
// 合成的 click 免得双触发; 记录起点加位移阈值, 挡住"滑动页面时扫过窄条"的误触。
|
||||||
|
// 鼠标端没有 touch 事件, 走原来的 @click, 行为一个字都不变。
|
||||||
|
const _tp = { x: 0, y: 0 };
|
||||||
|
function railTS(e) {
|
||||||
|
const p = (e.touches && e.touches[0]) || {};
|
||||||
|
_tp.x = p.clientX || 0; _tp.y = p.clientY || 0;
|
||||||
|
}
|
||||||
|
function railTE(e, side) {
|
||||||
|
const p = (e.changedTouches && e.changedTouches[0]) || {};
|
||||||
|
if (Math.hypot((p.clientX || 0) - _tp.x, (p.clientY || 0) - _tp.y) > 12) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openRailSide(side);
|
||||||
|
}
|
||||||
|
function scrimTE(e) { e.preventDefault(); closeRail(); }
|
||||||
function _onEsc(e) { if (e.key === 'Escape') closeRail(); }
|
function _onEsc(e) { if (e.key === 'Escape') closeRail(); }
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
_railClasses(); applyDock();
|
_railClasses(); applyDock();
|
||||||
|
|
@ -3274,7 +3363,8 @@ createApp({
|
||||||
MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown,
|
MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown,
|
||||||
msgLatest, msgLevelClass, setMsgKind, msgGo,
|
msgLatest, msgLevelClass, setMsgKind, msgGo,
|
||||||
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
|
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
|
||||||
lFold, rFold, railOpen, foldRail, closeRail, openRailSide,
|
lFold, rFold, railOpen, foldRail, closeRail, openRailSide, railTS, railTE, scrimTE,
|
||||||
|
clearResync, uidbg, dbgText,
|
||||||
authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport,
|
authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport,
|
||||||
sectClosed, toggleSect };
|
sectClosed, toggleSect };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,9 @@ class WsRunner:
|
||||||
self._acked_seq = 0 # 已发出 ack_seq 的水位
|
self._acked_seq = 0 # 已发出 ack_seq 的水位
|
||||||
self._pending_seq = set() # 乱序暂存 (正常恒空)
|
self._pending_seq = set() # 乱序暂存 (正常恒空)
|
||||||
self._unacked = 0 # 距上次 ack 又落了几条
|
self._unacked = 0 # 距上次 ack 又落了几条
|
||||||
self._baselined = False # 是否已对齐对端序号起点 (见 _set_baseline)
|
self._baselined = False # 本连接是否已对齐对端序号起点 (见 _set_baseline;
|
||||||
|
# 每次连接在 _session 里重置, 2026-08-31)
|
||||||
|
self._gap_stuck = None # (卡住时的水位, 卡住起始时刻) —— 弃洞watchdog用
|
||||||
self._db_ready = False # 通道三表是否可用 (缺表时空转重试, 不写心跳)
|
self._db_ready = False # 通道三表是否可用 (缺表时空转重试, 不写心跳)
|
||||||
self._params = {}
|
self._params = {}
|
||||||
self._warn = "" # 握手期发现的非致命异常, 连上后仍要挂在 last_error
|
self._warn = "" # 握手期发现的非致命异常, 连上后仍要挂在 last_error
|
||||||
|
|
@ -107,6 +109,7 @@ class WsRunner:
|
||||||
"max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3),
|
"max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3),
|
||||||
"connect_timeout": param_store.get_int("PMS_QMT_CONNECT_TIMEOUT_SEC", 10),
|
"connect_timeout": param_store.get_int("PMS_QMT_CONNECT_TIMEOUT_SEC", 10),
|
||||||
"query_interval_sec": param_store.get_int("PMS_QMT_QUERY_INTERVAL_SEC", 300),
|
"query_interval_sec": param_store.get_int("PMS_QMT_QUERY_INTERVAL_SEC", 300),
|
||||||
|
"gap_skip_sec": param_store.get_int("PMS_QMT_GAP_SKIP_SEC", 300),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
self._params = await _db(_load)
|
self._params = await _db(_load)
|
||||||
|
|
@ -117,7 +120,7 @@ class WsRunner:
|
||||||
"idle_timeout_sec": 15, "ack_batch": 20,
|
"idle_timeout_sec": 15, "ack_batch": 20,
|
||||||
"ack_interval_sec": 2.0, "outbox_poll_sec": 0.5,
|
"ack_interval_sec": 2.0, "outbox_poll_sec": 0.5,
|
||||||
"beat_sec": 2, "max_attempts": 3, "connect_timeout": 10,
|
"beat_sec": 2, "max_attempts": 3, "connect_timeout": 10,
|
||||||
"query_interval_sec": 300}
|
"query_interval_sec": 300, "gap_skip_sec": 300}
|
||||||
logger.warning("参数刷新失败, 沿用上一份: %s", _brief_err(e))
|
logger.warning("参数刷新失败, 沿用上一份: %s", _brief_err(e))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -292,6 +295,13 @@ class WsRunner:
|
||||||
self._stat.pop("ack_seq_degraded", None)
|
self._stat.pop("ack_seq_degraded", None)
|
||||||
for k in ("peer_skew_min", "peer_skew_max"):
|
for k in ("peer_skew_min", "peer_skew_max"):
|
||||||
self._stat.pop(k, None) # 时钟差/卡顿按会话统计, 跨连接混着看没意义
|
self._stat.pop(k, None) # 时钟差/卡顿按会话统计, 跨连接混着看没意义
|
||||||
|
# 序号基线每个连接都重新对齐一次 (2026-08-31): 原来 _baselined 一经置位终生
|
||||||
|
# 不再对齐, 会话中途出的洞 (对端跳号/上行验签失败被本端丢弃) 补发也补不出来,
|
||||||
|
# 水位就永远卡在洞前 —— 真机卡了三天、暂存五万三千条就是这么来的。重连后首条
|
||||||
|
# 补发消息与水位连续时 _set_baseline 是空操作, 行为与从前逐字节相同; 不连续
|
||||||
|
# 说明那段真拿不到了, 推基线 + 置 resync 走全量对账, 与冷启动同一条 §6.2 路。
|
||||||
|
self._baselined = False
|
||||||
|
self._gap_stuck = None
|
||||||
await self._handshake(seed, peer)
|
await self._handshake(seed, peer)
|
||||||
# last_error 用握手期攒下的告警覆盖: 连上了不等于没问题 (见 _handshake
|
# last_error 用握手期攒下的告警覆盖: 连上了不等于没问题 (见 _handshake
|
||||||
# 的序号倒挂检查), 一律清空会把唯一一条线索抹掉。
|
# 的序号倒挂检查), 一律清空会把唯一一条线索抹掉。
|
||||||
|
|
@ -416,6 +426,9 @@ class WsRunner:
|
||||||
except wsc.CodecError as e:
|
except wsc.CodecError as e:
|
||||||
# §2.1: 验签失败直接丢弃, **不执行任何业务动作**。不回 reject ——
|
# §2.1: 验签失败直接丢弃, **不执行任何业务动作**。不回 reject ——
|
||||||
# 连不上信任的对端时, 多说一句话只是多给攻击者一个探测面。
|
# 连不上信任的对端时, 多说一句话只是多给攻击者一个探测面。
|
||||||
|
# 计数挂 stat (2026-08-31): 被丢的消息若带 seq, 水位上就多一个洞,
|
||||||
|
# 这个数是排「水位为什么卡住」时的第一线索, 光靠翻日志太难受。
|
||||||
|
self._stat["sig_invalid"] = self._stat.get("sig_invalid", 0) + 1
|
||||||
logger.error("上行消息校验失败, 已丢弃 (%s): %s", e.code, e.message)
|
logger.error("上行消息校验失败, 已丢弃 (%s): %s", e.code, e.message)
|
||||||
continue
|
continue
|
||||||
self._track_skew(env)
|
self._track_skew(env)
|
||||||
|
|
@ -525,17 +538,58 @@ class WsRunner:
|
||||||
# 明确收尾 —— 有始有终才看得出它到底有没有卡住。
|
# 明确收尾 —— 有始有终才看得出它到底有没有卡住。
|
||||||
self._stat["ooo"] = self._stat.get("ooo", 0) + 1
|
self._stat["ooo"] = self._stat.get("ooo", 0) + 1
|
||||||
n, k = self._stat["ooo"], len(self._pending_seq)
|
n, k = self._stat["ooo"], len(self._pending_seq)
|
||||||
|
self._stat["pending_gap"] = k
|
||||||
if n == 1 or n % 200 == 0:
|
if n == 1 or n % 200 == 0:
|
||||||
logger.warning("水位未连续: 卡在 %s, 暂存 %s 条 (第 %s 次)。刚重连的话这是 "
|
logger.warning("水位未连续: 卡在 %s, 暂存 %s 条 (第 %s 次)。刚重连的话这是 "
|
||||||
"§6.1 补发的正常形态, 补完自会收敛; 若暂存条数持续增长, "
|
"§6.1 补发的正常形态, 补完自会收敛; 若暂存条数持续增长, "
|
||||||
"才是真乱序", self._last_seq, k, n)
|
"才是真乱序", self._last_seq, k, n)
|
||||||
|
await self._maybe_skip_gap()
|
||||||
elif had_pending:
|
elif had_pending:
|
||||||
logger.info("水位缺口已补齐, 连续到 %s (期间未连续 %s 次)",
|
logger.info("水位缺口已补齐, 连续到 %s (期间未连续 %s 次)",
|
||||||
self._last_seq, self._stat.get("ooo", 0))
|
self._last_seq, self._stat.get("ooo", 0))
|
||||||
self._stat["ooo"] = 0
|
self._stat["ooo"] = 0
|
||||||
|
self._stat["pending_gap"] = 0
|
||||||
|
self._gap_stuck = None
|
||||||
if self._unacked >= self._p("ack_batch", 20):
|
if self._unacked >= self._p("ack_batch", 20):
|
||||||
await self._flush_ack()
|
await self._flush_ack()
|
||||||
|
|
||||||
|
async def _maybe_skip_gap(self):
|
||||||
|
"""水位在同一位置卡满 gap_skip_sec 秒 → 按 §6.2 弃洞前进 (2026-08-31)。
|
||||||
|
|
||||||
|
ws 是单连接有序流: 会话内出现的洞 (对端跳号 / 上行验签失败被本端丢弃) 在本会话里
|
||||||
|
**永远**等不来, §6.1 补发只发生在重连握手时; 而对端日志滚动后连重连也补不出来。
|
||||||
|
原来的处置是干等 —— 暂存集合无限涨、ack 水位停摆、每次重连对端整段重发 (真机卡了
|
||||||
|
三天, 暂存五万三千条)。现在等满窗口就认定补不出来, 与 _set_baseline 处置真缺口
|
||||||
|
同一语义: 推水位到已到达的最高序号、清暂存、置 resync 标记走全量对账。
|
||||||
|
暂存里的序号全部来自**验签通过**的消息, 推进目标不受伪造数据影响。
|
||||||
|
窗口在参数中心调 (PMS_QMT_GAP_SKIP_SEC, 默认 300 秒; 0 = 关掉本机制)。
|
||||||
|
"""
|
||||||
|
skip_after = self._p("gap_skip_sec", 300)
|
||||||
|
if skip_after <= 0 or not self._pending_seq:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._gap_stuck is None or self._gap_stuck[0] != self._last_seq:
|
||||||
|
self._gap_stuck = (self._last_seq, now) # 水位动过 → 重新计时
|
||||||
|
return
|
||||||
|
if now - self._gap_stuck[1] < skip_after:
|
||||||
|
return
|
||||||
|
old, waited = self._last_seq, len(self._pending_seq)
|
||||||
|
self._last_seq, missing = wsc.skip_gap(self._last_seq, self._pending_seq)
|
||||||
|
self._pending_seq = set()
|
||||||
|
self._acked_seq = max(self._acked_seq, self._last_seq)
|
||||||
|
self._gap_stuck = None
|
||||||
|
self._stat["gap_skips"] = self._stat.get("gap_skips", 0) + 1
|
||||||
|
self._stat["ooo"] = 0
|
||||||
|
self._stat["pending_gap"] = 0
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await _db(qmt_repo.save_watermark, self._last_seq, self._acked_seq)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await _db(qmt_repo.set_conn, "ONLINE", resync=True)
|
||||||
|
logger.error("水位弃洞前进 (§6.2): 卡在 %s 已满 %s 秒, 暂存 %s 条早已到齐而中间 "
|
||||||
|
"%s 个序号等不来 —— 水位推到 %s。已置 resync 标记: 请做一次全量对账 "
|
||||||
|
"(运维「账本对账」, 差异清零), 然后在运维抽屉点「清除通道对账标记」",
|
||||||
|
old, skip_after, waited, missing, self._last_seq)
|
||||||
|
|
||||||
def _maybe_degrade_ack(self, pl: dict):
|
def _maybe_degrade_ack(self, pl: dict):
|
||||||
"""对端回「不认识 ack_seq」时停发 ack_seq。
|
"""对端回「不认识 ack_seq」时停发 ack_seq。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,10 @@ CREATE TABLE IF NOT EXISTS pms_industry_map (
|
||||||
-- 10. 运行参数持久层 (页面修改的参数落此, 优先于 settings 文件初值)
|
-- 10. 运行参数持久层 (页面修改的参数落此, 优先于 settings 文件初值)
|
||||||
CREATE TABLE IF NOT EXISTS pms_runtime_param (
|
CREATE TABLE IF NOT EXISTS pms_runtime_param (
|
||||||
param_key VARCHAR(64) PRIMARY KEY,
|
param_key VARCHAR(64) PRIMARY KEY,
|
||||||
param_value VARCHAR(200) NOT NULL,
|
-- TEXT 而非 VARCHAR(200) (2026-08-31 放宽): 清场名单 PMS_EXIT_CLEANUP_DONE 与回放游标
|
||||||
|
-- PMS_REPLAY_CURSOR 都是会随运行长大的 JSON, 30 只闭仓票的名单就超过 200 字符,
|
||||||
|
-- 写入每分钟报 1406 截断错。已有库用 scripts/migrate_param_value_text.py 迁移。
|
||||||
|
param_value TEXT NOT NULL,
|
||||||
updated_by VARCHAR(32) NOT NULL DEFAULT 'user',
|
updated_by VARCHAR(32) NOT NULL DEFAULT 'user',
|
||||||
updated_at DATETIME NOT NULL
|
updated_at DATETIME NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
迁移: pms_runtime_param.param_value 由 VARCHAR(200) 放宽为 TEXT
|
||||||
|
================================================================
|
||||||
|
运行:
|
||||||
|
docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py # 演练, 只打印
|
||||||
|
docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py --yes # 实际执行
|
||||||
|
|
||||||
|
为什么要迁:
|
||||||
|
这一列存的不全是短标量 —— 清场名单 (PMS_EXIT_CLEANUP_DONE, 闭仓票代码的 JSON 数组) 与
|
||||||
|
回放游标 (PMS_REPLAY_CURSOR, v2 起带 seen 去重字典) 都随运行越长越大。真机 2026-08-31
|
||||||
|
实测: 名单 30 只票就超 200 字符, 两处写入每分钟各报一次 1406 截断错, 游标推不动、
|
||||||
|
清场防呆标记存不上 (后者失守时, 闭仓后新下的建仓单会被清场误撤)。
|
||||||
|
|
||||||
|
为什么单独一个脚本, 不放进 init_db:
|
||||||
|
init_db.py 只认 `CREATE TABLE IF NOT EXISTS` 与幂等 INSERT, ALTER 语句会被它判成残句
|
||||||
|
整批中止; 而 CREATE IF NOT EXISTS 对已存在的表一个字都不改。建表语句 (ddl_pms_v1.sql)
|
||||||
|
已同步改成 TEXT, **新库直接就有**, 不需要跑本脚本。
|
||||||
|
|
||||||
|
幂等: 列已是 TEXT 时自动跳过, 重复跑安全。绕过 db.session 的单表守卫 (直接用引擎), 与
|
||||||
|
migrate_archived_at 同一套做法。若 ALTER 被 ShardingSphere-Proxy 拒绝, 脚本会把完整
|
||||||
|
语句打出来, 直接拿去物理库 (my_quant_db) 执行即可。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
TABLE = "pms_runtime_param"
|
||||||
|
COL = "param_value"
|
||||||
|
TARGET_TYPE = "text"
|
||||||
|
|
||||||
|
|
||||||
|
def _col_type(conn, table: str, col: str):
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = conn.execute(text(f"SHOW COLUMNS FROM {table}")).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
if r[0] == col:
|
||||||
|
return str(r[1] or "").lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--yes", action="store_true", help="确认执行 (缺省只演练)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.session import TABLE_PREFIX, get_engine, map_tables
|
||||||
|
eng = get_engine("proxy")
|
||||||
|
if TABLE_PREFIX:
|
||||||
|
print(f"[双实例] 表名前缀 {TABLE_PREFIX!r} 生效: 实际迁移的是 {TABLE_PREFIX}{TABLE}")
|
||||||
|
|
||||||
|
real_table = map_tables(TABLE)
|
||||||
|
try:
|
||||||
|
with eng.connect() as c:
|
||||||
|
cur = _col_type(c, real_table, COL)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL: 查列失败 {type(e).__name__}: {e} (表不存在就先跑 init_db)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if cur is None:
|
||||||
|
print(f"FAIL: {real_table} 没有 {COL} 列 —— 表结构与预期不符, 先人工核对")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"当前类型: {real_table}.{COL} = {cur}")
|
||||||
|
if cur.startswith(TARGET_TYPE):
|
||||||
|
print("无需迁移: 已是 TEXT。")
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt = map_tables(
|
||||||
|
f"ALTER TABLE {TABLE} MODIFY COLUMN {COL} TEXT NOT NULL "
|
||||||
|
f"COMMENT '参数值: 标量或 JSON; 2026-08-31 由 VARCHAR(200) 放宽 (清场名单/回放游标会长大)'")
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
print("\n[演练模式] 未执行任何语句。确认无误后加 --yes 重跑。将执行:")
|
||||||
|
print(" " + stmt)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with eng.begin() as c:
|
||||||
|
c.execute(text(stmt))
|
||||||
|
print("OK ALTER 执行成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {type(e).__name__}: {e}")
|
||||||
|
print(f"\n完整语句如下 —— 可直接拿到物理库 (my_quant_db) 执行:\n{stmt};")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with eng.connect() as c:
|
||||||
|
now = _col_type(c, real_table, COL)
|
||||||
|
if now and now.startswith(TARGET_TYPE):
|
||||||
|
print(f"ALL OK: {real_table}.{COL} 已是 {now}。清场名单与回放游标的写入即刻恢复, 无需重启。")
|
||||||
|
else:
|
||||||
|
print(f"FAIL: 迁移后类型仍为 {now}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (21 例)
|
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (21 例)
|
||||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
|
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
|
||||||
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例)
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例)
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
||||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例)
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例)
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
|
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
|
||||||
按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 例)
|
按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (67 例)
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (67 例)
|
||||||
共 588 例
|
共 591 例
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,8 @@ def _():
|
||||||
# init_db / migrate_archived_at 拿 get_engine 直执行 DDL (有意绕过单表守卫),
|
# init_db / migrate_archived_at 拿 get_engine 直执行 DDL (有意绕过单表守卫),
|
||||||
# 它们必须显式调 map_tables —— 否则正式实例 make deploy 会建出**无前缀**的表,
|
# 它们必须显式调 map_tables —— 否则正式实例 make deploy 会建出**无前缀**的表,
|
||||||
# 与模拟实例的表撞在一起
|
# 与模拟实例的表撞在一起
|
||||||
for f in ("scripts/init_db.py", "scripts/migrate_archived_at.py"):
|
for f in ("scripts/init_db.py", "scripts/migrate_archived_at.py",
|
||||||
|
"scripts/migrate_param_value_text.py"):
|
||||||
src = open(os.path.join(root, f), encoding="utf-8").read()
|
src = open(os.path.join(root, f), encoding="utf-8").read()
|
||||||
assert "map_tables" in src, f + " 直执 DDL 必须过 map_tables"
|
assert "map_tables" in src, f + " 直执 DDL 必须过 map_tables"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,26 @@ def run():
|
||||||
last, pend = wsc.next_watermark(last, pend, s)
|
last, pend = wsc.next_watermark(last, pend, s)
|
||||||
eq((last, pend), (10003, set()))
|
eq((last, pend), (10003, set()))
|
||||||
|
|
||||||
|
# --- 弃洞前进。2026-08-31 真机缺陷的回归防线: 会话中途 99 条验签失败的 pong 被丢,
|
||||||
|
# 每条占一个 seq → 水位卡在洞前三天、暂存五万三千条。ws 是有序流, 会话内的洞
|
||||||
|
# 等不来 (§6.1 补发只在重连握手时), 到点就按 §6.2 弃洞: 推水位、清暂存、置 resync。
|
||||||
|
@case("弃洞前进: 推到已到达的最高序号, 洞数点得清")
|
||||||
|
def _():
|
||||||
|
# 卡在 100, 暂存 {102,103,105}: 101 与 104 是洞
|
||||||
|
eq(wsc.skip_gap(100, {102, 103, 105}), (105, 2))
|
||||||
|
|
||||||
|
@case("弃洞前进: 暂存为空原地不动; 全连续时洞数为 0")
|
||||||
|
def _():
|
||||||
|
eq(wsc.skip_gap(100, set()), (100, 0))
|
||||||
|
eq(wsc.skip_gap(100, {101, 102}), (102, 0))
|
||||||
|
|
||||||
|
@case("弃洞前进后水位能正常前进 (与 next_watermark 接得上)")
|
||||||
|
def _():
|
||||||
|
last, _n = wsc.skip_gap(365003, {365100, 365101, 421943})
|
||||||
|
eq(last, 421943)
|
||||||
|
last, pend = wsc.next_watermark(last, set(), 421944)
|
||||||
|
eq((last, pend), (421944, set()))
|
||||||
|
|
||||||
print("\n[F] 去重键与成交自洽")
|
print("\n[F] 去重键与成交自洽")
|
||||||
|
|
||||||
@case("trade 的第二层去重键取 trade_no")
|
@case("trade 的第二层去重键取 trade_no")
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,18 @@ def cmd_status(args):
|
||||||
f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 "
|
f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 "
|
||||||
f"processed 改回 0")
|
f"processed 改回 0")
|
||||||
if ch.get("resync_required"):
|
if ch.get("resync_required"):
|
||||||
bad.append("resync_flag=1: 对端补发不全, 须走全量对账后经页面清除")
|
bad.append("resync_flag=1: 对端补发不全, 须走全量对账后经页面清除 "
|
||||||
|
"(运维抽屉「账本对账」→ 差异清零 →「清除通道对账标记」)")
|
||||||
|
if int(s.get("sig_invalid") or 0):
|
||||||
|
bad.append(f"验签失败被丢弃 {s['sig_invalid']} 条: 每条若带 seq 就在水位上留一个洞。"
|
||||||
|
f"持续增长要与 QMT 侧核对签名实现 (哪类消息在坏, 见 docker compose "
|
||||||
|
f"logs pms-ws | grep SIG_INVALID)")
|
||||||
|
if int(s.get("pending_gap") or 0) > 500:
|
||||||
|
bad.append(f"乱序暂存 {s['pending_gap']} 条: 水位卡在洞前。等满 PMS_QMT_GAP_SKIP_SEC "
|
||||||
|
f"(默认 300 秒) 会自动弃洞前进并置 resync 标记")
|
||||||
|
if int(s.get("gap_skips") or 0):
|
||||||
|
bad.append(f"本进程已弃洞前进 {s['gap_skips']} 次: 每次都置了 resync 标记, "
|
||||||
|
f"记得全量对账后清除")
|
||||||
if bad:
|
if bad:
|
||||||
print()
|
print()
|
||||||
for b in bad:
|
for b in bad:
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue