实时计划接口加当日缓存:装配要三四十秒,PMS 页面一次加载拉几路就超时;键带真实数据日,默认十分钟,nocache=1 强制重算,重新生成计划时清空

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-09 13:43:22 +08:00
parent 85b025a9c5
commit 12a2ab159d
1 changed files with 54 additions and 5 deletions

59
api.py
View File

@ -15,6 +15,8 @@ cron 的 docker exec 构建/出计划照旧,互不影响。局域网内部服
""" """
import logging import logging
import os import os
import time
import threading
import pandas as pd import pandas as pd
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@ -59,18 +61,62 @@ def plan_dates(limit: int = 30):
for x in df["trade_date"]]} for x in df["trade_date"]]}
# ---------------------------------------------------------------------------
# 实时计划的进程内缓存2026-09-09。装配一次要三四十秒读基座因果论断 20 秒、四类券商
# 事件 7.6 秒、事件日字段 6 秒09-08 加的两路PMS 页面一次加载要拉几路,累计超过它
# 60 秒的超时,人在页面上看到的就是"上游的选股计划读不到"。
# 同一数据日、同一参数的装配结果在一天里是确定的(用的全是昨收与昨夜的数据,盘中不变),
# 所以缓存是安全的键带数据日与三个参数过期时间可配默认十分钟nocache=1 强制重算。
# 计划文件每早重新生成,数据日一变缓存自然失效。
# ---------------------------------------------------------------------------
_PLAN_CACHE: dict = {}
_PLAN_CACHE_LOCK = threading.Lock()
PLAN_CACHE_SEC = int(os.environ.get("PLAN_CACHE_SEC", "600"))
_PLAN_CACHE_MAX = 8 # 不同参数组合最多留几份,防内存慢涨
def _plan_cached(date, top, obs_top, theme_cap, nocache: bool):
"""返回 (data, 命中与否)。data 是 plan.collect 的结果(已去掉 _full
键里放**真实数据日**而不是请求里的 date不传 date 时数据日随构建更新明早换日后
旧那一份必须立刻失效不能等缓存到期取最新数据日是一条毫秒级查询"""
ds_key = date
if not ds_key:
try:
ds_key = plan._latest_date("t_factor_akg_score") # noqa: SLF001 —— 同仓自用
except Exception: # noqa: BLE001 —— 取不到就退回按请求参数缓存
ds_key = ""
key = (ds_key or "", int(top), int(obs_top), int(theme_cap))
now = time.time()
if not nocache and PLAN_CACHE_SEC > 0:
with _PLAN_CACHE_LOCK:
hit = _PLAN_CACHE.get(key)
if hit and now - hit[0] < PLAN_CACHE_SEC:
return hit[1], True
data = plan.collect(date, top, obs_top, theme_cap)
data.pop("_full", None)
if PLAN_CACHE_SEC > 0:
with _PLAN_CACHE_LOCK:
_PLAN_CACHE[key] = (now, data)
if len(_PLAN_CACHE) > _PLAN_CACHE_MAX:
for k in sorted(_PLAN_CACHE, key=lambda x: _PLAN_CACHE[x][0])[:-_PLAN_CACHE_MAX]:
_PLAN_CACHE.pop(k, None)
return data, False
@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): top: int = 20, obs_top: int = 10, theme_cap: int = 5, nocache: int = 0):
"""向下兼容承诺2026-09-02 方案 2.7main / observe 的装配、排序、裁剪与既有字段 """向下兼容承诺2026-09-02 方案 2.7main / observe 的装配、排序、裁剪与既有字段
一字不动每行只多联入判决类字段顶层只新增 generated_atplan_versionregime 一字不动每行只多联入判决类字段顶层只新增 generated_atplan_versionregime
card_countscandidateswatchsegments_pointedsnapshot2026-09-03 再加 market card_countscandidateswatchsegments_pointedsnapshot2026-09-03 再加 market
环境段市场四项 regime 一样只从当日快照读快照缺失为空字典PMS 按字段名取值忽略未知键""" 环境段市场四项 regime 一样只从当日快照读快照缺失为空字典PMS 按字段名取值忽略未知键"""
t0 = time.time()
try: try:
data = plan.collect(date, top, obs_top, theme_cap) data, cached = _plan_cached(date, top, obs_top, theme_cap, bool(nocache))
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
data.pop("_full", None) data = dict(data) # 下面几个键按请求写,不污染缓存里那一份
ds = data["date"] ds = data["date"]
reg = regime.read_from_snapshot(ds) reg = regime.read_from_snapshot(ds)
data["snapshot"] = "present" if os.path.exists(regime.snapshot_path(ds)) else "missing" data["snapshot"] = "present" if os.path.exists(regime.snapshot_path(ds)) else "missing"
@ -78,10 +124,11 @@ def get_plan(request: Request, date: str | None = None, format: str = "json",
"source": "当日快照无环境段08:45 追加未跑或快照缺失)"} "source": "当日快照无环境段08:45 追加未跑或快照缺失)"}
data["market"] = regime.read_section(ds, "market") or {} data["market"] = regime.read_section(ds, "market") or {}
_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", "top=%s obs_top=%s theme_cap=%s cache=%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", 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")
@ -94,6 +141,8 @@ def refresh(date: str | None = None):
out = plan.generate(date) out = plan.generate(date)
except SystemExit as e: except SystemExit as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
with _PLAN_CACHE_LOCK: # 重新生成了计划,实时接口的缓存整体作废
_PLAN_CACHE.clear()
return {"ok": True, "file": out} return {"ok": True, "file": out}