tradingSystem/app/services/publish_export.py

414 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
公示表导出 (每日持仓 / 净值 → 公司《量化数据》xlsx)
====================================================
公司流程: 每日把持仓与净值导出为 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 同法);
剩余可开仓 = 可开仓总金额 存量成本合计; 持仓仓位 = 存量成本合计 / 可开仓总金额。
* 净值序列每交易日一行, 由调度 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)):
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:
"""把快照填进公司模板。纯函数 (不碰库), 可离线测试。"""
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)
# ---- 标题与时间 ----
_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():
ws.row_dimensions[r0].height = h0
# ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ----
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
# ---- 净值序列 + 折线图 (图占位与标题同模板: 汇总行下方, 横贯 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(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:
chart = LineChart()
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"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