页面收尾包:单票研究面接口补齐六块,抽屉四芯片、候选栏芯片、管理研究面列

评审发现六/七:研究面接口只给技术面一块,候选栏与管理视图缺研究面。补齐:
- GET /api/research/{ts_code} 补成六块:公司质地/基本面/技术面/择时/合议/各路来源状态。
  持仓票的买方评析从逻辑状态映射取,候选票从当日计划主榜与观察档取(_research_st),
  四块用 consensus_service.assemble 凑齐,company 块复用 logic_state_service.company_view。
  任一路取不到按无读数弃权,sources 逐路标 ok/无读数/错误。
- 单票抽屉那一节改成「研究面 · 三源合议」四枚芯片(基本面/技术面/择时/合议),
  按立场红涨绿跌、悬停看理由,下面保留技术面详细读数与评析报告链接。
- 候选栏每行加质地芯片与技术芯片:disposition_snapshot 把候选处置快照按代码挂上合议摘要
  (consensus_seen 加 overall),前端用 conOf(code) 取。
- 管理视图持仓总览加研究面列,与「我的持仓」那列同款。

前端三处由两道页面守卫(wiring 204/324 对齐、enum 通过)与本地 uvicorn 浏览器冒烟
(页面完整挂载不白屏)兜;真实数据视觉判收在 155 部署后过目。第三十三批5例,
全量871例 ALL SUITES PASS。未推未部署(随盘中确认包下批一起)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-14 12:18:58 +08:00
parent 64863171f4
commit 2f64274d53
5 changed files with 275 additions and 34 deletions

View File

@ -516,7 +516,9 @@ def _attach_consensus(cands, out=None, now=None) -> None:
rec = {"ts_code": code, "fund": fund.get("stance"), rec = {"ts_code": code, "fund": fund.get("stance"),
"tech": tech.get("stance"), "timing": tm.get("stance"), "tech": tech.get("stance"), "timing": tm.get("stance"),
"direction": con.get("direction"), "route": con.get("route"), "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: if ikind:
rec["intraday"] = ikind rec["intraday"] = ikind
seen.append(rec) seen.append(rec)
@ -705,9 +707,18 @@ def disposition_snapshot(now=None) -> dict:
if s.get("why"): if s.get("why"):
res["notes"].append(s["why"]) res["notes"].append(s["why"])
else: # 单票原因; 不覆盖已判 would 的 else: # 单票原因; 不覆盖已判 would 的
# 合议判观察的候选带 disp=wait_tech (等技术面开口), 其余不产出的按 deny。 # 合议判观察的候选带 disp=wait_tech/wait_confirm, 其余不产出的按 deny。
res["by_code"].setdefault( res["by_code"].setdefault(
code, {"disp": s.get("disp") or "deny", "why": s.get("why") or "未产出候选"}) 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 return res

View File

@ -737,22 +737,60 @@ def api_tech_pull():
return ok_logged("tech_pull", tech_service.pull_and_map) 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}") @app.get("/api/research/{ts_code}")
def api_research(ts_code: str): def api_research(ts_code: str):
"""单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。 """单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。六块: 公司质地 / 基本面 / 技术面 /
择时 / 合议 / 各路来源状态任何一路取不到按无读数弃权 (设计原则二), 页面按缺块处理"""
工作包一先给技术面读数一块; 基本面评析择时立场与三源合议由工作包二补 (现以 None 占位, from app.services import tech_service, consensus_service, logic_state_service
页面按缺块处理)代码归一后回"""
from app.services import tech_service
def _build(code): def _build(code):
code = cs.normalize_code(code) code = cs.normalize_code(code)
t = tech_service.research_feed(code) st, src = _research_st(code)
return {"ts_code": code, "company": None, "tech": t.get("tech"), cr = (st or {}).get("company_review")
"tech_note": t.get("note") or t.get("error"), company = logic_state_service.company_view(st) if st else None
"consensus": None, "timing": None, "feed": None, # 四块意见: assemble 用 company_review 算基本面、用映射算技术面、用昨夜定性算择时, 再合议。
"sources": {"tech": ("ok" if t.get("tech") nightly = consensus_service.nightly_map([code]).get(code)
else (t.get("error") or t.get("note") or "无读数"))}} 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) return ok(_build, ts_code)

View File

@ -1260,6 +1260,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="nm">{{ r.name || nm(r.ts_code) }}</span> <span class="nm">{{ r.name || nm(r.ts_code) }}</span>
<span class="cat" v-if="r.theme">{{ r.theme }}</span> <span class="cat" v-if="r.theme">{{ r.theme }}</span>
<span class="sp"></span> <span class="sp"></span>
<!-- 三源合议两枚芯片 (2026-09-14 页面收尾包): 质地档 + 技术面立场·相位。数据从候选处置的合议摘要来。 -->
<template v-if="conOf(r.ts_code)">
<b v-if="conOf(r.ts_code).overall" :class="conOf(r.ts_code).overall==='好'?'clr-up':(conOf(r.ts_code).overall==='差'?'clr-down':'muted')" :title="'基本面 ' + (conOf(r.ts_code).fund||'无读数')" style="font-size:11.5px">质地{{ conOf(r.ts_code).overall }}</b>
<b :class="techStanceCls(conOf(r.ts_code).tech)" :title="'技术面 ' + (conOf(r.ts_code).tech||'无读数') + ' · ' + (conOf(r.ts_code).phase||'—')" style="font-size:11.5px">{{ conOf(r.ts_code).tech || '技术—' }}</b>
</template>
<span class="disp" :class="dispOf(r.ts_code).cls" style="padding:0 6px">{{ dispOf(r.ts_code).label }}</span> <span class="disp" :class="dispOf(r.ts_code).cls" style="padding:0 6px">{{ dispOf(r.ts_code).label }}</span>
</div> </div>
<div class="muted" style="margin-top:6px;font-size:11.5px"> <div class="muted" style="margin-top:6px;font-size:11.5px">
@ -1724,6 +1729,18 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
class="muted">今天没有读数</span> class="muted">今天没有读数</span>
</template> </template>
</el-table-column> </el-table-column>
<!-- 研究面列 (2026-09-14 页面收尾包): 与「我的持仓」那一列同款, 点技术面立场看研究面抽屉。 -->
<el-table-column label="研究面" width="92">
<template #default="s">
<template v-if="s.row.tech">
<b :class="techStanceCls(s.row.tech.stance)" style="cursor:pointer;text-decoration:underline dotted"
@click.stop="openCodeSignals(s.row.ts_code)"
:title="'技术面:' + s.row.tech.stance + '' + (s.row.tech.phase||'') + '\n' + (s.row.tech.reason||'') + '\n点一下看研究面'">{{ s.row.tech.stance }}</b>
<div class="muted" style="font-size:11px" v-if="s.row.tech.phase">{{ s.row.tech.phase }}</div>
</template>
<span class="muted" v-else>无读数</span>
</template>
</el-table-column>
<el-table-column label="做T" width="80"> <el-table-column label="做T" width="80">
<template #default="s"> <template #default="s">
<el-tag v-if="s.row.t0_enabled" size="small" type="warning" effect="dark"> <el-tag v-if="s.row.t0_enabled" size="small" type="warning" effect="dark">
@ -2074,26 +2091,47 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
<div class="cs-sect" v-if="techFeed || techFeedErr"> <div class="cs-sect" v-if="techFeed || techFeedErr">
<div class="h">研究面 · 技术面<span class="muted" style="font-weight:400" v-if="techFeed && techFeed.tech"> · 读数日 {{ techFeed.tech.data_date }}</span></div> <div class="h">研究面 · 三源合议<span class="muted" style="font-weight:400" v-if="techFeed && techFeed.tech && techFeed.tech.data_date"> · 技术面读数日 {{ techFeed.tech.data_date }}</span></div>
<div class="muted" v-if="techFeedErr">读不到技术面:{{ techFeedErr }}</div> <div class="muted" v-if="techFeedErr">读不到研究面:{{ techFeedErr }}</div>
<div class="muted" v-else-if="!techFeed || !techFeed.tech">{{ (techFeed && techFeed.tech_note) || '这只票没有技术面读数' }}</div> <template v-else-if="techFeed">
<template v-else> <!-- 四枚芯片: 基本面 / 技术面 / 择时 / 合议。按立场红涨绿跌, 无读数灰; 悬停看一句理由。 -->
<div class="cs-row"> <div class="cs-row" style="gap:10px;flex-wrap:wrap">
<span class="s">立场</span> <b :class="techStanceCls((techFeed.fund||{}).stance)" :title="(techFeed.fund||{}).fact || (techFeed.fund||{}).why || ('来源:' + ((techFeed.sources||{}).fund||'—'))">基本面 {{ (techFeed.fund||{}).stance || '无读数' }}<span class="muted" v-if="(techFeed.fund||{}).mark"> · {{ techFeed.fund.mark }}</span></b>
<b :class="techStanceCls(techFeed.tech.stance)">{{ techFeed.tech.stance }}<span v-if="techFeed.tech.strength"> · {{ techFeed.tech.strength }}</span></b> <b :class="techStanceCls((techFeed.tech||{}).stance)" :title="(techFeed.tech||{}).reason || ('来源:' + ((techFeed.sources||{}).tech||'—'))">技术面 {{ (techFeed.tech||{}).stance || '无读数' }}<span class="muted" v-if="(techFeed.tech||{}).phase"> · {{ techFeed.tech.phase }}</span></b>
<span v-if="techFeed.tech.phase">{{ techFeed.tech.phase }}</span> <b :class="techStanceCls((techFeed.timing||{}).stance)" :title="'昨夜定性 ' + ((techFeed.timing||{}).nightly || '—') + '' + ((techFeed.timing||{}).trade_date || '—') + ''">择时 {{ (techFeed.timing||{}).stance || '无读数' }}</b>
<span class="ab-stale" v-if="techFeed.tech.choppy">震荡市</span> <b :class="techStanceCls((techFeed.consensus||{}).direction)" :title="(techFeed.consensus||{}).reason || ''">合议 {{ (techFeed.consensus||{}).direction || '—' }}<span class="muted" v-if="(techFeed.consensus||{}).strength"> · {{ techFeed.consensus.strength }}</span></b>
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted" v-if="techFeed.tech.reason">{{ techFeed.tech.reason }}</div>
</div> </div>
<div class="cs-row" v-if="techFeed.tech.latest"> <div class="cs-row" v-if="(techFeed.consensus||{}).route">
<span class="s">布林</span><span>{{ (techFeed.tech.latest.boll||{}).state || '—' }}</span> <span class="s">路由</span><span>{{ techFeed.consensus.route }}</span>
<span class="muted" v-if="(techFeed.tech.latest.boll||{}).squeeze">收口</span> <span class="muted" v-if="techFeed.consensus.route_reason">{{ techFeed.consensus.route_reason }}</span>
<span class="s">多空布林线</span><span>{{ (techFeed.tech.latest.bbi||{}).state || '—' }}</span>
<span class="s">SAR</span>
<b :class="techFeed.tech.sar_side==='多'?'clr-up':'clr-down'">{{ techFeed.tech.sar_side || '—' }}</b>
<span class="muted" v-if="techFeed.tech.sar_value!=null">止损位 <span class="mono">{{ techFeed.tech.sar_value }}</span></span>
<span class="muted" v-if="techFeed.tech.sar_flip_days!=null">翻向 {{ techFeed.tech.sar_flip_days }} 天</span>
</div> </div>
<div class="cs-row" v-if="techFeed.company">
<span class="s">质地</span><span>{{ techFeed.company.line }}</span>
<a v-if="techFeed.company.report_url" :href="techFeed.company.report_url" target="_blank" rel="noopener">评析报告</a>
<span class="ab-stale" v-if="techFeed.company.doubt_hard">硬疑点</span>
</div>
<div class="cs-row" v-if="(techFeed.company||{}).invalidation">
<span class="s muted">失效条件</span><span class="muted">{{ techFeed.company.invalidation }}</span>
</div>
<template v-if="techFeed.tech">
<div class="cs-row">
<span class="s">技术面</span>
<b :class="techStanceCls(techFeed.tech.stance)">{{ techFeed.tech.stance }}<span v-if="techFeed.tech.strength"> · {{ techFeed.tech.strength }}</span></b>
<span v-if="techFeed.tech.phase">{{ techFeed.tech.phase }}</span>
<span class="ab-stale" v-if="techFeed.tech.choppy">震荡市</span>
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted" v-if="techFeed.tech.reason">{{ techFeed.tech.reason }}</div>
</div>
<div class="cs-row" v-if="techFeed.tech.latest">
<span class="s">布林</span><span>{{ (techFeed.tech.latest.boll||{}).state || '—' }}</span>
<span class="muted" v-if="(techFeed.tech.latest.boll||{}).squeeze">收口</span>
<span class="s">多空布林线</span><span>{{ (techFeed.tech.latest.bbi||{}).state || '—' }}</span>
<span class="s">SAR</span>
<b :class="techFeed.tech.sar_side==='多'?'clr-up':'clr-down'">{{ techFeed.tech.sar_side || '—' }}</b>
<span class="muted" v-if="techFeed.tech.sar_value!=null">止损位 <span class="mono">{{ techFeed.tech.sar_value }}</span></span>
<span class="muted" v-if="techFeed.tech.sar_flip_days!=null">翻向 {{ techFeed.tech.sar_flip_days }} 天</span>
</div>
</template>
<div class="muted" v-else>{{ (techFeed.sources||{}).tech || '这只票没有技术面读数' }}</div>
</template> </template>
</div> </div>
@ -2888,6 +2926,9 @@ createApp({
} }
return { cls: 'wait', label: '在盯', why: '' }; 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 denyToday = computed(() => (planRows.value || []).map(r => {
const d = dispOf(r.ts_code); 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; 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, autoScanRes, autoScanBusy, autoScanPreview, stageTagType, autoInfo,
openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy, openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy,
showLedger, onPosExpand, 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, commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr,
cmdArchivable, stratArchivable, archiveRow, cmdArchivable, stratArchivable, archiveRow,
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh, showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,

View File

@ -98,16 +98,19 @@
取不到按等待/时段外开口进观察)/in_window reask_rules 同源哨兵/synthesize 取不到按等待/时段外开口进观察)/in_window reask_rules 同源哨兵/synthesize
_compact boll_upper/接线突破重合议为放行且硬数字带 intraday/开口未确认 _compact boll_upper/接线突破重合议为放行且硬数字带 intraday/开口未确认
路由改观察带 wait_confirm/总闸关掉 _intraday_cfg None/参数登记与范围 (19 ) 路由改观察带 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 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 ) 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
866 871
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 (总数按实跑逐批相加校正过两次: 曾写 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; 减持与研究理由两键各一例, 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) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (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_batch30_units.py",
"test_batch31_units.py", "test_batch31_units.py",
"test_batch32_units.py", "test_batch32_units.py",
"test_batch33_units.py",
"test_page_enum_guard.py", "test_page_enum_guard.py",
"test_page_wiring_guard.py", "test_page_wiring_guard.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -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_())