2026-07-27 16:02:43 +08:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
2026-07-27 17:12:09 +08:00
|
|
|
PMS 管理页面 · Web 入口 (FastAPI + 单页)
|
|
|
|
|
=========================================
|
|
|
|
|
设计 §3.3 四块: 参数设置 / 命令台 / 持仓与账本 / 提议确认。
|
|
|
|
|
|
|
|
|
|
工程原则:
|
|
|
|
|
* 任何后端异常都不得让页面开不了 —— 全部 API 走 `ok(...)` 包装, 失败返回
|
|
|
|
|
{"ok": false, "error": "..."} 且 HTTP 200, 由前端在顶部横幅提示。
|
|
|
|
|
* 页面只读参数一律经 ParamStore (表值优先), 改参即持久化到 pms_runtime_param。
|
|
|
|
|
* 手动运维按钮 (回放/对账/日终/日报) 与调度器调用同一份服务函数, 便于未接调度时先验证。
|
2026-07-27 16:02:43 +08:00
|
|
|
"""
|
2026-07-27 17:12:09 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-26 10:11:29 +08:00
|
|
|
import json
|
2026-07-27 17:12:09 +08:00
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
2026-08-26 16:51:11 +08:00
|
|
|
from fastapi import Body, FastAPI, Query, Request
|
2026-07-27 17:12:09 +08:00
|
|
|
from fastapi.responses import FileResponse, JSONResponse
|
2026-07-27 16:02:43 +08:00
|
|
|
|
|
|
|
|
from config.settings import settings
|
2026-07-27 17:12:09 +08:00
|
|
|
from app.core import command_spec as cs
|
|
|
|
|
from app.core import tradedays as td
|
|
|
|
|
from app.db import session as dbs
|
|
|
|
|
from app.repo import downstream_repo, pms_repo
|
2026-09-07 14:37:35 +08:00
|
|
|
from app.services import (command_service, industry, ledger_service, logic_state_service,
|
|
|
|
|
param_store, portfolio)
|
2026-08-26 16:51:11 +08:00
|
|
|
from app.web import auth as authmod
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
|
|
|
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
|
|
|
|
logger = logging.getLogger("pms.web")
|
2026-07-27 16:02:43 +08:00
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
VERSION = "0.2.0-dev"
|
|
|
|
|
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
2026-07-27 16:02:43 +08:00
|
|
|
|
|
|
|
|
app = FastAPI(title="PMS 持仓管理系统", version=VERSION)
|
|
|
|
|
|
2026-07-29 10:01:00 +08:00
|
|
|
# 把 static 目录挂到 /static —— 内网隔离时的离线兜底:
|
|
|
|
|
# 页面默认从 unpkg 取 Vue3 / ElementPlus / axios, 浏览器上不了外网就会白屏 (更准确地说是
|
|
|
|
|
# 只剩一个光秃秃的头部, 因为 el-* 组件全渲染不出来)。把三个库放进 static/vendor/ 再把
|
|
|
|
|
# index.html 头部那五个 URL 换成 /static/vendor/xxx 即可离线运行, 无需构建步骤。
|
|
|
|
|
# 具体做法见 README「管理页面」一节。
|
|
|
|
|
try:
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
if os.path.isdir(STATIC_DIR):
|
|
|
|
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
|
|
|
except Exception as _e: # 挂不上不影响任何 API, 页面照常从 CDN 取
|
|
|
|
|
logger.warning("静态目录挂载失败 (不影响接口): %s", _e)
|
|
|
|
|
|
2026-07-27 16:02:43 +08:00
|
|
|
|
2026-08-26 16:51:11 +08:00
|
|
|
# ================================================================ 登录闸 (中间件)
|
|
|
|
|
@app.middleware("http")
|
|
|
|
|
async def _auth_guard(request: Request, call_next):
|
|
|
|
|
"""除公开白名单外都要有效会话票; 交易员碰管理员接口回 403。
|
|
|
|
|
关键: 中间件里只 return JSONResponse, 不 raise —— 在这里抛异常会变成 500 而非 401/403。"""
|
|
|
|
|
if not settings.PMS_AUTH_ENABLED:
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
path = request.url.path
|
|
|
|
|
if authmod.is_public_path(path):
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
if not settings.PMS_SESSION_SECRET:
|
|
|
|
|
# 开着登录却没配密钥: 失败即关门, 绝不用空密钥签/验票
|
|
|
|
|
logger.error("PMS_AUTH_ENABLED 开着但 PMS_SESSION_SECRET 为空 —— 受保护请求一律拒绝")
|
|
|
|
|
return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"},
|
|
|
|
|
status_code=503)
|
|
|
|
|
sess = authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE),
|
|
|
|
|
settings.PMS_SESSION_SECRET)
|
|
|
|
|
if not sess:
|
|
|
|
|
return JSONResponse({"ok": False, "error": "未登录或登录已过期", "auth": "login_required"},
|
|
|
|
|
status_code=401)
|
|
|
|
|
if (authmod.authz_decision(request.method, path) == "admin"
|
2026-08-27 10:16:45 +08:00
|
|
|
and not authmod.is_admin(sess.get("roles"))):
|
|
|
|
|
return JSONResponse({"ok": False, "error": "无权限: 该操作需要系统管理员", "auth": "forbidden"},
|
2026-08-26 16:51:11 +08:00
|
|
|
status_code=403)
|
|
|
|
|
request.state.user = sess
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
def ok(fn, *args, **kw):
|
|
|
|
|
"""统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。"""
|
|
|
|
|
try:
|
|
|
|
|
data = fn(*args, **kw)
|
|
|
|
|
if isinstance(data, dict) and "ok" in data:
|
|
|
|
|
return data
|
|
|
|
|
return {"ok": True, "data": data}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("API 失败: %s", getattr(fn, "__name__", fn))
|
|
|
|
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 13:07:27 +08:00
|
|
|
def _actor(request: Request, payload: dict = None) -> str:
|
|
|
|
|
"""操作人一律取**会话里的登录身份** (2026-08-28 审查修): 操作日志是问责凭证,
|
|
|
|
|
原来所有写接口的操作人取自请求体 payload.by —— 任何登录用户都能在 JSON 里写别人的
|
|
|
|
|
名字, 把下单撤单记到他人头上。认证身份中间件已放在 request.state.user, 就用它;
|
|
|
|
|
关闭登录 (PMS_AUTH_ENABLED=False) 时才退回请求体与 "user" 兜底。"""
|
|
|
|
|
u = getattr(request.state, "user", None) or {}
|
|
|
|
|
name = str(u.get("username") or u.get("phone") or "").strip()
|
|
|
|
|
if name:
|
|
|
|
|
return name
|
|
|
|
|
return str((payload or {}).get("by") or "user")
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 11:58:16 +08:00
|
|
|
def _oplog(op, *, ok_flag, reason=None, ts_code=None, params=None, ref=None, by="user"):
|
|
|
|
|
"""交易员操作日志: 每个写操作落一行 (OK/BLOCKED 都记, 含原因)。日志失败不影响操作本体 (但记 logger)。"""
|
|
|
|
|
try:
|
|
|
|
|
pms_repo.insert_op_log(op=op, by=by, ts_code=ts_code,
|
|
|
|
|
result=("OK" if ok_flag else "BLOCKED"),
|
|
|
|
|
reason=reason, params=params, ref=ref)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("[op_log] 写操作日志失败 op=%s: %s (操作本体不受影响)", op, e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ok_logged(op, fn, *args, ts_code=None, params=None, by="user", **kw):
|
|
|
|
|
"""跑 fn (经 ok 包装) 并按结果落一条操作日志。ok=False -> BLOCKED + error/errors 作原因。"""
|
|
|
|
|
data = ok(fn, *args, **kw)
|
|
|
|
|
okf = not (isinstance(data, dict) and data.get("ok") is False)
|
|
|
|
|
reason = None
|
|
|
|
|
if not okf:
|
|
|
|
|
reason = "; ".join(data.get("errors") or []) or data.get("error") or "被约束挡下"
|
|
|
|
|
ref = None
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
ref = (data.get("command_id") or data.get("instruction_id")
|
|
|
|
|
or data.get("strategy_id") or data.get("proposal_id"))
|
|
|
|
|
_oplog(op, ok_flag=okf, reason=reason, ts_code=ts_code, params=params, ref=ref, by=by)
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
# ================================================================ 基础
|
2026-07-27 16:02:43 +08:00
|
|
|
@app.get("/health")
|
|
|
|
|
def health():
|
2026-08-28 13:07:27 +08:00
|
|
|
"""容器健康检查 (**免登录, 只回布尔级状态**)。
|
|
|
|
|
|
|
|
|
|
2026-08-28 审查修: 原来免登录就吐总操作规模、各仓位上限、自主档位和数据库错误串
|
|
|
|
|
(可能带内网地址) —— 对一套下真钱的系统, 这些是未认证不该看到的。健康检查只需要
|
|
|
|
|
活不活; 完整自证挪到管理员专属的 /api/ops/health-detail。"""
|
|
|
|
|
db = dbs.ping("proxy")
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok" if db["ok"] else "degraded",
|
|
|
|
|
"version": VERSION, "service": "pms-web",
|
|
|
|
|
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
"trade_day": td.is_trade_day(), "calendar_degraded": td.calendar_degraded(),
|
|
|
|
|
"db_ok": bool(db.get("ok")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/ops/health-detail")
|
|
|
|
|
def api_health_detail():
|
|
|
|
|
"""完整自证 (系统管理员): 配置装载、库连通、行业源、下发通道 —— 原 /health 的全量内容。"""
|
2026-07-27 17:12:09 +08:00
|
|
|
db = dbs.ping("proxy")
|
2026-07-27 16:02:43 +08:00
|
|
|
return {
|
2026-07-27 17:12:09 +08:00
|
|
|
"status": "ok" if db["ok"] else "degraded",
|
|
|
|
|
"version": VERSION, "service": "pms-web",
|
|
|
|
|
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
"trade_day": td.is_trade_day(), "calendar_degraded": td.calendar_degraded(),
|
|
|
|
|
"db_proxy": db,
|
2026-07-27 16:02:43 +08:00
|
|
|
"config_loaded": {
|
2026-07-27 17:12:09 +08:00
|
|
|
"total_scale": param_store.get("PMS_TOTAL_SCALE"),
|
|
|
|
|
"portfolio_cap": param_store.get("PMS_PORTFOLIO_CAP"),
|
|
|
|
|
"stock_cap": param_store.get("PMS_STOCK_CAP"),
|
|
|
|
|
"max_names": param_store.get("PMS_MAX_NAMES"),
|
|
|
|
|
"autonomy": param_store.get("PMS_AUTONOMY"),
|
2026-07-27 16:02:43 +08:00
|
|
|
"web_port": settings.PMS_WEB_PORT,
|
|
|
|
|
},
|
2026-07-27 17:12:09 +08:00
|
|
|
"sector": industry.status(),
|
2026-07-28 15:48:57 +08:00
|
|
|
"dispatch": _dispatch_health(),
|
2026-07-27 16:02:43 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
def _dispatch_health() -> dict:
|
|
|
|
|
"""下发通道健康快照。通道读不到不能让 /health 挂 —— 容器健康检查靠它。"""
|
|
|
|
|
try:
|
|
|
|
|
from app.services import dispatcher
|
|
|
|
|
d = dispatcher.describe()
|
|
|
|
|
ch = d.get("channel") or {}
|
|
|
|
|
return {"mode": d["mode"], "online": ch.get("online"),
|
|
|
|
|
"conn_state": ch.get("conn_state"), "queued": (ch.get("queue") or {}).get("QUEUED", 0),
|
|
|
|
|
"hint": d.get("hint")}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 16:02:43 +08:00
|
|
|
@app.get("/")
|
|
|
|
|
def index():
|
2026-07-27 17:12:09 +08:00
|
|
|
path = os.path.join(STATIC_DIR, "index.html")
|
|
|
|
|
if os.path.exists(path):
|
|
|
|
|
return FileResponse(path, media_type="text/html; charset=utf-8")
|
|
|
|
|
return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"})
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 16:51:11 +08:00
|
|
|
# ================================================================ 登录与权限接口
|
|
|
|
|
@app.post("/api/auth/login")
|
|
|
|
|
def api_auth_login(payload: dict = Body(default=None)):
|
|
|
|
|
"""手机号+密码 -> 调 bshop 校验 -> 判角色 -> 签会话票写 cookie。前端发 JSON。"""
|
|
|
|
|
payload = payload or {}
|
|
|
|
|
phone = str(payload.get("phone") or "").strip()
|
|
|
|
|
password = str(payload.get("password") or "")
|
|
|
|
|
if not phone or not password:
|
|
|
|
|
return JSONResponse({"ok": False, "error": "手机号和密码都要填"}, status_code=400)
|
|
|
|
|
res = authmod.call_bshop_login(settings.PMS_BSHOP_LOGIN_URL, phone, password,
|
|
|
|
|
settings.PMS_BSHOP_TIMEOUT)
|
|
|
|
|
if not res.get("ok"):
|
|
|
|
|
return JSONResponse({"ok": False, "error": res.get("error") or "登录失败"}, status_code=401)
|
2026-08-27 10:16:45 +08:00
|
|
|
roles = authmod.roles_from_permissions(res["permissions"], settings.PMS_ADMIN_PERMS,
|
|
|
|
|
settings.PMS_TRADER_PERMS)
|
|
|
|
|
if not roles:
|
2026-08-26 16:51:11 +08:00
|
|
|
return JSONResponse({"ok": False, "error": "这个账号没有持仓管理系统的使用权限"}, status_code=403)
|
|
|
|
|
if not settings.PMS_SESSION_SECRET:
|
|
|
|
|
return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"}, status_code=503)
|
2026-08-27 10:16:45 +08:00
|
|
|
token = authmod.make_session(res["phone"], res["username"], roles,
|
2026-08-26 16:51:11 +08:00
|
|
|
settings.PMS_SESSION_SECRET, settings.PMS_SESSION_TTL_HOURS)
|
|
|
|
|
resp = JSONResponse({"ok": True, "user": {"phone": res["phone"], "username": res["username"]},
|
2026-08-27 10:16:45 +08:00
|
|
|
"roles": roles, "orgs": res.get("orgs") or []})
|
2026-08-26 16:51:11 +08:00
|
|
|
resp.set_cookie(settings.PMS_SESSION_COOKIE, token,
|
|
|
|
|
max_age=settings.PMS_SESSION_TTL_HOURS * 3600,
|
|
|
|
|
httponly=True, samesite="lax", path="/")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
|
|
|
def api_auth_logout():
|
|
|
|
|
"""退出: 清掉会话 cookie。"""
|
|
|
|
|
resp = JSONResponse({"ok": True})
|
|
|
|
|
resp.delete_cookie(settings.PMS_SESSION_COOKIE, path="/")
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/me")
|
|
|
|
|
def api_me(request: Request):
|
|
|
|
|
"""当前登录人与角色。关掉登录时回一个合成的管理员, 前端就不弹登录框也不藏东西。"""
|
|
|
|
|
if not settings.PMS_AUTH_ENABLED:
|
|
|
|
|
return {"ok": True, "auth_enabled": False,
|
2026-08-27 10:16:45 +08:00
|
|
|
"user": {"phone": "-", "username": "本地(未开登录)"}, "roles": [authmod.ROLE_ADMIN, authmod.ROLE_TRADER]}
|
2026-08-26 16:51:11 +08:00
|
|
|
secret = settings.PMS_SESSION_SECRET
|
|
|
|
|
sess = (authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), secret)
|
|
|
|
|
if secret else None)
|
|
|
|
|
if not sess:
|
|
|
|
|
return JSONResponse({"ok": False, "auth": "login_required"}, status_code=401)
|
|
|
|
|
return {"ok": True, "auth_enabled": True,
|
|
|
|
|
"user": {"phone": sess.get("phone"), "username": sess.get("username")},
|
2026-08-27 10:16:45 +08:00
|
|
|
"roles": sess.get("roles") or []}
|
2026-08-26 16:51:11 +08:00
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
@app.get("/api/overview")
|
|
|
|
|
def api_overview():
|
|
|
|
|
return ok(portfolio.overview)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 15:09:29 +08:00
|
|
|
@app.get("/api/names")
|
|
|
|
|
def api_names(codes: str = Query("")):
|
|
|
|
|
"""代码 → 中文名 (gp_code_all)。codes 逗号分隔点式代码。绝不抛错 —— 交易员视图靠它显示中文名。"""
|
|
|
|
|
cs_list = [c.strip() for c in (codes or "").split(",") if c.strip()]
|
|
|
|
|
return ok(lambda: {"ok": True, "names": downstream_repo.fetch_names(cs_list)})
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
# ================================================================ ① 参数设置
|
|
|
|
|
@app.get("/api/params")
|
|
|
|
|
def api_params():
|
|
|
|
|
return ok(param_store.snapshot)
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 13:07:27 +08:00
|
|
|
# 只有系统管理员能改的开关 (2026-08-28 审查加): 把下发通道从影子切到实盘直连是
|
|
|
|
|
# 运维级动作, 不属于交易员的"参数微调"。其余键的边界维持用户定的原则不动。
|
|
|
|
|
_ADMIN_ONLY_PARAM_KEYS = {"PMS_DISPATCH_MODE"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
@app.post("/api/params")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_set_params(request: Request, payload: dict = Body(...)):
|
2026-07-27 17:12:09 +08:00
|
|
|
"""单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。"""
|
|
|
|
|
items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}]
|
2026-08-28 13:07:27 +08:00
|
|
|
actor = _actor(request, payload)
|
|
|
|
|
sess = getattr(request.state, "user", None) or {}
|
|
|
|
|
is_admin = (not settings.PMS_AUTH_ENABLED) or authmod.is_admin(sess.get("roles"))
|
2026-07-27 17:12:09 +08:00
|
|
|
results = []
|
|
|
|
|
for it in items:
|
2026-08-28 13:07:27 +08:00
|
|
|
if not isinstance(it, dict):
|
|
|
|
|
# 元素不是对象直接给出明确错误, 不再冒 500 (2026-08-28 审查修)
|
|
|
|
|
results.append({"ok": False, "error": f"条目格式非法 (应为 {{key, value}}): {it!r}"})
|
|
|
|
|
continue
|
2026-07-27 17:12:09 +08:00
|
|
|
k = it.get("key")
|
|
|
|
|
if not k:
|
|
|
|
|
results.append({"ok": False, "error": "缺少 key"})
|
|
|
|
|
continue
|
2026-08-28 13:07:27 +08:00
|
|
|
if k in _ADMIN_ONLY_PARAM_KEYS and not is_admin:
|
|
|
|
|
results.append({"ok": False, "error": f"{k} 需要系统管理员才能修改"})
|
|
|
|
|
continue
|
|
|
|
|
results.append(param_store.set_param(k, it.get("value"), updated_by=actor))
|
2026-08-11 11:58:16 +08:00
|
|
|
_okf = all(r.get("ok") for r in results)
|
|
|
|
|
_oplog("set_params", ok_flag=_okf,
|
2026-08-28 13:07:27 +08:00
|
|
|
params={(it.get("key") if isinstance(it, dict) else str(it)):
|
|
|
|
|
(it.get("value") if isinstance(it, dict) else None) for it in items},
|
2026-08-11 11:58:16 +08:00
|
|
|
reason=(None if _okf else "; ".join((r.get("error") or "") for r in results if not r.get("ok"))),
|
2026-08-28 13:07:27 +08:00
|
|
|
by=actor)
|
2026-08-11 11:58:16 +08:00
|
|
|
return {"ok": _okf, "results": results}
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ ② 命令台
|
|
|
|
|
@app.get("/api/commands/catalog")
|
|
|
|
|
def api_catalog():
|
|
|
|
|
return ok(lambda: {"commands": cs.list_commands(sector_source_ready=industry.ready()),
|
|
|
|
|
"sector": industry.status()})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/commands/active")
|
|
|
|
|
def api_commands_active():
|
|
|
|
|
return ok(command_service.active_commands)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/commands")
|
2026-08-25 11:09:58 +08:00
|
|
|
def api_commands(status: str = Query(None), limit: int = Query(100),
|
|
|
|
|
include_archived: bool = Query(False)):
|
2026-07-27 17:12:09 +08:00
|
|
|
statuses = [s for s in (status or "").split(",") if s] or None
|
2026-08-25 11:09:58 +08:00
|
|
|
return ok(pms_repo.list_commands, statuses=statuses, limit=limit,
|
|
|
|
|
include_archived=include_archived)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/commands/{command_id}")
|
|
|
|
|
def api_command_detail(command_id: str):
|
|
|
|
|
def _detail():
|
|
|
|
|
c = pms_repo.get_command(command_id)
|
|
|
|
|
if not c:
|
|
|
|
|
return {"ok": False, "error": f"命令 {command_id} 不存在"}
|
|
|
|
|
return {"ok": True, "command": c,
|
|
|
|
|
"plans": pms_repo.list_plans(command_id=command_id, limit=500)}
|
|
|
|
|
return ok(_detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/commands")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_issue(request: Request, payload: dict = Body(...)):
|
|
|
|
|
actor = _actor(request, payload)
|
2026-08-11 11:58:16 +08:00
|
|
|
return ok_logged("issue_command:" + str(payload.get("cmd_type")),
|
|
|
|
|
command_service.issue, payload.get("cmd_type"), payload.get("params") or {},
|
2026-08-28 13:07:27 +08:00
|
|
|
note=payload.get("note"), issued_by=actor,
|
2026-08-11 11:58:16 +08:00
|
|
|
force_conflict=bool(payload.get("force")),
|
|
|
|
|
ts_code=(payload.get("params") or {}).get("ts_code"),
|
2026-08-28 13:07:27 +08:00
|
|
|
params=payload, by=actor)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/commands/{command_id}/cancel")
|
|
|
|
|
def api_cancel(command_id: str):
|
2026-08-11 11:58:16 +08:00
|
|
|
return ok_logged("cancel_command", command_service.cancel, command_id,
|
|
|
|
|
params={"command_id": command_id})
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/commands/{command_id}/replan")
|
|
|
|
|
def api_replan(command_id: str):
|
|
|
|
|
"""重新生成方案 (窗口内行情变化后可重算; 旧方案作废)。"""
|
|
|
|
|
def _replan():
|
|
|
|
|
c = pms_repo.get_command(command_id)
|
|
|
|
|
if not c:
|
|
|
|
|
return {"ok": False, "error": "命令不存在"}
|
|
|
|
|
if c["status"] not in cs.ACTIVE_TASK_STATES:
|
|
|
|
|
return {"ok": False, "error": f"命令处于 {c['status']}, 不可重规划"}
|
|
|
|
|
pms_repo.cancel_plans_of_command(command_id)
|
|
|
|
|
return command_service.plan_command(c)
|
2026-08-11 11:58:16 +08:00
|
|
|
return ok_logged("replan_command", _replan, params={"command_id": command_id})
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/plans")
|
|
|
|
|
def api_plans(command_id: str = Query(None), status: str = Query(None), limit: int = 300):
|
|
|
|
|
statuses = [s for s in (status or "").split(",") if s] or None
|
|
|
|
|
return ok(pms_repo.list_plans, command_id=command_id, statuses=statuses, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ ③ 持仓与账本
|
|
|
|
|
@app.get("/api/positions")
|
|
|
|
|
def api_positions():
|
2026-09-03 16:30:49 +08:00
|
|
|
def _view():
|
|
|
|
|
v = portfolio.positions_view()
|
|
|
|
|
sp = command_service.effective_stock_params()
|
|
|
|
|
# 用户设的止损价与目标价, 事实源在命令表, 与持仓行上系统每天早上重算的参考位
|
|
|
|
|
# (support_ref / pressure_ref / stop_ref) 是两回事。在这里合到一起, 是为了让页面
|
|
|
|
|
# 把两个数分列显示 —— 只是显示, 判断那一侧照旧各读各的事实源, 见
|
|
|
|
|
# command_service.attach_user_prices 的说明。
|
|
|
|
|
command_service.attach_user_prices(v["positions"], sp)
|
2026-09-07 14:37:35 +08:00
|
|
|
# 入场论点随持仓走 (2026-09-07 第三件): 每行加「当初为什么买」(entry) 与「现在证据还在不在」
|
|
|
|
|
# (logic_now)。只是显示; 任何一行取不到都写原因, 页面不能因此塌掉。
|
|
|
|
|
try:
|
|
|
|
|
logic_state_service.decorate_positions(v["positions"])
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
logger.warning("[持仓] 入场论点与逻辑状态两栏取不到: %s", e)
|
2026-09-03 16:30:49 +08:00
|
|
|
return {"ok": True, **v, "stock_params": sp}
|
|
|
|
|
return ok(_view)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/positions/{ts_code}/lots")
|
|
|
|
|
def api_lots(ts_code: str, status: str = Query("OPEN")):
|
|
|
|
|
return ok(pms_repo.list_lots, ts_code, status=(status or None))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/instructions")
|
2026-08-25 11:09:58 +08:00
|
|
|
def api_instructions(status: str = Query(None), limit: int = 200,
|
|
|
|
|
include_archived: bool = Query(False)):
|
2026-07-27 17:12:09 +08:00
|
|
|
statuses = [s for s in (status or "").split(",") if s] or None
|
2026-08-25 11:09:58 +08:00
|
|
|
return ok(pms_repo.list_instructions, statuses=statuses, limit=limit,
|
|
|
|
|
include_archived=include_archived)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/ledger")
|
|
|
|
|
def api_ledger(ts_code: str = Query(None), limit: int = 100):
|
|
|
|
|
return ok(pms_repo.list_ledger, ts_code=ts_code, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/report")
|
|
|
|
|
def api_report(ymd: int = Query(None)):
|
|
|
|
|
return ok(lambda: (pms_repo.get_report(ymd) if ymd else pms_repo.latest_report())
|
|
|
|
|
or {"ymd": None, "report": {}})
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 15:45:30 +08:00
|
|
|
@app.get("/api/export/publish-xlsx")
|
|
|
|
|
def api_export_publish_xlsx():
|
|
|
|
|
"""公示表导出 (2026-08-28): 持仓/平仓/净值一张表, 版式对齐公司《量化数据》模板,
|
|
|
|
|
数据只含系统接管后的账本 (口径见 publish_export 模块头)。二进制下载不走 ok()
|
|
|
|
|
包装; 失败按站内惯例回 JSON (HTTP 200), 新开的下载页会直接显示错误原因。"""
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
|
|
|
|
from fastapi.responses import Response
|
|
|
|
|
try:
|
|
|
|
|
from app.services import publish_export
|
|
|
|
|
fname, blob = publish_export.export_xlsx()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("公示表导出失败")
|
|
|
|
|
return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"})
|
|
|
|
|
return Response(
|
|
|
|
|
content=blob,
|
|
|
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(fname)}"})
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
# ================================================================ ④ 提议确认
|
|
|
|
|
@app.get("/api/proposals")
|
2026-08-25 11:09:58 +08:00
|
|
|
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
|
|
|
|
|
include_archived: bool = Query(False)):
|
2026-07-27 17:12:09 +08:00
|
|
|
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
|
2026-08-25 11:09:58 +08:00
|
|
|
return ok(pms_repo.list_proposals, statuses=statuses, limit=limit,
|
|
|
|
|
include_archived=include_archived)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/proposals/{proposal_id}/decide")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_decide(request: Request, proposal_id: str, payload: dict = Body(default={})):
|
2026-07-27 17:12:09 +08:00
|
|
|
"""采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。"""
|
|
|
|
|
decision = str(payload.get("decision") or "").upper()
|
|
|
|
|
if decision not in ("ACCEPTED", "DECLINED"):
|
|
|
|
|
return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"}
|
2026-09-07 11:22:05 +08:00
|
|
|
# 理由在服务端强制 (2026-09-07): 页面 09-03 起就弹必填框, 但只在浏览器里成立 ——
|
|
|
|
|
# 任何脚本、旧缓存页面都能发一条没有理由的裁决, 账本上落一句「页面人工裁决」的
|
|
|
|
|
# 默认文案, 与真裁决看不出区别。复盘要的是「人为什么这么判」, 没理由的不收。
|
|
|
|
|
reason = str(payload.get("reason") or "").strip()
|
|
|
|
|
if not reason:
|
|
|
|
|
return {"ok": False, "error": "采纳或驳回都要写理由, 空理由不收"}
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
def _decide():
|
2026-09-07 11:22:05 +08:00
|
|
|
from app.core import action_engine as ae
|
2026-07-27 17:12:09 +08:00
|
|
|
p = pms_repo.get_proposal(proposal_id)
|
|
|
|
|
if not p:
|
|
|
|
|
return {"ok": False, "error": "提议不存在"}
|
|
|
|
|
if p["status"] != "WAIT_USER":
|
|
|
|
|
return {"ok": False, "error": f"提议已处于 {p['status']}"}
|
|
|
|
|
if not pms_repo.decide_proposal(proposal_id, decision):
|
|
|
|
|
return {"ok": False, "error": "提议状态已变更, 请刷新"}
|
|
|
|
|
hn = p.get("hard_numbers") or {}
|
|
|
|
|
pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
|
|
|
|
|
verdict="PASS" if decision == "ACCEPTED" else "REJECT",
|
|
|
|
|
price_at=float(hn.get("price") or 0), hard_numbers=hn,
|
2026-09-07 11:22:05 +08:00
|
|
|
ref_id=proposal_id, reason=reason)
|
2026-07-27 17:12:09 +08:00
|
|
|
instruction_id = None
|
|
|
|
|
if decision == "ACCEPTED":
|
2026-08-11 11:58:16 +08:00
|
|
|
# 策略(confirm 档)提议: 两腿同 action、side 无法由 action 反推, 交策略层按腿谱发指令
|
|
|
|
|
if str((hn or {}).get("kind")) == "strategy":
|
|
|
|
|
from app.services import strategy_runner
|
|
|
|
|
rr = strategy_runner.emit_from_spec(hn)
|
|
|
|
|
if not rr.get("ok"):
|
|
|
|
|
return {"ok": False, "error": rr.get("error") or "策略提议发指令失败"}
|
|
|
|
|
return {"ok": True, "decision": decision,
|
|
|
|
|
"instruction_id": rr.get("instruction_id"), "strategy": True}
|
2026-07-27 17:12:09 +08:00
|
|
|
instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1)
|
|
|
|
|
side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy"
|
2026-08-12 11:42:43 +08:00
|
|
|
_win = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
|
2026-09-07 11:22:05 +08:00
|
|
|
qty = int(p["qty"] or 0)
|
|
|
|
|
if side == "sell":
|
|
|
|
|
# 按拍板这一刻的可卖量重算 (2026-09-07 审查修): 提议记的是扫描那一刻的股数,
|
|
|
|
|
# 等人点采纳时持仓可能已被别的路卖少, 照原数落单会被卖出前的检查整条驳回,
|
|
|
|
|
# 人点了「清掉」结果一股没卖, 且那条指令会永久在途、挡住后续所有清仓。
|
|
|
|
|
cur = pms_repo.get_position(p["ts_code"]) or {}
|
|
|
|
|
qty = ae.clamp_sell_qty(qty, cur)
|
|
|
|
|
if qty <= 0:
|
|
|
|
|
return {"ok": True, "decision": decision, "instruction_id": None,
|
|
|
|
|
"note": "这只票现在没有可卖的股数 (已被清掉或今天刚买入), 未落单"}
|
2026-07-27 17:12:09 +08:00
|
|
|
pms_repo.insert_instruction(
|
|
|
|
|
instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id,
|
2026-09-07 11:22:05 +08:00
|
|
|
ts_code=p["ts_code"], action=p["action"], side=side, qty=qty,
|
2026-07-27 17:12:09 +08:00
|
|
|
limit_price=hn.get("price"),
|
2026-08-12 11:42:43 +08:00
|
|
|
window_tdays=_win,
|
|
|
|
|
status="PROPOSED",
|
|
|
|
|
progress={"from_proposal": proposal_id, "is_command": False, "children": [],
|
|
|
|
|
"deadline": str(td.window_deadline(None, _win))})
|
2026-07-29 10:49:56 +08:00
|
|
|
# 与自主执行同一口径: 采纳即算「做过一次」, 计数器要跟着走
|
|
|
|
|
# (否则页面采纳的那条动作绕开了 §6 的一次性约束)
|
|
|
|
|
from app.services import proposal_service
|
2026-07-31 16:10:58 +08:00
|
|
|
g = proposal_service.bump_once_guards(p["ts_code"], p["action"], hn) or {}
|
|
|
|
|
if not g.get("ok"):
|
|
|
|
|
# 指令已落表, 不回滚; 但要让页面看见"这条一次性纪律本轮没锁上"
|
|
|
|
|
return {"ok": True, "decision": decision, "instruction_id": instruction_id,
|
|
|
|
|
"warning": f"一次性守卫计数器未写入 ({g.get('error')}) —— "
|
|
|
|
|
f"{p['ts_code']} 的 {p['action']}「只做一次」本轮失效"}
|
2026-07-27 17:12:09 +08:00
|
|
|
return {"ok": True, "decision": decision, "instruction_id": instruction_id}
|
2026-08-11 11:58:16 +08:00
|
|
|
return ok_logged("decide_proposal", _decide,
|
|
|
|
|
params={"proposal_id": proposal_id, "decision": decision},
|
2026-08-28 13:07:27 +08:00
|
|
|
by=_actor(request, payload))
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/proposals")
|
|
|
|
|
def api_create_proposal(payload: dict = Body(...)):
|
|
|
|
|
"""人工补录一条待确认提议 (影子运行期造数与联调用)。"""
|
|
|
|
|
def _create():
|
|
|
|
|
pid = payload.get("proposal_id") or f"PRP_{td.ymd()}_{int(datetime.now().timestamp())}"
|
|
|
|
|
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
|
|
|
|
|
pms_repo.insert_proposal(
|
|
|
|
|
proposal_id=pid, ts_code=cs.normalize_code(payload.get("ts_code") or ""),
|
|
|
|
|
action=payload.get("action") or "ADD", qty=int(payload.get("qty") or 0),
|
|
|
|
|
hard_numbers=payload.get("hard_numbers") or {},
|
|
|
|
|
expire_at=datetime.now() + timedelta(hours=ttl))
|
|
|
|
|
return {"ok": True, "proposal_id": pid}
|
|
|
|
|
return ok(_create)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 运维操作 (与调度器同一实现)
|
|
|
|
|
@app.post("/api/ops/replay")
|
|
|
|
|
def api_replay(limit: int = Query(500)):
|
|
|
|
|
return ok(ledger_service.replay_fills, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 13:46:15 +08:00
|
|
|
@app.get("/api/ops/rebuild-preflight")
|
|
|
|
|
def api_rebuild_preflight():
|
|
|
|
|
"""账本重建的只读预检 (README 待办 #4)。不写任何东西, 随时可点。
|
|
|
|
|
|
|
|
|
|
成本价能不能用是这一步的成败所在 —— 详见 app/core/rebuild_check.py 的模块说明。
|
|
|
|
|
"""
|
|
|
|
|
def _pf():
|
|
|
|
|
from app.services import ledger_service
|
|
|
|
|
return ledger_service.rebuild_preflight()
|
|
|
|
|
return ok(_pf)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/ops/rebuild-accept")
|
|
|
|
|
def api_rebuild_accept():
|
|
|
|
|
"""重建之后的只读判收: 安全垫分布 / 批次账 / 行业集中度。"""
|
|
|
|
|
def _ac():
|
|
|
|
|
from app.services import ledger_service
|
|
|
|
|
return ledger_service.rebuild_accept()
|
|
|
|
|
return ok(_ac)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
@app.post("/api/ops/reconcile")
|
2026-07-29 16:55:45 +08:00
|
|
|
def api_reconcile(apply_fix: bool = Query(True), force: bool = Query(False)):
|
|
|
|
|
"""force=true 绕过「差异面过大不自动改账」的限制, 仅在人工确认下游读数无误后使用。"""
|
|
|
|
|
return ok(ledger_service.reconcile, apply_fix=apply_fix, force=force)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/premarket")
|
|
|
|
|
def api_premarket():
|
|
|
|
|
return ok(ledger_service.premarket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/daily-settle")
|
|
|
|
|
def api_daily_settle():
|
|
|
|
|
return ok(ledger_service.daily_settle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/report")
|
|
|
|
|
def api_build_report():
|
|
|
|
|
return ok(ledger_service.build_daily_report)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/plan-pending")
|
|
|
|
|
def api_plan_pending():
|
|
|
|
|
return ok(command_service.plan_pending)
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
@app.post("/api/ops/materialize")
|
|
|
|
|
def api_materialize():
|
|
|
|
|
"""方案 → 指令 (先记账后动作)。"""
|
|
|
|
|
from app.services import executor
|
|
|
|
|
return ok(executor.materialize_plans)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/exec-tick")
|
|
|
|
|
def api_exec_tick(dry_run: bool = Query(False)):
|
|
|
|
|
"""择时出手一跳。dry_run=true 只试算不下发, 用来在盘中先看「现在会怎么动」。"""
|
|
|
|
|
from app.services import executor
|
|
|
|
|
return ok(executor.run_tick, dry_run=dry_run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/sweep-windows")
|
|
|
|
|
def api_sweep_windows():
|
|
|
|
|
from app.services import executor
|
|
|
|
|
return ok(executor.sweep_windows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/instructions/{instruction_id}/cancel")
|
|
|
|
|
def api_cancel_instruction(instruction_id: str, payload: dict = Body(default={})):
|
|
|
|
|
from app.services import executor
|
2026-08-11 11:58:16 +08:00
|
|
|
return ok_logged("cancel_instruction", executor.cancel_instruction, instruction_id,
|
|
|
|
|
payload.get("reason") or "页面人工撤销",
|
|
|
|
|
params={"instruction_id": instruction_id})
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
2026-07-28 09:29:53 +08:00
|
|
|
@app.post("/api/ops/scan-proposals")
|
|
|
|
|
def api_scan_proposals(dry_run: bool = Query(False)):
|
|
|
|
|
"""自主提议扫描一轮 (动作引擎 → 规则闸 → 研判闸 → 按档位分流)。"""
|
|
|
|
|
from app.services import proposal_service
|
|
|
|
|
return ok(proposal_service.scan_and_route, dry_run=dry_run)
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 11:14:02 +08:00
|
|
|
@app.post("/api/ops/digest-signals")
|
|
|
|
|
def api_digest_signals(dry_run: bool = Query(False)):
|
|
|
|
|
"""消化一批决策系统盘中信号。dry_run=true 只解析判定, 不落表也不 ACK。"""
|
|
|
|
|
from app.services import signal_service
|
|
|
|
|
return ok(signal_service.consume, dry_run=dry_run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/signal-status")
|
|
|
|
|
def api_signal_status():
|
|
|
|
|
from app.services import signal_service
|
|
|
|
|
return ok(signal_service.status)
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
@app.get("/api/dispatch-mode")
|
|
|
|
|
def api_dispatch_mode():
|
2026-07-28 09:29:53 +08:00
|
|
|
from app.services import dispatcher, judge
|
|
|
|
|
return ok(lambda: {"ok": True, **dispatcher.describe(), "judge": judge.status()})
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
@app.get("/api/ws-channel")
|
|
|
|
|
def api_ws_channel(limit: int = Query(50)):
|
|
|
|
|
"""ws 通道运维视图: 连接状态 / seq 水位 / 出口队列 / 最近上行消息。
|
|
|
|
|
|
|
|
|
|
这四样凑一起才看得出通道到底"通没通" —— 心跳说明进程在、conn_state 说明连接在、
|
|
|
|
|
水位说明消息没断层、队列深度说明指令有没有卡住。少看一样都可能误判。
|
|
|
|
|
"""
|
|
|
|
|
from app.repo import qmt_repo
|
|
|
|
|
from app.services import dispatcher
|
|
|
|
|
|
|
|
|
|
def _view():
|
|
|
|
|
out = {"ok": True, "mode": dispatcher.mode(),
|
|
|
|
|
"channel": dispatcher.channel_status()}
|
|
|
|
|
try:
|
|
|
|
|
out["orders"] = qmt_repo.list_orders(limit=limit)
|
|
|
|
|
out["inbox"] = qmt_repo.inbox_list(limit=limit)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
out["error"] = f"{type(e).__name__}: {e}"
|
|
|
|
|
return out
|
|
|
|
|
return ok(_view)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ws-channel/clear-resync")
|
|
|
|
|
def api_clear_resync():
|
|
|
|
|
"""人工确认全量对账已做完后清 resync 标记 (协议 §6.2)。
|
|
|
|
|
|
|
|
|
|
这个标记只能人来清 —— 它的含义是「对端补发不全, 中间那段消息我们永远拿不到了」,
|
|
|
|
|
自动清等于假装没发生过。
|
|
|
|
|
"""
|
|
|
|
|
from app.repo import qmt_repo
|
|
|
|
|
|
|
|
|
|
def _clear():
|
|
|
|
|
st = qmt_repo.get_state()
|
|
|
|
|
qmt_repo.set_conn(st.get("conn_state") or "OFFLINE", resync=False, beat=False)
|
|
|
|
|
return {"ok": True, "message": "已清除 resync 标记 —— 请确认全量对账确实已完成"}
|
|
|
|
|
return ok(_clear)
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:21:16 +08:00
|
|
|
# ================================================================ 上游选股计划
|
2026-08-12 14:51:22 +08:00
|
|
|
@app.get("/api/open-scan")
|
|
|
|
|
def api_open_scan():
|
|
|
|
|
"""候选池处置快照 (只读): 每只候选今天为什么下单 / 没下单。
|
|
|
|
|
|
|
|
|
|
复用每分钟那条自主扫描的 scan_open 口径, 但止步于规则闸之前、不送研判、不落任何表 ——
|
|
|
|
|
补上「名额/资金/上限/不足一手/在途/已持有」这些算完即弃、评审账本里查不到的原因。
|
|
|
|
|
「已下单/已生成提议」由前端拿它已在手的 instructions/proposals 按股票对齐, 这里不重复查。
|
|
|
|
|
"""
|
|
|
|
|
from app.services import proposal_service
|
|
|
|
|
return ok(proposal_service.disposition_snapshot)
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 10:45:04 +08:00
|
|
|
@app.get("/api/upstream-signals")
|
|
|
|
|
def api_upstream_signals():
|
|
|
|
|
"""上游信号只读快照 (三系统盘中信号: 决策系统买卖 / 择时层 BUY / 双向告警 / 资金异动 / 实况分)。
|
|
|
|
|
只展示、不进任何下单或决策逻辑; 内部一律 XREVRANGE/ZREVRANGE, 不建消费组、不 ACK、不写。"""
|
|
|
|
|
from app.services import upstream_signals
|
|
|
|
|
return ok(upstream_signals.snapshot)
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 14:53:29 +08:00
|
|
|
@app.get("/api/upstream/signals/{ts_code}")
|
|
|
|
|
def api_upstream_signals_by_code(ts_code: str):
|
|
|
|
|
"""一只票**今天的全部**上游信号 (2026-09-09, 台账 055)。
|
|
|
|
|
|
|
|
|
|
与 /api/upstream-signals 的分工: 那个是全场最近 N 条的快照, 告警每类只留最新 20 条,
|
|
|
|
|
到下午一只活跃票早盘的信号早被挤掉; 这个按代码倒序回扫到当天开盘, 所以才叫"全部"。
|
|
|
|
|
|
|
|
|
|
**只能点击触发, 绝不能进页面轮询** —— 进轮询就是每 30 秒对上游做一次全流扫描,
|
|
|
|
|
把只读观察变成压力源。内部纪律与快照相同: 一律 XREVRANGE/ZREVRANGE, 不建消费组、不 ACK、不写。
|
|
|
|
|
"""
|
|
|
|
|
from app.services import upstream_signals
|
|
|
|
|
return ok(upstream_signals.by_code, ts_code)
|
|
|
|
|
|
|
|
|
|
|
2026-08-19 10:04:20 +08:00
|
|
|
# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md)
|
|
|
|
|
@app.get("/api/macro/status")
|
|
|
|
|
def api_macro_status():
|
|
|
|
|
"""宏观择时面板快照: 各信号当前值/区域/近 20 日序列/当日建议/最近命令/冷却 + 闸状态。"""
|
|
|
|
|
from app.services import macro_service
|
|
|
|
|
return ok(macro_service.status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/macro-scan")
|
|
|
|
|
def api_macro_scan(dry_run: bool = Query(False)):
|
|
|
|
|
"""手动宏观扫描 (= 09:35 调度位)。dry_run=true 只算不落表不下达, 供判收与盘中复核。"""
|
|
|
|
|
from app.services import macro_service
|
|
|
|
|
return ok_logged("macro_scan", macro_service.scan, dry_run=dry_run,
|
|
|
|
|
params={"dry_run": dry_run})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/macro/adopt")
|
|
|
|
|
def api_macro_adopt(payload: dict = Body(default={})):
|
|
|
|
|
"""采纳当日宏观建议 → 以用户名义下命令 (幂等, 建议隔日作废)。"""
|
|
|
|
|
from app.services import macro_service
|
|
|
|
|
key = (payload or {}).get("signal_key") or "hedge_fx"
|
|
|
|
|
return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key})
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 13:45:51 +08:00
|
|
|
@app.post("/api/ops/strategy-attach-scan")
|
|
|
|
|
def api_strategy_attach_scan(dry_run: bool = Query(False)):
|
|
|
|
|
"""手动策略自动挂载 (= 09:40 调度位)。dry_run=true 只判不写 —— 不挂、不留痕、
|
|
|
|
|
不动买入暂停表, 返回本轮会做什么, 供上线判收与日常复核。"""
|
|
|
|
|
from app.services import strategy_advisor
|
|
|
|
|
return ok_logged("strategy_attach_scan", strategy_advisor.scan, dry_run=dry_run,
|
|
|
|
|
params={"dry_run": dry_run})
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:21:16 +08:00
|
|
|
@app.get("/api/upstream/plan")
|
|
|
|
|
def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
|
|
|
|
|
bucket: str = Query("main")):
|
|
|
|
|
"""上游 /plan 预览。绝不抛错 —— 接口不通时 status.ok=false + hint, 页面照常渲染。
|
|
|
|
|
|
|
|
|
|
candidates_raw 是**不扣持仓/黑名单**的原始筛选结果 (纯看参数选出来什么),
|
|
|
|
|
真正下命令时的候选池还会再剔除已持有、黑名单与取不到价的票。
|
|
|
|
|
"""
|
|
|
|
|
def _plan():
|
|
|
|
|
from app.services import plan_feed
|
|
|
|
|
st = plan_feed.status()
|
|
|
|
|
out = {"ok": True, "status": st, "rows": [], "candidates_raw": None}
|
|
|
|
|
if not st.get("ok"):
|
|
|
|
|
return out
|
|
|
|
|
try:
|
|
|
|
|
plan = plan_feed.get_plan(date=(date or None))
|
|
|
|
|
rows = plan.get("observe" if bucket == "observe" else "main") or []
|
|
|
|
|
out["rows"] = rows[:max(1, int(limit or 30))]
|
|
|
|
|
out["candidates_raw"] = plan_feed.candidates()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
out["status"] = {**st, "ok": False, "hint": f"{type(e).__name__}: {e}"}
|
|
|
|
|
return out
|
|
|
|
|
return ok(_plan)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/ops/plan-refresh")
|
|
|
|
|
def api_plan_refresh(date: str = Query(None)):
|
|
|
|
|
"""强刷上游计划, 并把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)。"""
|
|
|
|
|
def _refresh():
|
|
|
|
|
from app.services import plan_feed
|
|
|
|
|
plan_feed.invalidate()
|
|
|
|
|
plan = plan_feed.get_plan(force=True, date=(date or None))
|
|
|
|
|
return {"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"),
|
|
|
|
|
"returned": plan["returned"], "theme_sync": plan.get("theme_sync"),
|
2026-07-31 13:28:10 +08:00
|
|
|
"snapshot": plan.get("snapshot"), "industry": industry.status()}
|
2026-07-30 16:21:16 +08:00
|
|
|
return ok(_refresh)
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 13:28:10 +08:00
|
|
|
@app.get("/api/upstream/plan-changes")
|
|
|
|
|
def api_plan_changes(limit: int = Query(50)):
|
|
|
|
|
"""榜单变化 (PMS 自算, 上游 `changes` 字段恒为 null —— UPSTREAM_PLAN_API.md §9)。
|
|
|
|
|
|
|
|
|
|
绝不抛错: 变化提示是锦上添花, 它坏了不该让「上游计划」抽屉打不开。
|
|
|
|
|
`log.revised` 里某个 plan_date 出现多版, 就是 §7.3 那个"同一 date 会变"。
|
|
|
|
|
|
|
|
|
|
子状态**嵌在 changes 里**而不是顶层 —— 顶层 `ok` 一为假, 页面会挂全局红条。
|
|
|
|
|
「快照关着」「还没有快照」都不是接口故障, 不该长成那副样子 (同 status 的处理)。
|
|
|
|
|
"""
|
|
|
|
|
def _changes():
|
|
|
|
|
from app.services import plan_feed
|
|
|
|
|
return {"ok": True, "changes": plan_feed.changes(),
|
|
|
|
|
"log": plan_feed.snapshot_log(limit=max(1, int(limit or 50)))}
|
|
|
|
|
return ok(_changes)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
@app.get("/api/ops/downstream-schema")
|
|
|
|
|
def api_downstream_schema():
|
|
|
|
|
"""导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""
|
|
|
|
|
def _schema():
|
|
|
|
|
out = {}
|
2026-07-31 11:11:07 +08:00
|
|
|
for t in ("trading_position", "trading_order", "trading_buy_plan",
|
|
|
|
|
"gp_stock_category"):
|
2026-07-27 17:12:09 +08:00
|
|
|
try:
|
|
|
|
|
out[t] = downstream_repo.describe(t)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
out[t] = {"error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
try:
|
|
|
|
|
out["_position_probe"] = downstream_repo.fetch_positions()["columns"]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
out["_position_probe"] = {"error": str(e)}
|
2026-07-31 11:11:07 +08:00
|
|
|
try:
|
|
|
|
|
out["_category_probe"] = downstream_repo.category_columns(force=True)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
out["_category_probe"] = {"error": str(e)}
|
2026-07-27 17:12:09 +08:00
|
|
|
return out
|
|
|
|
|
return ok(_schema)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 行业映射
|
|
|
|
|
@app.get("/api/industry")
|
|
|
|
|
def api_industry(limit: int = 2000):
|
|
|
|
|
return ok(lambda: {"ok": True, "status": industry.status(),
|
|
|
|
|
"rows": pms_repo.list_industry(limit=limit)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/industry/import")
|
|
|
|
|
def api_industry_import(payload: dict = Body(...)):
|
|
|
|
|
"""导入行业映射。支持 {rows:[{ts_code,industry}]} 或 {text:"600000.SH,银行\\n..."}。"""
|
|
|
|
|
def _imp():
|
|
|
|
|
rows = payload.get("rows")
|
|
|
|
|
if not rows and payload.get("text"):
|
|
|
|
|
rows = []
|
|
|
|
|
for line in str(payload["text"]).splitlines():
|
|
|
|
|
parts = [x.strip() for x in line.replace("\t", ",").split(",") if x.strip()]
|
|
|
|
|
if len(parts) >= 2:
|
|
|
|
|
rows.append({"ts_code": cs.normalize_code(parts[0]), "industry": parts[1]})
|
|
|
|
|
rows = [r for r in (rows or []) if r.get("ts_code") and r.get("industry")]
|
|
|
|
|
n = pms_repo.upsert_industry(rows)
|
|
|
|
|
industry.invalidate()
|
|
|
|
|
return {"ok": True, "imported": len(rows), "affected": n, "status": industry.status()}
|
|
|
|
|
return ok(_imp)
|
2026-08-11 11:58:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 个股交易方案 (策略) + 操作日志
|
|
|
|
|
@app.get("/api/strategies")
|
2026-08-25 11:09:58 +08:00
|
|
|
def api_strategies(status: str = Query(None), include_archived: bool = Query(False)):
|
2026-08-11 11:58:16 +08:00
|
|
|
statuses = [s for s in (status or "").split(",") if s] or None
|
2026-08-11 15:28:46 +08:00
|
|
|
from app.services import strategy_service
|
|
|
|
|
|
|
|
|
|
def _load():
|
2026-08-25 11:09:58 +08:00
|
|
|
rows = pms_repo.list_strategies(statuses=statuses, limit=300,
|
|
|
|
|
include_archived=include_archived)
|
2026-08-11 15:28:46 +08:00
|
|
|
try:
|
|
|
|
|
bp = strategy_service.buypause_map()
|
|
|
|
|
except Exception:
|
|
|
|
|
bp = {}
|
|
|
|
|
for r in rows:
|
|
|
|
|
r["buy_paused"] = bp.get(r.get("ts_code")) or None
|
2026-08-26 10:11:29 +08:00
|
|
|
# 自动挂载仪表 (2026-08-26): 总开关 + 今天的正式一跳跑没跑。摘要由 advisor 真跑后
|
|
|
|
|
# 写进 PMS_AUTO_LAST_SCAN, 这里只读; 解析不了按没跑显示, 不猜。
|
|
|
|
|
last, ran_today = None, False
|
|
|
|
|
try:
|
|
|
|
|
raw = param_store.get("PMS_AUTO_LAST_SCAN") or ""
|
|
|
|
|
last = json.loads(raw) if raw else None
|
|
|
|
|
ran_today = bool(last and int(last.get("ymd") or 0) == td.ymd())
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
last = None
|
2026-08-11 15:28:46 +08:00
|
|
|
return {"ok": True, "strategies": rows,
|
2026-08-26 10:11:29 +08:00
|
|
|
"enabled": param_store.get_bool("PMS_STRATEGY_ENABLED", False),
|
|
|
|
|
"auto": {"enabled": param_store.get_bool("PMS_AUTO_STRATEGY_ENABLED", False),
|
|
|
|
|
"ran_today": ran_today, "last_scan": last}}
|
2026-08-11 15:28:46 +08:00
|
|
|
return ok(_load)
|
2026-08-11 11:58:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/strategies/validate")
|
|
|
|
|
def api_strategy_validate(payload: dict = Body(...)):
|
|
|
|
|
"""挂载前约束校验 (不写库): 返回 {ok, reasons}。违反仓位/存量/上限就给中文原因, 页面红字提示、不落库。"""
|
|
|
|
|
from app.services import strategy_service
|
|
|
|
|
return ok(lambda: strategy_service.validate(payload))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/strategies")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_strategy_attach(request: Request, payload: dict = Body(...)):
|
2026-08-11 11:58:16 +08:00
|
|
|
"""挂载一条策略 (先校验再落库)。校验不过返回 {ok:false, errors}, 并落一条 BLOCKED 操作日志。"""
|
|
|
|
|
from app.services import strategy_service
|
|
|
|
|
return ok_logged("attach_strategy:" + str(payload.get("type")),
|
|
|
|
|
strategy_service.attach, payload,
|
|
|
|
|
ts_code=payload.get("ts_code"), params=payload,
|
2026-08-28 13:07:27 +08:00
|
|
|
by=_actor(request, payload))
|
2026-08-11 11:58:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/strategies/{strategy_id}/status")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_strategy_status(request: Request, strategy_id: str, payload: dict = Body(...)):
|
2026-08-11 11:58:16 +08:00
|
|
|
"""暂停(PAUSED)/恢复(ACTIVE)/撤下(CANCELLED): payload {status}。"""
|
|
|
|
|
from app.services import strategy_service
|
|
|
|
|
return ok_logged("set_strategy_status:" + str(payload.get("status")),
|
|
|
|
|
strategy_service.set_status, strategy_id,
|
|
|
|
|
str(payload.get("status") or "").upper(),
|
|
|
|
|
params={"strategy_id": strategy_id, "status": payload.get("status")},
|
2026-08-28 13:07:27 +08:00
|
|
|
by=_actor(request, payload))
|
2026-08-11 11:58:16 +08:00
|
|
|
|
|
|
|
|
|
2026-08-25 11:09:58 +08:00
|
|
|
# ================================ 软归档: 把终态记录从在办/在途/待确认/策略列表移除 (不删行, 可恢复)
|
|
|
|
|
def _arch_result(n: int, on: bool) -> dict:
|
|
|
|
|
if on:
|
|
|
|
|
note = "已移除, 从视图隐藏 (行仍在库, 对账与审计不受影响)" if n > 0 \
|
|
|
|
|
else "未改动: 该记录不是终态、不存在、或早已移除"
|
|
|
|
|
else:
|
|
|
|
|
note = "已恢复显示" if n > 0 else "未改动: 不存在或本就未移除"
|
|
|
|
|
return {"ok": n > 0, "affected": n, "archived": on, "note": note}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/commands/{command_id}/archive")
|
|
|
|
|
def api_archive_command(command_id: str, payload: dict = Body(default={})):
|
|
|
|
|
on = bool(payload.get("archived", True))
|
|
|
|
|
return ok_logged("archive_command" if on else "unarchive_command",
|
|
|
|
|
lambda: _arch_result(
|
|
|
|
|
(pms_repo.archive_command if on else pms_repo.unarchive_command)(command_id), on),
|
|
|
|
|
params={"command_id": command_id, "archived": on})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/instructions/{instruction_id}/archive")
|
|
|
|
|
def api_archive_instruction(instruction_id: str, payload: dict = Body(default={})):
|
|
|
|
|
on = bool(payload.get("archived", True))
|
|
|
|
|
return ok_logged("archive_instruction" if on else "unarchive_instruction",
|
|
|
|
|
lambda: _arch_result(
|
|
|
|
|
(pms_repo.archive_instruction if on else pms_repo.unarchive_instruction)(instruction_id), on),
|
|
|
|
|
params={"instruction_id": instruction_id, "archived": on})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/proposals/{proposal_id}/archive")
|
|
|
|
|
def api_archive_proposal(proposal_id: str, payload: dict = Body(default={})):
|
|
|
|
|
on = bool(payload.get("archived", True))
|
|
|
|
|
return ok_logged("archive_proposal" if on else "unarchive_proposal",
|
|
|
|
|
lambda: _arch_result(
|
|
|
|
|
(pms_repo.archive_proposal if on else pms_repo.unarchive_proposal)(proposal_id), on),
|
|
|
|
|
params={"proposal_id": proposal_id, "archived": on})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/strategies/{strategy_id}/archive")
|
|
|
|
|
def api_archive_strategy(strategy_id: str, payload: dict = Body(default={})):
|
|
|
|
|
on = bool(payload.get("archived", True))
|
|
|
|
|
return ok_logged("archive_strategy" if on else "unarchive_strategy",
|
|
|
|
|
lambda: _arch_result(
|
|
|
|
|
(pms_repo.archive_strategy if on else pms_repo.unarchive_strategy)(strategy_id), on),
|
|
|
|
|
params={"strategy_id": strategy_id, "archived": on})
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 11:58:16 +08:00
|
|
|
@app.get("/api/op-log")
|
|
|
|
|
def api_op_log(limit: int = Query(200)):
|
|
|
|
|
"""交易员操作日志 (每个页面写操作一行, 含 OK/BLOCKED 与原因)。"""
|
|
|
|
|
return ok(lambda: {"ok": True, "rows": pms_repo.list_op_log(limit=limit)})
|
2026-08-11 15:28:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/strategies/{strategy_id}/resume-buy")
|
2026-08-28 13:07:27 +08:00
|
|
|
def api_strategy_resume_buy(request: Request, strategy_id: str, payload: dict = Body(default={})):
|
2026-08-11 15:28:46 +08:00
|
|
|
"""恢复该策略的买入 (决策系统风控预警触发的暂停由你手动解除; 只恢复买入, 不影响卖出/平回)。"""
|
|
|
|
|
from app.services import strategy_service
|
|
|
|
|
return ok_logged("resume_strategy_buy", strategy_service.resume_buy, strategy_id,
|
2026-08-28 13:07:27 +08:00
|
|
|
params={"strategy_id": strategy_id}, by=_actor(request, payload))
|