想象两个朋友用同一套双均线信号交易同一只 ETF:信号完全一样,一个人每次满仓进出,另一个人只动用四分之一的资金;再算上手续费,前者每次调仓都是大进大出,后者轻装简行。一年下来,两个人的账户差距,往往比「信号准不准」本身的差距大得多。这就是本篇的主角:交易成本与仓位管理。信号决定你「什么时候看对」,成本和仓位决定你「看对之后还剩多少」——后者恰恰是新手最容易忽略、也最致命的一环。
上一篇我们在 Backtrader 里装上了手续费、滑点与 Sizer,知道了成本「要收」;本篇把这笔账算到底:先亲手量化不同换手频率下成本对收益的复利侵蚀,再回答一个更重要的问题——看对方向之后,该押多大仓位?我们会认识仓位管理里最著名的凯利公式(Kelly Criterion)与「半凯利」惯例,动手实现固定比例与固定金额两种仓位规则,最后聊聊为什么新手应该用比凯利更保守的仓位。
本篇目标
- 能用十几行代码算出「换手频率 × 费率」对长期收益的复利侵蚀表;
- 能说出凯利公式
f* = p − (1 − p) / b中每个符号的含义,并手算一个简单例子; - 能用 Python 从策略收益里估计胜率(Win Rate)与盈亏比(Payoff Ratio),算出凯利仓位与半凯利仓位;
- 能实现固定比例(Fixed Fractional)与固定金额两种仓位规则,并在回测中对比它们的净值与回撤;
- 能说出至少三条「新手应比凯利更保守」的具体理由。
核心概念
成本是复利的敌人:换手越多,刀越深
先统一口径:本系列所说的双边 0.1%,指一次完整的买入加卖出合计收 0.1%(单边各 0.05%)。单看一次,0.1% 像根头发丝;问题在于成本是按次数复利累积的。若每年有 n 次完整买卖(换手率(Turnover)的直观体现就是买卖次数),扣费后的净年化约等于:
净年化 ≈ (1 + 毛年化) × (1 − 0.1%)^n − 1
白话解释:每完整交易一次,你的资金就先乘上一个 0.999,交易多少次就乘多少次,最后才轮到策略收益登场。一年 250 次(几乎每个交易日进出)就是连乘 250 个 0.999,约等于把年终资产打 7.8 折——这时即便毛年化有 15%,扣完费也只剩约 −10.5%(下面代码会算给你看)。成本还有个更狠的特性:亏损与盈利不对称,亏 20% 需要涨 25% 才能回本,所以高换手会先把你打入数学上的劣势区。
划重点:成本不是一次性过路费,而是随换手次数复利累积的「隐形租金」。频率越高,租金越贵,很多「样本内很美」的高频信号就是被这笔租金拖垮的。
凯利公式:数学家的「最优」下注比例
1956 年贝尔实验室的约翰·凯利(John Kelly)回答了一个问题:已知胜率和赔率,每次押多大比例,才能让长期复利增速最大?答案是凯利公式:
f* = p − (1 − p) / b
p:胜率,赢的概率;b:盈亏比,平均每次赢的金额 ÷ 平均每次输的金额;f*:每次应投入的资金比例。
白话拆一下:(1 − p) / b 可以理解为「你的劣势被赔率补偿后剩下的部分」,用胜率减掉它,余下的才是值得押上的比例。手算两个例子:胜率 60%、盈亏比 1 的公平赌局,f* = 0.6 − 0.4/1 = 0.2,押 20%;胜率 55%、盈亏比 1.2,f* = 0.55 − 0.45/1.2 ≈ 0.175,押 17.5%。再体会一个边界:胜率 50%、盈亏比 1 时 f* = 0——没有优势就一分钱都别押,公式很诚实。
同时注意凯利的两个前提:它假设每次下注只有「赢 b 倍」或「输掉所押部分」两档结果,且 p 与 b 精确已知。真实交易的盈亏是连续分布、带肥尾的,p 和 b 只能估计——这两个前提的松动,正是下一节要打折扣的原因。
半凯利:实践者的默认起点
如果 p 估高了哪怕 2 个百分点,f* 可能被高估一大截;而且满凯利仓位的资金曲线颠簸得非常厉害,很多人撑不到「长期」就被中途的回撤洗出局。于是实践者形成了一个几乎约定俗成的惯例:半凯利(Half Kelly)——把算出来的 f* 除以二再使用。粗略地说,半凯利能保住全凯利约四分之三的长期增速,但资金曲线的波动小得多,性价比极高。本篇代码里你会看到全仓、凯利、半凯利、四分之一凯利四档仓位的对比。
划重点:凯利公式最大化的是「长期复利增速」这个数学目标,不是你账户的舒服程度;它给出的是思考仓位的起点,不是必须执行的命令。
固定比例与固定金额:两种好执行的规则
- 固定比例:每次开仓都投入当前总资金的固定百分比(比如 10%)。赚了,绝对金额水涨船高;亏了,自动越押越少,天然带有「越亏越收手」的复利保护。
- 固定金额:每次只投入固定的一笔钱(比如 10 万元)。直观好算,但占总资金的比例会随盈亏漂移——亏了之后同样的金额占比反而变大,等于亏越多押越重,方向和固定比例相反。
本篇代码用「仓位占比 × 当日涨跌 − 换手 × 单边成本」的向量化方式实现两者,教学上足够贴近真实;差异与适用场景在代码解释里展开。
为什么新手应该比凯利更保守
四条理由,条条都劝你把 f* 再打个折扣:
- p 和 b 估不准。 你只有有限的历史样本,几十笔交易估出的胜率误差可以很大,而
f*对 p 极其敏感,输入错一点,输出错一截。 - 真实收益不是两档。 凯利假设输赢两档,真实市场是连续的盈亏分布,还偶发极端行情(肥尾),极端日会造成公式没预料到的重仓损失。
- 心理账户会先破产。 全凯利的回撤幅度足以让大多数人在低点放弃整个系统——策略死了不可怕,人先离开系统才是最贵的成本。
- 公式里没有成本。 凯利不知道手续费和滑点,换手越高的策略,实际可用的
f*越低。
所以很多实践者的做法是:算出 f*,除以二(半凯利),再给单笔仓位设一个上限(教学讨论中常见的口径是不超过总资金的百分之十几到三十),然后从更小的仓位开始记录实盘表现。本教程的建议只有一句:把凯利当思考工具,别当油门。
完整代码
依赖安装:uv pip install akshare pandas。第一段代码先算「成本侵蚀表」:
import pandas as pd
COST = 0.001 # 一次完整买卖(双边)合计 0.1%,全系列统一口径
def net_annual(gross_annual: float, trades_per_year: int) -> float:
"""给定毛年化与每年完整买卖次数,估算扣费后的净年化"""
return (1 + gross_annual) * (1 - COST) ** trades_per_year - 1
rows = []
for t in [2, 12, 52, 250]: # 低频择时 / 月调 / 周调 / 逐日进出
net = net_annual(0.15, t) # 假设毛年化 15%
rows.append({"每年买卖次数": t,
"扣费后年化": f"{net:.1%}",
"10年后净值": f"{(1 + net) ** 10:.2f}"})
print(pd.DataFrame(rows).to_string(index=False))
这张表是本篇的第一记警钟。接下来是主菜——凯利仓位与固定比例仓位的完整回测:
import akshare as ak
import numpy as np
import pandas as pd
COST_SIDE = 0.0005 # 单边 0.05%,一买一卖合计 0.1%,与全系列口径一致
def load_data() -> pd.DataFrame:
"""拉取沪深300ETF 后复权日线;数据源不可用时退回模拟数据,保证代码能跑"""
try:
df = ak.fund_etf_hist_em(symbol="510300", period="daily",
start_date="20200101", end_date="20241231", adjust="hfq")
df = df.rename(columns={"日期": "date", "收盘": "close"})[["date", "close"]]
df["date"] = pd.to_datetime(df["date"])
return df.reset_index(drop=True)
except Exception:
rng = np.random.default_rng(42) # 固定种子,模拟结果可复现
dates = pd.bdate_range("2020-01-01", "2024-12-31")
rets = rng.normal(0.0002, 0.015, len(dates))
return pd.DataFrame({"date": dates, "close": 4000 * np.cumprod(1 + rets)})
def run_backtest(df: pd.DataFrame, fast: int, slow: int, frac: float) -> pd.Series:
"""双均线择时 × 固定比例仓位:信号当日收盘计算、次日才生效,无未来函数"""
close = df.set_index("date")["close"]
signal = (close.rolling(fast).mean() > close.rolling(slow).mean()).astype(int)
pos = signal.shift(1).fillna(0.0) * frac # 关键:shift(1),明天才持仓
ret = close.pct_change().fillna(0.0)
turn = pos.diff().abs().fillna(0.0) # 仓位变化即换手
return pos * ret - turn * COST_SIDE # 扣单边 0.05% 成本
def kelly_stats(rets: pd.Series) -> tuple[float, float, float]:
"""把每个持仓日的盈亏近似当一次「下注」,估计 p、b 与凯利仓位(教学简化)"""
wins, losses = rets[rets > 0], -rets[rets < 0]
if len(wins) == 0 or len(losses) == 0 or losses.mean() == 0:
return 0.0, float("nan"), float("nan") # 样本不足,估计不了
p = len(wins) / (len(wins) + len(losses)) # 胜率
b = wins.mean() / losses.mean() # 盈亏比
kelly = max(min(p - (1 - p) / b, 1.0), 0.0) # 截断到 [0, 1]
return kelly, p, b
df = load_data()
full = run_backtest(df, fast=20, slow=60, frac=1.0) # 全仓跑一遍拿基础收益
kelly, p, b = kelly_stats(full[full != 0]) # 只统计持仓日的盈亏
print(f"胜率 p = {p:.2f},盈亏比 b = {b:.2f},凯利仓位 f* = {kelly:.1%}")
print("\n不同仓位下的表现(2020-01-01 至 2024-12-31,数值随数据变化,此处为示意):")
for name, frac in [("全仓", 1.0), ("凯利 f*", kelly),
("半凯利", kelly / 2), ("1/4 凯利", kelly / 4)]:
r = run_backtest(df, 20, 60, frac=frac)
equity = (1 + r).cumprod()
ann = equity.iloc[-1] ** (252 / len(r)) - 1
dd = (equity / equity.cummax() - 1).min()
print(f"{name:<8} 仓位 {frac:>6.1%} 年化 {ann:>7.2%} 最大回撤 {dd:>7.2%}")
最后补上固定金额仓位的实现,与固定比例放在一起对比:
# 固定金额:每次持仓市值固定为 10 万元(初始资金按 100 万计)
def fixed_amount_returns(df: pd.DataFrame, fast: int, slow: int,
amount: float = 100_000, capital: float = 1_000_000) -> pd.Series:
"""固定金额仓位的近似实现:用初始资金把目标市值换算成占比(教学简化)"""
close = df.set_index("date")["close"]
signal = (close.rolling(fast).mean() > close.rolling(slow).mean()).astype(int)
frac = signal.shift(1).fillna(0.0) * amount / capital # 固定金额 → 等效占比
ret = close.pct_change().fillna(0.0)
turn = frac.diff().abs().fillna(0.0)
return frac * ret - turn * COST_SIDE
r_amount = fixed_amount_returns(df, 20, 60)
r_frac = run_backtest(df, 20, 60, frac=0.1) # 固定比例:始终用 10% 资金
for name, r in [("固定金额 10 万", r_amount), ("固定比例 10%", r_frac)]:
eq = (1 + r).cumprod()
dd = (eq / eq.cummax() - 1).min()
print(f"{name}: 期末净值 {eq.iloc[-1]:.3f},最大回撤 {dd:.2%}")
代码逐段解释
- 成本侵蚀表(第一段代码):
net_annual把毛年化按完整买卖次数逐次乘上 0.999,返回净年化;主循环对每年 2、12、52、250 次四种频率各算一遍,并顺手折算 10 年复利净值。示例输出(数值为示意,用于展示输出形态):
每年买卖次数 扣费后年化 10年后净值
2 14.8% 3.97
12 13.6% 3.59
52 9.2% 2.40
250 -10.5% 0.33
一年 250 次的那一行就是「复利之刀」:毛年化 15% 的策略被成本磨成负收益,十年只剩三毛钱。
-
数据加载与降级(
load_data):优先用 akshare 拉沪深300ETF(510300)2020-01-01 至 2024-12-31 的后复权日线;网络或接口不可用时,退回固定种子的模拟数据,保证读者复制就能跑通——模拟数据只用于演示代码逻辑,不代表真实行情。 -
无未来函数的向量化回测(
run_backtest):signal由当日收盘价算出的两条均线比较得到,signal.shift(1)让它次日才生效——今天收盘才能知道的事,明天才允许用来持仓。仓位pos = signal.shift(1) × frac,当日收益pos × ret,换手turn = pos.diff().abs(),成本按换手幅度乘单边 0.05%。整个过程每个交易日只用当天及之前的数据。 -
凯利参数估计(
kelly_stats):把每个持仓日的盈亏近似当作一次「下注」(教学简化:严格的凯利应以整笔交易为单位,这里为了与向量化口径统一),wins与losses分别统计盈利日与亏损日,p为胜率、b为盈亏比,最后按公式算f*并截断到 0~100%。示例输出(示意):
胜率 p = 0.52,盈亏比 b = 1.21,凯利仓位 f* = 12.3%
- 四档仓位对比:对全仓、凯利、半凯利、四分之一凯利各跑一遍回测。由于收益与成本都随仓位近似等比缩放,示例输出(示意)里凯利仓位的年化约为全仓的 12.3%,最大回撤也约为全仓的 12.3%——注意仓位并不改变策略本身的质量(夏普比率基本不变),它改变的是你承受的绝对风险:把回撤压到你能睡得着的水平,才有资格等到长期优势兑现的那天。
全仓 仓位 100.0% 年化 5.90% 最大回撤 -22.10%
凯利 f* 仓位 12.3% 年化 0.73% 最大回撤 -2.72%
半凯利 仓位 6.2% 年化 0.36% 最大回撤 -1.36%
1/4 凯利 仓位 3.1% 年化 0.18% 最大回撤 -0.68%
- 固定金额对比(第三段代码):
fixed_amount_returns把「固定 10 万」除以初始资金 100 万换算成等效占比(教学简化:没有逐日跟踪总资金的变化),与「固定比例 10%」对比。示例输出(示意):
固定金额 10 万: 期末净值 1.031,最大回撤 -2.18%
固定比例 10%: 期末净值 1.033,最大回撤 -2.21%
两者数字接近,但机制不同:固定比例的占比恒定,固定金额的占比会随净值漂移。把 amount 改大、或把回测换成大起大落的行情,差异会明显起来。
常见坑与小练习
常见坑
- 把毛收益当净收益做规划。 现象:回测年化 15%,就按 15% 安排预期。原因:忽略了成本随换手次数复利累积。避免:任何收益结论都在扣完成本之后再下,先用本篇第一张表感受换手的代价。
- 用三五笔交易算凯利。 现象:策略刚跑通就统计 p 和 b,算出惊人的
f*。原因:样本太小,胜率估计的随机波动极大。避免:积累覆盖多种行情的足够样本再谈凯利,小样本算出的读数只是玩具。 f*超过 100% 就想上杠杆。 现象:在高胜率高盈亏比的估计下f*大于 1。原因:估计误差叠加了公式假设的理想化。避免:本篇代码已把f*截断到 100% 以内;教学阶段一律不碰杠杆,先保证无杠杆时活得好。- 用全样本估计的凯利仓位回测同一段全样本。 现象:仓位参数「恰好」让曲线很好看。原因:这是温和的未来函数——期初就用了期末才能估出的 p 和 b。避免:严格做法是滚动估计(用过去一段估计 p、b,指导下一段的仓位);本篇为教学做了简化并在此显式声明,第 25 篇的 Walk-Forward 思路可以迁移过来。
小练习
- 把第一段代码里的
COST改成 0.0005(费率减半),重跑成本侵蚀表,观察每年 250 次那一行的变化。思路提示:费率减半后,「一年 250 次」的净年化会从约 −10.5% 回到约 +1.5%,10 年后净值从 0.33 回到 1.16 左右——自己动手验证费率对高频策略有多敏感。
- 把主回测的
fast、slow改成 10 和 120 再跑一遍,比较胜率、盈亏比与凯利仓位的变化。思路提示:慢线越长信号越少、单次持仓越久,p 和 b 都会变,
f*未必变大——体会凯利对参数的敏感。 - 实现严格的固定金额仓位:逐日跟踪总资金,每次调仓把目标持仓市值精确调回 10 万元,与教学简化版对比期末净值。
思路提示:需要一个逐日循环维护「现金 + 持仓市值」,调仓日的换手等于「目标市值与当前市值之差」,成本照旧按换手比例扣。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。