修复公示导出:标的名按名字接口真实形状(简称/全称字典)提取为字符串

This commit is contained in:
zlt 2026-08-28 16:35:40 +08:00
parent fa00abe7e8
commit 88592bb349
7 changed files with 414 additions and 164 deletions

View File

@ -1068,6 +1068,45 @@ scripts/test_batch19_units.py、scripts/run_tests.py新增 2 例, 全套 587
---
## 2026-08-28 · 公示导出重做:模板文件直填 + 持仓合一 + 含费口径, 588 例全绿
**做了什么**
公司流程要求每日把持仓与净值按固定 excel 版式导出公示。在既有实现 (按钮/接口/参数/
净值快照表/调度 都已就位) 的基础上重做了两层:
1. **版式不再用代码重画**: 把公司模板裁成空模板进仓库 (app/assets/publish_template.xlsx,
保留全部字体/颜色/边框/列宽/数字格式/行高), 导出时只插行、填数、按最终行位重排合并区
与行高 (openpyxl 插删行不搬 merges 与 row_dimensions, 全部手工重排)。图表按模板标题
与占位重建 (原模板图表引用外部工作簿, 数据改为本表净值序列)。
2. **口径按模板逐项对数**: 持仓明细按**股票合并一行** (多批加权成本、起始取最早);
平仓明细按 **(股票, 平仓日)** 合并; 自然天数**含头含尾** (7-03→8-03 记 32, 原实现差 1);
持仓行盈亏不含费 (模板 S=Q×L 逐分一致)、**平仓行盈亏 = 已实现 当日该票费用**
(模板卖出行含双边费, 费用取 pms_cash_flow FEE 按日按码聚合); 买入侧/校准费摊不进
明细的部分在表尾按残差如实标注 —— S 合计=持仓Σ+平仓Σ 是模板恒等式, 不为塞费用破坏它。
表内金额与比率一律写公式 (SUM/引用), 不写死数。
**动了哪些文件**
app/services/publish_export.py (重写)、app/repo/pms_repo.py (fee_sum_by_code /
sum_fee_all 两个聚合)、scripts/test_wiring.py (FakeRepo 补 lot 明细/净值快照/费用桩,
新增公示导出装配用例)、scripts/run_tests.py (67/588)、新增 app/assets/publish_template.xlsx。
**部署方式**
tlai4090: git pull 后 make deploy (openpyxl 已在 requirements, pms_nav_daily 建表由
init_db 幂等带上)。无新 env。
**真机判收**
开发机: 全部 20 套 588 例 ALL PASS; 生成样例经 LibreOffice 全量重算 54 条公式 0 错误,
恒等式 (S合计/剩余可开仓/仓位/累计净值) 现场核验; 样例与公司原表并排渲染逐区比对
(段落标签/小计粉底/存量合计黄条/底行/图表占位)。部署后判收: 页面点「导出公示表」,
下载的表与人工表并排看一屏。
**还欠着什么**
1. 三个口径请使用者过目拍板 (都按模板反推, 改口一句话): 平仓按(票,平仓日)合并;
平仓行含费=已实现−当日该票 FEE; 起始时间=系统接管日 (不回填历史)。
2. 图表纵轴范围/网格样式与公司原图未逐像素对齐 (原图数据在外部工作簿, 只对齐了
标题/位置/尺寸), 有要求再调。
---
<!--
下一条节点从这里往下写,格式照抄上面:
## YYYY-MM-DD · 一句话标题

Binary file not shown.

View File

@ -556,6 +556,21 @@ def sum_cash_flow(ymd, kind=None) -> float:
return float((fetch_one(sql, p) or {}).get("s") or 0)
def fee_sum_by_code() -> list:
"""公示导出: FEE 现金流按 (日期, 代码) 聚合。无代码归属的行 (校准) 不在内,
它们由 sum_fee_all 兜总数在公示表尾按残差标注"""
return fetch_all(
"SELECT ymd, ts_code, COALESCE(SUM(amount), 0) AS fee FROM pms_cash_flow "
"WHERE kind = 'FEE' AND ts_code IS NOT NULL GROUP BY ymd, ts_code")
def sum_fee_all() -> float:
"""公示导出: 费用总额 (FEE + CALIBRATE), 用于算未摊入明细行的费用残差。"""
r = fetch_one("SELECT COALESCE(SUM(amount), 0) AS s FROM pms_cash_flow "
"WHERE kind IN ('FEE', 'CALIBRATE')")
return float((r or {}).get("s") or 0)
def list_cash_flow(*, ymd=None, kind=None, limit: int = 200) -> list:
where, p = [], {"n": int(limit)}
if ymd:

View File

@ -2,28 +2,44 @@
"""
公示表导出 (每日持仓 / 净值 公司量化数据xlsx)
====================================================
公司流程: 每日把持仓与净值导出为 excel 公示本模块按公司模板
(量化数据2026.8.3.xlsx) 的版式与口径生成同构表格, 数据只含**系统接管之后**
账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与明细继续查人工表格)
公司流程: 每日把持仓与净值导出为 excel 公示版式**不重画**: 仓库里放着由公司模板
(量化数据2026.8.3.xlsx) 裁出来的空模板 app/assets/publish_template.xlsx 字体
颜色边框列宽数字格式全部原样保留, 导出时只做三件事: 插行填数重算合并区
数据只含**系统接管之后**的账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与
明细继续查人工表格)
口径 (逐项对模板核过数, docs 拍板记录):
* 每行: 交易金额 = 成本价 × 数量; 涨跌幅 = 现价/成本 1; 净值估算 = 1 + 收益率
* 持仓+平仓收益合计 = 持仓浮动盈亏 Σ + 平仓已实现盈亏 Σ;
累计净值 = 1 + 该合计 / 净值规模 (参数 PMS_PUBLISH_NAV_SCALE, 0= PMS_TOTAL_SCALE)
* 可开仓总金额 = 净值规模 + 平仓已实现盈亏 Σ (亏损使其变小, 模板 F107 同法);
明细口径 (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;
导出时当日行用实时价现算覆盖, 保证盘中导出也有今天
* 现价取不到的票按成本价顶上并**在表尾如实标注只数** 拿不到不装有
* 现价取不到的票按成本价顶上并在表尾如实标注只数 拿不到不装有
明细行来自批次账 (pms_lot): 持仓明细 = 未平批次 (剩余数量>0), 一行一笔开单;
平仓明细 = 有平仓量的批次 (含部分平仓), 结算价为该批加权平均平仓价
账户名 / 结构 / 标题是公示口径固定字段, 全部放参数中心 (PMS_PUBLISH_*), 页面可改
表内金额与比率一律写**公式** (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
@ -32,6 +48,13 @@ 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
# ---------------------------------------------------------------- 取数与口径
@ -51,9 +74,19 @@ def _as_date(v):
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=代码本身) 公示表用简称, 缺简称用全称, 都没有用代码
@ -63,6 +96,17 @@ def _display_name(v, code: str) -> str:
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()
@ -78,55 +122,87 @@ def compute_snapshot() -> dict:
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 "")
holdings, price_missing = [], set()
# ---- 持仓: 按股票合并 (多笔持仓合为一笔, 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)
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
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(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,
"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),
})
closed = []
# ---- 平仓: 按 (股票, 平仓日) 合并; 盈亏 = 已实现 当日该票费用 ----
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)
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
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(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,
"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(h["pnl"] 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
@ -139,7 +215,7 @@ def compute_snapshot() -> dict:
"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),
"price_missing": sorted(price_missing), "fee_resid": fee_resid,
"title": str(param_store.get("PMS_PUBLISH_TITLE") or "量化产品基本信息"),
}
@ -170,157 +246,159 @@ def _nav_series(snapshot: dict) -> list:
return out
# ---------------------------------------------------------------- 版式
# ---------------------------------------------------------------- 版式 (模板填充)
def _copy_row_style(ws, src_row: int, dst_row: int, cols=range(2, 22)):
for col in cols:
ws.cell(row=dst_row, column=col)._style = copy(
ws.cell(row=src_row, column=col)._style)
ws.row_dimensions[dst_row].height = ws.row_dimensions[src_row].height
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:
"""按公司模板版式生成工作簿。纯函数 (不碰库), 可离线测试。"""
from openpyxl import Workbook
"""把快照填进公司模板。纯函数 (不碰库), 可离线测试。"""
import openpyxl
from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.styles import Font
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")
wb = openpyxl.load_workbook(TEMPLATE_PATH)
ws = wb["Sheet1"]
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]
n_h, n_c = max(1, len(s["holdings"])), max(1, len(s["closed"]))
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
# 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)
def header_row(row, heads):
for i, h in enumerate(heads):
put(row, 3 + i, h, f=bold, align=center, fill=head_fill)
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
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)
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)
# ---- 标题与时间 ----
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)
_put(ws, 1, 2, s["title"])
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)
_put(ws, 2, 20, datetime.strptime(str(start), "%Y%m%d").date())
_put(ws, 3, 20, s["today"])
# ---- 持仓明细 ----
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)
# ---- 明细与小计 ----
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"]))
# ---- 平仓明细 ----
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)
# ---- 汇总两行 (公式引用两张小计, 口径同模板) ----
_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}")
# ---- 汇总两行 (公式引用两张小计行, 口径同模板) ----
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)
# ---- 合并区按最终行位重排 (与模板同构) ----
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():
ws.row_dimensions[r0].height = h0
# ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ----
note_row = sum2 + 1
notes = []
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)
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
# ---- 净值序列 (表右侧, 位置同模板 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)
# ---- 净值序列 + 折线图 (图占位与标题同模板: 汇总行下方, 横贯 B..U) ----
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(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)
_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(4 + i, NC + 2, float(pr) if pr is not None else None, fmt=P_FMT)
_put(ws, base + i, NC + 2, float(pr) if pr is not None else None, fmt="0.00%")
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.title = "量化净值数据及持仓仓位变化"
chart.height, chart.width = 9.0, 30.0 # 与模板图区 (B..U 宽 × 16 行) 相当
data = Reference(ws, min_col=NC + 1, min_row=base, max_row=base + len(nav_rows))
cats = Reference(ws, min_col=NC, min_row=base + 1, max_row=base + 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.add_chart(chart, f"B{base}")
for col, w in {25: 11, 26: 10, 27: 10}.items():
ws.column_dimensions[get_column_letter(col)].width = w
ws.freeze_panes = "C5"
buf = io.BytesIO()
wb.save(buf)

View File

@ -38,8 +38,8 @@
取整与部分卖/同轮买卖互斥/信号百分制契约/除权核销
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (66 )
587
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (67 )
588
任一子集失败即整体失败 (退出码 1)
"""
import os

View File

@ -34,6 +34,7 @@ class FakeRepo:
self.positions, self.lots, self.instructions = {}, [], {}
self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {}
self.cash_flows = []
self.nav_daily = {}
self.strategies = {}
self._lot_id = 0
self._strategy_id = 0
@ -343,6 +344,40 @@ class FakeRepo:
self.industry[r["ts_code"]] = r["industry"]
return len(rows)
# --- 公示导出 (pms_lot 明细视图 / pms_nav_daily / 费用聚合) ---
def list_open_lot_rows(self, limit=2000):
rows = [dict(l) for l in self.lots if l["status"] == "OPEN" and int(l["qty"]) > 0]
return sorted(rows, key=lambda x: (str(x["open_date"]), x["id"]))[:int(limit)]
def list_closed_lot_rows(self, limit=2000):
rows = [dict(l) for l in self.lots if int(l.get("closed_qty") or 0) > 0]
for x in rows:
x.setdefault("updated_at", x["open_date"])
return sorted(rows, key=lambda x: (str(x.get("updated_at")), x["id"]))[:int(limit)]
def upsert_nav_daily(self, *, ymd, nav, pos_ratio, holding_pnl, realized_pnl,
nav_scale, price_missing):
self.nav_daily[int(ymd)] = {
"ymd": int(ymd), "nav": nav, "pos_ratio": pos_ratio,
"holding_pnl": holding_pnl, "realized_pnl": realized_pnl,
"nav_scale": nav_scale, "price_missing": price_missing}
return 1
def list_nav_daily(self, limit=400):
return [self.nav_daily[k] for k in sorted(self.nav_daily)][-int(limit):]
def fee_sum_by_code(self):
agg = {}
for f in self.cash_flows:
if f.get("kind") == "FEE" and f.get("ts_code"):
k = (int(f["ymd"]), f["ts_code"])
agg[k] = agg.get(k, 0.0) + float(f["amount"])
return [{"ymd": k[0], "ts_code": k[1], "fee": v} for k, v in agg.items()]
def sum_fee_all(self):
return float(sum(f["amount"] for f in self.cash_flows
if f.get("kind") in ("FEE", "CALIBRATE")))
# --- pms_strategy (个股交易方案) ---
# 2026-08-28 审查补: 桩里原来**一个策略函数都没有**, 于是所有走
# pms_repo.list_strategies / active_strategy_codes 的服务代码在单测里都命中
@ -558,6 +593,8 @@ def install_fakes(prices=None, positions=None, params=None, high5=None, prev_clo
downstream_repo.fetch_positions = lambda: {"rows": [], "columns": {"qty": None},
"raw_count": 0}
downstream_repo.fetch_refs = lambda c: None
downstream_repo.fetch_names = lambda codes: {
c: {"name": "" + str(c)[:6], "full": ""} for c in (codes or [])}
downstream_repo.category_columns = lambda force=False: {
"columns": None, "code_col": None, "industry_col": None,
"error": "单测桩: 未接行业表"}
@ -2377,6 +2414,87 @@ def _():
for x in fake.ledger), "撤策略必须在评审账本留痕"
@case("公示导出·多笔持仓合一/平仓按日合并/含费口径/模板版式与公式")
def _():
import io as _io
from datetime import date, timedelta
import openpyxl
from app.services import publish_export as pub
fake = install_fakes(prices={"600000.SH": 11.0, "000001.SZ": 8.0},
params={"PMS_TOTAL_SCALE": "1000000",
"PMS_PUBLISH_ACCOUNT": "测试账户",
"PMS_PUBLISH_STRUCTURE": "二级(平层)",
"PMS_PUBLISH_TITLE": "测试产品基本信息"})
d0, d1 = date.today() - timedelta(days=9), date.today() - timedelta(days=4)
# 同一票两批 → 必须合并成一行: 1000@10 + 1000@12 = 2000@加权11
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
open_date=d0)
fake.insert_lot(ts_code="600000.SH", lot_type="ADD", qty=1000, open_price=12.0,
open_date=d1)
# 另一票部分平仓: 2000@8 平掉 1000@9 (价差盈 1000), 平仓日今天, 当日该票费用 12.5
fake.insert_lot(ts_code="000001.SZ", lot_type="BASE", qty=2000, open_price=8.0,
open_date=d0)
lot_id = fake.lots[-1]["id"]
fake.close_lot_qty(lot_id, qty=1000, close_price=9.0, realized_pnl=1000.0)
fake.update_lot(lot_id, updated_at=datetime.now())
ymd = int(date.today().strftime("%Y%m%d"))
fake.insert_cash_flow(ymd=ymd, kind="FEE", amount=12.5, ts_code="000001.SZ")
fake.insert_cash_flow(ymd=ymd, kind="CALIBRATE", amount=3.5) # 无代码归属 → 残差
s = pub.compute_snapshot()
h = {x["code"]: x for x in s["holdings"]}
assert set(h) == {"600000", "000001"}, s["holdings"]
a = h["600000"]
assert a["qty"] == 2000 and abs(a["cost"] - 11.0) < 1e-9 and a["amount"] == 22000.0, a
assert a["days"] == 10, a # 含头含尾: 相差 9 天记 10 天 (模板口径)
assert a["open_date"] == d0, a # 起始时间取最早一批
b = h["000001"]
assert b["qty"] == 1000 and abs(b["amount"] - 8000.0) < 1e-6, b # 只剩未平部分
assert len(s["closed"]) == 1, s["closed"]
c0 = s["closed"][0]
assert c0["qty"] == 1000 and abs(c0["settle"] - 9.0) < 1e-9, c0
assert abs(c0["pnl"] - 987.5) < 1e-6, c0 # 含费: 1000 当日该票费用 12.5
assert abs(s["fee_resid"] - 3.5) < 1e-6, s["fee_resid"] # 校准费 → 表尾残差
assert abs(s["realized"] - 987.5) < 1e-6, s
assert abs(s["nav"] - round(1 + 987.5 / 1e6, 4)) < 1e-9, s["nav"]
assert abs(s["openable"] - 1000987.5) < 1e-4 and abs(
s["open_room"] - (1000987.5 - 30000.0)) < 1e-4, s
assert s["price_missing"] == [], s
# 版式: 填进公司模板后, 关键行位 / 公式 / 合并区 / 图表都要在
blob = pub.build_workbook(s, pub._nav_series(s))
wb = openpyxl.load_workbook(_io.BytesIO(blob))
ws = wb["Sheet1"]
assert ws["B1"].value == "测试产品基本信息"
# 2 行持仓 → 5,6 行; 小计 7; 卖出头 8; 明细 9; 小计 10; 汇总 11/12
assert ws["E5"].value == "000001" and ws["E6"].value == "600000", (
ws["E5"].value, ws["E6"].value) # 按最早开仓日排序
assert ws["L6"].value == 2000 and ws["D5"].value == "测试账户"
assert ws["U5"].value == "=1+T5" and ws["S5"].value == "=Q5*L5"
assert ws["C8"].value == "" + chr(10) + "", repr(ws["C8"].value)
assert ws["S9"].value == 987.5 and ws["T9"].value == "=IF(G9=0,0,S9/G9)"
assert ws["G7"].value == "=SUM(G5:G6)" and ws["G10"].value == "=SUM(G9:G9)"
assert ws["S11"].value == "=S7+S10" and ws["F12"].value.startswith("=1000000")
assert ws["O12"].value == "=U11" and ws["Q12"].value == "=U11"
merges = {str(r) for r in ws.merged_cells.ranges}
assert {"B1:T1", "B4:B7", "B8:B10", "B11:F11", "B12:E12"} <= merges, merges
# 两个竖排段落标签必须都在 (模板裁剪曾把「平层卖出」丢过一次, 钉死)
assert ws["B4"].value == "平层" + chr(10) + "持仓", repr(ws["B4"].value)
assert ws["B8"].value == "平层" + chr(10) + "卖出", repr(ws["B8"].value)
assert len(ws._charts) == 1 and "净值" in str(ws._charts[0].title.tx.rich.p[0].r[0].t)
# 费用残差注在表尾
assert any("3.50 元费用" in str(ws.cell(row=r, column=2).value or "")
for r in range(13, 16)), "表尾必须标注未摊费用残差"
# E 列文本格式 (代码不许被 excel 吃成数字)
assert ws["E5"].number_format == "@", ws["E5"].number_format
# 净值快照落库 → 序列含今天
r = pub.record_nav_snapshot()
assert r["ok"] and fake.nav_daily, r
rows = pub._nav_series(s)
assert rows[-1]["ymd"] == ymd and abs(rows[-1]["nav"] - s["nav"]) < 1e-9, rows[-1]
def main():
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
# (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警),

Binary file not shown.