From 8a3362a3d7e4bdae248005bbb27e3040e94e733e Mon Sep 17 00:00:00 2001 From: zlt Date: Fri, 28 Aug 2026 15:45:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=AF=8F=E6=97=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REAL_TRADING_DEPLOY_PLAN.md | 3 + app/repo/pms_repo.py | 39 ++++ app/scheduler.py | 13 ++ app/services/param_store.py | 6 + app/services/publish_export.py | 323 +++++++++++++++++++++++++++++++++ app/web/main.py | 20 ++ app/web/static/index.html | 7 +- ddl_pms_v1.sql | 16 ++ requirements.txt | 2 + scripts/check_db.py | 4 +- 10 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 app/services/publish_export.py diff --git a/REAL_TRADING_DEPLOY_PLAN.md b/REAL_TRADING_DEPLOY_PLAN.md index 25bb159..c753fe4 100644 --- a/REAL_TRADING_DEPLOY_PLAN.md +++ b/REAL_TRADING_DEPLOY_PLAN.md @@ -104,6 +104,9 @@ ws_smoke 发的联调单绕开这个开关、且带 SMOKE 前缀——成交回 `ws_smoke.py inbox` 里有 snapshot/心跳类上行。 9. S2 式发单(挂不上才是预期):盘中发一张限价远离市价的买单(--ttl 5 到点对端自动撤), watch 里状态走 QUEUED→SENT→受理,inbox 里有 ack 与回报。 + **下单窗口只认 9:31–14:56**:14:57–15:00 是收盘集合竞价、9:15–9:30 是开盘竞价段, + QMT 侧按非交易时段拒(2026-08-28 实测 14:59 下单回 NOT_TRADING_TIME); + 带 TTL 的测试单别让有效期跨过 14:56。 10. **一笔真实买卖**(判收核心):建议选一只当日可回转(T+0)的跨境或货币 ETF,一手 一两百块,当天买进当天卖出;用普通股票也行,但 A 股 T+1,卖出要等次日。判收三条: 两张单全成、inbox 有 trade 回报、**账本零变化**(SMOKE 联调单闸把成交挡在账外)。 diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index de5e361..614ae01 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -351,6 +351,45 @@ def update_lot(lot_id: int, **fields) -> int: return execute(f"UPDATE pms_lot SET {clause}, updated_at = :ts WHERE id = :id", p) +def list_open_lot_rows(limit: int = 2000) -> list: + """公示导出·持仓明细: 未平部分>0 的批次, 开仓时间升序。qty 即剩余数量 + (close_lot_qty 核销时就地扣减), 不需要再拿 closed_qty 去算。""" + return fetch_all( + "SELECT * FROM pms_lot WHERE status = 'OPEN' AND qty > 0 " + "ORDER BY open_date ASC, id ASC LIMIT :n", {"n": int(limit)}) + + +def list_closed_lot_rows(limit: int = 2000) -> list: + """公示导出·平仓明细: 有平仓量的批次 (含部分平仓, 此时同一批次会同时出现在 + 持仓与平仓两张明细里, 各按剩余量/已平量计, 与公司表格口径一致)。""" + return fetch_all( + "SELECT * FROM pms_lot WHERE closed_qty > 0 " + "ORDER BY updated_at ASC, id ASC LIMIT :n", {"n": int(limit)}) + + +# ================================================================ pms_nav_daily (公示导出) +def upsert_nav_daily(*, ymd: int, nav: float, pos_ratio, holding_pnl: float, + realized_pnl: float, nav_scale: float, price_missing: int) -> int: + """每日净值快照落一行; 同日重跑覆盖更新 (以收盘后最后一次为准)。""" + return execute( + "INSERT INTO pms_nav_daily (ymd, nav, pos_ratio, holding_pnl, realized_pnl, " + "nav_scale, price_missing, created_at) VALUES " + "(:ymd, :nav, :pr, :hp, :rp, :sc, :pm, :ts) " + "ON DUPLICATE KEY UPDATE nav = :nav, pos_ratio = :pr, holding_pnl = :hp, " + "realized_pnl = :rp, nav_scale = :sc, price_missing = :pm, created_at = :ts", + {"ymd": int(ymd), "nav": float(nav), + "pr": (float(pos_ratio) if pos_ratio is not None else None), + "hp": float(holding_pnl), "rp": float(realized_pnl), "sc": float(nav_scale), + "pm": int(price_missing), "ts": _NOW()}) + + +def list_nav_daily(limit: int = 400) -> list: + """净值序列, 升序返回 (查询按 ymd 降序取最近 N 行再反转, 序列长了也只拿近段)。""" + rows = fetch_all("SELECT * FROM pms_nav_daily ORDER BY ymd DESC LIMIT :n", + {"n": int(limit)}) + return list(reversed(rows)) + + # ================================================================ pms_instruction def insert_instruction(*, instruction_id, origin_type, origin_id, ts_code, action, side, qty, limit_price=None, window_tdays=3, status="PROPOSED", diff --git a/app/scheduler.py b/app/scheduler.py index 6c9a202..020ef07 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -14,6 +14,7 @@ | 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 | | T 仓平回 | 14:50 (二期) | 做T强制平回 | | 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 | +| 净值快照 | 15:20 | 公示净值落一行 (导出表的净值序列) | | 运营日报 | 15:30 | 关注区 + 全量统计, 页面可查 | 三条守卫: @@ -305,6 +306,17 @@ def daily_settle(): return out +@celery_app.task(name="pms.nav_snapshot") +@guard(trade_day=True, respect_exec_halt=False) # 记账类动作, 休假模式照跑 +def nav_snapshot(): + """每日净值快照 (15:20): 按公示口径记一行净值 (pms_nav_daily), 供公示表导出的 + 净值序列使用。排在日终结算 (15:10) 之后 —— 尾窗成交已入账、收盘价已落定; + 在运营日报 (15:30) 之前。同日重跑覆盖, 以最后一次为准。历史不回填 + (2026-08-28 拍板: 启用日之前的净值继续查人工表格)。""" + from app.services import publish_export + return publish_export.record_nav_snapshot() + + @celery_app.task(name="pms.daily_report") @guard(trade_day=True, respect_exec_halt=False) def daily_report(): @@ -332,5 +344,6 @@ celery_app.conf.beat_schedule = { "strategy_attach": {"task": "pms.strategy_attach", "schedule": crontab(hour=9, minute=40)}, "t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)}, "daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)}, + "nav_snapshot": {"task": "pms.nav_snapshot", "schedule": crontab(hour=15, minute=20)}, "daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)}, } diff --git a/app/services/param_store.py b/app/services/param_store.py index 34b6733..8f24306 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -58,6 +58,12 @@ RUNTIME_EXTRA = { # 2026-08-26: 页面要能回答「今天的正式一跳跑没跑、做了什么」。零动作的一跳在账上没有 # 任何痕迹 (设计如此, 台账只记真动作), 所以由 advisor 每次真跑后写一份摘要在这里。 "PMS_AUTO_LAST_SCAN": ("", str, "自动挂载最近一次正式扫描的摘要 (advisor 真跑后写入的 JSON: 日期/时刻/各类计数), 页面只读, 勿手改"), + # 2026-08-28 公示导出: 每日持仓/净值导出为公司《量化数据》公示表 (页面按钮 + 15:20 + # 净值快照)。账户名/结构/标题是公示口径的固定文案, 不进账务, 放这里页面可改。 + "PMS_PUBLISH_TITLE": ("25年投研量化平层产品基本信息", str, "公示表标题 (首行大字)"), + "PMS_PUBLISH_ACCOUNT": ("天盟实业", str, "公示表·开单账户列的账户名"), + "PMS_PUBLISH_STRUCTURE": ("二级(平层)", str, "公示表·结构列的产品结构标签"), + "PMS_PUBLISH_NAV_SCALE": (0.0, float, "公示净值规模 (元): 净值=1+累计盈亏/此数; 0=用 PMS_TOTAL_SCALE"), } # **读不到时必须按"已暂停"处理的键 (fail-closed)。** diff --git a/app/services/publish_export.py b/app/services/publish_export.py new file mode 100644 index 0000000..07d9ebf --- /dev/null +++ b/app/services/publish_export.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +""" +公示表导出 (每日持仓 / 净值 → 公司《量化数据》xlsx) +==================================================== +公司流程: 每日把持仓与净值导出为 excel 公示。本模块按公司模板 +(量化数据2026.8.3.xlsx) 的版式与口径生成同构表格, 数据只含**系统接管之后**的 +账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与明细继续查人工表格)。 + +口径 (逐项对模板核过数, 见 docs 拍板记录): + * 每行: 交易金额 = 成本价 × 数量; 涨跌幅 = 现价/成本 − 1; 净值估算 = 1 + 收益率。 + * 持仓+平仓收益合计 = 持仓浮动盈亏 Σ + 平仓已实现盈亏 Σ; + 累计净值 = 1 + 该合计 / 净值规模 (参数 PMS_PUBLISH_NAV_SCALE, 0=用 PMS_TOTAL_SCALE)。 + * 可开仓总金额 = 净值规模 + 平仓已实现盈亏 Σ (亏损使其变小, 模板 F107 同法); + 剩余可开仓 = 可开仓总金额 − 存量成本合计; 持仓仓位 = 存量成本合计 / 可开仓总金额。 + * 净值序列每交易日一行, 由调度 15:20 (pms.nav_snapshot) 落 pms_nav_daily; + 导出时当日行用实时价现算覆盖, 保证盘中导出也有今天。 + * 现价取不到的票按成本价顶上并**在表尾如实标注只数** —— 拿不到不装有。 + +明细行来自批次账 (pms_lot): 持仓明细 = 未平批次 (剩余数量>0), 一行一笔开单; +平仓明细 = 有平仓量的批次 (含部分平仓), 结算价为该批加权平均平仓价。 +账户名 / 结构 / 标题是公示口径固定字段, 全部放参数中心 (PMS_PUBLISH_*), 页面可改。 +""" +from __future__ import annotations + +import io +import logging +from datetime import date, datetime + +from app.repo import downstream_repo, pms_repo +from app.services import market, param_store + +logger = logging.getLogger("pms.publish") + +_FIN_NONE = "无" # 融资金额/融资费用列: 本产品无融资, 固定"无" (模板同) + + +# ---------------------------------------------------------------- 取数与口径 +def _nav_scale() -> float: + """净值规模: 参数为 0 时退回总操作规模。两者都为 0 说明参数没配, 直接报错 + 比导出一张全是除零的表诚实。""" + v = param_store.get_float("PMS_PUBLISH_NAV_SCALE", 0.0) or 0.0 + if v <= 0: + v = param_store.get_float("PMS_TOTAL_SCALE", 0.0) or 0.0 + if v <= 0: + raise ValueError("净值规模未配置: PMS_PUBLISH_NAV_SCALE 与 PMS_TOTAL_SCALE 都是 0") + return float(v) + + +def _as_date(v): + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + return None + + +def compute_snapshot() -> dict: + """组装公示快照: 持仓明细 / 平仓明细 / 汇总 / 当日净值。全部只读。""" + today = date.today() + scale = _nav_scale() + + open_lots = pms_repo.list_open_lot_rows() + closed_lots = pms_repo.list_closed_lot_rows() + codes = sorted({r["ts_code"] for r in open_lots} | {r["ts_code"] for r in closed_lots}) + try: + names = downstream_repo.fetch_names(codes) if codes else {} + except Exception as e: # 名字取不到不拦导出, 代码列还在 + logger.warning("公示导出取中文名失败 (用代码顶): %s", e) + names = {} + open_codes = sorted({r["ts_code"] for r in open_lots}) + prices = market.get_prices(open_codes) if open_codes else {} + + account = param_store.get_str("PMS_PUBLISH_ACCOUNT", "") + structure = param_store.get_str("PMS_PUBLISH_STRUCTURE", "") + + holdings, price_missing = [], set() + for r in open_lots: + code = r["ts_code"] + qty = int(r.get("qty") or 0) + cost = float(r.get("open_price") or 0) + px = prices.get(code) + price_ok = bool(px and px > 0) + if not price_ok: + px = cost # 顶价只为市值可算; 缺价只数在表尾如实标注 + price_missing.add(code) + od = _as_date(r.get("open_date")) or today + chg = (px / cost - 1.0) if cost > 0 else 0.0 + holdings.append({ + "account": account, "code": code.split(".")[0], "name": names.get(code) or code, + "amount": round(cost * qty, 2), "open_date": od, "upd_date": today, + "structure": structure, "cost": cost, "qty": qty, "price": round(px, 3), + "days": (today - od).days, "per_share": round(px - cost, 3), + "chg": chg, "pnl": round((px - cost) * qty, 2), "ret": chg, "nav_est": 1 + chg, + }) + + closed = [] + for r in closed_lots: + code = r["ts_code"] + cq = int(r.get("closed_qty") or 0) + cost = float(r.get("open_price") or 0) + settle = float(r.get("close_avg_price") or 0) + pnl = float(r.get("realized_pnl") or 0) + od = _as_date(r.get("open_date")) or today + cd = _as_date(r.get("updated_at")) or today + amt = cost * cq + ret = (pnl / amt) if amt > 0 else 0.0 + closed.append({ + "account": account, "code": code.split(".")[0], "name": names.get(code) or code, + "amount": round(amt, 2), "open_date": od, "close_date": cd, + "structure": structure, "cost": cost, "qty": cq, "settle": round(settle, 3), + "days": (cd - od).days, "per_share": round(settle - cost, 3), + "chg": (settle / cost - 1.0) if cost > 0 else 0.0, + "pnl": round(pnl, 2), "ret": ret, "nav_est": 1 + ret, + }) + + hold_cost = sum(h["amount"] for h in holdings) + hold_pnl = sum(h["pnl"] for h in holdings) + realized = sum(c["pnl"] for c in closed) + total_pnl = hold_pnl + realized + nav = 1.0 + total_pnl / scale + openable = scale + realized # 可开仓总金额 = 净值规模 + 已实现盈亏 (模板口径) + return { + "today": today, "scale": scale, "holdings": holdings, "closed": closed, + "hold_cost": round(hold_cost, 2), "hold_pnl": round(hold_pnl, 2), + "closed_cost": round(sum(c["amount"] for c in closed), 2), + "realized": round(realized, 2), "total_pnl": round(total_pnl, 2), + "nav": round(nav, 4), "openable": round(openable, 2), + "open_room": round(openable - hold_cost, 2), + "pos_ratio": round(hold_cost / openable, 4) if openable > 0 else None, + "price_missing": sorted(price_missing), + "title": param_store.get_str("PMS_PUBLISH_TITLE", "量化产品基本信息"), + } + + +def record_nav_snapshot() -> dict: + """净值快照落库 (调度 15:20 调用; 同日重跑覆盖)。失败抛给调度守卫记 ERROR。""" + s = compute_snapshot() + ymd = int(s["today"].strftime("%Y%m%d")) + pms_repo.upsert_nav_daily( + ymd=ymd, nav=s["nav"], pos_ratio=s["pos_ratio"], holding_pnl=s["hold_pnl"], + realized_pnl=s["realized"], nav_scale=s["scale"], + price_missing=len(s["price_missing"])) + return {"ok": True, "ymd": ymd, "nav": s["nav"], "pos_ratio": s["pos_ratio"], + "price_missing": len(s["price_missing"])} + + +def _nav_series(snapshot: dict) -> list: + """净值序列 = 库里逐日快照 + 当日实时行 (同日覆盖, 盘中导出也有今天)。""" + ymd_today = int(snapshot["today"].strftime("%Y%m%d")) + rows = [] + try: + rows = pms_repo.list_nav_daily() + except Exception as e: # 表还没建好 (未跑 init_db) 时导出仍可用, 只有当日一行 + logger.warning("读净值序列失败 (导出只含当日): %s", e) + out = [r for r in rows if int(r["ymd"]) != ymd_today] + out.append({"ymd": ymd_today, "nav": snapshot["nav"], + "pos_ratio": snapshot["pos_ratio"]}) + return out + + +# ---------------------------------------------------------------- 版式 +def build_workbook(s: dict, nav_rows: list) -> bytes: + """按公司模板版式生成工作簿。纯函数 (不碰库), 可离线测试。""" + from openpyxl import Workbook + from openpyxl.chart import LineChart, Reference + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + from openpyxl.utils import get_column_letter + + wb = Workbook() + ws = wb.active + ws.title = "Sheet1" + font = Font(name="等线", size=11) + bold = Font(name="等线", size=11, bold=True) + center = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin = Side(style="thin", color="999999") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + head_fill = PatternFill("solid", fgColor="DDEBF7") + + D_FMT, M_FMT, P_FMT, N_FMT = "yyyy/m/d", "#,##0.00", "0.00%", "0.0000" + HEAD_H = ["序号", "开单账户", "代码", "标的", "交易金额(元)", "起始时间", "更新时间", + "结构", "交易成本价", "持股数量", "当日价格", "自然天数", "融资金额", + "融资费用", "每股较期初价盈(元)", "较期初价涨跌幅", "总持股\n浮动盈亏(元)", + "持有收益率", "净值估算"] + HEAD_C = HEAD_H.copy() + HEAD_C[6], HEAD_C[10] = "平仓时间", "结算价" + FMTS = [None, None, "@", None, M_FMT, D_FMT, D_FMT, None, "0.000", "#,##0", "0.000", + "0", None, None, "0.000", P_FMT, M_FMT, P_FMT, N_FMT] + + def put(row, col, value, *, f=font, fmt=None, align=None, fill=None, border=box): + c = ws.cell(row=row, column=col, value=value) + c.font = f + if fmt: + c.number_format = fmt + if align: + c.alignment = align + if fill: + c.fill = fill + if border: + c.border = border + return c + + def header_row(row, heads): + for i, h in enumerate(heads): + put(row, 3 + i, h, f=bold, align=center, fill=head_fill) + + def entry_row(row, e, *, date2_key, px_key): + vals = [e.get("seq"), e["account"], e["code"], e["name"], e["amount"], + e["open_date"], e[date2_key], e["structure"], e["cost"], e["qty"], + e[px_key], e["days"], _FIN_NONE, _FIN_NONE, e["per_share"], e["chg"], + e["pnl"], e["ret"], e["nav_est"]] + for i, v in enumerate(vals): + put(row, 3 + i, v, fmt=FMTS[i]) + + def subtotal_row(row, top, bottom): + """小计行: 金额与盈亏用 SUM 公式, 比率按公式引用 (空表保护为 0)。""" + put(row, 5, "小计", f=bold, align=center) + put(row, 7, f"=SUM(G{top}:G{bottom})" if bottom >= top else 0, f=bold, fmt=M_FMT) + put(row, 18, "合计", f=bold, align=center) + put(row, 19, f"=SUM(S{top}:S{bottom})" if bottom >= top else 0, f=bold, fmt=M_FMT) + put(row, 20, f"=IF(G{row}=0,0,S{row}/G{row})", f=bold, fmt=P_FMT) + put(row, 21, f"=1+T{row}", f=bold, fmt=N_FMT) + + # ---- 标题与时间 ---- + ws.merge_cells(start_row=1, start_column=2, end_row=1, end_column=21) + put(1, 2, s["title"], f=Font(name="等线", size=16, bold=True), align=center, border=None) + start = nav_rows[0]["ymd"] if nav_rows else int(s["today"].strftime("%Y%m%d")) + put(2, 19, "起始时间:", border=None) + put(2, 20, datetime.strptime(str(start), "%Y%m%d").date(), fmt=D_FMT, border=None) + put(3, 19, "更新时间:", border=None) + put(3, 20, s["today"], fmt=D_FMT, border=None) + + # ---- 持仓明细 ---- + r = 4 + header_row(r, HEAD_H) + hold_top = r + 1 + for i, e in enumerate(s["holdings"], 1): + e["seq"] = i + entry_row(r + i, e, date2_key="upd_date", px_key="price") + if not s["holdings"]: + put(hold_top, 3, "(当前无持仓)", align=center) + r_sub = hold_top + (len(s["holdings"]) if s["holdings"] else 1) + subtotal_row(r_sub, hold_top, r_sub - 1 if s["holdings"] else hold_top - 1) + ws.merge_cells(start_row=4, start_column=2, end_row=r_sub, end_column=2) + put(4, 2, "平层\n持仓", f=bold, align=center) + + # ---- 平仓明细 ---- + r = r_sub + 1 + header_row(r, HEAD_C) + close_top = r + 1 + for i, e in enumerate(s["closed"], 1): + e["seq"] = i + entry_row(r + i, e, date2_key="close_date", px_key="settle") + if not s["closed"]: + put(close_top, 3, "(暂无平仓记录)", align=center) + r_csub = close_top + (len(s["closed"]) if s["closed"] else 1) + subtotal_row(r_csub, close_top, r_csub - 1 if s["closed"] else close_top - 1) + ws.merge_cells(start_row=r_sub + 1, start_column=2, end_row=r_csub, end_column=2) + put(r_sub + 1, 2, "平层\n卖出", f=bold, align=center) + + # ---- 汇总两行 (公式引用两张小计行, 口径同模板) ---- + r1, r2 = r_csub + 1, r_csub + 2 + put(r1, 2, "存量合计", f=bold, align=center) + put(r1, 7, f"=G{r_sub}", f=bold, fmt=M_FMT) + ws.merge_cells(start_row=r1, start_column=17, end_row=r1, end_column=18) + put(r1, 17, "持仓+平仓收益合计", f=bold, align=center) + put(r1, 19, f"=S{r_sub}+S{r_csub}", f=bold, fmt=M_FMT) + put(r1, 20, f"=S{r1}/{s['scale']}", f=bold, fmt=P_FMT) + put(r1, 21, f"=1+T{r1}", f=bold, fmt=N_FMT) + ws.merge_cells(start_row=r2, start_column=2, end_row=r2, end_column=5) + put(r2, 2, "初始规模+已回收益后可开仓总金额", f=bold, align=center) + put(r2, 6, f"={s['scale']}+S{r_csub}", f=bold, fmt=M_FMT) + put(r2, 8, "剩余可开仓金额", f=bold, align=center) + put(r2, 10, f"=F{r2}-G{r1}", f=bold, fmt=M_FMT) + put(r2, 11, "持仓仓位", f=bold, align=center) + put(r2, 12, f"=IF(F{r2}=0,0,G{r1}/F{r2})", f=bold, fmt=P_FMT) + put(r2, 13, "累计净值", f=bold, align=center) + put(r2, 15, f"=U{r1}", f=bold, fmt=N_FMT) + if s["price_missing"]: + put(r2 + 1, 2, f"注:{len(s['price_missing'])} 只标的当日无行情,按成本价计入市值" + f"({'、'.join(s['price_missing'][:5])}" + f"{' 等' if len(s['price_missing']) > 5 else ''})。", + border=None) + + # ---- 净值序列 (表右侧, 位置同模板 Y/Z/AA 列) + 折线图 ---- + NC = 25 # Y 列 + put(4, NC, "时间", f=bold, align=center, fill=head_fill) + put(4, NC + 1, "净值数据", f=bold, align=center, fill=head_fill) + put(4, NC + 2, "仓位占比", f=bold, align=center, fill=head_fill) + for i, row in enumerate(nav_rows, 1): + put(4 + i, NC, datetime.strptime(str(row["ymd"]), "%Y%m%d").date(), fmt=D_FMT) + put(4 + i, NC + 1, float(row["nav"]), fmt=N_FMT) + pr = row.get("pos_ratio") + put(4 + i, NC + 2, float(pr) if pr is not None else None, fmt=P_FMT) + if nav_rows: + chart = LineChart() + chart.title = "净值走势" + chart.height, chart.width = 8, 16 + chart.y_axis.title = "净值" + data = Reference(ws, min_col=NC + 1, min_row=4, max_row=4 + len(nav_rows)) + cats = Reference(ws, min_col=NC, min_row=5, max_row=4 + len(nav_rows)) + chart.add_data(data, titles_from_data=True) + chart.set_categories(cats) + ws.add_chart(chart, f"{get_column_letter(NC)}{6 + len(nav_rows)}") + + # ---- 列宽 ---- + widths = {2: 9, 3: 5, 4: 10, 5: 9, 6: 13, 7: 11, 8: 11, 9: 12, 10: 9, 11: 9, 12: 9, + 13: 9, 14: 9, 15: 10, 16: 12, 17: 12, 18: 13, 19: 11, 20: 11, 21: 9, + 25: 11, 26: 10, 27: 10} + for col, w in widths.items(): + ws.column_dimensions[get_column_letter(col)].width = w + ws.freeze_panes = "C5" + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def export_xlsx() -> tuple: + """导出入口: 返回 (文件名, xlsx 字节)。文件名沿用公司习惯: 量化数据YYYY.M.D.xlsx。""" + s = compute_snapshot() + blob = build_workbook(s, _nav_series(s)) + t = s["today"] + return f"量化数据{t.year}.{t.month}.{t.day}.xlsx", blob diff --git a/app/web/main.py b/app/web/main.py index 121fc0d..f785ae6 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -394,6 +394,26 @@ def api_report(ymd: int = Query(None)): or {"ymd": None, "report": {}}) +@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)}"}) + + # ================================================================ ④ 提议确认 @app.get("/api/proposals") def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100, diff --git a/app/web/static/index.html b/app/web/static/index.html index 3e881a3..396cb24 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -429,6 +429,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} + {{ autoRefresh ? '自动' : '手动' }} · {{ lastRefresh }} @@ -2490,6 +2491,10 @@ createApp({ me.value = { phone: '', username: '', roles: [] }; } + // 公示表导出 (2026-08-28): 直接开新页下载, 登录票在 cookie 里自然带上。 + // 后端失败按站内惯例回 JSON (HTTP 200), 新页会显示错误原因, 不做弹窗翻译。 + function doExport() { window.open('/api/export/publish-xlsx', '_blank'); } + async function loadParams() { const d = await call('get', '/api/params'); const list = ((d.data || d).params) || []; @@ -3270,7 +3275,7 @@ createApp({ msgLatest, msgLevelClass, setMsgKind, msgGo, sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther, lFold, rFold, railOpen, foldRail, closeRail, openRailSide, - authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, + authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport, sectClosed, toggleSect }; } }).use(ElementPlus).mount('#app'); diff --git a/ddl_pms_v1.sql b/ddl_pms_v1.sql index 3c4d091..20f1673 100644 --- a/ddl_pms_v1.sql +++ b/ddl_pms_v1.sql @@ -397,3 +397,19 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal ( UNIQUE KEY uk_sig_date (signal_key, trade_date), KEY idx_key_id (signal_key, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宏观信号日快照与动作记录'; + +-- 20. 每日净值快照 (公示导出, 2026-08-28) +-- 收盘后 15:20 调度落一行 (pms.nav_snapshot), 同日重跑覆盖。口径与公司《量化数据》 +-- 公示表一致: 净值 = 1 + (持仓浮动盈亏 + 平仓已实现盈亏) / 净值规模; +-- 净值规模取参数 PMS_PUBLISH_NAV_SCALE (0 = 用 PMS_TOTAL_SCALE)。 +-- 历史不回填 (2026-08-28 拍板): 系统只记启用日之后的净值, 之前的查人工表格。 +CREATE TABLE IF NOT EXISTS pms_nav_daily ( + ymd INT PRIMARY KEY COMMENT 'YYYYMMDD', + nav DECIMAL(10,4) NOT NULL COMMENT '累计净值', + pos_ratio DECIMAL(8,4) NULL COMMENT '仓位占比 = 存量成本额/可开仓总金额', + holding_pnl DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '持仓浮动盈亏合计', + realized_pnl DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '平仓已实现盈亏合计(含T)', + nav_scale DECIMAL(14,2) NOT NULL COMMENT '当日采用的净值规模', + price_missing INT NOT NULL DEFAULT 0 COMMENT '现价缺失只数(>0=按成本顶价估算)', + created_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日净值快照 (公示导出)'; diff --git a/requirements.txt b/requirements.txt index 64c0e3d..22c3f1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ chinesecalendar>=1.9 websockets>=12.0,<15.0 # Ed25519 签名/验签 (协议 §2.1)。python:3.11-slim 上有 manylinux 轮子, 不需要编译链。 cryptography>=42.0 +# ---- 公示表导出 (2026-08-28): 生成持仓/净值 xlsx。锁 3.x 大版本内, 纯 python 轮子。 +openpyxl>=3.1,<4 diff --git a/scripts/check_db.py b/scripts/check_db.py index 86c9521..2db9497 100644 --- a/scripts/check_db.py +++ b/scripts/check_db.py @@ -30,7 +30,9 @@ PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruc # 18 号表: 宏观信号日快照 (2026-08-18 宏观择时层)。 # 顺带记录既有欠账: 16/17 号表 pms_strategy / pms_op_log 不在本清单里, # 属加表时漏登记 —— 按"不夹带"纪律这次不补, 单独提单独拍板。 - "pms_macro_signal"] + "pms_macro_signal", + # 20 号表: 每日净值快照 (2026-08-28 公示导出)。 + "pms_nav_daily"] DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"] FAILED, WARNED = [], []