返回交易笔记
TR Day 84

月度自动报告生成

机构级 portfolio monthly report 的标准结构、归因报表的视觉语言、PDF 生成技术栈取舍

2026-08-01
Phase 3: 实盘+规模化+迁移
MonthlyReportPDFAttributionPortfolioReportingmatplotlibreportlabProductionGrade

日期: 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

三张图叠在一页:

  1. NAV 曲线 + SPY benchmark:必须把 SPY normalize 到同一起点(月初 = 1.0),让读者一眼看出 α
  2. Drawdown underwater chart:底部红色填充,横轴日期,纵轴 0 到 -X%。永远画在 NAV 下方,视觉对应
  3. 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 return0.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&LSharpe (annualized)最大单笔损备注
双因子动量55%+$8471.83-$112因子滚动表现稳定
Wheel30%+$1320.94-$180NVDA assignment 拖累
IC 财报15%+$2032.41-$485 笔 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.7Wheel 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
占 NAV0.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 三个方案对比

维度reportlabweasyprintjupyter + nbconvert
思路Python API 直接画 PDFHTML/CSS → PDFNotebook → LaTeX → PDF
排版控制精细,learning curve 陡CSS 熟悉就好受限于 LaTeX 模板
图表插入matplotlib PNG → embed<img> 标签inline 自动
中文支持要注册字体,但稳字体一般要装系统级LaTeX CJK 配置麻烦
适合标准化、批量生成设计感强的报告一次性分析
维护成本中(代码即模板)低(HTML 工程师都会)高(notebook 易腐烂)
我们选--

为什么选 reportlab

  1. 月报每月生成一次,模板代码化更好维护,diff 友好
  2. 不用装 GTK / Pango / Cairo(weasyprint 在 Windows 上的痛点)
  3. 中文字体处理用 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()

这个骨架的设计选择

  1. MonthlyMetrics@dataclass,可以直接 asdict → JSON,机器可读
  2. matplotlib.use('Agg') 必须在 import pyplot 之前——cron 环境没有 X server
  3. version 控制在 render_pdf 末端,前面所有 metrics 计算共享
  4. PDF 生成与图表生成分离,方便单测

六、图表设计原则:viridis、RdYlGn、benchmark

6.1 配色规则

用途配色原因
顺序型数据(如热力图收益)RdYlGn(红黄绿)直觉对应「亏-持平-赚」
连续型分布(如 VaR 直方图)viridis色盲友好,打印灰度仍可分辨
双策略对比tab10 前两色避免红绿混淆
警告 / 回撤单色红 #d62728强调
benchmark灰色虚线 #888不抢主色

6.2 必备元素

每张图必须有:

  1. 标题 — 当前显示什么(不要省)
  2. 轴标签 — 单位明确("Return (%)",不是 "Return")
  3. 图例 — 多线时
  4. 网格grid(True, alpha=0.3),辅助读数但不抢眼
  5. 数据来源 + 时间窗口注脚 — 至少在 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 里最容易出问题的三个点:

  1. PDF 字体没注册 CJK → 中文是方块
  2. matplotlib 没设 family → 图里中文是方块
  3. 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。这样:

  1. 我可以 git log reports/2026-07/metrics.json 看是否被改过(永远不要改历史月报,错了就出 errata
  2. 未来给 prop firm 看时,整段 git log 是 audit trail
  3. 公开版本(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.jsonannual 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.pdfpublic.pdf mask 逻辑验证
  • [hh:mm] cron 配置 + 邮件 stub 联通
  • [hh:mm] git commit reports/2026-07/metrics.json(首份归档)
  • 卡点 / 学到的:
    • matplotlib 中文渲染:________
    • reportlab 中英文混排:________
    • 归因数字与心理预期偏差:________

总字数:约 6,400 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓