akg-factor-bridge/test_plan_reviews_live.py

173 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""计划接口按请求实时补个股深度评析2026-09-10
## 为什么要有这一段
个股深度评析是全天在跑的(数据基座每小时一批),而计划一天只装配一次、
接口又带十二小时缓存。于是装配那一刻还没出报告的票,当天就再也补不上。
2026-09-10 实测:早上七点十分那份计划里 154 行只有 7 只带评析;中午十二点二十三
到三十五分之间新跑出五份报告而这五只688556.SH、605598.SH、300118.SZ、
688698.SH、300638.SZ正是持仓管理系统当天出提议的票 —— 它当天一份都看不到,
卡片上写的全是「数据基座还没出这家的报告」。
三层原因叠在一起:评析的批次时刻晚于出计划、计划一天只装配一次、接口缓存十二小时。
实时补充一次解掉后两层:评析什么时候跑完都行,跑完下一次请求就带上。
## 这里钉住什么
一、有新报告的行会被补上(结构化字段与整句都补)。
二、**补充绝不能污染缓存**。get_plan 里的 dict(data) 只拷了顶层main / observe
里的行对象与缓存里那一份是同一批;不先拷贝就改,等于把当次请求的结果写进缓存,
全天所有人都会拿到它。这一条是这个文件存在的主要理由。
三、上游读不到时静默沿用装配时那一份,不抛异常、不拖垮计划接口。
四、没有任何一只票有新报告时,行对象原样返回,不做无谓的拷贝。
离线,不连库不起服务(上游取数用替身)。但要在容器里跑 —— api.py 的依赖链里有
`str | None` 这种写法,需要 Python 3.10 以上,而开发机是 3.9。
跑法(在 155 上):
docker exec akg_factor_bridge python3 test_plan_reviews_live.py
预期最后一行是 ALL OK。
"""
import sys
import types
for _n in ("pandas", "pymysql", "psycopg"):
if _n not in sys.modules:
try:
__import__(_n)
except Exception: # noqa: BLE001
_m = types.ModuleType(_n)
if _n == "pandas":
_m.DataFrame = type("DataFrame", (), {})
sys.modules[_n] = _m
# 开发机上多半没装 fastapi它只在容器里跑。造一个够用的替身
# 这样这份测试在开发机与容器里都能跑,测的仍然是 api.py 里的真代码。
if "fastapi" not in sys.modules:
try:
__import__("fastapi")
except Exception: # noqa: BLE001
_fa = types.ModuleType("fastapi")
class _StubApp:
def get(self, *a, **k):
return lambda f: f
def post(self, *a, **k):
return lambda f: f
_fa.FastAPI = lambda *a, **k: _StubApp()
_fa.HTTPException = type("HTTPException", (Exception,), {})
_fa.Request = type("Request", (), {})
sys.modules["fastapi"] = _fa
_fr = types.ModuleType("fastapi.responses")
_fr.PlainTextResponse = type("PlainTextResponse", (), {})
sys.modules["fastapi.responses"] = _fr
import api # noqa: E402
import sources # noqa: E402
def t(name, cond, extra=""):
print((" ok " if cond else " FAIL ") + name + ((" " + str(extra)) if not cond else ""))
assert cond, name
def _plan(with_review_on=()):
"""造一份计划main 两行、observe 一行,指定哪些代码在装配时就已经有评析。"""
def row(code):
r = {"code": code, "name": code, "rank": 1}
if code in with_review_on:
r["company_review"] = {"overall": "", "period": "2025Q4"}
r["company_review_text"] = "公司深度(旧那一份)"
return r
return {"date": "2026-09-09",
"main": [row("SH688556"), row("SZ300118")],
"observe": [row("SZ300638")]}
def _fake_reviews(hit: dict):
"""替身sources.company_reviews 的返回形状是 {前缀码: 摘要字典},没报告的票不在里面。"""
def f(codes, ds, **kw):
return {c: hit[c] for c in codes if c in hit}
return f
def main():
print("=" * 62)
print("计划接口实时补评析")
print("=" * 62)
orig = sources.company_reviews
try:
# 一、有新报告的行被补上
print("\n[A] 补上新报告")
data = _plan()
sources.company_reviews = _fake_reviews({
"SH688556": {"overall": "", "period": "2026Q1", "thesis": "论点一句"},
"SZ300638": {"overall": "", "period": "2026Q1"},
})
n = api._attach_fresh_reviews(data, "2026-09-09")
t("补了两只(主榜一只、观察档一只)", n == 2, n)
t("主榜那只拿到结构化字段", (data["main"][0].get("company_review") or {}).get("overall") == "")
t("主榜那只拿到整句", "公司深度" in (data["main"][0].get("company_review_text") or ""))
t("观察档那只也补上了", (data["observe"][0].get("company_review") or {}).get("overall") == "")
t("没报告的那只原样不动", "company_review" not in data["main"][1])
# 二、不污染缓存 —— 这一条是重点
print("\n[B] 不许污染缓存(行对象与缓存共用,必须先拷贝再改)")
cached = _plan()
keep_main0 = cached["main"][0] # 记住缓存里那个行对象本身
keep_obs0 = cached["observe"][0]
served = dict(cached) # get_plan 里就是这么浅拷一层
sources.company_reviews = _fake_reviews({
"SH688556": {"overall": "", "period": "2026Q1"},
"SZ300638": {"overall": "", "period": "2026Q1"},
})
api._attach_fresh_reviews(served, "2026-09-09")
t("这次请求拿到了补充", (served["main"][0].get("company_review") or {}).get("overall") == "")
t("缓存里那个行对象没被写进 company_review", "company_review" not in keep_main0)
t("缓存里观察档那行也没被写", "company_review" not in keep_obs0)
t("补过之后返回的是新的行对象", served["main"][0] is not keep_main0)
t("缓存里那份列表本身没被改", cached["main"][0] is keep_main0)
# 三、覆盖旧版本
print("\n[C] 装配时是旧报告,现在有新的 → 覆盖")
data = _plan(with_review_on=("SH688556",))
sources.company_reviews = _fake_reviews({"SH688556": {"overall": "", "period": "2026Q2"}})
api._attach_fresh_reviews(data, "2026-09-09")
t("覆盖成新那一份", (data["main"][0].get("company_review") or {}).get("overall") == "")
t("整句跟着换", "旧那一份" not in (data["main"][0].get("company_review_text") or ""))
# 四、上游读不到 → 静默沿用,不抛
print("\n[D] 上游读不到时不许拖垮计划接口")
data = _plan(with_review_on=("SH688556",))
def _boom(codes, ds, **kw):
raise RuntimeError("PG 连不上")
sources.company_reviews = _boom
n = api._attach_fresh_reviews(data, "2026-09-09")
t("补了零只、没有抛异常", n == 0, n)
t("装配时那一份原样保留", (data["main"][0].get("company_review") or {}).get("overall") == "")
# 五、一只都没有新报告 → 不做无谓拷贝
print("\n[E] 一只都没有新报告时不动行对象")
data = _plan()
keep = data["main"][0]
sources.company_reviews = _fake_reviews({})
n = api._attach_fresh_reviews(data, "2026-09-09")
t("补了零只", n == 0, n)
t("行对象原样,没有多余拷贝", data["main"][0] is keep)
# 六、空计划不炸
print("\n[F] 空计划")
n = api._attach_fresh_reviews({"date": "2026-09-09", "main": [], "observe": []}, "2026-09-09")
t("补了零只、没有抛异常", n == 0, n)
finally:
sources.company_reviews = orig
print("\nALL OK")
if __name__ == "__main__":
main()