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

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,7 +74,17 @@ 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:
@ -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)
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)
price_ok = bool(px and px > 0)
if not price_ok:
if not (px and px > 0):
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": _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 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
# ---------------------------------------------------------------- 版式 (模板填充)
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
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):
def _put(ws, row, col, value, fmt=None):
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"]]
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(row, 3 + i, v, fmt=FMTS[i])
_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_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)
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.styles import Font
from openpyxl.utils import get_column_letter
wb = openpyxl.load_workbook(TEMPLATE_PATH)
ws = wb["Sheet1"]
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)
# ---- 标题与时间 ----
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'])} 只标的当日无行情,按成本价计入市值"
notes.append(f"{len(s['price_missing'])} 只标的当日无行情,按成本价计入市值"
f"{''.join(s['price_missing'][:5])}"
f"{'' if len(s['price_missing']) > 5 else ''})。",
border=None)
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.