107 lines
4.4 KiB
Python
107 lines
4.4 KiB
Python
"""桥侧计划 API(07-30 用户需求):对外提供每日选股计划。
|
||
|
||
容器常驻命令改为 uvicorn 后随容器启动(docker-compose 已配端口,默认 8300);
|
||
cron 的 docker exec 构建/出计划照旧,互不影响。局域网内部服务,v1 无鉴权。
|
||
|
||
GET /health 存活 + 最新计划日
|
||
GET /plan 最新一天的计划(JSON)
|
||
GET /plan?date=2026-07-30 指定日期
|
||
GET /plan?format=md Markdown 原文(浏览器直接可读)
|
||
GET /plan/dates 可用日期列表
|
||
POST /plan/refresh?date=... 重新生成该日计划文件(data/plan/*.md)
|
||
|
||
统一任务调度平台(XXL-JOB)触发入口挂在 /api/v1/xxl/*(见 xxl.py,2026-08-03):
|
||
盘前链(build → plan → push-pool)可由平台拉起并回调结案,.env 配 XXL_TRIGGER_KEY 才启用。
|
||
"""
|
||
import pandas as pd
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import PlainTextResponse
|
||
|
||
import db
|
||
import plan
|
||
import plan_reconcile
|
||
from xxl import router as xxl_router
|
||
|
||
app = FastAPI(title="akg-factor-bridge · 每日选股计划", version="0.1")
|
||
app.include_router(xxl_router)
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
try:
|
||
d = plan._latest_date("t_factor_akg_score") # noqa: SLF001 —— 桥内自用
|
||
except Exception as e: # noqa: BLE001 —— 库连不上也要能回答"我还活着"
|
||
return {"ok": False, "error": repr(e)}
|
||
return {"ok": True, "latest_plan_date": d}
|
||
|
||
|
||
@app.get("/plan/dates")
|
||
def plan_dates(limit: int = 30):
|
||
df = db.read_mysql(
|
||
"factor", "SELECT DISTINCT trade_date FROM t_factor_akg_score "
|
||
"ORDER BY trade_date DESC LIMIT %s", (int(limit),))
|
||
if df.empty:
|
||
return {"dates": []}
|
||
return {"dates": [pd.Timestamp(x).date().isoformat()
|
||
for x in df["trade_date"]]}
|
||
|
||
|
||
@app.get("/plan")
|
||
def get_plan(date: str | None = None, format: str = "json",
|
||
top: int = 20, obs_top: int = 10, theme_cap: int = 5):
|
||
try:
|
||
data = plan.collect(date, top, obs_top, theme_cap)
|
||
except RuntimeError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
if format == "md":
|
||
return PlainTextResponse(plan.render_md(data),
|
||
media_type="text/markdown; charset=utf-8")
|
||
return data
|
||
|
||
|
||
@app.post("/plan/refresh")
|
||
def refresh(date: str | None = None):
|
||
try:
|
||
out = plan.generate(date)
|
||
except SystemExit as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
return {"ok": True, "file": out}
|
||
|
||
|
||
@app.get("/plan/verdict")
|
||
def plan_verdict(codes: str | None = None, code: str | None = None,
|
||
date: str | None = None):
|
||
"""逐票『计划判决』(只读)——今日页 / 机会线索页对齐用。
|
||
|
||
GET /plan/verdict?codes=300750,600438,SH688041 多只(逗号分隔)
|
||
GET /plan/verdict?code=300750&date=2026-08-04 单只 + 指定档位日
|
||
|
||
每只票返回 decision(main 主榜 / observe 观察档 / reject 不采纳 / absent 无此票)、
|
||
与命令行 plan_reconcile 完全同口径的 verdict_text(页面直接展示的一行解释),
|
||
以及 score/rank/tier/upside/热度/传导/赛道/图谱证据等明细。
|
||
date 缺省=当日档位日。三种代码形态都收(600000.SH / SH600000 / 600000)。纯 SELECT,不写任何库。
|
||
"""
|
||
raw = (codes or code or "").strip()
|
||
want = [c.strip() for c in raw.split(",") if c.strip()]
|
||
if not want:
|
||
raise HTTPException(status_code=400, detail="缺少 code / codes 参数")
|
||
d = date or plan_reconcile.latest_date()
|
||
if not d:
|
||
raise HTTPException(status_code=404, detail="档位表为空——先跑当日构建")
|
||
try:
|
||
L = plan_reconcile._load(d) # noqa: SLF001 —— 桥内自用只读加载
|
||
except Exception as e: # noqa: BLE001 —— 数据层异常统一收成 500
|
||
raise HTTPException(status_code=500, detail=f"加载档位数据失败: {e!r}")
|
||
verdicts = []
|
||
for c in want:
|
||
try:
|
||
k = plan_reconcile._norm_code(c) # noqa: SLF001
|
||
except SystemExit as e: # _norm_code 认不出的形态会 raise SystemExit
|
||
verdicts.append({"input": c, "error": str(e)})
|
||
continue
|
||
v = plan_reconcile.verdict(k, L)
|
||
v["input"] = c
|
||
verdicts.append(v)
|
||
return {"date": d, "stale": L.get("stale", ""),
|
||
"count": len(verdicts), "verdicts": verdicts}
|