# -*- 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) 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}"} # ================================================================ 基础 @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(), } @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/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")) return {"ok": all(r.get("ok") for r in results), "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(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"))) @app.post("/api/commands/{command_id}/cancel") def api_cancel(command_id: str): return ok(command_service.cancel, 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(_replan) @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": 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}) return {"ok": True, "decision": decision, "instruction_id": instruction_id} return ok(_decide) @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.post("/api/ops/reconcile") def api_reconcile(apply_fix: bool = Query(True)): return ok(ledger_service.reconcile, apply_fix=apply_fix) @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(executor.cancel_instruction, instruction_id, payload.get("reason") or "页面人工撤销") @app.get("/api/dispatch-mode") def api_dispatch_mode(): from app.services import dispatcher return ok(dispatcher.describe) @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"): 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)} 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)