2026-08-28 15:45:30 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
公示表导出 (每日持仓 / 净值 → 公司《量化数据》xlsx)
|
|
|
|
|
|
====================================================
|
2026-08-28 16:35:40 +08:00
|
|
|
|
公司流程: 每日把持仓与净值导出为 excel 公示。版式**不重画**: 仓库里放着由公司模板
|
|
|
|
|
|
(量化数据2026.8.3.xlsx) 裁出来的空模板 app/assets/publish_template.xlsx —— 字体、
|
|
|
|
|
|
颜色、边框、列宽、数字格式全部原样保留, 导出时只做三件事: 插行、填数、重算合并区。
|
|
|
|
|
|
数据只含**系统接管之后**的账本 (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 同法);
|
2026-08-28 15:45:30 +08:00
|
|
|
|
剩余可开仓 = 可开仓总金额 − 存量成本合计; 持仓仓位 = 存量成本合计 / 可开仓总金额。
|
|
|
|
|
|
* 净值序列每交易日一行, 由调度 15:20 (pms.nav_snapshot) 落 pms_nav_daily;
|
|
|
|
|
|
导出时当日行用实时价现算覆盖, 保证盘中导出也有今天。
|
2026-08-28 16:35:40 +08:00
|
|
|
|
* 现价取不到的票按成本价顶上并在表尾如实标注只数 —— 拿不到不装有。
|
2026-08-28 15:45:30 +08:00
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
表内金额与比率一律写**公式** (SUM / 引用), 不写算好的死数 —— 公示表拿到手里改一个
|
|
|
|
|
|
数, 合计与净值会跟着动, 这比一张全是常量的表诚实。账户名 / 结构 / 标题放参数中心
|
|
|
|
|
|
(PMS_PUBLISH_*), 页面可改。
|
2026-08-28 15:45:30 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import io
|
|
|
|
|
|
import logging
|
2026-08-28 16:35:40 +08:00
|
|
|
|
import os
|
|
|
|
|
|
from copy import copy
|
2026-08-28 15:45:30 +08:00
|
|
|
|
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 = "无" # 融资金额/融资费用列: 本产品无融资, 固定"无" (模板同)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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
|
2026-08-28 15:45:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 取数与口径
|
|
|
|
|
|
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
|
2026-08-28 16:35:40 +08:00
|
|
|
|
if isinstance(v, str) and len(v) >= 10:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.strptime(v[:10], "%Y-%m-%d").date()
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
2026-08-28 15:45:30 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
def _days(d0, d1) -> int:
|
|
|
|
|
|
"""自然天数, 含头含尾 (模板口径: 7-03 → 8-03 = 32)。"""
|
|
|
|
|
|
return (d1 - d0).days + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 16:06:42 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 15:45:30 +08:00
|
|
|
|
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 {}
|
2026-08-28 16:35:40 +08:00
|
|
|
|
fees = _fee_map()
|
2026-08-28 15:45:30 +08:00
|
|
|
|
|
2026-08-28 16:02:13 +08:00
|
|
|
|
# 字符串参数走通用 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 15:45:30 +08:00
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
# ---- 持仓: 按股票合并 (多笔持仓合为一笔, 2026-08-28 拍板) ----
|
|
|
|
|
|
grp = {}
|
2026-08-28 15:45:30 +08:00
|
|
|
|
for r in open_lots:
|
|
|
|
|
|
code = r["ts_code"]
|
|
|
|
|
|
qty = int(r.get("qty") or 0)
|
|
|
|
|
|
cost = float(r.get("open_price") or 0)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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"]
|
2026-08-28 15:45:30 +08:00
|
|
|
|
px = prices.get(code)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
if not (px and px > 0):
|
|
|
|
|
|
px = cost # 顶价只为市值可算; 缺价只数在表尾如实标注
|
2026-08-28 15:45:30 +08:00
|
|
|
|
price_missing.add(code)
|
|
|
|
|
|
holdings.append({
|
2026-08-28 16:06:42 +08:00
|
|
|
|
"account": account, "code": code.split(".")[0],
|
|
|
|
|
|
"name": _display_name(names.get(code), code),
|
2026-08-28 16:35:40 +08:00
|
|
|
|
"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),
|
2026-08-28 15:45:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
# ---- 平仓: 按 (股票, 平仓日) 合并; 盈亏 = 已实现 − 当日该票费用 ----
|
|
|
|
|
|
cgrp = {}
|
2026-08-28 15:45:30 +08:00
|
|
|
|
for r in closed_lots:
|
|
|
|
|
|
code = r["ts_code"]
|
|
|
|
|
|
cq = int(r.get("closed_qty") or 0)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
if cq <= 0:
|
|
|
|
|
|
continue
|
2026-08-28 15:45:30 +08:00
|
|
|
|
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
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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
|
2026-08-28 15:45:30 +08:00
|
|
|
|
closed.append({
|
2026-08-28 16:06:42 +08:00
|
|
|
|
"account": account, "code": code.split(".")[0],
|
|
|
|
|
|
"name": _display_name(names.get(code), code),
|
2026-08-28 16:35:40 +08:00
|
|
|
|
"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),
|
2026-08-28 15:45:30 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
# 未摊入明细的费用 (买入侧 + 无代码归属的校准行): 表尾如实标注, 不悄悄吞掉
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-28 15:45:30 +08:00
|
|
|
|
hold_cost = sum(h["amount"] for h in holdings)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
hold_pnl = sum(round((h["price"] - h["cost"]) * h["qty"], 2) for h in holdings)
|
2026-08-28 15:45:30 +08:00
|
|
|
|
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,
|
2026-08-28 16:35:40 +08:00
|
|
|
|
"price_missing": sorted(price_missing), "fee_resid": fee_resid,
|
2026-08-28 16:02:13 +08:00
|
|
|
|
"title": str(param_store.get("PMS_PUBLISH_TITLE") or "量化产品基本信息"),
|
2026-08-28 15:45:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
# ---------------------------------------------------------------- 版式 (模板填充)
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 15:45:30 +08:00
|
|
|
|
def build_workbook(s: dict, nav_rows: list) -> bytes:
|
2026-08-28 16:35:40 +08:00
|
|
|
|
"""把快照填进公司模板。纯函数 (不碰库), 可离线测试。"""
|
|
|
|
|
|
import openpyxl
|
2026-08-28 15:45:30 +08:00
|
|
|
|
from openpyxl.chart import LineChart, Reference
|
2026-08-28 16:35:40 +08:00
|
|
|
|
from openpyxl.styles import Font
|
2026-08-28 15:45:30 +08:00
|
|
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
|
|
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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)
|
2026-08-28 15:45:30 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- 标题与时间 ----
|
2026-08-28 16:35:40 +08:00
|
|
|
|
_put(ws, 1, 2, s["title"])
|
2026-08-28 15:45:30 +08:00
|
|
|
|
start = nav_rows[0]["ymd"] if nav_rows else int(s["today"].strftime("%Y%m%d"))
|
2026-08-28 16:35:40 +08:00
|
|
|
|
_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():
|
|
|
|
|
|
ws.row_dimensions[r0].height = h0
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ----
|
|
|
|
|
|
note_row = sum2 + 1
|
|
|
|
|
|
notes = []
|
2026-08-28 15:45:30 +08:00
|
|
|
|
if s["price_missing"]:
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 净值序列 + 折线图 (图占位与标题同模板: 汇总行下方, 横贯 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
|
2026-08-28 15:45:30 +08:00
|
|
|
|
for i, row in enumerate(nav_rows, 1):
|
2026-08-28 16:35:40 +08:00
|
|
|
|
_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")
|
2026-08-28 15:45:30 +08:00
|
|
|
|
pr = row.get("pos_ratio")
|
2026-08-28 16:35:40 +08:00
|
|
|
|
_put(ws, base + i, NC + 2, float(pr) if pr is not None else None, fmt="0.00%")
|
2026-08-28 15:45:30 +08:00
|
|
|
|
if nav_rows:
|
|
|
|
|
|
chart = LineChart()
|
2026-08-28 16:35:40 +08:00
|
|
|
|
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))
|
2026-08-28 15:45:30 +08:00
|
|
|
|
chart.add_data(data, titles_from_data=True)
|
|
|
|
|
|
chart.set_categories(cats)
|
2026-08-28 16:35:40 +08:00
|
|
|
|
ws.add_chart(chart, f"B{base}")
|
|
|
|
|
|
for col, w in {25: 11, 26: 10, 27: 10}.items():
|
2026-08-28 15:45:30 +08:00
|
|
|
|
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
|