"""计划接口按请求实时补个股深度评析(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()