tradingSystem/app/web/main.py

326 lines
13 KiB
Python

# -*- 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.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)