# -*- coding: utf-8 -*- """ PMS 管理页面 · Web 入口 (FastAPI + 单页) ========================================= 设计 §3.3 四块: 参数设置 / 命令台 / 持仓与账本 / 提议确认。 工程原则: * 任何后端异常都不得让页面开不了 —— 全部 API 走 `ok(...)` 包装, 失败返回 {"ok": false, "error": "..."} 且 HTTP 200, 由前端在顶部横幅提示。 * 页面只读参数一律经 ParamStore (表值优先), 改参即持久化到 pms_runtime_param。 * 手动运维按钮 (回放/对账/日终/日报) 与调度器调用同一份服务函数, 便于未接调度时先验证。 """ from __future__ import annotations import logging import os from datetime import datetime, timedelta from fastapi import Body, FastAPI, Query from fastapi.responses import FileResponse, JSONResponse from config.settings import settings 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 from app.services import command_service, industry, ledger_service, param_store, portfolio logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") logger = logging.getLogger("pms.web") VERSION = "0.2.0-dev" STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") app = FastAPI(title="PMS 持仓管理系统", version=VERSION) # 把 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) 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}"} 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 # ================================================================ 基础 @app.get("/health") def health(): """容器健康检查 + 自证: 配置装载、库连通、参数来源、交易日历。""" 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_proxy": db, "config_loaded": { "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"), "web_port": settings.PMS_WEB_PORT, }, "sector": industry.status(), "dispatch": _dispatch_health(), } 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}"} @app.get("/") def index(): 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"}) @app.get("/api/overview") def api_overview(): return ok(portfolio.overview) @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)}) # ================================================================ ① 参数设置 @app.get("/api/params") def api_params(): return ok(param_store.snapshot) @app.post("/api/params") def api_set_params(payload: dict = Body(...)): """单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。""" items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}] results = [] for it in items: k = it.get("key") if not k: results.append({"ok": False, "error": "缺少 key"}) continue results.append(param_store.set_param(k, it.get("value"), updated_by=payload.get("by") or "user")) _okf = all(r.get("ok") for r in results) _oplog("set_params", ok_flag=_okf, params={it.get("key"): it.get("value") for it in items}, reason=(None if _okf else "; ".join((r.get("error") or "") for r in results if not r.get("ok"))), by=payload.get("by") or "user") return {"ok": _okf, "results": results} # ================================================================ ② 命令台 @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") def api_commands(status: str = Query(None), limit: int = Query(100)): statuses = [s for s in (status or "").split(",") if s] or None return ok(pms_repo.list_commands, statuses=statuses, limit=limit) @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") def api_issue(payload: dict = Body(...)): return ok_logged("issue_command:" + str(payload.get("cmd_type")), command_service.issue, payload.get("cmd_type"), payload.get("params") or {}, note=payload.get("note"), issued_by=payload.get("by") or "user", force_conflict=bool(payload.get("force")), ts_code=(payload.get("params") or {}).get("ts_code"), params=payload, by=payload.get("by") or "user") @app.post("/api/commands/{command_id}/cancel") def api_cancel(command_id: str): return ok_logged("cancel_command", command_service.cancel, command_id, params={"command_id": command_id}) @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) return ok_logged("replan_command", _replan, params={"command_id": command_id}) @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(): return ok(lambda: {"ok": True, **portfolio.positions_view(), "stock_params": command_service.effective_stock_params()}) @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") def api_instructions(status: str = Query(None), limit: int = 200): statuses = [s for s in (status or "").split(",") if s] or None return ok(pms_repo.list_instructions, statuses=statuses, limit=limit) @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": {}}) # ================================================================ ④ 提议确认 @app.get("/api/proposals") def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100): statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",) return ok(pms_repo.list_proposals, statuses=statuses, limit=limit) @app.post("/api/proposals/{proposal_id}/decide") def api_decide(proposal_id: str, payload: dict = Body(default={})): """采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。""" decision = str(payload.get("decision") or "").upper() if decision not in ("ACCEPTED", "DECLINED"): return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"} def _decide(): 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, ref_id=proposal_id, reason=payload.get("reason") or "页面人工裁决") instruction_id = None if decision == "ACCEPTED": # 策略(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} instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1) side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy" pms_repo.insert_instruction( instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id, ts_code=p["ts_code"], action=p["action"], side=side, qty=int(p["qty"] or 0), limit_price=hn.get("price"), window_tdays=param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3), status="PROPOSED", progress={"from_proposal": proposal_id}) # 与自主执行同一口径: 采纳即算「做过一次」, 计数器要跟着走 # (否则页面采纳的那条动作绕开了 §6 的一次性约束) from app.services import proposal_service 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']}「只做一次」本轮失效"} return {"ok": True, "decision": decision, "instruction_id": instruction_id} return ok_logged("decide_proposal", _decide, params={"proposal_id": proposal_id, "decision": decision}, by=payload.get("by") or "user") @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) @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) @app.post("/api/ops/reconcile") 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) @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) @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 return ok_logged("cancel_instruction", executor.cancel_instruction, instruction_id, payload.get("reason") or "页面人工撤销", params={"instruction_id": instruction_id}) @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) @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) @app.get("/api/dispatch-mode") def api_dispatch_mode(): from app.services import dispatcher, judge return ok(lambda: {"ok": True, **dispatcher.describe(), "judge": judge.status()}) @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) # ================================================================ 上游选股计划 @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"), "snapshot": plan.get("snapshot"), "industry": industry.status()} return ok(_refresh) @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) @app.get("/api/ops/downstream-schema") def api_downstream_schema(): """导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。""" def _schema(): out = {} for t in ("trading_position", "trading_order", "trading_buy_plan", "gp_stock_category"): 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)} try: out["_category_probe"] = downstream_repo.category_columns(force=True) except Exception as e: out["_category_probe"] = {"error": str(e)} 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) # ================================================================ 个股交易方案 (策略) + 操作日志 @app.get("/api/strategies") def api_strategies(status: str = Query(None)): statuses = [s for s in (status or "").split(",") if s] or None return ok(lambda: {"ok": True, "strategies": pms_repo.list_strategies(statuses=statuses, limit=300), "enabled": param_store.get_bool("PMS_STRATEGY_ENABLED", False)}) @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") def api_strategy_attach(payload: dict = Body(...)): """挂载一条策略 (先校验再落库)。校验不过返回 {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, by=payload.get("by") or "user") @app.post("/api/strategies/{strategy_id}/status") def api_strategy_status(strategy_id: str, payload: dict = Body(...)): """暂停(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")}, by=payload.get("by") or "user") @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)})