diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py
index c5f7700..19bb440 100644
--- a/app/services/proposal_service.py
+++ b/app/services/proposal_service.py
@@ -516,7 +516,9 @@ def _attach_consensus(cands, out=None, now=None) -> None:
rec = {"ts_code": code, "fund": fund.get("stance"),
"tech": tech.get("stance"), "timing": tm.get("stance"),
"direction": con.get("direction"), "route": con.get("route"),
- "phase": tech.get("phase")}
+ "phase": tech.get("phase"),
+ # 候选栏芯片用: 基本面质地档 (好/中/差), 从候选自带的买方评析取
+ "overall": (c.get("company_review") or {}).get("overall")}
if ikind:
rec["intraday"] = ikind
seen.append(rec)
@@ -705,9 +707,18 @@ def disposition_snapshot(now=None) -> dict:
if s.get("why"):
res["notes"].append(s["why"])
else: # 单票原因; 不覆盖已判 would 的
- # 合议判观察的候选带 disp=wait_tech (等技术面开口), 其余不产出的按 deny。
+ # 合议判观察的候选带 disp=wait_tech/wait_confirm, 其余不产出的按 deny。
res["by_code"].setdefault(
code, {"disp": s.get("disp") or "deny", "why": s.get("why") or "未产出候选"})
+ # 候选栏三小块 (2026-09-14 页面收尾包): 每只候选的合议摘要, 从 consensus_seen 来。
+ # 基本面立场与质地档 / 技术面立场与相位 / 合议方向与路由。挂到对应代码的 by_code 行上。
+ for rec in (sink.get("consensus_seen") or []):
+ code = rec.get("ts_code")
+ if code and code in res["by_code"]:
+ res["by_code"][code]["consensus"] = {
+ "fund": rec.get("fund"), "overall": rec.get("overall"),
+ "tech": rec.get("tech"), "phase": rec.get("phase"),
+ "direction": rec.get("direction"), "route": rec.get("route")}
return res
diff --git a/app/web/main.py b/app/web/main.py
index f1ec179..5fc19d8 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -737,22 +737,60 @@ def api_tech_pull():
return ok_logged("tech_pull", tech_service.pull_and_map)
+def _research_st(code):
+ """研究面的公司评析来源: 持仓票从逻辑状态映射取, 候选票从当日计划主榜与观察档取。
+ 返回 (逻辑状态样式的 dict 或 None, 来源标签)。取不到就当无读数弃权, 绝不折成看空。"""
+ from app.services import logic_state_service, plan_feed
+ try:
+ st = logic_state_service.state_map().get(code)
+ if st and (st.get("company_review") or {}).get("overall"):
+ return st, "held"
+ except Exception as e: # noqa: BLE001
+ logger.warning("[研究面] 取持仓逻辑状态失败 %s: %s", code, e)
+ try:
+ plan = plan_feed.get_plan()
+ for r in (plan.get("main") or []) + (plan.get("observe") or []):
+ if cs.normalize_code(r.get("ts_code") or "") == code and (r.get("company_review") or {}).get("overall"):
+ return {"company_review": r.get("company_review"),
+ "company_review_text": r.get("company_review_text")}, "candidate"
+ except Exception as e: # noqa: BLE001
+ logger.warning("[研究面] 取候选计划失败 %s: %s", code, e)
+ return None, "none"
+
+
@app.get("/api/research/{ts_code}")
def api_research(ts_code: str):
- """单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。
-
- 工作包一先给技术面读数一块; 基本面评析、择时立场与三源合议由工作包二补 (现以 None 占位,
- 页面按缺块处理)。代码归一后回。"""
- from app.services import tech_service
+ """单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。六块: 公司质地 / 基本面 / 技术面 /
+ 择时 / 合议 / 各路来源状态。任何一路取不到按无读数弃权 (设计原则二), 页面按缺块处理。"""
+ from app.services import tech_service, consensus_service, logic_state_service
def _build(code):
code = cs.normalize_code(code)
- t = tech_service.research_feed(code)
- return {"ts_code": code, "company": None, "tech": t.get("tech"),
- "tech_note": t.get("note") or t.get("error"),
- "consensus": None, "timing": None, "feed": None,
- "sources": {"tech": ("ok" if t.get("tech")
- else (t.get("error") or t.get("note") or "无读数"))}}
+ st, src = _research_st(code)
+ cr = (st or {}).get("company_review")
+ company = logic_state_service.company_view(st) if st else None
+ # 四块意见: assemble 用 company_review 算基本面、用映射算技术面、用昨夜定性算择时, 再合议。
+ nightly = consensus_service.nightly_map([code]).get(code)
+ tech_states = consensus_service.state_map()
+ blocks = consensus_service.assemble({"ts_code": code, "company_review": cr},
+ nightly=nightly, tech_states=tech_states)
+ # 技术面详细读数 (点击时现读, 带最新一行与近日翻向次数); 缺则退回映射里的紧凑块。
+ tfeed = tech_service.research_feed(code)
+ tech = tfeed.get("tech") or blocks["tech"]
+ tm = blocks["timing"]
+ timing = {"stance": tm.get("stance"), "nightly": tm.get("nightly"),
+ "trade_date": (nightly or {}).get("trade_date"),
+ "flip_at": tm.get("intraday_flip_at")} # 转多时刻点击不回扫, 留昨夜定性带的
+ fund = blocks["fund"]
+ sources = {
+ "company": ("ok" if company else ("无读数" if src != "none" else "不在持仓也不在候选")),
+ "fund": ("ok" if fund.get("stance") != "无读数" else (fund.get("why") or "无读数")),
+ "tech": ("ok" if tfeed.get("tech") else (tfeed.get("error") or tfeed.get("note") or "无读数")),
+ "timing": ("ok" if tm.get("stance") != "无读数" else (tm.get("no_read_why") or "无读数")),
+ "consensus": "ok",
+ }
+ return {"ts_code": code, "company": company, "fund": fund, "tech": tech,
+ "timing": timing, "consensus": blocks["consensus"], "sources": sources}
return ok(_build, ts_code)
diff --git a/app/web/static/index.html b/app/web/static/index.html
index 96e896a..9b56ca4 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -1260,6 +1260,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ r.name || nm(r.ts_code) }}
{{ r.theme }}
+
+
+ 质地{{ conOf(r.ts_code).overall }}
+ {{ conOf(r.ts_code).tech || '技术—' }}
+
{{ dispOf(r.ts_code).label }}
@@ -1724,6 +1729,18 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
class="muted">今天没有读数
+
+
+
+
+ {{ s.row.tech.stance }}
+ {{ s.row.tech.phase }}
+
+ 无读数
+
+
@@ -2074,26 +2091,47 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
-
研究面 · 技术面 · 读数日 {{ techFeed.tech.data_date }}
-
读不到技术面:{{ techFeedErr }}
-
{{ (techFeed && techFeed.tech_note) || '这只票没有技术面读数' }}
-
-
-
立场
-
{{ techFeed.tech.stance }} · {{ techFeed.tech.strength }}
-
{{ techFeed.tech.phase }}
-
震荡市
-
{{ techFeed.tech.reason }}
+
研究面 · 三源合议 · 技术面读数日 {{ techFeed.tech.data_date }}
+
读不到研究面:{{ techFeedErr }}
+
+
+
+ 基本面 {{ (techFeed.fund||{}).stance || '无读数' }} · {{ techFeed.fund.mark }}
+ 技术面 {{ (techFeed.tech||{}).stance || '无读数' }} · {{ techFeed.tech.phase }}
+ 择时 {{ (techFeed.timing||{}).stance || '无读数' }}
+ 合议 {{ (techFeed.consensus||{}).direction || '—' }} · {{ techFeed.consensus.strength }}
-
-
布林{{ (techFeed.tech.latest.boll||{}).state || '—' }}
-
收口
-
多空布林线{{ (techFeed.tech.latest.bbi||{}).state || '—' }}
-
SAR
-
{{ techFeed.tech.sar_side || '—' }}
-
止损位 {{ techFeed.tech.sar_value }}
-
翻向 {{ techFeed.tech.sar_flip_days }} 天
+
+ 路由{{ techFeed.consensus.route }}
+ {{ techFeed.consensus.route_reason }}
+
+
质地{{ techFeed.company.line }}
+
评析报告
+
硬疑点
+
+
+ 失效条件{{ techFeed.company.invalidation }}
+
+
+
+
技术面
+
{{ techFeed.tech.stance }} · {{ techFeed.tech.strength }}
+
{{ techFeed.tech.phase }}
+
震荡市
+
{{ techFeed.tech.reason }}
+
+
+ 布林{{ (techFeed.tech.latest.boll||{}).state || '—' }}
+ 收口
+ 多空布林线{{ (techFeed.tech.latest.bbi||{}).state || '—' }}
+ SAR
+ {{ techFeed.tech.sar_side || '—' }}
+ 止损位 {{ techFeed.tech.sar_value }}
+ 翻向 {{ techFeed.tech.sar_flip_days }} 天
+
+
+
{{ (techFeed.sources||{}).tech || '这只票没有技术面读数' }}
@@ -2888,6 +2926,9 @@ createApp({
}
return { cls: 'wait', label: '在盯', why: '' };
}
+ // 候选行的三源合议摘要 (2026-09-14 页面收尾包): 后端候选处置快照 by_code 里挂的 consensus 小块
+ // (fund/overall/tech/phase/direction/route)。没有就返回 null, 芯片整段不显示。
+ const conOf = (code) => ((openScan.value.by_code || {})[code] || {}).consensus || null;
const denyToday = computed(() => (planRows.value || []).map(r => {
const d = dispOf(r.ts_code);
return d.cls === 'deny' ? { ts_code: r.ts_code, name: (r.name || nm(r.ts_code)), why: d.why } : null;
@@ -4287,7 +4328,7 @@ createApp({
autoScanRes, autoScanBusy, autoScanPreview, stageTagType, autoInfo,
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
showLedger, onPosExpand,
- openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
+ openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, conOf, loadOpenScan, denyToday,
commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr,
cmdArchivable, stratArchivable, archiveRow,
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 8ae6566..05c53f0 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -98,16 +98,19 @@
取不到按等待/时段外开口进观察)/in_window 与 reask_rules 同源哨兵/synthesize
与 _compact 带 boll_upper/接线突破重合议为放行且硬数字带 intraday/开口未确认
路由改观察带 wait_confirm/总闸关掉 _intraday_cfg 为 None/参数登记与范围 (19 例)
+ test_batch33_units.py 页面收尾包·单票研究面接口 (2026-09-14): _research_st 来源解析 (持仓/候选/都没有)/
+ api_research 六块齐全且来源逐路标 ok / 无读数票 company 为 None 且来源标明。
+ 前端三处 (抽屉四芯片、候选栏芯片、管理研究面列) 由两道页面守卫与真机视觉判收兜 (5 例)
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 例)
- 共 866 例
+ 共 871 例
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820;
- 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例, 现为 866)
+ 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例, 现为 871)
任一子集失败即整体失败 (退出码 1)。
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@@ -163,6 +166,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch30_units.py",
"test_batch31_units.py",
"test_batch32_units.py",
+ "test_batch33_units.py",
"test_page_enum_guard.py",
"test_page_wiring_guard.py",
"test_wiring.py"]
diff --git a/scripts/test_batch33_units.py b/scripts/test_batch33_units.py
new file mode 100644
index 0000000..b17e2e4
--- /dev/null
+++ b/scripts/test_batch33_units.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+"""页面收尾包 · 单票研究面接口 (2026-09-14)。后端离线单测, 不连库。
+
+研究面接口补齐六块 (公司/基本面/技术面/择时/合议/来源), 页面抽屉四芯片、候选栏芯片、
+管理视图研究面列消费它。前端三处由两道页面守卫 (wiring/enum) 与真机视觉判收兜, 这里测后端:
+ A 研究面来源解析 _research_st: 持仓票 / 候选票 / 都没有 三情形。
+ B 研究面接口 api_research: 六块齐全且来源逐路标明; 无读数票 company 为 None、来源标不在持仓也不在候选。
+"""
+import os
+import sys
+import traceback
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.web import main # noqa: E402
+from app.services import (consensus_service, tech_service, # noqa: E402
+ logic_state_service, plan_feed)
+
+RESULTS = []
+
+
+def case(name):
+ def deco(fn):
+ RESULTS.append((name, fn))
+ return fn
+ return deco
+
+
+class _patch:
+ """按 (模块, 属性名, 替身) 列表打桩, with 结束自动还原。"""
+
+ def __init__(self, *specs):
+ self.specs = specs
+ self._saved = []
+
+ def __enter__(self):
+ for mod, attr, fn in self.specs:
+ self._saved.append((mod, attr, getattr(mod, attr)))
+ setattr(mod, attr, fn)
+ return self
+
+ def __exit__(self, *a):
+ for mod, attr, orig in reversed(self._saved):
+ setattr(mod, attr, orig)
+ return False
+
+
+# ================================================================ A 来源解析
+@case("A _research_st·持仓票 → held (逻辑状态映射有买方评析)")
+def _():
+ with _patch((logic_state_service, "state_map",
+ lambda: {"600000.SH": {"company_review": {"overall": "好"},
+ "company_review_text": "x"}})):
+ st, src = main._research_st("600000.SH")
+ assert src == "held" and (st.get("company_review") or {}).get("overall") == "好"
+
+
+@case("A _research_st·候选票 → candidate (不在持仓, 当日计划主榜有)")
+def _():
+ with _patch((logic_state_service, "state_map", lambda: {}),
+ (plan_feed, "get_plan",
+ lambda **k: {"main": [{"ts_code": "600001.SH",
+ "company_review": {"overall": "中"},
+ "company_review_text": "y"}], "observe": []})):
+ st, src = main._research_st("600001.SH")
+ assert src == "candidate" and (st.get("company_review") or {}).get("overall") == "中"
+
+
+@case("A _research_st·都没有 → none")
+def _():
+ with _patch((logic_state_service, "state_map", lambda: {}),
+ (plan_feed, "get_plan", lambda **k: {"main": [], "observe": []})):
+ st, src = main._research_st("600002.SH")
+ assert st is None and src == "none"
+
+
+# ================================================================ B 研究面接口
+def _mock_assemble(bull=True):
+ if bull:
+ return lambda row, **k: {"fund": {"stance": "看多", "fact": "质地好"},
+ "tech": {"stance": "看多", "phase": "趋势多"},
+ "timing": {"stance": "看多", "nightly": "BUY", "intraday_flip_at": None},
+ "consensus": {"direction": "看多", "route": "放行", "reason": "三方看多"}}
+ return lambda row, **k: {"fund": {"stance": "无读数", "why": "没有买方评析"},
+ "tech": {"stance": "无读数"},
+ "timing": {"stance": "无读数", "no_read_why": "没有昨夜结论"},
+ "consensus": {"direction": "中性", "route": "跳过"}}
+
+
+@case("B api_research·六块齐全, 来源逐路标 ok")
+def _():
+ with _patch(
+ (logic_state_service, "state_map",
+ lambda: {"600000.SH": {"company_review": {"overall": "好"}, "company_review_text": "x"}}),
+ (logic_state_service, "company_view",
+ lambda st: {"overall": "好", "line": "质地好(…)", "report_url": None}),
+ (consensus_service, "nightly_map", lambda codes: {"600000.SH": {"verdict": "BUY", "trade_date": 20260911}}),
+ (consensus_service, "state_map", lambda: {}),
+ (consensus_service, "assemble", _mock_assemble(True)),
+ (tech_service, "research_feed", lambda code: {"tech": {"stance": "看多", "phase": "趋势多", "latest": {}}}),
+ ):
+ r = main.api_research("600000.SH")
+ d = r["data"]
+ for k in ("company", "fund", "tech", "timing", "consensus", "sources"):
+ assert k in d, k
+ assert d["consensus"]["direction"] == "看多"
+ assert d["timing"]["trade_date"] == 20260911
+ assert d["sources"]["company"] == "ok" and d["sources"]["consensus"] == "ok"
+ assert d["sources"]["fund"] == "ok" and d["sources"]["tech"] == "ok"
+
+
+@case("B api_research·无读数票 → company 为 None, 来源标不在持仓也不在候选")
+def _():
+ with _patch(
+ (logic_state_service, "state_map", lambda: {}),
+ (plan_feed, "get_plan", lambda **k: {"main": [], "observe": []}),
+ (consensus_service, "nightly_map", lambda codes: {}),
+ (consensus_service, "state_map", lambda: {}),
+ (consensus_service, "assemble", _mock_assemble(False)),
+ (tech_service, "research_feed", lambda code: {"tech": None, "note": "这只票没有技术面读数"}),
+ ):
+ d = main.api_research("600002.SH")["data"]
+ assert d["company"] is None
+ assert d["sources"]["company"] == "不在持仓也不在候选"
+ assert d["sources"]["fund"] != "ok" and d["sources"]["tech"] != "ok"
+
+
+def main_():
+ ok = 0
+ for name, fn in RESULTS:
+ try:
+ fn()
+ ok += 1
+ print(" ok " + name)
+ except Exception:
+ print(" FAIL " + name)
+ traceback.print_exc()
+ print("-" * 60)
+ if ok == len(RESULTS):
+ print("ALL PASS (%d cases)" % ok)
+ return 0
+ print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main_())