Files
group_fqcd_jr/app/service/promotion_performance.py
T

256 lines
9.4 KiB
Python
Raw Normal View History

2026-09-11 16:57:47 +08:00
"""业绩曲线数据解析与图表生成。"""
from __future__ import annotations
import csv
import hashlib
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
def parse_performance_file(path: str | Path) -> dict[str, Any]:
source = Path(path)
if source.suffix.lower() == ".csv":
with source.open("r", encoding="utf-8-sig", newline="") as stream:
rows = list(csv.DictReader(stream))
elif source.suffix.lower() in {".xlsx", ".xlsm"}:
from openpyxl import load_workbook # type: ignore[import-untyped]
# read_only 模式的工作簿会持有文件句柄,必须显式关闭;否则上传后的
# 业绩文件一直被后端进程占用,运维侧无法移动或删除该文件。
workbook = load_workbook(source, read_only=True, data_only=True)
try:
values = list(workbook.active.values)
finally:
workbook.close()
if not values:
return {"columns": [], "rows": [], "row_count": 0}
headers = [str(value or "").strip() for value in values[0]]
rows = [
{headers[index]: value for index, value in enumerate(row) if index < len(headers)}
for row in values[1:]
]
else:
raise ValueError("业绩数据只支持 CSV、XLSX 或 XLSM")
normalized = [_normalize_row(row) for row in rows if any(row.values())]
columns = list(normalized[0]) if normalized else []
result = {"columns": columns, "rows": normalized, "row_count": len(normalized)}
_validate_performance_data(result)
2026-09-14 18:13:10 +08:00
result["summary"] = summarize_performance_data(result)
2026-09-11 16:57:47 +08:00
return result
2026-09-14 18:13:10 +08:00
def summarize_performance_data(data: dict[str, Any]) -> dict[str, Any]:
"""从已校验的业绩数据中提取材料需要的业绩摘要。"""
date_key = _find_key(data, ("date", "日期", "净值日期", "统计日期"))
if date_key is None:
raise ValueError("业绩数据必须包含日期列")
dates = [_parse_date(row.get(date_key)) for row in data.get("rows", [])]
valid_dates = [item for item in dates if item is not None]
if not valid_dates:
raise ValueError("业绩数据没有有效日期")
first_date = valid_dates[0]
latest_date = valid_dates[-1]
product_key = _find_key(data, ("product_return", "产品收益率"))
product_values = (
[_number(row.get(product_key)) for row in data.get("rows", [])]
if product_key is not None
else []
)
valid_product_values = [value for value in product_values if value is not None]
return {
"initial_date": first_date.isoformat(),
"as_of_date": latest_date.isoformat(),
"history_months": (
(latest_date.year - first_date.year) * 12
+ latest_date.month
- first_date.month
),
"product_return": (
_format_percentage(valid_product_values[-1])
if valid_product_values
else None
),
"max_drawdown": _format_percentage(_max_drawdown(valid_product_values)),
}
def _max_drawdown(values: list[float]) -> float | None:
if not values:
return None
# 业绩文件中的产品收益率是累计收益百分比,先还原为指数化净值再计算回撤。
levels = [100 + value for value in values]
peak = levels[0]
drawdown = 0.0
for level in levels:
peak = max(peak, level)
if peak:
drawdown = min(drawdown, (level / peak - 1) * 100)
return drawdown
def _format_percentage(value: float | None) -> str | None:
if value is None:
return None
return f"{value:.2f}".rstrip("0").rstrip(".") + "%"
2026-09-11 16:57:47 +08:00
def create_performance_chart(
data: dict[str, Any], output_path: str | Path, *, title: str
) -> str:
import matplotlib
import matplotlib.dates as mdates
from matplotlib import font_manager
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# 优先使用部署机上的中文字体,避免图表标题和图例缺字。
font_candidates = (
Path(r"C:\Windows\Fonts\msyh.ttc"),
Path(r"C:\Windows\Fonts\simhei.ttf"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
)
chinese_font = next((item for item in font_candidates if item.exists()), None)
if chinese_font is not None:
font_manager.fontManager.addfont(str(chinese_font))
font_name = font_manager.FontProperties(fname=str(chinese_font)).get_name()
matplotlib.rcParams["font.family"] = font_name
matplotlib.rcParams["axes.unicode_minus"] = False
rows = data.get("rows", [])
if not rows:
raise ValueError("业绩曲线没有可用数据")
date_key = _find_key(data, ("date", "日期", "净值日期", "统计日期"))
series = (
("product_return", "产品", "#1f4e79"),
("benchmark_return", "业绩比较基准", "#7f8c8d"),
("manager_representative_return", "基金经理代表产品", "#b5651d"),
)
fig, axis = plt.subplots(figsize=(10, 4.8), dpi=160)
plotted = 0
for key, label, color in series:
actual_key = _find_key(data, (key, _chinese_key(key)))
if actual_key is None:
continue
values = [_number(row.get(actual_key)) for row in rows]
dates = [_parse_date(row.get(date_key)) for row in rows]
pairs = [
(day, value)
for day, value in zip(dates, values, strict=True)
if day and value is not None
]
if not pairs:
continue
axis.plot(
[
float(mdates.date2num(item[0])) # type: ignore[no-untyped-call]
for item in pairs
],
[item[1] for item in pairs],
label=label, color=color, linewidth=1.8)
plotted += 1
if not plotted:
plt.close(fig)
raise ValueError("业绩曲线缺少可绘制序列")
axis.set_title(title)
axis.set_ylabel("累计收益 / 指数化净值")
axis.xaxis_date()
axis.grid(alpha=0.25)
axis.legend()
fig.autofmt_xdate()
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return str(output)
def file_hash(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _normalize_row(row: dict[Any, Any]) -> dict[str, Any]:
# openpyxl 把日期单元格读成 datetime,写进 MySQL JSON 列会直接抛
# "Object of type datetime is not JSON serializable"(表现为上传业绩文件 500)。
# 这里统一转成 JSON 安全类型:日期转 YYYY-MM-DD 字符串(_parse_date 可再次解析),
# Decimal 转 float,保证 CSV 和 XLSX 两种来源产出的结构一致。
return {
str(key).strip(): _json_safe(value)
for key, value in row.items()
if key is not None
}
def _json_safe(value: Any) -> Any:
if isinstance(value, datetime):
return value.date().strftime("%Y-%m-%d")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
if isinstance(value, Decimal):
return float(value)
return value
def _find_key(data: dict[str, Any], candidates: tuple[str, ...]) -> str | None:
columns = {str(column).strip() for column in data.get("columns", [])}
for candidate in candidates:
if candidate in columns:
return candidate
return None
def _validate_performance_data(data: dict[str, Any]) -> None:
if not data.get("rows"):
raise ValueError("业绩数据没有有效数据行")
date_key = _find_key(data, ("date", "日期", "净值日期", "统计日期"))
if date_key is None:
raise ValueError("业绩数据必须包含日期列")
parsed_dates = [_parse_date(row.get(date_key)) for row in data["rows"]]
if any(item is None for item in parsed_dates):
raise ValueError("业绩数据存在无法识别的日期")
valid_dates = [item for item in parsed_dates if item is not None]
if len(set(valid_dates)) != len(valid_dates):
raise ValueError("业绩数据存在重复日期")
if valid_dates != sorted(valid_dates):
raise ValueError("业绩数据必须按日期升序排列")
series_keys = (
("product_return", "产品收益率"),
("benchmark_return", "业绩比较基准收益率"),
("manager_representative_return", "基金经理代表产品收益率"),
)
if not any(_find_key(data, pair) for pair in series_keys):
raise ValueError("业绩数据至少需要一条可绘制曲线")
def _chinese_key(key: str) -> str:
return {
"product_return": "产品收益率",
"benchmark_return": "业绩比较基准收益率",
"manager_representative_return": "基金经理代表产品收益率",
}.get(key, key)
def _number(value: object) -> float | None:
try:
return float(str(value).replace("%", "").replace(",", "").strip())
except (TypeError, ValueError):
return None
def _parse_date(value: object) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
try:
return datetime.strptime(str(value), fmt).date()
except (TypeError, ValueError):
continue
return None