diff --git a/app/web/static/index.html b/app/web/static/index.html index 94efcfa..6986ad4 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -989,7 +989,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ (o.at||'').slice(11,16) }} {{ o.result==='OK'?'已执行':(o.result==='BLOCKED'?'被挡下':o.result) }} - {{ o.op }} · {{ nm(o.ts_code) }} + {{ opCn(o) }} · {{ nm(o.ts_code) }} — {{ o.reason }}
@@ -1026,7 +1026,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} {{ tx('action', p.action) }} {{ p.expire_at }} 前有效
决策系统:{{ p.judge_reason }}(置信度 {{ (p.hard_numbers||{}).judge_conf }})
-
选股系统:判为「{{ (p.hard_numbers||{}).verdict }}」,{{ propReasons(p).join(';') }}
+
选股系统:{{ propVerdictLine(p) }}
{{ propBasis(p) }}
研报说:{{ propLogic(p) }}(另有 {{ propLogicMore(p) }} 条)
{{ propWhy(p) }}
@@ -1443,7 +1443,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
-

待确认队列 (自主档位 {{ ov.autonomy }})

+

待确认队列(现在是{{ tx('autonomy', ov.autonomy) }})

propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。 采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。 @@ -1674,15 +1674,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} - - - - + + + + + + + + + - @@ -1858,7 +1862,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} 按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} → 取 {{ (planCand.items||[]).length }} 只 · 白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} · - 丢弃 {{ JSON.stringify(planCand.dropped||{}) }} + 落选 {{ dropCn(planCand.dropped) }} · {{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行
{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }} @@ -2225,7 +2229,138 @@ createApp({ const m = T[g]; if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c]; if (c == null || c === '') return '—'; - return String(c); // 未登记的码:显示原样, 不臆造 + // 未登记的码:不臆造它的意思, 但也不能让一个英文词单独立在页面上当句子。 + // 退到括号里, 前面加一句实话 —— 读的人至少知道「这是系统没翻译, 不是我该懂的词」。 + return '其他(' + String(c) + ')'; + }; + + // 命令的参数原先整个字典打出来。人要读的就是那几个数, 键名换成中文, 空值不显示。 + const PARAM_CN = { + target_pct: '目标仓位', pct: '比例', qty: '数量', price: '价格', + ts_code: '股票', cmd_type: '命令', reason: '理由', note: '备注', + side: '方向', amount: '金额', level: '档位', status: '状态', type: '类型', + }; + const paramsCn = (d) => { + const o = d || {}; + const bits = []; + for (const [k, v] of Object.entries(o)) { + if (v === null || v === undefined || v === '') continue; + if (typeof v === 'object') continue; // 嵌套的不展开, 留给悬浮提示 + let val = v; + if (k === 'side') val = tx('side', v); + if (k === 'status') val = tx('stratStatus', v); + if (k === 'type') val = tx('stype', v); + bits.push((PARAM_CN[k] || k) + ' ' + val); + } + return bits.join(','); + }; + + // 账本里那一列原先叫「硬数字」, 把整个字典打出来给人看。它其实只有几项是人要读的: + // 当时的判决依据、价格、以及决策系统的置信度。完整原文退到悬浮提示里, + // 运维要查随时能看到, 交易员不用在一堆键值对里找。 + const hardCn = (h) => { + const o = h || {}; + const bits = []; + if (o.basis) bits.push(String(o.basis)); + if (o.price != null) bits.push('当时价格 ' + o.price); + if (o.judge_conf != null) bits.push('决策系统把握 ' + o.judge_conf); + if (o.plan_rank != null) bits.push('候选榜第 ' + o.plan_rank + ' 名'); + if (!bits.length) { + const n = Object.keys(o).length; + return n ? ('这一条记了 ' + n + ' 项,鼠标停上去看原文') : '—'; + } + return bits.join(';'); + }; + + // 候选池落选原因的中文说法。与后端 app/services/proposal_service.py 的 _DROP_CN + // 同源, 顺序也一样 —— 那是筛选实际发生的顺序, 照着念下来正好是一只票走过的路。 + // 改一处必须两处同改。 + const DROP_CN = [ + ['held', '已经持有'], ['black', '在黑名单里'], ['display', '选股系统判定今天不买'], + ['st', '是 ST 类'], ['tier', '档位不够'], ['score', '分数不够'], + ['sources', '传导源数不够'], ['upside', '券商预期空间不够'], + ['theme', '同一主题已经选够'], ['dup', '和已选的重复'], ['capped', '超出今天的名额'], + ]; + // 只念不为零的那几项。原先把整个计数字典打出来, 键全是英文、值为零的也全列出来, + // 读的人要在十一个数字里自己找哪个不是零。 + const dropCn = (d) => { + const o = d || {}; + const parts = DROP_CN.filter(([k]) => Number(o[k]) > 0) + .map(([k, cn]) => cn + ' ' + o[k] + ' 只'); + return parts.length ? parts.join(',') : '没有落选的'; + }; + + // 选股系统的判决怎么说给人听。三个判决词(候选/关注/仅展示)是跨系统的**数据**, + // 被六个文件和十几条断言按字面比对, 所以只改显示、绝不改值。 + // 原先页面上写「判为「仅展示」,只差覆盖」—— 判决词本身不告诉人能不能买, + // 所以句子主干换成原因, 判决词不再出现。 + const propVerdictLine = (row) => { + const h = (row && row.hard_numbers) || {}; + const v = String(h.verdict || '').trim(); + if (!v) return ''; + const basis = String(h.basis || '').trim(); + const missing = (h.missing || []).filter(Boolean); + if (v === '候选') return basis || '选股系统认为今天可以买'; + if (v === '关注') { + return missing.length + ? ('它自己也拿不准,还缺:' + missing.join(';') + ',要你来定') + : '它自己也拿不准,要你来定'; + } + if (v === '仅展示') return basis || '选股系统今天不建议买入'; + return '选股系统给的结论是「' + v + '」,本系统没见过这个说法,按「要你来定」处理'; + }; + + // 操作日志里那一列, 后端落库的是函数名式的操作码, 例如 issue_command:EXIT_STOCK、 + // set_strategy_status:PAUSED、decide_proposal。原先直接印出来, 页面上就是一串英文。 + // 冒号前是做了什么、冒号后是对象, 后半段复用页面已有的翻译组。 + // 这些码同时是账本里的字面值, **只改显示不改值**。 + const OP_CN = { + decide_proposal: '处理提议', cancel_command: '撤销这条命令', + cancel_instruction: '撤销一笔还没执行的下单', replan_command: '重新算这条命令的执行方案', + resume_strategy_buy: '恢复这个方案的买入', + macro_scan: '重算大盘信号(只算,不下单)', macro_adopt: '采纳今天的大盘建议', + strategy_attach_scan: '重算该给哪些持仓挂交易方案(只算,不挂)', + archive_command: '从列表移除这条命令', unarchive_command: '重新显示这条命令', + archive_instruction: '从列表移除这笔下单', unarchive_instruction: '重新显示这笔下单', + archive_proposal: '从列表移除这条提议', unarchive_proposal: '重新显示这条提议', + archive_strategy: '从列表移除这个交易方案', unarchive_strategy: '重新显示这个交易方案', + }; + const opCn = (o) => { + const raw = String((o && o.op) || '').trim(); + if (!raw) return '其他操作'; + const i = raw.indexOf(':'); + if (i < 0) { + if (raw === 'decide_proposal') { + const d = ((o.params || {}).decision) || ''; + if (d === 'ACCEPTED') return '采纳提议'; + if (d === 'DECLINED') return '否决提议'; + } + return OP_CN[raw] || ('其他操作(' + raw + ')'); + } + const head = raw.slice(0, i), tail = raw.slice(i + 1); + if (head === 'issue_command') { + const spec = (catalog.value || []).find(c => c.cmd_type === tail); + return '下达命令 · ' + ((spec && spec.label) || tail); + } + if (head === 'attach_strategy') return '挂交易方案 · ' + tx('stype', tail); + if (head === 'set_strategy_status') { + const m = { PAUSED: '暂停这个交易方案', ACTIVE: '恢复这个交易方案', + CANCELLED: '撤下这个交易方案', DONE: '标记这个交易方案已完成' }; + return m[tail] || ('改交易方案状态 · ' + tx('stratStatus', tail)); + } + return (OP_CN[head] || head) + ' · ' + tail; + }; + + // 剥掉给运维看的大写判据码, 只留中文说明。与后端 app/core/copy.py 的 strip_code + // 同一口径, 改一处必须两处同改 —— 判据码留在句首是承重的(账本靠它去重、命令进度 + // 靠它聚类), 所以只能在显示层剥。 + const plainCheck = (t) => { + const s = String(t == null ? '' : t).trim(); + const i = s.indexOf(':'); + if (i <= 0) return s; + const head = s.slice(0, i).trim(); + if (head && /^[A-Z0-9_]+$/.test(head)) return s.slice(i + 1).trim() || s; + return s; }; const nameMap = ref({}); @@ -3276,7 +3411,7 @@ createApp({ for (const o of (opLog.value || [])) { if (o.result === 'OK') continue; // 顺利做完的不占消息栏, 在「你做过的操作」里查 out.push({ kind:'strat', time:_hm(o.at), sort:_hms(o.at), ts_code:o.ts_code || '', - cat:o.op + ' ' + (o.result === 'BLOCKED' ? '被合规检查挡下' : ('结果 ' + o.result)), + cat:opCn(o) + ' ' + (o.result === 'BLOCKED' ? '被合规检查挡下' : ('结果 ' + o.result)), strong:true, why:o.reason || '', goLabel:'看记录', go:'ins' }); } out.sort((a, b) => String(b.sort).localeCompare(String(a.sort))); diff --git a/scripts/run_tests.py b/scripts/run_tests.py index c715f3e..93b0c55 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -53,12 +53,15 @@ test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例) + test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 + 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 + 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 654 例 + 共 655 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 - 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 现为 654) + 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 现为 655) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): @@ -80,6 +83,9 @@ 死参数名单 scripts/test_batch10_units.py 的 KNOWN_DEAD ([P] 组) 与 param_store.py 顶部 那段清点 —— 「页面能改而全库没人读」的参数只能同时改这两处。接通了、摘掉了、 或者又添了一个摆设, 都要在这里过一遍手 + 页面枚举名单 scripts/test_page_enum_guard.py 顶部 ENUM_FIELDS —— 后端新增会送到页面的 + 枚举字段就登记一行, 并在 index.html 的 T 表里加对应的组。不登记等于不检查。 + index.html 没有别的测试, 这道扫描是它唯一的护栏。 动作求值器 scripts/test_batch12_units.py 用例「回归·既有四类动作的判据与研判范围没被 动过」—— action_engine.EVALUATORS 的名单与次序钉在那里 (次序有意义: 减持排 在买入前面, 同轮买卖互斥才让得了路) @@ -98,6 +104,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch16_units.py", "test_batch17_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", "test_batch21_units.py", + "test_page_enum_guard.py", "test_wiring.py"] diff --git a/scripts/test_page_enum_guard.py b/scripts/test_page_enum_guard.py new file mode 100644 index 0000000..3720ad0 --- /dev/null +++ b/scripts/test_page_enum_guard.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""页面文案守卫:枚举字段不许直接印到页面上(静态扫描,不连库、不起浏览器)。 + +## 为什么要有它 + +app/web/static/index.html 是三千行的单文件页面,**没有任何单测**。改错了不会有 +东西报警。而它上面最容易出的错,是把后端送来的枚举值直接塞进模板—— +页面上就出现 COMMAND_HALT、REJECT、issue_command:EXIT_STOCK 这种东西。 + +页面里其实早就有翻译层(T 表加 tx 函数),问题是它被系统性地绕开了: +2026-09-04 实测,同一份文件里 :899 走了 tx('frozen', …)、:1533 没走,印的是 +同一个字段。这不是没想到,是执行漏网。所以需要一道机器检查。 + +## 它检查什么 + +一、**枚举字段直出**:模板里形如 {{ x.frozen_reason }} 的插值,字段名落在 + ENUM_FIELDS 里,而外面没有包 tx( 或别的翻译函数,就报。 +二、**判据码直出**:failed_checks、reason 这类会带 `CODE: ` 前缀的字段, + 必须过 plainCheck 才能显示。 +三、**整个对象打给人看**:JSON.stringify 出现在给交易员看的区域就报。 + 运维面板那几处是有意的,登记在 JSON_OK 里放行。 +四、**翻译表兜底不许裸奔**:tx 找不到码时必须把码退到括号里, + 不能让一个英文词单独立在页面上当句子。 + +## 怎么维护 + +加了新的枚举字段就往 ENUM_FIELDS 里加一行。这份名单是这道检查的全部依据, +不加就等于不检查——所以它和 T 表要一起改。 + +跑法:python3 scripts/test_page_enum_guard.py +""" +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PAGE = os.path.join(os.path.dirname(HERE), "app", "web", "static", "index.html") + +# 后端会送到页面的枚举字段。取值全集在 T 表里,两者要一起改。 +# 只列**枚举**字段:后端已经写成中文句子的自由文本(basis、why、note 这类)不在此列, +# 那些本来就该原样显示。 +ENUM_FIELDS = [ + "frozen_reason", # 冻结来路:COMMAND_HALT / BRAKE / MANUAL / NONE + "cushion_state", # 安全垫:SOLID / THIN / NONE + "lot_kind", # 批次:BASE / FILL / ADD / DCA / T0 / RECON + "arbiter", # 谁裁的:rule / judge / user + "stype", # 交易方案类型:T0 / GRID / TRAIL + "autonomy", # 自主档:full / propose_only / off + "ref_source", # 参考位来源:bionic / self_calc / user / none +] + +# 这些字段带 `CODE: ` 前缀,显示前必须剥掉。 +CODED_FIELDS = ["failed_checks"] + +# 把整个对象打给人看,只有落在**交易员会看的区域**才算问题。两类不算: +# 一是存本地状态(sessionStorage / localStorage),那不是给人看的; +# 二是运维面板里那几个"原样看接口返回"的框,读者是运维不是交易员, +# 它们本来就该显示原始数据。 +# 判定靠变量名:写进 opsResult / planResult 这类运维面板的框,或者进浏览器存储, +# 都放行。别的地方出现 JSON.stringify 就报。 +JSON_SKIP_MARKS = ( + "sessionStorage", "localStorage", # 存本地状态,不是给人看的 + "opsResult", "planResult", # 运维面板:有意显示接口原文 + "console.", # 控制台 + # 下面几处都在运维面板里,读者是运维不是交易员,本来就该看原始数据。 + # 每加一条都要写清它在哪个面板、为什么放行。 + "planStatus.theme_sync", # 上游计划诊断行:主题映射,排查用 + "planStatus.query", # 上游计划诊断行:本次请求参数,排查用 + "chgLog", # 运维面板「快照台账」:整行原文 + "report.report", # 运维面板「快照台账」:日报原文 + "wsc.queue", # 运维面板「ws 通道」:出口队列计数 + "s.row.payload", # 运维面板「ws 通道」:报文原文 + "s.row.hard_numbers", # 只在悬浮提示里作原文备查,正文已是中文 + "s.row)", # 运维面板「快照台账」:整行原文 +) + +# 允许包住枚举字段的翻译函数。 +WRAPPERS = ("tx(", "opCn(", "plainCheck(", "propVerdictLine(") + + +def fail(msgs): + for m in msgs: + print(" FAIL", m) + + +def main(): + src = open(PAGE, encoding="utf-8").read() + lines = src.split("\n") + bad = [] + + # 一、枚举字段直出 + for i, line in enumerate(lines, 1): + for f in ENUM_FIELDS: + # 只看插值与属性绑定里出现的字段引用 + for m in re.finditer(r"[\w.\[\]]*\.%s\b" % re.escape(f), line): + seg = line[max(0, m.start() - 60):m.end() + 20] + if any(w in seg for w in WRAPPERS): + continue + # v-if 这类判断用途不算显示 + if re.search(r'v-(if|else-if|show)="[^"]*\.%s\b' % re.escape(f), line): + continue + # JS 里做比较也不算显示 + if re.search(r"\.%s\s*(===|!==|==|!=|&&|\|\||\?)" % re.escape(f), seg): + continue + if "{{" not in line and ":title=" not in line and ":content=" not in line: + continue + bad.append(f"第 {i} 行:枚举字段 {f} 直接印到页面上,没走翻译。\n" + f" {line.strip()[:120]}") + + # 二、判据码直出 + for i, line in enumerate(lines, 1): + for f in CODED_FIELDS: + if f"{f}" in line and "{{" in line and "plainCheck" not in line: + bad.append(f"第 {i} 行:{f} 带大写判据码前缀,显示前必须过 plainCheck。\n" + f" {line.strip()[:120]}") + + # 三、整个对象打给人看 + for i, line in enumerate(lines, 1): + if "JSON.stringify" in line: + if not any(k in line for k in JSON_SKIP_MARKS): + bad.append(f"第 {i} 行:把整个对象打给交易员看。要么改成中文说明," + f"要么说明它落在运维面板并登记进 JSON_SKIP_MARKS。\n" + f" {line.strip()[:120]}") + + # 四、兜底不许裸奔 + m = re.search(r"const tx = \(g, c\) => \{(.*?)\n \};", src, re.S) + if not m: + bad.append("找不到 tx 函数,这道检查的前提没了") + elif "return String(c);" in m.group(1): + bad.append("tx 的兜底是原样返回,会让一个英文词单独立在页面上当句子。" + "改成把码退到括号里。") + + # 五、翻译表与本文件的字段名单要对得上 + for f in ENUM_FIELDS: + group = {"frozen_reason": "frozen", "cushion_state": "cushion", + "lot_kind": "lot", "arbiter": "arbiter", "stype": "stype", + "autonomy": "autonomy", "ref_source": "refSource"}.get(f) + if group and f" {group}: {{" not in src and f" {group}:{{" not in src: + bad.append(f"字段 {f} 在名单里,但 T 表里没有对应的 {group} 组") + + if bad: + print(f"页面文案守卫:发现 {len(bad)} 处") + fail(bad) + sys.exit(1) + print(f"ALL OK — 页面文案守卫:{len(ENUM_FIELDS)} 个枚举字段、" + f"{len(CODED_FIELDS)} 个带判据码的字段、整对象直出、翻译兜底 全部通过") + + +if __name__ == "__main__": + main()