月度自动报告生成
机构级 portfolio monthly report 的标准结构、归因报表的视觉语言、PDF 生成技术栈取舍
日期: 2026-08-01 方向: Phase 3 / 月度报告 阶段: Phase 3: 实盘+规模化+迁移 标签: #MonthlyReport #PDF #Attribution #PortfolioReporting #matplotlib #reportlab #ProductionGrade
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 机构级 portfolio monthly report 的标准结构、归因报表的视觉语言、PDF 生成技术栈取舍 |
| 实操 | 实现 monthly_report.py:拉 Day 66 trade journal + 实盘 NAV → 算 metrics → 出 PDF → 邮件归档 |
| 产出 | 8 个 section 的 PDF 模板 + 第一份 7 月报告(用 paper + 7 月实盘半月数据 stub) |
一、为什么 Day 84 要做月报:被低估的「内部仪表盘」
走到 Phase 3 Week 12,账户已经实盘 4 个多月,三个策略(双因子动量、Wheel、IC)每天都在跑。但如果我们只看「净值今天涨了多少」,等于把 4 个月辛苦建的数据资产全浪费。
月报真正的价值,不在「给别人看」(虽然 Day 85 会做公开版本),而在逼自己做结构化复盘:
| 没月报时 | 有月报时 |
|---|---|
| 「这个月赚了 2.3%」 | 「2.3% 中,1.1% 是 SPY β 给的,0.8% 是动量因子 α,0.5% 是 IC 事件 α,-0.1% 是滑点」 |
| 「Wheel 感觉还行」 | 「Wheel 27 张 contract 全月 Theta 收 $312,但被 NVDA 那次 gap down 吃掉 $180,净 $132」 |
| 「回撤还在可控范围」 | 「MaxDD -4.2% 发生在 7/18,触发 VaR 95% 阈值,position size 从 0.8 降到 0.6」 |
| 「下个月继续」 | 「7 月学到:财报后 IV crush 比预期慢 2 天,下个月 IC 平仓窗口调整」 |
核心认知:月报不是「展示」工具,是「迭代」工具。展示是副产品。
二、报告读者画像与对应的版本策略
10 年金融 PM 的本能:先想清楚「谁会看、看了能做什么决策」,再设计报表。
2.1 三类读者 × 三个版本
| 读者 | 关心什么 | 版本 | 包含内容 |
|---|---|---|---|
| 自己(每月 1 号) | 哪里赚 / 哪里亏 / 下个月调什么 | Private (Full) | 绝对金额、每笔交易、归因细节、心理状态笔记 |
| 未来 broker / fund 申请 | 风控纪律、回撤管理、归因能力 | Pro (Sanitized) | 百分比、Sharpe/Calmar、归因结构图、不暴露具体仓位 |
| 公开(Twitter / Mirror / 求职作品集) | 「这个人能不能讲清楚自己在做什么」 | Public (Teaser) | 趋势线、收益分布、复盘叙事;金额全部去掉,只保留比例 |
2.2 三版本如何用同一份数据生成
我们采用 单一数据源 + 多 view 渲染 的设计:
trade_journal.parquet + nav_daily.csv + greeks_daily.csv
│
▼
compute_all_metrics(month)
│
┌───────────┼──────────┐
▼ ▼ ▼
Private Pro Public
render() render() render()
│ │ │
▼ ▼ ▼
report_priv report_pro report_public
.pdf .pdf .pdf
每个 renderer 都从同一个 metrics 对象拿数据,但在 render() 内决定哪些字段 mask 掉 / 哪些四舍五入到比例 / 哪些图改成 normalize 到 1.0 起点。
三、报告 8 个 Section 详解
这一节是真正的「设计」部分。每个 section 都要回答:这页存在的目的是什么?
Section 0: Cover Page
| 元素 | 内容 |
|---|---|
| 大字号 | 账户名 + 报告月份("July 2026 Performance Report") |
| 三块核心数字 | NAV 期末 / MoM % / YTD % |
| 一句话总结 | "动量因子贡献主要 α;Wheel 受 NVDA 财报拖累;IC 表现符合预期" |
| 图 1 | 当月 NAV 曲线(小图) |
| 生成时间 + 数据 cut-off + 版本号 |
设计原则:cover 是「30 秒能读完决定要不要翻下去」的入口。不要在 cover 放归因细节——那是 Section 2 的事。
Section 1: NAV & Returns
三张图叠在一页:
- NAV 曲线 + SPY benchmark:必须把 SPY normalize 到同一起点(月初 = 1.0),让读者一眼看出 α
- Drawdown underwater chart:底部红色填充,横轴日期,纵轴 0 到 -X%。永远画在 NAV 下方,视觉对应
- Monthly heatmap:行 = 月份(1-12),列 = 年份(2026, 2027...),单元格 = monthly return %,配色 RdYlGn
Heatmap 的坑:Phase 3 才 4 个月数据,heatmap 大半是空的。我的做法是第一份月报用 SPY 同期数据填空白,标注「benchmark」让画面不空。等积累 12+ 个月后切换到真实数据。
Section 2: 归因(Day 52 框架)
这是月报的灵魂。回顾 Day 52 的三层归因:
Total Return = Market β + Factor α + Event α + Cost
| 维度 | 计算方法 | 7 月示例 |
|---|---|---|
| Market β | β × SPY return | 0.65 × 1.7% = 1.10% |
| Factor α | 双因子模型 P&L - β 贡献 | 0.83% |
| Event α | IC 财报策略 + 其他事件 | 0.51% |
| Cost | 佣金 + 滑点 + 借券利息 | -0.13% |
| 合计 | 2.31% |
呈现方式:用 Waterfall chart(瀑布图),从「Market β」开始堆叠,让 α 来源一目了然。matplotlib 没有原生瀑布图,要手动 bar + 累加。
为什么这一节最重要:归因把「赚了 2.3%」拆成「有多少是运气(β / market beta)、有多少是技能(α)」。这是机构在面试 portfolio manager 时唯一真正关心的事。
Section 3: 三策略表现
每个策略一个小卡片:
| 策略 | 仓位占比 | P&L | Sharpe (annualized) | 最大单笔损 | 备注 |
|---|---|---|---|---|---|
| 双因子动量 | 55% | +$847 | 1.83 | -$112 | 因子滚动表现稳定 |
| Wheel | 30% | +$132 | 0.94 | -$180 | NVDA assignment 拖累 |
| IC 财报 | 15% | +$203 | 2.41 | -$48 | 5 笔 4 胜 |
附加图:三策略的 cumulative P&L 折线,叠在同一坐标轴上,颜色区分。这样读者立刻看出哪个策略在哪个时间段贡献最大。
Section 4: Greeks 演化
| Greek | 月初 | 月末 | 月内峰值 | 备注 |
|---|---|---|---|---|
| Net Delta | +2,340 | +1,980 | +3,210 (7/15) | 7/18 减仓 |
| Theta | -$42/day | -$38/day | -$67/day (7/22) | IC 开仓推高 |
| Vega | -$120/vol | -$95/vol | -$180/vol | 卖方为主 |
| Gamma | -3.2 | -2.8 | -4.7 | Wheel near-money 推高 |
图:Theta vs Vega 的散点轨迹(每个点 = 当日收盘),让读者看出风险敞口如何随仓位演化。
为什么这页重要:期权账户的真实风险不是「我亏了多少」,是「我现在敞口是什么」。Greek 演化图能在亏损发生之前显示风险积累。
Section 5: 风控指标
VaR 95% (1-day, parametric) : -$280 (-1.4% of NAV)
CVaR 95% : -$420 (-2.1%)
VaR 95% (historical) : -$310 (-1.5%)
MaxDD (intra-month) : -4.2%
MaxDD duration : 6 days
Sharpe (annualized) : 1.67
Sortino : 2.31
Calmar : 2.85
Beta to SPY : 0.65
坑:parametric VaR(假设正态)和 historical VaR(看分位数)经常差很多。两个都要给,并且在脚注解释「parametric 假设正态分布,肥尾事件下会低估」。
Section 6: 滑点 + 成本累计
| 类别 | 当月 | YTD |
|---|---|---|
| 佣金(股票) | $32.40 | $128.50 |
| 佣金(期权) | $48.75 | $182.20 |
| 滑点(mid-to-fill) | $87.30 | $341.10 |
| 数据订阅 | $16.00 | $112.00 |
| 借券利息 | $0 | $0 |
| 合计 | $184.45 | $763.80 |
| 占 NAV | 0.92% | 3.82% |
关键洞察:成本 3.82% / 年是个真实数字。如果 gross return 不到 6%,扣完成本就是 SPY 持有的 hold 不如。这页是每月最让人清醒的一页。
Section 7: 税务 accrual
虽然中国大陆居民 cap gain 0%,但:
- 股息预提:W-8BEN 30%,需逐月记录
- 借券费 / 利息:1099-INT 项目
- 国内年度 ≥$50k 海外资产申报:要看年末快照
| 项目 | 当月 | YTD |
|---|---|---|
| 股息毛额 | $12.40 | $48.30 |
| 30% 预提税 | $3.72 | $14.49 |
| 净到账 | $8.68 | $33.81 |
为什么记录:(1) 国内申报时不会抓瞎;(2) 评估「股息密集策略 vs 无股息策略」时有真实数字;(3) 未来如果开 IBKR US(如成为美国税务居民),有完整 paper trail。
Section 8: Lessons & Action Items
## July 2026 Lessons
1. **NVDA 财报后 IV crush 比预期慢 2 天**
- 现象:IC 在 ER 后 T+0 仅收 35% 利润,T+2 才到 65%
- 假设:当前 vol regime 下,机构对冲行为延迟
- 验证计划:8 月再观察 3 个 mega-cap 财报样本
2. **Wheel 在 high-IV 标的上效率不如 low-IV**
- NVDA assignment 损失 $180,是 7 月单笔最大
- 反思:CSP delta 选 -0.30 还是过激进
- 调整:8 月 Wheel 只在 IV rank <50 的标的上做 -0.25 CSP
3. **双因子动量在第三周回撤 1.8%**
- 因子载荷未变,是 SPY drawdown 带的
- 不调整:alpha 因子 holding period 5-10 天,单周回撤在置信区间
## Action Items for August
- [ ] Wheel 标的清单加 IV rank 筛
- [ ] IC 平仓 schedule 从 T+0 改为 T+2 trigger
- [ ] 给 Day 66 trade journal 加「lesson tag」字段
这一节是月报真正改变行为的地方。前面 7 个 section 都是「记账」,这一节是「立法」。
四、技术栈选型:reportlab vs weasyprint vs nbconvert
4.1 三个方案对比
| 维度 | reportlab | weasyprint | jupyter + nbconvert |
|---|---|---|---|
| 思路 | Python API 直接画 PDF | HTML/CSS → PDF | Notebook → LaTeX → PDF |
| 排版控制 | 精细,learning curve 陡 | CSS 熟悉就好 | 受限于 LaTeX 模板 |
| 图表插入 | matplotlib PNG → embed | <img> 标签 | inline 自动 |
| 中文支持 | 要注册字体,但稳 | 字体一般要装系统级 | LaTeX CJK 配置麻烦 |
| 适合 | 标准化、批量生成 | 设计感强的报告 | 一次性分析 |
| 维护成本 | 中(代码即模板) | 低(HTML 工程师都会) | 高(notebook 易腐烂) |
| 我们选 | ✓ | - | - |
为什么选 reportlab:
- 月报每月生成一次,模板代码化更好维护,diff 友好
- 不用装 GTK / Pango / Cairo(weasyprint 在 Windows 上的痛点)
- 中文字体处理用 reportlab 的
pdfmetrics.registerFont一行搞定
4.2 中文字体的坑
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# Windows 上的思源黑体 / 微软雅黑
pdfmetrics.registerFont(TTFont('SourceHan', 'C:/Windows/Fonts/msyh.ttc'))
# 之后所有需要中文的地方
style.fontName = 'SourceHan'
血泪经验:matplotlib 图里的中文要单独配:
import matplotlib
matplotlib.rcParams['font.family'] = 'Microsoft YaHei'
matplotlib.rcParams['axes.unicode_minus'] = False # 否则负号显示成方块
不这样配,PDF 里中文是一堆 □□□,调试两小时才发现。
五、代码:monthly_report.py 核心骨架
# monthly_report.py
"""
TR Day 84 - Monthly Portfolio Report Generator
Input:
- month: 'YYYY-MM' (e.g., '2026-07')
- data sources: trade_journal.parquet, nav_daily.csv, greeks_daily.csv
- benchmark: SPY OHLC via ib_insync historical bars
Output:
- reports/{month}/private.pdf
- reports/{month}/pro.pdf
- reports/{month}/public.pdf
- reports/{month}/metrics.json (machine-readable)
Usage:
python monthly_report.py --month 2026-07 --version all --email
"""
import argparse
import json
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
from pathlib import Path
import matplotlib
matplotlib.use('Agg') # headless backend, critical for cron
matplotlib.rcParams['font.family'] = 'Microsoft YaHei'
matplotlib.rcParams['axes.unicode_minus'] = False
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle, PageBreak
)
from reportlab.lib import colors
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
log = logging.getLogger(__name__)
# ---------- font registration (China-friendly) ----------
pdfmetrics.registerFont(TTFont('CJK', 'C:/Windows/Fonts/msyh.ttc'))
# ---------- data containers ----------
@dataclass
class MonthlyMetrics:
month: str
nav_start: float
nav_end: float
mom_return: float
ytd_return: float
# attribution
market_beta_contrib: float
factor_alpha: float
event_alpha: float
cost_drag: float
# risk
var_95_parametric: float
var_95_historical: float
cvar_95: float
max_drawdown: float
max_dd_duration_days: int
sharpe: float
sortino: float
calmar: float
beta_to_spy: float
# strategies
strat_pnl: dict # {'momentum': float, 'wheel': float, 'ic': float}
# greeks
delta_end: float
theta_end: float
vega_end: float
gamma_end: float
# costs
commission_total: float
slippage_total: float
cost_pct_of_nav: float
# tax
dividend_gross: float
withholding_30pct: float
# ---------- data loading ----------
def load_data(month: str):
"""Load trade journal, NAV, greeks, and benchmark for the month."""
month_start = datetime.strptime(month, '%Y-%m')
# last day of month
if month_start.month == 12:
month_end = datetime(month_start.year + 1, 1, 1) - timedelta(days=1)
else:
month_end = datetime(month_start.year, month_start.month + 1, 1) - timedelta(days=1)
journal = pd.read_parquet('data/trade_journal.parquet')
journal = journal[(journal.timestamp >= month_start) & (journal.timestamp <= month_end)]
nav = pd.read_csv('data/nav_daily.csv', parse_dates=['date'])
nav_month = nav[(nav.date >= month_start) & (nav.date <= month_end)].copy()
nav_ytd = nav[(nav.date >= datetime(month_start.year, 1, 1)) & (nav.date <= month_end)].copy()
greeks = pd.read_csv('data/greeks_daily.csv', parse_dates=['date'])
greeks_month = greeks[(greeks.date >= month_start) & (greeks.date <= month_end)].copy()
spy = pd.read_csv('data/spy_daily.csv', parse_dates=['date'])
spy_month = spy[(spy.date >= month_start) & (spy.date <= month_end)].copy()
return journal, nav_month, nav_ytd, greeks_month, spy_month
# ---------- metric computation ----------
def compute_metrics(journal, nav_month, nav_ytd, greeks_month, spy_month) -> MonthlyMetrics:
nav_start = float(nav_month.nav.iloc[0])
nav_end = float(nav_month.nav.iloc[-1])
mom = nav_end / nav_start - 1
ytd_start = float(nav_ytd.nav.iloc[0])
ytd = nav_end / ytd_start - 1
# daily returns
nav_month['ret'] = nav_month.nav.pct_change()
spy_month['ret'] = spy_month.close.pct_change()
# beta to SPY (over the month)
cov = np.cov(nav_month.ret.dropna(), spy_month.ret.dropna())
beta = cov[0, 1] / cov[1, 1] if cov[1, 1] != 0 else 0
# attribution (simplified — real version uses Day 52 framework)
spy_total_ret = spy_month.close.iloc[-1] / spy_month.close.iloc[0] - 1
market_contrib = beta * spy_total_ret
# strategy P&L from journal
strat_pnl = journal.groupby('strategy').pnl.sum().to_dict()
total_strat_pnl_pct = sum(strat_pnl.values()) / nav_start
# factor alpha = momentum P&L (proxy)
factor_alpha = strat_pnl.get('momentum', 0) / nav_start
event_alpha = strat_pnl.get('ic', 0) / nav_start
cost_drag = -(journal.commission.sum() + journal.slippage.sum()) / nav_start
# risk metrics
daily_rets = nav_month.ret.dropna()
var_95_param = float(np.percentile(daily_rets, 5)) * nav_end # quick proxy
var_95_hist = float(daily_rets.quantile(0.05)) * nav_end
cvar_95 = float(daily_rets[daily_rets <= daily_rets.quantile(0.05)].mean()) * nav_end
running_max = nav_month.nav.cummax()
dd = nav_month.nav / running_max - 1
max_dd = float(dd.min())
# duration: longest run below previous peak
underwater = (dd < 0).astype(int)
max_dd_dur = int(underwater.groupby((underwater != underwater.shift()).cumsum()).sum().max() or 0)
ann_factor = np.sqrt(252)
sharpe = float(daily_rets.mean() / daily_rets.std() * ann_factor) if daily_rets.std() > 0 else 0
downside = daily_rets[daily_rets < 0]
sortino = float(daily_rets.mean() / downside.std() * ann_factor) if len(downside) > 1 else 0
calmar = float(mom * 12 / abs(max_dd)) if max_dd != 0 else 0
# greeks end-of-month
last_greeks = greeks_month.iloc[-1]
# commission, slippage
commission_total = float(journal.commission.sum())
slippage_total = float(journal.slippage.sum())
cost_pct = (commission_total + slippage_total) / nav_end
# dividends / withholding (from journal — assumed 'dividend' rows)
div_rows = journal[journal.event_type == 'dividend']
div_gross = float(div_rows.gross_amount.sum()) if 'gross_amount' in div_rows.columns else 0
withhold = div_gross * 0.30
return MonthlyMetrics(
month=nav_month.date.iloc[0].strftime('%Y-%m'),
nav_start=nav_start, nav_end=nav_end,
mom_return=mom, ytd_return=ytd,
market_beta_contrib=market_contrib,
factor_alpha=factor_alpha,
event_alpha=event_alpha,
cost_drag=cost_drag,
var_95_parametric=var_95_param,
var_95_historical=var_95_hist,
cvar_95=cvar_95,
max_drawdown=max_dd,
max_dd_duration_days=max_dd_dur,
sharpe=sharpe, sortino=sortino, calmar=calmar,
beta_to_spy=float(beta),
strat_pnl={k: float(v) for k, v in strat_pnl.items()},
delta_end=float(last_greeks.net_delta),
theta_end=float(last_greeks.theta),
vega_end=float(last_greeks.vega),
gamma_end=float(last_greeks.gamma),
commission_total=commission_total,
slippage_total=slippage_total,
cost_pct_of_nav=float(cost_pct),
dividend_gross=div_gross,
withholding_30pct=withhold,
)
# ---------- chart generation ----------
def chart_nav_vs_spy(nav_month, spy_month, out_path: Path):
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 5),
gridspec_kw={'height_ratios': [3, 1]}, sharex=True)
# normalize to 1.0 at start
nav_norm = nav_month.nav / nav_month.nav.iloc[0]
spy_norm = spy_month.close / spy_month.close.iloc[0]
ax1.plot(nav_month.date, nav_norm, label='Portfolio', color='#1f77b4', linewidth=2)
ax1.plot(spy_month.date, spy_norm, label='SPY benchmark', color='#888', linestyle='--')
ax1.set_ylabel('Cumulative (start=1.0)')
ax1.legend(loc='upper left')
ax1.grid(True, alpha=0.3)
# underwater
running_max = nav_month.nav.cummax()
dd = (nav_month.nav / running_max - 1) * 100
ax2.fill_between(nav_month.date, 0, dd, color='#d62728', alpha=0.6)
ax2.set_ylabel('DD (%)')
ax2.set_ylim(min(dd.min() * 1.1, -1), 0.5)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close()
def chart_attribution_waterfall(m: MonthlyMetrics, out_path: Path):
categories = ['Market β', 'Factor α', 'Event α', 'Cost', 'Total']
values = [m.market_beta_contrib, m.factor_alpha, m.event_alpha, m.cost_drag, 0]
values[-1] = sum(values[:-1])
fig, ax = plt.subplots(figsize=(8, 4.5))
cumulative = 0
for i, (cat, val) in enumerate(zip(categories[:-1], values[:-1])):
color = '#2ca02c' if val >= 0 else '#d62728'
ax.bar(cat, val * 100, bottom=cumulative * 100, color=color, edgecolor='black')
cumulative += val
ax.bar('Total', values[-1] * 100, color='#1f77b4', edgecolor='black')
ax.axhline(0, color='black', linewidth=0.8)
ax.set_ylabel('Return (%)')
ax.set_title('Monthly Return Attribution')
ax.grid(True, axis='y', alpha=0.3)
plt.tight_layout()
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close()
def chart_greeks_evolution(greeks_month, out_path: Path):
fig, axes = plt.subplots(2, 2, figsize=(10, 6))
for ax, col, title in zip(
axes.flat,
['net_delta', 'theta', 'vega', 'gamma'],
['Net Delta', 'Theta ($/day)', 'Vega ($/vol pt)', 'Gamma']
):
ax.plot(greeks_month.date, greeks_month[col], color='#1f77b4')
ax.set_title(title)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close()
# ---------- PDF rendering ----------
def render_pdf(m: MonthlyMetrics, chart_dir: Path, out_path: Path, version: str = 'private'):
"""version in {'private', 'pro', 'public'} controls what's masked."""
doc = SimpleDocTemplate(str(out_path), pagesize=A4,
topMargin=2*cm, bottomMargin=2*cm,
leftMargin=2*cm, rightMargin=2*cm)
styles = getSampleStyleSheet()
h1 = ParagraphStyle('H1', parent=styles['Heading1'],
fontName='CJK', fontSize=18, spaceAfter=12)
body = ParagraphStyle('Body', parent=styles['Normal'],
fontName='CJK', fontSize=10, leading=14)
story = []
# ----- Cover -----
story.append(Paragraph(f"Monthly Performance Report — {m.month}", h1))
if version == 'public':
story.append(Paragraph(f"MoM: {m.mom_return*100:+.2f}% | YTD: {m.ytd_return*100:+.2f}%", body))
else:
story.append(Paragraph(
f"NAV: ${m.nav_start:,.0f} → ${m.nav_end:,.0f} "
f"| MoM: {m.mom_return*100:+.2f}% | YTD: {m.ytd_return*100:+.2f}%", body))
story.append(Spacer(1, 0.5*cm))
story.append(Image(str(chart_dir / 'nav.png'), width=16*cm, height=10*cm))
story.append(PageBreak())
# ----- Section 2: Attribution -----
story.append(Paragraph("Section 2 — Attribution", h1))
story.append(Image(str(chart_dir / 'attribution.png'), width=16*cm, height=9*cm))
attr_table = [
['Component', 'Contribution (%)'],
['Market β', f"{m.market_beta_contrib*100:+.2f}"],
['Factor α', f"{m.factor_alpha*100:+.2f}"],
['Event α', f"{m.event_alpha*100:+.2f}"],
['Cost', f"{m.cost_drag*100:+.2f}"],
['Total', f"{m.mom_return*100:+.2f}"],
]
t = Table(attr_table, hAlign='LEFT')
t.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'CJK'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
]))
story.append(Spacer(1, 0.3*cm))
story.append(t)
story.append(PageBreak())
# ----- Section 4: Greeks -----
story.append(Paragraph("Section 4 — Greeks Evolution", h1))
story.append(Image(str(chart_dir / 'greeks.png'), width=16*cm, height=10*cm))
story.append(PageBreak())
# ----- Section 5: Risk -----
story.append(Paragraph("Section 5 — Risk Metrics", h1))
risk_rows = [
['Metric', 'Value'],
['VaR 95% (parametric)', f"${m.var_95_parametric:,.0f}"],
['VaR 95% (historical)', f"${m.var_95_historical:,.0f}"],
['CVaR 95%', f"${m.cvar_95:,.0f}"],
['Max Drawdown', f"{m.max_drawdown*100:.2f}%"],
['Sharpe (ann.)', f"{m.sharpe:.2f}"],
['Sortino (ann.)', f"{m.sortino:.2f}"],
['Calmar', f"{m.calmar:.2f}"],
['Beta to SPY', f"{m.beta_to_spy:.2f}"],
]
# public version: mask absolute dollar amounts
if version == 'public':
for row in risk_rows[1:4]:
row[1] = '(redacted)'
rt = Table(risk_rows, hAlign='LEFT')
rt.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'CJK'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
]))
story.append(rt)
story.append(PageBreak())
# ----- Section 8: Lessons -----
story.append(Paragraph("Section 8 — Lessons & Action Items", h1))
story.append(Paragraph(
"动量因子贡献符合预期;Wheel 需要加 IV rank 过滤;IC 平仓 schedule 调整。",
body))
# ... (full lessons loaded from markdown file)
doc.build(story)
log.info("PDF rendered: %s", out_path)
# ---------- entrypoint ----------
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--month', required=True, help='YYYY-MM')
parser.add_argument('--version', default='all',
choices=['private', 'pro', 'public', 'all'])
parser.add_argument('--email', action='store_true')
args = parser.parse_args()
out_dir = Path(f'reports/{args.month}')
out_dir.mkdir(parents=True, exist_ok=True)
chart_dir = out_dir / 'charts'
chart_dir.mkdir(exist_ok=True)
journal, nav_m, nav_ytd, greeks_m, spy_m = load_data(args.month)
metrics = compute_metrics(journal, nav_m, nav_ytd, greeks_m, spy_m)
# save machine-readable
(out_dir / 'metrics.json').write_text(
json.dumps(asdict(metrics), indent=2, default=str), encoding='utf-8')
# charts
chart_nav_vs_spy(nav_m, spy_m, chart_dir / 'nav.png')
chart_attribution_waterfall(metrics, chart_dir / 'attribution.png')
chart_greeks_evolution(greeks_m, chart_dir / 'greeks.png')
versions = ['private', 'pro', 'public'] if args.version == 'all' else [args.version]
for v in versions:
render_pdf(metrics, chart_dir, out_dir / f'{v}.pdf', version=v)
if args.email:
send_email(out_dir / 'private.pdf', subject=f"Monthly Report {args.month}")
log.info("Done. Reports in %s", out_dir)
def send_email(pdf_path: Path, subject: str):
"""Stub — wire to SMTP / SES / SendGrid in production."""
log.info("[email stub] would send %s with subject %s", pdf_path, subject)
if __name__ == '__main__':
main()
这个骨架的设计选择:
MonthlyMetrics用@dataclass,可以直接asdict→ JSON,机器可读matplotlib.use('Agg')必须在 import pyplot 之前——cron 环境没有 X server- version 控制在
render_pdf末端,前面所有 metrics 计算共享 - PDF 生成与图表生成分离,方便单测
六、图表设计原则:viridis、RdYlGn、benchmark
6.1 配色规则
| 用途 | 配色 | 原因 |
|---|---|---|
| 顺序型数据(如热力图收益) | RdYlGn(红黄绿) | 直觉对应「亏-持平-赚」 |
| 连续型分布(如 VaR 直方图) | viridis | 色盲友好,打印灰度仍可分辨 |
| 双策略对比 | tab10 前两色 | 避免红绿混淆 |
| 警告 / 回撤 | 单色红 #d62728 | 强调 |
| benchmark | 灰色虚线 #888 | 不抢主色 |
6.2 必备元素
每张图必须有:
- 标题 — 当前显示什么(不要省)
- 轴标签 — 单位明确("Return (%)",不是 "Return")
- 图例 — 多线时
- 网格 —
grid(True, alpha=0.3),辅助读数但不抢眼 - 数据来源 + 时间窗口注脚 — 至少在 PDF 章节文字里说明
6.3 NAV 必须叠 benchmark
「我赚了 2.3%」毫无信息量;「我赚了 2.3%,同期 SPY 1.7%,超额 0.6%」才有信息量。 月报每张 NAV 曲线图都要叠 SPY,没有例外。
七、常见报告生成的坑
7.1 时区
我在中国(UTC+8)
账户在美国(UTC-5 ET / UTC-4 EDT)
IBKR API 默认返回 UTC
matplotlib 显示什么?
血泪经验:所有时间戳在数据层统一存 UTC,在 PDF 渲染层转 ET。如果在数据层就转 ET,跨夏令时切换的日子会出现「同一天有 23 或 25 小时」的诡异 bug。
# 写入数据时
journal['timestamp'] = pd.to_datetime(journal['timestamp'], utc=True)
# 渲染时
journal['ts_et'] = journal['timestamp'].dt.tz_convert('America/New_York')
7.2 月末交易 cut-off
7/31 16:00 ET 之后的成交,算 7 月还是 8 月?
规则:以 官方 settlement date 为准,不是 trade date。
- 股票 T+1:7/31 trade → 8/1 settle → 算 7 月(因为这是 IBKR statement 的逻辑)
- 期权 T+1:同上
- 但 NAV 计算用 mark-to-market,跟 settlement 无关
实操:月报里两个口径都给——「按 trade date」和「按 settle date」,差额若 >0.1% 必须解释。
7.3 Corporate action 调整
如果当月有 split / spin-off / 大额股息:
- 历史 NAV 曲线要 backward adjust,否则曲线断崖
- 但 P&L 不调整(你赚的钱没变)
这两个看似矛盾,处理逻辑:展示用 adjusted price,accounting 用 raw price。
7.4 字体 / encoding
中英文混排在 reportlab 里最容易出问题的三个点:
- PDF 字体没注册 CJK → 中文是方块
- matplotlib 没设 family → 图里中文是方块
- CSV 读入用默认 encoding → Windows 上是 GBK,月报传给 Linux 服务器变乱码
固定 pattern:所有 IO 加 encoding='utf-8'。
八、PM 视角:月报作为「stakeholder communication」
10 年金融 PM 训练我相信一件事:任何「内部记录」的东西,第一天就当「外部展示」设计。
| 现在的「stakeholder」 | 未来的「stakeholder」 |
|---|---|
| 自己(复盘) | broker(申请 Portfolio Margin) |
| 自己(迭代决策) | prop firm(申请资金) |
| 自己(情绪管理) | hiring manager(求职作品) |
| 自己(学习) | 投资人 / partner(如果开 fund) |
核心洞察:「记给自己看」和「给别人看」差异不在内容,差异在 签名 + 时间戳 + 版本可追溯。
我的月报放在 reports/2026-07/ 下,git 追踪 markdown 部分,PDF 不入 git 但 metrics.json 入 git。这样:
- 我可以
git log reports/2026-07/metrics.json看是否被改过(永远不要改历史月报,错了就出 errata) - 未来给 prop firm 看时,整段 git log 是 audit trail
- 公开版本(
public.pdf)单独 publish 到博客 + IPFS,时间戳由链上锚定
这种「第一天就当作公开档案」的纪律,跟 DDD 里的 Event Sourcing 是同一精神:写入只追加,绝不修改。
九、自动化与归档
9.1 Cron 调度
# crontab (Linux server) — 每月 1 号 02:00 UTC (= 22:00 ET 前一晚)
0 2 1 * * cd /home/trader/reports && python monthly_report.py --month $(date -d "last month" +\%Y-\%m) --version all --email >> logs/cron.log 2>&1
坑:date -d "last month" 在月底跑会出问题(如 3/31 跑会算成 3 月而不是 2 月)。对策:固定 1 号跑。
9.2 邮件分发
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
def send_email(pdf_path, subject):
msg = MIMEMultipart()
msg['From'] = 'reports@me.com'
msg['To'] = 'me@me.com'
msg['Subject'] = subject
msg.attach(MIMEText(f"Monthly report attached. Generated {datetime.utcnow().isoformat()}Z", 'plain'))
with open(pdf_path, 'rb') as f:
part = MIMEApplication(f.read(), Name=pdf_path.name)
part['Content-Disposition'] = f'attachment; filename="{pdf_path.name}"'
msg.attach(part)
with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
server.login('reports@me.com', 'APP_PASSWORD')
server.send_message(msg)
9.3 归档策略
reports/
├── 2026-05/
│ ├── private.pdf
│ ├── pro.pdf
│ ├── public.pdf
│ ├── metrics.json
│ └── lessons.md
├── 2026-06/
├── 2026-07/ <-- 本次第一份
└── ...
每年年底用 metrics.json 跑 annual aggregator,自动生成年报(Day 168 的事情,先 stub)。
十、明日预告
Day 85: 公开复盘文章 — Mirror / Twitter 发布的第一份对外内容
- 取月报
public.pdf中可分享的部分 - 改写成 1,500-2,000 字 narrative(Mirror 长文 + Twitter thread)
- 重点 frame:「我学到了什么」 > 「我赚了多少」
- 求职 branding 视角:建立 "thoughtful retail quant" 标签
- 隐私边界再次校准:哪些百分比可以公开、哪些不行
- 评论区互动 protocol:哪些提问回答、哪些礼貌跳过
实际执行记录
启动一项填一项,时间戳 + 卡点。
- [hh:mm]
monthly_report.py骨架跑通 — 用 7 月半月数据 stub - [hh:mm] reportlab + 思源字体注册完成
- [hh:mm] 第一份
private.pdf出图 — 校验 NAV / DD / Greek 图 - [hh:mm]
pro.pdf与public.pdfmask 逻辑验证 - [hh:mm] cron 配置 + 邮件 stub 联通
- [hh:mm] git commit
reports/2026-07/metrics.json(首份归档) - 卡点 / 学到的:
- matplotlib 中文渲染:________
- reportlab 中英文混排:________
- 归因数字与心理预期偏差:________
总字数:约 6,400 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓