计划接口按请求实时补个股深度评析:评析什么时候跑完都算数
用户问:今天要计划的股票理论上应该优先做买方评析,为什么没有。查下来是三层 原因叠在一起,前一层今天已经解掉,这里解掉后两层。 第一层(今天已解)。评析原先每天凌晨 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:
parent
4f1700ae60
commit
bccc2e5970
52
api.py
52
api.py
|
|
@ -151,6 +151,53 @@ def _plan_cached(date, top, obs_top, theme_cap, nocache: bool):
|
||||||
return data, False
|
return data, False
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_fresh_reviews(data: dict, ds: str) -> int:
|
||||||
|
"""把个股深度评析按请求实时补一次,返回补了几只。
|
||||||
|
|
||||||
|
为什么非补不可。评析是全天在跑的(数据基座每小时一批),而计划一天只装配
|
||||||
|
一次、这个接口又带十二小时缓存。于是装配那一刻还没出报告的票,当天就再也
|
||||||
|
补不上了 —— 2026-09-10 实测:早上七点十分那份计划里 154 行只有 7 只带评析;
|
||||||
|
中午十二点二十三到三十五分之间新跑出五份报告,而这五只正是持仓管理系统当天
|
||||||
|
出提议的票,它当天一份都看不到,卡片上写的全是「数据基座还没出这家的报告」。
|
||||||
|
|
||||||
|
三层原因叠在一起:评析的批次时刻晚于出计划、计划一天只装配一次、接口缓存
|
||||||
|
十二小时。这一段一次解掉后两层 —— 评析什么时候跑完都行,跑完下一次请求就带上。
|
||||||
|
|
||||||
|
做法与上面 regime、market 两个键同一个路子:按请求补、不进缓存。代价是每次
|
||||||
|
请求多一条数据库查询(一次批量取,不是逐票取)。
|
||||||
|
|
||||||
|
注意浅拷贝: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")
|
@app.get("/plan")
|
||||||
def get_plan(request: Request, date: str | None = None, format: str = "json",
|
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):
|
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,
|
data["regime"] = reg or {"status": regime.UNKNOWN, "weak_day": None,
|
||||||
"source": "当日快照无环境段(08:45 追加未跑或快照缺失)"}
|
"source": "当日快照无环境段(08:45 追加未跑或快照缺失)"}
|
||||||
data["market"] = regime.read_section(ds, "market") or {}
|
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 "
|
_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,
|
request.client.host if request.client else "-", ds,
|
||||||
data["regime"].get("status"), data.get("generated_at"),
|
data["regime"].get("status"), data.get("generated_at"),
|
||||||
data.get("plan_version"), top, obs_top, theme_cap,
|
data.get("plan_version"), top, obs_top, theme_cap,
|
||||||
"hit" if cached else "miss", data.get("plan_source") or "-",
|
"hit" if cached else "miss", data.get("plan_source") or "-",
|
||||||
time.time() - t0)
|
n_cr, time.time() - t0)
|
||||||
if format == "md":
|
if format == "md":
|
||||||
return PlainTextResponse(plan.render_md(data),
|
return PlainTextResponse(plan.render_md(data),
|
||||||
media_type="text/markdown; charset=utf-8")
|
media_type="text/markdown; charset=utf-8")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""计划接口按请求实时补个股深度评析(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()
|
||||||
Loading…
Reference in New Issue