【Python量化策略评估体系 #04】策略赚了钱,到底是谁的功劳?PnL·持仓·净值三视角归因

回测跑完总收益 +15%,你很满意。但老板问了一句:"这 15% 里,多少是选股选的,多少是大盘涨的,多少是你中途加仓加出来的?"

答不上来,策略就还是黑盒。

策略评估不能只看一个最终数字。本文给你三个归因视角,把收益拆到每一块钱,让每一次调仓都有解释。

一、本文你将得到什么

  1. PnL 归因:把单笔区间收益拆成「价格效应 + 数量效应 + 交易效应」。
  2. 持仓归因:用期初权重 × 区间收益,看每个时间点的持仓对总收益的贡献。
  3. 净值归因:从组合整体视角,把期末净值相对期初的盈亏拆到「初始持仓」和「新增仓位」。
  4. 一段可直接复制运行的 Python 代码,输入你的持仓快照就能出三份归因表。

二、为什么需要三视角

同一个策略,不同人问的问题不一样:

  • 交易员问:我这次加仓是对是错?→ 看 PnL 归因里的数量效应。
  • 研究员问:这段时间哪些持仓真正贡献了收益?→ 看持仓归因里的权重 × 收益。
  • 投资人问:组合整体赚了还是亏了?→ 看净值归因里的总盈亏拆分。

三视角不是重复计算,而是从「交易动作」「持仓结构」「组合整体」三个维度回答同一个问题。

三、核心公式

  1. PnL 归因(单区间)

设上一期持仓为 (P_prev, Q_prev),本期为 (P_cur, Q_cur):

  • 价格效应 = (P_cur - P_prev) × Q_prev
  • 数量效应 = (Q_cur - Q_prev) × P_prev
  • 交易效应 = (P_cur - P_prev) × (Q_cur - Q_prev)
  • 总 PnL = 三者之和
  1. 持仓归因

每个时间点的贡献 = 该时点持仓市值 / 总初始市值 × 该区间收益率

  1. 净值归因
  • 总盈亏 = 期末净值 - 期初净值 - 期间追加投入
  • 初始持仓盈亏 = (P_end - P_start) × Q_start
  • 新增仓位盈亏 = (P_end - P_trade) × ΔQ

注意:有外部资金流入时,简单用 (期末 - 期初)/期初 会虚高收益,必须扣掉追加投入。

四、代码实战

import json
import random
from mairui import Client

DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073" # 演示证书,请换成你自己的正式证书
CODE = "600519"

def perturb_ohlc(bars, seed=2026091304):
"""演示证返回的是同一份 mock 数据,这里做确定性扰动让序列有起伏。"""
rng = random.Random(seed)
out = []
for b in bars:
k = 1.0 + (rng.random() - 0.5) * 0.10
nb = dict(b)
for f in ("o", "c", "h", "l"):
if f in nb and nb[f] is not None:
nb[f] = round(float(nb[f]) * k, 2)
out.append(nb)
return out

def fmt_pct(x):
return f"{float(x) * 100:.4f}%"

client = Client(DEMO_LICENCE)
bars = perturb_ohlc(client.stock_history(code=CODE, period="d", dividend="f"))[:30]

构造 3 个持仓快照:t0 建仓 100 股,t1 加仓到 130 股,t2 持有

snapshots = [
{"date": bars[0]["t"], "price": float(bars[0]["c"]), "qty": 100},
{"date": bars[10]["t"], "price": float(bars[10]["c"]), "qty": 130},
{"date": bars[20]["t"], "price": float(bars[20]["c"]), "qty": 130},
]

---------- 1. PnL 归因 ----------

pnl_rows = []
for i in range(1, len(snapshots)):
prev, cur = snapshots[i - 1], snapshots[i]
delta_p = cur["price"] - prev["price"]
delta_q = cur["qty"] - prev["qty"]

price_effect = delta_p * prev["qty"]
quantity_effect = delta_q * prev["price"]
trading_effect = delta_p * delta_q
total_pnl = price_effect + quantity_effect + trading_effect

pnl_rows.append({
"区间": f"{prev['date']} -> {cur['date']}",
"价格效应": round(price_effect, 2),
"数量效应": round(quantity_effect, 2),
"交易效应": round(trading_effect, 2),
"总 PnL": round(total_pnl, 2),
})

---------- 2. 持仓归因 ----------

holding_rows = []
total_initial_value = sum(s["price"] * s["qty"] for s in snapshots)
for i, s in enumerate(snapshots):
weight = (s["price"] * s["qty"]) / total_initial_value
ret = 0.0 if i == 0 else (s["price"] - snapshots[i - 1]["price"]) / snapshots[i - 1]["price"]
holding_rows.append({
"日期": s["date"],
"初始权重": fmt_pct(weight),
"区间收益": fmt_pct(ret),
"归因贡献": fmt_pct(weight * ret),
})

---------- 3. 净值归因 ----------

p0, p1, p2 = snapshots[0]["price"], snapshots[1]["price"], snapshots[2]["price"]
q0, q2 = snapshots[0]["qty"], snapshots[2]["qty"]
delta_q = q2 - q0
nav_start = p0 * q0
cash_inflow = p1 * delta_q
nav_end = p2 * q2

total_pnl = nav_end - nav_start - cash_inflow
hold_pnl = (p2 - p0) * q0
trade_pnl = (p2 - p1) * delta_q

nav_attribution = {
"初始净值": round(nav_start, 2),
"追加投入": round(cash_inflow, 2),
"期末净值": round(nav_end, 2),
"总盈亏": round(total_pnl, 2),
"初始持仓盈亏": round(hold_pnl, 2),
"新增仓位盈亏": round(trade_pnl, 2),
"对应收益率": fmt_pct(total_pnl / (nav_start + cash_inflow)),
}

print(json.dumps({
"PnL归因": pnl_rows,
"持仓归因": holding_rows,
"净值归因": nav_attribution,
}, ensure_ascii=False, indent=2))

五、运行结果(演示环境)

{
"PnL归因": [
{
"区间": "2025-09-15 -> 2025-09-29",
"价格效应": 3.0,
"数量效应": 328.2,
"交易效应": 0.9,
"总 PnL": 332.1
},
{
"区间": "2025-09-29 -> 2025-10-21",
"价格效应": -37.7,
"数量效应": 0.0,
"交易效应": -0.0,
"总 PnL": -37.7
}
],
"持仓归因": [
{"日期": "2025-09-15", "初始权重": "27.99%", "区间收益": "0.0000%", "归因贡献": "0.0000%"},
{"日期": "2025-09-29", "初始权重": "36.49%", "区间收益": "0.2742%", "归因贡献": "0.1001%"},
{"日期": "2025-10-21", "初始权重": "35.52%", "区间收益": "-2.6436%", "归因贡献": "-0.9391%"}
],
"净值归因": {
"初始净值": 1094.0,
"追加投入": 329.1,
"期末净值": 1388.4,
"总盈亏": -34.7,
"初始持仓盈亏": -26.0,
"新增仓位盈亏": -8.7,
"对应收益率": "-2.4383%"
}
}

可以看到:

  • 第一区间总 PnL +332.1 元里,数量效应占绝对大头(328.2 元),说明这次盈利主要来自加仓,而不是价格本身上涨。
  • 第二区间没有调仓,亏损 -37.7 元全部来自价格效应。
  • 净值视角下,虽然期末市值高于期初,但扣除追加投入后真实盈亏是 -34.7 元,对应收益率 -2.44%。

六、常见坑与注意

  1. 别把市值增长当收益。中途加了钱,期末市值高不等于赚钱,必须扣掉现金流入。
  2. 归因残差要归零。如果三效应之和不等于总 PnL,说明公式写错了。
  3. 多标的同理。把单只股票的快照换成每只股票的 (市值, 股数),就能做组合层面的 Brinson + PnL 联合归因。
  4. 演示证数据有限。正式环境换成自己的 licence 后,stock_history 会返回真实日线,去掉 perturb_ohlc 即可。

七、小结

  • PnL 归因回答「单笔调仓赚了哪部分钱」。
  • 持仓归因回答「哪个时间段的持仓结构贡献最多」。
  • 净值归因回答「组合整体的真实盈亏来源」。

三者结合,才能把「策略赚了 15%」变成「选股贡献 8%、择时贡献 3%、加仓时点贡献 4%」。

下篇预告:【Python量化策略评估体系 #05】极端行情扛得住吗?用 2008/2015/2020 三场景做压力测试。


了解更多

免责声明:本文仅为技术方法演示,不构成投资建议。历史表现不代表未来收益,投资有风险,入市需谨慎。

posted @ 2026-10-10 06:39  量化分析码农  阅读(9)  评论(0)    收藏  举报