计划接口按请求实时补个股深度评析:评析什么时候跑完都算数

用户问:今天要计划的股票理论上应该优先做买方评析,为什么没有。查下来是三层
原因叠在一起,前一层今天已经解掉,这里解掉后两层。

第一层(今天已解)。评析原先每天凌晨 00:45 跑一批二十家,而当天的计划要
07:10 才出,所以它读到的永远是前一天的名单。今天改成全天每小时一批之后,
07:10 之后的批次能读到当天名单了。

第二层。计划一天只装配一次。07:10 出计划时读一次评析库,之后新产出的报告
再也进不了那份快照。

第三层。计划接口带十二小时缓存(今天早上为修页面超时从十分钟改上去的),
于是连「重新装配一次」这条路也堵上了 —— 原先十分钟过期重算,新报告十分钟内
能进;现在要等十二小时。这一层是今天早上那笔改动的副作用。

实测证据。今天早上七点十分那份计划里 154 行只有 7 只带评析。中午十二点二十三
到三十五分之间新跑出五份报告,而这五只(688556.SH 名单第 2、605598.SH 第 7、
300118.SZ 第 8、688698.SH 第 11、300638.SZ 第 13)正是持仓管理系统当天出提议的
票 —— 它当天一份都看不到,卡片上写的全是「数据基座还没出这家的报告」。

为什么不靠排期解决。查过两天名单的重合度:09-08 与 09-09 只重合 66 只 = 55%,
前 40 名里只有 46% 重合,每天有 54 只是新进名单的。也就是说「凌晨提前跑前一天
的名单」最多覆盖一半,剩下一半当天无论如何都赶不上 —— 名单本身就是计划的产物,
不出计划就不知道要跑谁。

修法。在 /plan 返回时实时补一次,与 regime、market 两个键同一个路子:按请求补、
不进缓存。这样评析什么时候跑完都行,跑完下一次请求就带上;计划缓存多久、走快照
还是现算,都不再影响它。代价是每次请求多一条批量数据库查询。

最要小心的是浅拷贝:get_plan 里的 dict(data) 只拷了顶层,main / observe 里的行
对象与缓存里那一份是同一批,不先拷贝就改等于把当次请求的结果写进缓存、污染全天。
新增的 test_plan_reviews_live.py 六组用例里,第二组专门钉这一条。

访问日志加了「评析补 N 只」,事后能核对。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-10 13:17:22 +08:00
parent 4f1700ae60
commit bccc2e5970
2 changed files with 222 additions and 2 deletions

52
api.py
View File

@ -151,6 +151,53 @@ def _plan_cached(date, top, obs_top, theme_cap, nocache: bool):
return data, False
def _attach_fresh_reviews(data: dict, ds: str) -> int:
"""把个股深度评析按请求实时补一次,返回补了几只。
为什么非补不可评析是全天在跑的数据基座每小时一批而计划一天只装配
一次这个接口又带十二小时缓存于是装配那一刻还没出报告的票当天就再也
补不上了 2026-09-10 实测早上七点十分那份计划里 154 行只有 7 只带评析
中午十二点二十三到三十五分之间新跑出五份报告而这五只正是持仓管理系统当天
出提议的票它当天一份都看不到卡片上写的全是数据基座还没出这家的报告
三层原因叠在一起评析的批次时刻晚于出计划计划一天只装配一次接口缓存
十二小时这一段一次解掉后两层 评析什么时候跑完都行跑完下一次请求就带上
做法与上面 regimemarket 两个键同一个路子按请求补不进缓存代价是每次
请求多一条数据库查询一次批量取不是逐票取
注意浅拷贝get_plan 里的 dict(data) 只拷了顶层main / observe 两个列表里的
行对象与缓存里那一份是同一批要改行必须先把那一行拷出来否则会把当次
请求的结果写进缓存污染全天
"""
rows = list(data.get("main") or []) + list(data.get("observe") or [])
codes = [r.get("code") for r in rows if isinstance(r, dict) and r.get("code")]
if not codes:
return 0
try:
fresh = sources.company_reviews(codes, ds)
except Exception as e: # noqa: BLE001 —— 评析读不到绝不拖垮计划接口
_access.warning("plan 评析实时补充失败, 本次沿用装配时那一份: %r", e)
return 0
if not fresh:
return 0
n = 0
for key in ("main", "observe"):
out, changed = [], False
for r in (data.get(key) or []):
cr = fresh.get(r.get("code")) if isinstance(r, dict) else None
if cr:
r = dict(r) # 先拷贝再改, 别碰缓存里那一份
r["company_review"] = cr
r["company_review_text"] = card.company_review_view(cr)
changed = True
n += 1
out.append(r)
if changed:
data[key] = out
return n
@app.get("/plan")
def get_plan(request: Request, date: str | None = None, format: str = "json",
top: int = 20, obs_top: int = 10, theme_cap: int = 5, nocache: int = 0):
@ -170,13 +217,14 @@ def get_plan(request: Request, date: str | None = None, format: str = "json",
data["regime"] = reg or {"status": regime.UNKNOWN, "weak_day": None,
"source": "当日快照无环境段08:45 追加未跑或快照缺失)"}
data["market"] = regime.read_section(ds, "market") or {}
n_cr = _attach_fresh_reviews(data, ds)
_access.info("plan client=%s date=%s regime=%s generated_at=%s version=%s "
"top=%s obs_top=%s theme_cap=%s cache=%s source=%s 耗时=%.1fs",
"top=%s obs_top=%s theme_cap=%s cache=%s source=%s 评析补%s耗时=%.1fs",
request.client.host if request.client else "-", ds,
data["regime"].get("status"), data.get("generated_at"),
data.get("plan_version"), top, obs_top, theme_cap,
"hit" if cached else "miss", data.get("plan_source") or "-",
time.time() - t0)
n_cr, time.time() - t0)
if format == "md":
return PlainTextResponse(plan.render_md(data),
media_type="text/markdown; charset=utf-8")

172
test_plan_reviews_live.py Normal file
View File

@ -0,0 +1,172 @@
"""计划接口按请求实时补个股深度评析2026-09-10
## 为什么要有这一段
个股深度评析是全天在跑的数据基座每小时一批而计划一天只装配一次
接口又带十二小时缓存于是装配那一刻还没出报告的票当天就再也补不上
2026-09-10 实测早上七点十分那份计划里 154 行只有 7 只带评析中午十二点二十三
到三十五分之间新跑出五份报告而这五只688556.SH605598.SH300118.SZ
688698.SH300638.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()