# -*- coding: utf-8 -*- """ 公示表导出 (每日持仓 / 净值 → 公司《量化数据》xlsx) ==================================================== 公司流程: 每日把持仓与净值导出为 excel 公示。版式**不重画**: 仓库里放着由公司模板 (量化数据2026.8.3.xlsx) 裁出来的空模板 app/assets/publish_template.xlsx —— 字体、 颜色、边框、列宽、数字格式全部原样保留, 导出时做四件事: 插行、填数、按最终行位重排 合并区 / 行高 / 红绿条件格式、画净值+仓位双系列折线图。openpyxl 的插行既不搬合并区 也不搬 row_dimensions, 这些全按最终行位重建; 模板行位之外的行属性进场即清 (公司原表 把老平仓行折叠隐藏, 残留 hidden 标记曾把整段合计行与图藏掉, 2026-08-28 正式机实锅)。 数据只含**系统接管之后**的账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与 明细继续查人工表格)。 明细口径 (2026-08-28 第二次拍板: **多笔持仓合为一笔**): * 持仓明细: 未平批次按**股票**合并成一行 —— 数量为各批合计, 成本价为加权平均, 交易金额为各批成本额合计, 起始时间取最早一批。 * 平仓明细: 已平部分按 **(股票, 平仓日)** 合并成一行 —— 同一天对同一只票的分批 卖出在公司表里就是一笔; 隔日再平仓另起一行。结算价为加权平均平仓价。 部分平仓的批次会同时出现在两张明细里 (剩余量在持仓、已平量在平仓), 与公司表同法。 * 自然天数 = 起始日到截止日**含头含尾** (模板实测口径: 7-03 到 8-03 记 32 天)。 金额口径 (逐项对模板核过数): * 持仓行: 浮动盈亏 = (现价 − 成本) × 数量, **不含费** (模板 S 列与 Q×L 逐分一致)。 * 平仓行: 盈亏 = 批次已实现盈亏 − **平仓当日该票的费用** (模板卖出行含费: Q×L 与 S 的差恰为双边费用)。费用取 pms_cash_flow 的 FEE 行按 (日期, 代码) 聚合; 买入侧费用与无代码归属的校准费摊不进任何一行, 有此残差时在表尾如实标注金额 —— 表内合计公式 (S 合计 = 持仓Σ + 平仓Σ) 是模板的恒等式, 不能为了塞费用把它弄破。 * 持仓+平仓收益合计 = 两张小计之和; 累计净值 = 1 + 该合计 / 净值规模 (参数 PMS_PUBLISH_NAV_SCALE, 0 = 用 PMS_TOTAL_SCALE)。 * 可开仓总金额 = 净值规模 + 平仓小计 (亏损使其变小, 模板 F107 同法); 剩余可开仓 = 可开仓总金额 − 存量成本合计; 持仓仓位 = 存量成本合计 / 可开仓总金额。 * 净值序列每交易日一行, 由调度 15:20 (pms.nav_snapshot) 落 pms_nav_daily; 导出时当日行用实时价现算覆盖, 保证盘中导出也有今天。 * 现价取不到的票按成本价顶上并在表尾如实标注只数 —— 拿不到不装有。 表内金额与比率一律写**公式** (SUM / 引用), 不写算好的死数 —— 公示表拿到手里改一个 数, 合计与净值会跟着动, 这比一张全是常量的表诚实。账户名 / 结构 / 标题放参数中心 (PMS_PUBLISH_*), 页面可改。 """ from __future__ import annotations import io import logging import os from copy import copy 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 = "无" # 融资金额/融资费用列: 本产品无融资, 固定"无" (模板同) TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "publish_template.xlsx") # 模板固定行位 (app/assets/publish_template.xlsx 的裁法, 动模板必须同步这里): # 1 标题 · 2 起始时间 · 3 更新时间 · 4 持仓表头 · 5 持仓样式行 · 6 持仓小计 # 7 卖出表头 · 8 卖出样式行 · 9 卖出小计 · 10 存量合计行 · 11 底部指标行 _R_HPROTO, _R_HSUB, _R_CHEAD, _R_CPROTO, _R_CSUB, _R_SUM1, _R_SUM2 = 5, 6, 7, 8, 9, 10, 11 # ---------------------------------------------------------------- 取数与口径 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 if isinstance(v, str) and len(v) >= 10: try: return datetime.strptime(v[:10], "%Y-%m-%d").date() except ValueError: return None return None def _days(d0, d1) -> int: """自然天数, 含头含尾 (模板口径: 7-03 → 8-03 = 32)。""" return (d1 - d0).days + 1 def _display_name(v, code: str) -> str: """标的列显示名。downstream_repo.fetch_names 返回 {"name": 简称, "full": 全称} (取不到的代码它退回 name=代码本身) —— 公示表用简称, 缺简称用全称, 都没有用代码。 单元格必须是字符串, 字典直接写会被 openpyxl 拒收 (2026-08-28 实机报错修)。""" if isinstance(v, dict): v = v.get("name") or v.get("full") return str(v) if v else code.split(".")[0] def _fee_map() -> dict: """{(ymd, ts_code): 费用合计}。读不到按空 —— 费用缺席只影响平仓行含费口径, 表尾会把未摊费用标出来, 不拦导出。""" try: return {(int(r["ymd"]), r["ts_code"]): float(r["fee"] or 0) for r in pms_repo.fee_sum_by_code()} except Exception as e: # noqa: BLE001 logger.warning("公示导出读费用失败 (平仓行按不含费出): %s", e) return {} 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 {} fees = _fee_map() # 字符串参数走通用 get() (ParamStore 没有 get_str; 类型按 RUNTIME_EXTRA 注册项转换) account = str(param_store.get("PMS_PUBLISH_ACCOUNT") or "") structure = str(param_store.get("PMS_PUBLISH_STRUCTURE") or "") # ---- 持仓: 按股票合并 (多笔持仓合为一笔, 2026-08-28 拍板) ---- grp = {} for r in open_lots: code = r["ts_code"] qty = int(r.get("qty") or 0) cost = float(r.get("open_price") or 0) od = _as_date(r.get("open_date")) or today g = grp.setdefault(code, {"qty": 0, "amount": 0.0, "open_date": od}) g["qty"] += qty g["amount"] += cost * qty g["open_date"] = min(g["open_date"], od) holdings, price_missing = [], set() for code in sorted(grp, key=lambda c: (grp[c]["open_date"], c)): g = grp[code] if g["qty"] <= 0: continue cost = g["amount"] / g["qty"] px = prices.get(code) if not (px and px > 0): px = cost # 顶价只为市值可算; 缺价只数在表尾如实标注 price_missing.add(code) holdings.append({ "account": account, "code": code.split(".")[0], "name": _display_name(names.get(code), code), "amount": round(g["amount"], 2), "open_date": g["open_date"], "upd_date": today, "structure": structure, "cost": round(cost, 3), "qty": g["qty"], "price": round(float(px), 3), "days": _days(g["open_date"], today), }) # ---- 平仓: 按 (股票, 平仓日) 合并; 盈亏 = 已实现 − 当日该票费用 ---- cgrp = {} for r in closed_lots: code = r["ts_code"] cq = int(r.get("closed_qty") or 0) if cq <= 0: continue cost = float(r.get("open_price") or 0) settle = float(r.get("close_avg_price") or 0) od = _as_date(r.get("open_date")) or today cd = _as_date(r.get("updated_at")) or today g = cgrp.setdefault((code, cd), {"qty": 0, "amount": 0.0, "settle_amt": 0.0, "pnl": 0.0, "open_date": od}) g["qty"] += cq g["amount"] += cost * cq g["settle_amt"] += settle * cq g["pnl"] += float(r.get("realized_pnl") or 0) g["open_date"] = min(g["open_date"], od) closed, fee_alloc = [], 0.0 for (code, cd) in sorted(cgrp, key=lambda k: (k[1], k[0])): g = cgrp[(code, cd)] fee = fees.get((int(cd.strftime("%Y%m%d")), code), 0.0) fee_alloc += fee closed.append({ "account": account, "code": code.split(".")[0], "name": _display_name(names.get(code), code), "amount": round(g["amount"], 2), "open_date": g["open_date"], "close_date": cd, "structure": structure, "cost": round(g["amount"] / g["qty"], 3), "qty": g["qty"], "settle": round(g["settle_amt"] / g["qty"], 3), "days": _days(g["open_date"], cd), "pnl": round(g["pnl"] - fee, 2), }) # 未摊入明细的费用 (买入侧 + 无代码归属的校准行): 表尾如实标注, 不悄悄吞掉 try: fee_total = float(pms_repo.sum_fee_all()) except Exception: # noqa: BLE001 fee_total = fee_alloc fee_resid = round(fee_total - fee_alloc, 2) hold_cost = sum(h["amount"] for h in holdings) hold_pnl = sum(round((h["price"] - h["cost"]) * h["qty"], 2) 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), "fee_resid": fee_resid, "title": str(param_store.get("PMS_PUBLISH_TITLE") or "量化产品基本信息"), } 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 _copy_row_style(ws, src_row: int, dst_row: int, cols=range(2, 22)): """只拷单元格样式。行高不在这拷 —— openpyxl 插行不搬 row_dimensions, 拷邻行行高 等于拷一个错位的旧值, 行高在 build_workbook 里按最终行位统一重排。""" for col in cols: ws.cell(row=dst_row, column=col)._style = copy( ws.cell(row=src_row, column=col)._style) def _put(ws, row, col, value, fmt=None): c = ws.cell(row=row, column=col, value=value) if fmt: c.number_format = fmt return c def _entry_row(ws, row, seq, e, *, date2, px): vals = [seq, e["account"], e["code"], e["name"], e["amount"], e["open_date"], date2, e["structure"], e["cost"], e["qty"], px, e["days"], _FIN_NONE, _FIN_NONE, f"=M{row}-K{row}", # Q 每股较期初价盈 f"=IF(K{row}=0,0,Q{row}/K{row})"] # R 较期初价涨跌幅 for i, v in enumerate(vals): _put(ws, row, 3 + i, v) # S 总持股浮动盈亏: 持仓 = Q×L (不含费, 模板同); 平仓 = 含费实得, 写值 if e.get("pnl") is None: _put(ws, row, 19, f"=Q{row}*L{row}") else: _put(ws, row, 19, e["pnl"]) _put(ws, row, 20, f"=IF(G{row}=0,0,S{row}/G{row})") # T 持有收益率 _put(ws, row, 21, f"=1+T{row}") # U 净值估算 def _subtotal(ws, row, top, bottom, has_rows): _put(ws, row, 7, f"=SUM(G{top}:G{bottom})" if has_rows else 0) _put(ws, row, 19, f"=SUM(S{top}:S{bottom})" if has_rows else 0) _put(ws, row, 20, f"=IF(G{row}=0,0,S{row}/G{row})") _put(ws, row, 21, f"=1+T{row}") def build_workbook(s: dict, nav_rows: list) -> bytes: """把快照填进公司模板。纯函数 (不碰库), 可离线测试。""" import openpyxl from openpyxl.chart import LineChart, Reference from openpyxl.chart.marker import Marker from openpyxl.formatting.formatting import ConditionalFormattingList from openpyxl.formatting.rule import CellIsRule from openpyxl.styles import Font, PatternFill from openpyxl.utils import get_column_letter wb = openpyxl.load_workbook(TEMPLATE_PATH) ws = wb["Sheet1"] # 进场先做模板卫生 (2026-08-28 正式机实锅): 公司原表把老平仓行折叠隐藏 (24~97 行 # hidden), 裁模板时 openpyxl 的 delete_rows 不搬 row_dimensions, 这批隐藏标记曾 # 原样留在模板里; 表体一长到 24 行, 卖出小计/存量合计/底行/图表数据整段被隐藏。 # 模板已重裁干净, 这里再兜一层底: 模板行位之外的行属性一律不留, 隐藏标记一律清掉; # 残留的条件格式区间 (原表手工长出来的死区间) 同样清空, 后面按最终行位重建。 for r0 in [r for r in ws.row_dimensions if r > _R_SUM2]: del ws.row_dimensions[r0] for r0 in list(ws.row_dimensions): ws.row_dimensions[r0].hidden = False ws.conditional_formatting = ConditionalFormattingList() n_h, n_c = max(1, len(s["holdings"])), max(1, len(s["closed"])) # openpyxl 的插行不搬合并区 —— 先全拆, 插完行按最终行位重新合并 for rng in list(ws.merged_cells.ranges): ws.unmerge_cells(str(rng)) if n_c > 1: # 自底向上插, 行号才不互相踩 ws.insert_rows(_R_CPROTO + 1, n_c - 1) if n_h > 1: ws.insert_rows(_R_HPROTO + 1, n_h - 1) hold_top = _R_HPROTO hsub = hold_top + n_h chead = hsub + 1 close_top = chead + 1 csub = close_top + n_c sum1, sum2 = csub + 1, csub + 2 for i in range(1, n_h): _copy_row_style(ws, hold_top, hold_top + i) for i in range(1, n_c): _copy_row_style(ws, close_top, close_top + i) # ---- 标题与时间 ---- _put(ws, 1, 2, s["title"]) start = nav_rows[0]["ymd"] if nav_rows else int(s["today"].strftime("%Y%m%d")) _put(ws, 2, 20, datetime.strptime(str(start), "%Y%m%d").date()) _put(ws, 3, 20, s["today"]) # ---- 明细与小计 ---- if s["holdings"]: for i, e in enumerate(s["holdings"]): _entry_row(ws, hold_top + i, i + 1, e, date2=e["upd_date"], px=e["price"]) else: _put(ws, hold_top, 6, "(当前无持仓)") _subtotal(ws, hsub, hold_top, hsub - 1, bool(s["holdings"])) if s["closed"]: for i, e in enumerate(s["closed"]): _entry_row(ws, close_top + i, i + 1, e, date2=e["close_date"], px=e["settle"]) else: _put(ws, close_top, 6, "(暂无平仓记录)") _subtotal(ws, csub, close_top, csub - 1, bool(s["closed"])) # ---- 汇总两行 (公式引用两张小计, 口径同模板) ---- _put(ws, sum1, 7, f"=G{hsub}") _put(ws, sum1, 19, f"=S{hsub}+S{csub}") _put(ws, sum1, 20, f"=S{sum1}/{s['scale']}") _put(ws, sum1, 21, f"=1+T{sum1}") _put(ws, sum2, 6, f"={s['scale']}+S{csub}") _put(ws, sum2, 10, f"=F{sum2}-G{sum1}") _put(ws, sum2, 12, f"=IF(F{sum2}=0,0,G{sum1}/F{sum2})") _put(ws, sum2, 15, f"=U{sum1}") _put(ws, sum2, 17, f"=U{sum1}") # ---- 合并区按最终行位重排 (与模板同构) ---- for rng in ([f"B1:T1", f"B4:B{hsub}", f"B{chead}:B{csub}", f"B{sum1}:F{sum1}", f"H{sum1}:I{sum1}", f"Q{sum1}:R{sum1}", f"B{sum2}:E{sum2}", f"F{sum2}:G{sum2}", f"H{sum2}:I{sum2}", f"M{sum2}:N{sum2}", f"R{sum2}:T{sum2}"]): ws.merge_cells(rng) # ---- 行高按最终行位重排 (openpyxl 插行不搬行高, 与合并区同病同治) ---- hts = {1: 30.0, 2: 16.5, 3: 16.5, 4: 30.0, chead: 30.0, sum1: 18.0, sum2: 40.5} for r0 in list(range(hold_top, hsub + 1)) + list(range(close_top, csub + 1)): hts.setdefault(r0, 20.0) for r0, h0 in hts.items(): rd = ws.row_dimensions[r0] rd.height = h0 rd.hidden = False # ---- 红涨绿跌条件格式 (原表样式按最终行位重建: Q/R/S 红涨绿跌字色, # 持仓 S 列独一份用红绿底色, 0 也算红; 颜色与原表 dxf 逐字节同) ---- def _font_rule(op, color): return CellIsRule(operator=op, formula=["0"], font=Font(color=color)) for rng in (f"Q{hold_top}:R{hsub - 1}", f"Q{close_top}:S{csub - 1}", f"S{hsub} S{csub}"): ws.conditional_formatting.add(rng, _font_rule("greaterThan", "FFFF0000")) ws.conditional_formatting.add(rng, _font_rule("lessThan", "FF00B050")) ws.conditional_formatting.add( f"S{hold_top}:S{hsub - 1}", CellIsRule(operator="lessThan", formula=["0"], fill=PatternFill(fill_type="solid", bgColor="FF00B050"))) ws.conditional_formatting.add( f"S{hold_top}:S{hsub - 1}", CellIsRule(operator="greaterThanOrEqual", formula=["0"], fill=PatternFill(fill_type="solid", bgColor="FFFF0000"))) # ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ---- note_row = sum2 + 1 notes = [] if s["price_missing"]: notes.append(f"{len(s['price_missing'])} 只标的当日无行情,按成本价计入市值" f"({'、'.join(s['price_missing'][:5])}" f"{' 等' if len(s['price_missing']) > 5 else ''})") if s.get("fee_resid"): notes.append(f"另有 {s['fee_resid']:.2f} 元费用(买入侧/校准)未摊入明细行") if notes: c = _put(ws, note_row, 2, "注:" + ";".join(notes) + "。") c.font = Font(name="宋体", size=10) note_row += 1 # ---- 净值序列 + 折线图 (双系列同原表: 净值主轴 + 仓位占比副轴, 标题同原图; # 每个数据点带标记, 首日只有一个点也看得见; plotVisOnly=0 隐藏单元格照画) ---- base = note_row + 1 NC = 25 # Y 列起三列数据, 在图区右侧, 不碰表体 hd = Font(name="宋体", size=10, bold=True) for j, h in enumerate(["时间", "净值数据", "仓位占比"]): _put(ws, base, NC + j, h).font = hd for i, row in enumerate(nav_rows, 1): _put(ws, base + i, NC, datetime.strptime(str(row["ymd"]), "%Y%m%d").date(), fmt="mm-dd-yy") _put(ws, base + i, NC + 1, float(row["nav"]), fmt="0.0000") pr = row.get("pos_ratio") _put(ws, base + i, NC + 2, float(pr) if pr is not None else None, fmt="0.00%") if nav_rows: last = base + len(nav_rows) cats = Reference(ws, min_col=NC, min_row=base + 1, max_row=last) nav_c = LineChart() nav_c.title = "量化净值数据及持仓仓位变化" nav_c.height, nav_c.width = 9.0, 30.0 # 与模板图区 (B..U 宽 × 16 行) 相当 nav_c.add_data(Reference(ws, min_col=NC + 1, min_row=base, max_row=last), titles_from_data=True) nav_c.set_categories(cats) nav_c.y_axis.number_format = "0.000" pos_c = LineChart() pos_c.add_data(Reference(ws, min_col=NC + 2, min_row=base, max_row=last), titles_from_data=True) pos_c.set_categories(cats) pos_c.y_axis.axId = 200 # 仓位占比走副轴 (原图同) pos_c.y_axis.number_format = "0%" pos_c.y_axis.majorGridlines = None pos_c.y_axis.crosses = "max" # 副轴靠右, 净值主轴留在左 (原图同) for c0, sym in ((nav_c, "circle"), (pos_c, "diamond")): for ser in c0.series: ser.smooth = False ser.marker = Marker(symbol=sym, size=7) nav_c += pos_c nav_c.visible_cells_only = False ws.add_chart(nav_c, f"B{base}") for col, w in {25: 11, 26: 10, 27: 10}.items(): ws.column_dimensions[get_column_letter(col)].width = w 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