148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
# -*- 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_())
|