好的,我们来使用 Backtrader 复现国信证券这篇报告《CTA 系列专题之五:基于连续信号的商品期货交易策略》。
请注意:
- 数据: 报告使用了特定标准(如过去半年日均成交额超过50亿)筛选的商品期货主力合约 15 分钟数据。此复现将使用 Backtrader 自带的示例数据或需要您自行准备符合格式的商品期货 15 分钟 K 线数据 (CSV 格式,至少包含
datetime, open, high, low, close, volume
列),并需要在添加数据时指定合约乘数 (mult
) 和保证金 (margin
) 以便进行准确的风险和收益计算。 - 简化:
- 成交额筛选: 动态筛选合约逻辑复杂,此代码假定传入的数据
datas
已经是筛选好的合约。 - VWAP 成交: Backtrader 默认使用下一 Bar 开盘价成交。实现 5 分钟 VWAP 需要更高频的数据或复杂的委托逻辑,此处简化为下一 Bar 开盘价。
- 波动率目标调整: 报告中提到每月根据过去一年策略表现调整杠杆以达到 15% 的年化波动率目标。这需要滚动计算策略历史波动率并动态调整下单比例,实现较为复杂。代码中暂时省略了这一动态调整,使用固定的基准风险比例,但保留了最大杠杆限制的思路。您可以在此基础上进一步开发波动率目标模块。
- TNR 指标周期: 报告未明确 TNR 计算周期 N 和 ΔTNR 计算周期 k,代码中暂定 N=20, k=3。
- ATR 周期: 海龟交易法原文和报告未明确 ATR 周期,代码中暂定为 14。
- 成交额筛选: 动态筛选合约逻辑复杂,此代码假定传入的数据
- 参数: 报告中的参数(如均线周期、概率更新因子、U2P 阈值等)已尽量遵循,但某些细节可能需要根据实际数据和回测结果进行调优。
复现思路总结
本次 Backtrader 复现旨在将报告的核心思想转化为代码:
- 基础信号与过滤: 使用 EMA 均线交叉作为基础方向判断,但实际开仓需结合报告提出的两个过滤条件:
- 低波动过滤 (
Lev_ATR > 1
):通过计算 ATR 与价格的比值,要求波动相对较小时才入场。 - 噪音下降过滤 (
ΔTNR > 0
):通过计算趋势噪音比(TNR)的变化,要求市场噪音呈现下降趋势时才入场。
- 低波动过滤 (
- 连续信号核心 (U2P):
- 引入
UpProb
(上涨概率) 和DownProb
(下跌概率),并在每个 Bar 根据基础信号(均线交叉状态)进行更新。 - 计算
U2P = UpProb - DownProb
作为核心的连续信号强度指标。
- 引入
- 交易决策:
- 仅当满足开仓过滤条件 (
Lev_ATR > 1
且ΔTNR > 0
) 且|U2P| > 0.2
时才考虑开仓或调整仓位。 - 信号方向由
U2P
的正负决定。 - 目标仓位大小与
U2P
的绝对值成正比(体现信号强度),并结合基于 ATR 的风险控制(类似海龟,每单位 ATR 承担固定风险比例,如 0.5%)。
- 仅当满足开仓过滤条件 (
- 风险管理:
- 使用基于 ATR 的资金管理方法确定基础头寸规模。
- (简化)设定一个最大杠杆比例限制总风险暴露。
- 多品种: 策略设计为可同时运行在多个符合条件的商品期货合约上,资金按等风险权重分配(通过 ATR Sizer 实现)。
Backtrader 复现代码
import backtrader as bt
import backtrader.indicators as btind
import backtrader.feeds as btfeeds
import datetime
import math
import numpy as np
# --- 自定义指标 ---
class TNRIndicator(bt.Indicator):
"""趋势噪音比 Trend to Noise Ratio"""
lines = ('tnr',)
params = (('period', 20),) # N in the report
def __init__(self):
self.addminperiod(self.p.period + 1) # 需要 N+1 根 Bar
def next(self):
# 分子:|Close_t - Close_{t-N}|
numerator = abs(self.data.close[0] - self.data.close[-self.p.period])
# 分母:sum(|Close_i - Close_{i-1}|) for i from t-N+1 to t
price_diffs = np.abs(np.diff(self.data.close.get(size=self.p.period + 1)))
denominator = np.sum(price_diffs)
if denominator == 0:
self.lines.tnr[0] = 0.0 # Avoid division by zero
else:
self.lines.tnr[0] = numerator / denominator
class DeltaTNRIndicator(bt.Indicator):
"""TNR 变化率"""
lines = ('delta_tnr',)
params = (('period', 3),) # k in the report
def __init__(self):
self.tnr = TNRIndicator(self.data) # Depends on TNR
self.tnr_mean = bt.indicators.SimpleMovingAverage(self.tnr(-1), period=self.p.period) # Mean of T-k to T-1
self.addminperiod(self.tnr.minperiod + self.p.period)
def next(self):
# ΔTNR = TNR_t - mean(TNR_{t-k}..TNR_{t-1})
# Note: self.tnr[0] is TNR_t
# self.tnr_mean[0] is the mean of TNR from t-k-1 to t-1 (using tnr(-1))
self.lines.delta_tnr[0] = self.tnr[0] - self.tnr_mean[0]
class LevATRIndicator(bt.Indicator):
"""报告中的 Lev_ATR (波动调整杠杆率的中间计算,用于判断波动大小)"""
lines = ('lev_atr',)
params = (('period', 14), ('risk_pct', 0.005)) # ATR Period and 0.5% risk per ATR unit
def __init__(self):
self.atr = bt.indicators.AverageTrueRange(self.data, period=self.p.period)
self.addminperiod(self.atr.minperiod)
def next(self):
if self.atr[0] == 0:
self.lines.lev_atr[0] = float('inf') # Avoid division by zero, represent very low vol
else:
# Formula: (Risk % / ATR) * Close
# Condition Lev_ATR > 1 means (Risk % / ATR) * Close > 1
# Which simplifies to ATR / Close < Risk %
# So Lev_ATR > 1 indicates low relative volatility
self.lines.lev_atr[0] = (self.p.risk_pct / self.atr[0]) * self.data.close[0]
# --- 策略 ---
class ContinuousSignalStrategy(bt.Strategy):
params = (
('ema_long_period', 100),
('ema_short_period', 10),
('tnr_n_period', 20), # TNR lookback N
('tnr_k_period', 3), # Delta TNR lookback k
('atr_period', 14), # ATR period for LevATR and Sizer
('atr_risk_pct', 0.005), # Risk per ATR unit (0.5%)
('u2p_threshold', 0.2), # |U2P| threshold for action
('prob_update_factor', 0.5), # Factor for updating probabilities
('max_leverage', 4.0), # Maximum allowed leverage per instrument (approximate)
('printlog', True),
)
def log(self, txt, dt=None, doprint=False):
''' Logging function for this strategy'''
if self.params.printlog or doprint:
dt = dt or self.datas[0].datetime.date(0)
tm = self.datas[0].datetime.time(0)
print('%s %s, %s' % (dt.isoformat(), tm.isoformat(), txt))
def __init__(self):
self.inds = {}
self.up_prob = {}
self.down_prob = {}
self.u2p = {}
self.entry_signal_long = {} # Tracks if basic EMA cross UP happened this bar
self.entry_signal_short = {} # Tracks if basic EMA cross DOWN happened this bar
self.order = {} # To keep track of pending orders per data feed
for i, d in enumerate(self.datas):
self.inds[d] = {}
self.inds[d]['ema_long'] = bt.indicators.ExponentialMovingAverage(d.close, period=self.p.ema_long_period)
self.inds[d]['ema_short'] = bt.indicators.ExponentialMovingAverage(d.close, period=self.p.ema_short_period)
self.inds[d]['ema_cross'] = bt.indicators.CrossOver(self.inds[d]['ema_short'], self.inds[d]['ema_long'])
self.inds[d]['lev_atr'] = LevATRIndicator(d, period=self.p.atr_period, risk_pct=self.p.atr_risk_pct)
self.inds[d]['delta_tnr'] = DeltaTNRIndicator(d, period=self.p.tnr_k_period)
self.inds[d]['atr'] = bt.indicators.AverageTrueRange(d, period=self.p.atr_period) # Needed for sizer
# Initialize probabilities and U2P
self.up_prob[d] = 0.5
self.down_prob[d] = 0.5
self.u2p[d] = 0.0
self.entry_signal_long[d] = False
self.entry_signal_short[d] = False
self.order[d] = None
# --- Sizer ---
# We use a built-in sizer for simplicity, but scaling happens inside next()
# The actual sizing logic should consider ATR risk per unit.
# A custom sizer would be better but more complex.
# We'll use order_target_percent, calculating the percent dynamically.
# self.setsizer(bt.sizers.PercentSizer(percents=10)) # Placeholder sizer
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
# Buy/Sell order submitted/accepted to/by broker - Nothing to do
self.log(f'ORDER {order.ordtype.name} {order.Status.names[order.status]} - Data: {order.data._name}')
self.order[order.data] = order # Track the order
return
# Check if an order has been completed
if order.status in [order.Completed]:
if order.isbuy():
self.log(
f'BUY EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}, Size: {order.executed.size} - Data: {order.data._name}')
elif order.issell():
self.log(
f'SELL EXECUTED, Price: {order.executed.price:.2f}, Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}, Size: {order.executed.size} - Data: {order.data._name}')
self.bar_executed = len(self) # Bar number when trade was executed
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'ORDER {order.Status.names[order.status]} - Data: {order.data._name}')
# Allow new orders for this data feed
self.order[order.data] = None
def notify_trade(self, trade):
if not trade.isclosed:
return
self.log(f'OPERATION PROFIT, GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f} - Data: {trade.data._name}')
def next(self):
for i, d in enumerate(self.datas):
# Skip if there's a pending order for this data feed
if self.order[d]:
continue
# --- 1. Update Probabilities based on Basic Signals ---
# Check for NEW EMA Crossovers this bar
current_cross = self.inds[d]['ema_cross'][0]
long_signal_active = current_cross == 1.0
short_signal_active = current_cross == -1.0
# Get previous probabilities
prev_up_prob = self.up_prob[d]
prev_down_prob = self.down_prob[d]
# Apply update rules from the report
# UpProb_t = UpProb_{t-1} + factor * (DownProb_{t-1} * Long_t - UpProb_{t-1} * Short_t)
up_prob_change = self.p.prob_update_factor * (prev_down_prob * long_signal_active - prev_up_prob * short_signal_active)
self.up_prob[d] = max(0.0, min(1.0, prev_up_prob + up_prob_change)) # Clamp between 0 and 1
# DownProb_t = DownProb_{t-1} + factor * (UpProb_{t-1} * Short_t - DownProb_{t-1} * Long_t)
down_prob_change = self.p.prob_update_factor * (prev_up_prob * short_signal_active - prev_down_prob * long_signal_active)
self.down_prob[d] = max(0.0, min(1.0, prev_down_prob + down_prob_change)) # Clamp between 0 and 1
# Normalize probabilities (should ideally sum to 1, but updates might slightly deviate)
prob_sum = self.up_prob[d] + self.down_prob[d]
if prob_sum > 0:
self.up_prob[d] /= prob_sum
self.down_prob[d] /= prob_sum
else: # Should not happen if initialized at 0.5/0.5
self.up_prob[d] = 0.5
self.down_prob[d] = 0.5
# Calculate U2P
self.u2p[d] = self.up_prob[d] - self.down_prob[d]
# --- 2. Check Entry Filters ---
lev_atr_val = self.inds[d]['lev_atr'][0]
delta_tnr_val = self.inds[d]['delta_tnr'][0]
# Entry allowed ONLY if Volatility is low AND Noise is decreasing
entry_allowed = lev_atr_val > 1.0 and delta_tnr_val > 0.0
# --- 3. Determine Target Position based on U2P and Filters ---
target_percent = 0.0 # Default to flat
if entry_allowed and abs(self.u2p[d]) > self.p.u2p_threshold:
# Signal is active and strong enough
signal_strength = self.u2p[d] # Use U2P directly for strength and direction
# --- Sizing Logic ---
# Calculate size based on ATR risk per unit, scaled by signal strength
# Target value per position = SignalStrength * (ATR Risk % * Portfolio Value)
# Target percent = Target value / Current Price / Portfolio Value * Price ? No.
# Target percent = (SignalStrength * ATR Risk % * PortVal) / (Price * Multiplier * Qty) * Qty ???
# Target percent = SignalStrength * SizePerATRRiskUnit * Price * Multiplier / PortVal
# SizePerATRRiskUnit = (ATR Risk % * PortVal) / (ATR * Multiplier)
# TargetPercent = SignalStrength * [(ATR Risk % * PortVal) / (ATR * Multiplier)] * Price * Multiplier / PortVal
# TargetPercent = SignalStrength * ATR Risk % * Price / ATR
atr_val = self.inds[d]['atr'][0]
price = d.close[0]
if atr_val > 0:
base_percent_per_signal_unit = self.p.atr_risk_pct * price / atr_val
target_percent = signal_strength * base_percent_per_signal_unit
# Apply Max Leverage Cap (approximate)
# Max percent = Max Leverage / (Price * Multiplier / Margin) # Incorrect
# Max percent = Max Leverage * Margin / (Price * Multiplier) # Incorrect
# Approximate: Max percent based on 1 / N assets, scaled by max leverage
# E.g., max total leverage = 4, 10 assets -> max leverage per asset ~ 0.4? No.
# Simpler cap: ensure |target_percent| * value / margin <= max_leverage
# Need margin per contract. Let's just cap the target_percent directly for simplicity.
# Max individual percent = MaxLeverage / NumberOfAssets (if equal allocation)
max_pos_percent = self.p.max_leverage / len(self.datas) # Rough approximation
target_percent = max(-max_pos_percent, min(max_pos_percent, target_percent))
# --- 4. Place Order ---
# Use order_target_percent to manage position based on continuous signal
self.order_target_percent(data=d, target=target_percent)
# Log the target calculation
# self.log(f'{d._name}: U2P={self.u2p[d]:.4f}, LevATR={lev_atr_val:.2f}, dTN R={delta_tnr_val:.4f}, Allowed={entry_allowed}, TargetPct={target_percent:.4f}')
# --- Main Execution ---
if __name__ == '__main__':
cerebro = bt.Cerebro()
# --- Add Data ---
# NOTE: You MUST replace this with your actual 15-minute data for commodity futures
# Ensure CSV has: datetime, open, high, low, close, volume
# Add 'mult' (contract multiplier) and 'margin' (per contract) parameters to data feed
datapath_template = 'path_to_your_data/{}.csv' # Replace with your path and file naming convention
# Example symbols (replace with yours!)
symbols = ['RB000', 'HC000', 'I000', 'J000', 'JM000'] # Example rebar, hot coil, iron ore etc.
start_date = datetime.datetime(2016, 1, 1) # Adjust start/end dates
end_date = datetime.datetime(2023, 5, 31)
for sym in symbols:
try:
# --- CRITICAL: Define multiplier and margin for each symbol ---
# These are EXAMPLE values, replace with actuals!
if sym == 'RB000': mult, margin = 10, 3000
elif sym == 'HC000': mult, margin = 10, 3000
elif sym == 'I000': mult, margin = 100, 8000
elif sym == 'J000': mult, margin = 100, 9000
elif sym == 'JM000': mult, margin = 60, 7000
else: mult, margin = 1, 1000 # Default fallback
data = btfeeds.GenericCSVData(
dataname=datapath_template.format(sym),
fromdate=start_date,
todate=end_date,
nullvalue=0.0,
dtformat=('%Y-%m-%d %H:%M:%S'), # Adjust if your datetime format is different
timeframe=bt.TimeFrame.Minutes,
compression=15, # 15 Minutes
datetime=0,
open=1,
high=2,
low=3,
close=4,
volume=5,
openinterest=-1, # No open interest in this example file
name=sym # Assign name to data feed
)
# Add contract multiplier and margin info
data.p.mult = mult
data.p.margin = margin
cerebro.adddata(data)
print(f"Added data for {sym} with multiplier {mult}, margin {margin}")
except Exception as e:
print(f"Could not load data for {sym}: {e}")
if not cerebro.datas:
print("No data loaded. Exiting.")
exit()
# --- Add Strategy ---
cerebro.addstrategy(ContinuousSignalStrategy, printlog=False) # Set printlog=True for detailed logs
# --- Set Initial Capital ---
start_cash = 10000000.0 # 10 Million
cerebro.broker.setcash(start_cash)
# --- Set Commission and Slippage ---
# Commission: 0.3%% = 0.0003 (percentage)
# Slippage: 1%% = 0.01 (percentage) - This is very high, adjust as needed
cerebro.broker.setcommission(commission=0.0003, mult=1.0, margin=None) # Futures commission often per contract, use mult
# Slippage can be set using cerebro.broker.set_slippage_perc(perc=0.01) or fixed points
# Using Percent Slippage for this example
cerebro.broker.set_slippage_perc(perc=0.01, slip_open=True, slip_limit=True, slip_match=True, slip_out=True)
# --- Add Analyzers ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='mysharpe', timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='mydrawdown')
cerebro.addanalyzer(bt.analyzers.Returns, _name='myreturns', timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='mytradeanalysis')
cerebro.addanalyzer(bt.analyzers.SQN, _name='mysqn')
# --- Run Backtest ---
print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
results = cerebro.run()
print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
# --- Print Analysis Results ---
strat = results[0] # Get the first strategy instance
analysis = strat.analyzers
print("\n--- Strategy Analysis ---")
print(f"Sharpe Ratio: {analysis.mysharpe.get_analysis().get('sharperatio', 'N/A')}")
dd = analysis.mydrawdown.get_analysis()
print(f"Max Drawdown: {dd.max.drawdown:.2f}%")
print(f"Max Drawdown Period: {dd.max.len} bars")
ret = analysis.myreturns.get_analysis()
print(f"Total Return: {ret.get('rtot', 'N/A'):.4f}")
print(f"Annualized Return: {ret.get('ravg', 'N/A'):.4f}") # Note: Backtrader's ravg might need scaling depending on timeframe
sqn = analysis.mysqn.get_analysis()
print(f"SQN: {sqn.get('sqn', 'N/A'):.2f}")
print(f"SQN Avg Trade: {sqn.get('avg_trade', 'N/A'):.2f}")
trade_analysis = analysis.mytradeanalysis.get_analysis()
if trade_analysis:
print("\n--- Trade Analysis ---")
print(f"Total Trades: {trade_analysis.total.total}")
print(f"Winning Trades: {trade_analysis.won.total}")
print(f"Losing Trades: {trade_analysis.lost.total}")
print(f"Win Rate: {trade_analysis.won.total / trade_analysis.total.total * 100:.2f}%" if trade_analysis.total.total else "N/A")
print(f"Average Win $: {trade_analysis.won.pnl.average:.2f}")
print(f"Average Loss $: {trade_analysis.lost.pnl.average:.2f}")
print(f"Profit Factor: {abs(trade_analysis.won.pnl.total / trade_analysis.lost.pnl.total):.2f}" if trade_analysis.lost.pnl.total else "INF")
# --- Plot Results ---
# Ensure matplotlib is installed: pip install matplotlib
try:
print("\nPlotting results...")
cerebro.plot(style='candlestick', barup='green', bardown='red', volup='green', voldown='red')
except Exception as e:
print(f"\nCould not plot results: {e}. Ensure matplotlib is installed and a GUI backend is available.")
如何运行:
- 安装 Backtrader:
pip install backtrader
- 安装 Numpy:
pip install numpy
- 安装 Matplotlib (可选,用于绘图):
pip install matplotlib
- 准备数据:
- 创建数据文件夹。
- 将你的 15 分钟商品期货 CSV 数据放入该文件夹。
- 确保 CSV 文件名与代码中
symbols
列表中的名称对应(例如RB000.csv
)。 - 确保 CSV 格式正确,包含
datetime, open, high, low, close, volume
列,且日期时间格式与dtformat
参数匹配。
- 修改代码:
- 修改
datapath_template
指向你的数据文件夹。 - 修改
symbols
列表为你实际拥有的数据文件名(不含.csv
)。 - !! 关键 !! 修改
if __name__ == '__main__':
中的mult
(合约乘数) 和margin
(每手保证金) 为你所用合约的实际值。这对正确的风险计算至关重要。 - 调整
start_date
,end_date
。 - 调整
start_cash
。 - 根据需要调整佣金 (
commission
) 和滑点 (slippage_perc
)。报告中 1‰ 的滑点按百分比算是 0.1% (0.001),代码中暂设为 1% (0.01) 是一个较大值,请务必调整。 - (可选)调整策略参数
params
。
- 修改
- 运行脚本:
python your_script_name.py
这个复现版本提供了一个框架,抓住了报告的核心逻辑,但在某些高级特性(如动态波动率目标调整)上进行了简化。你可以基于此代码,根据实际需求和更深入的研究进行扩展和优化。
我们来对这个基于连续信号的商品期货 CTA 策略进行一次深度总结。
核心目标与创新
该策略的核心目标是克服传统 CTA 策略信号二元化(非多即空或空仓)的局限性,通过引入信号强度的概念,实现更动态和精细化的仓位管理。其主要创新在于:
- 信号不再是开关,而是刻度尺: 摒弃了简单的 0/1/-1 信号,尝试量化信号的“可信度”或“强度”。
- 双重过滤提升入场质量: 在基础信号(如均线交叉)之上,增加了市场状态的判断,旨在过滤掉不利于趋势跟踪的环境,只在“更有可能”成功的条件下入场。
- 仓位大小与信号强度挂钩: 这是策略的灵魂。通过一个动态变化的指标(U2P 概率差)来决定投入多少风险,而非简单的全仓或空仓。
策略运作机制详解
-
基础方向判定(骨架): 策略仍然需要一个基础的趋势判断机制,报告和代码中使用了经典的 EMA 均线交叉(短周期上穿长周期为多头,下穿为空头)。但这只是提供了初步的方向感。
-
入场质量控制(门禁): 在考虑执行基础信号前,设置了两道“门禁”:
- 低相对波动率 (
Lev_ATR > 1
): 要求入场时的市场波动(ATR 相对于价格)处于相对较低的水平。逻辑在于:过高的波动率可能意味着趋势不稳定、噪音大或接近转折点,此时追涨杀跌风险较高。该策略倾向于在相对“平静”时捕捉趋势的启动或延续。 - 噪音下降趋势 (
ΔTNR > 0
): 要求衡量趋势清晰度的趋势噪音比(TNR)呈现改善趋势(即噪音占比在减少)。逻辑在于:一个正在变得越来越“干净”的趋势,其持续的可能性被认为更高。在噪音减弱时入场,增加了信号的可靠性。
- 低相对波动率 (
-
信号强度量化与持续跟踪(核心引擎 - U2P): 这是策略与传统 CTA 最本质的区别。
- 概率动态更新: 策略并不直接使用均线交叉信号来开仓,而是用它来“驱动”两个内部状态变量:
UpProb
(上涨概率)和DownProb
(下跌概率)。当多头信号(短上穿长)出现时,UpProb
会增加,DownProb
会减少;空头信号反之。这个更新过程是持续的,反映了基础信号的持续状态而非仅仅是交叉瞬间。更新幅度由prob_update_factor
控制。 - U2P 概率差: 计算
U2P = UpProb - DownProb
。这个值在 -1 到 +1 之间波动:- 接近 +1:表明策略内部模型对上涨趋势有很强信心。
- 接近 -1:表明对下跌趋势有很强信心。
- 接近 0:表明多空力量均衡或信号不明朗。
- 行动阈值 (
|U2P| > 0.2
): 只有当 U2P 的绝对值超过一定阈值(如 0.2)时,才认为信号强度足够明确,值得采取行动(开仓或调整仓位),以过滤掉 U2P 在零附近徘徊时的噪音。
- 概率动态更新: 策略并不直接使用均线交叉信号来开仓,而是用它来“驱动”两个内部状态变量:
-
动态风险分配与仓位调整:
- ATR 风险单位: 借鉴海龟交易法则,首先基于 ATR 计算一个标准风险单位对应的头寸(例如,设定每单位 ATR 承担账户 0.5% 的风险)。
- U2P 强度缩放: 计算出的目标仓位大小,直接与
U2P
的值(信号强度)成正比。即目标仓位 ≈ U2P * (基于 ATR 的标准风险头寸)
。这意味着:- 当
U2P
接近 +1 时,持有多头仓位,且接近 ATR 风险允许的最大头寸。 - 当
U2P
接近 -1 时,持有空头仓位,同样接近最大风险头寸。 - 当
U2P
绝对值较小(但大于阈值)时,持有对应方向的仓位,但头寸较小。
- 当
- 最大杠杆限制: 设置一个总体的最大杠杆上限,防止即使在 U2P 很高的情况下,单一品种或整体风险暴露过大。
- 持续调整: 由于 U2P 在每个 Bar 都会更新,策略会不断地重新评估目标仓位,并发出指令以趋近这个目标仓位。这导致仓位是动态变化的,而非简单的开仓/平仓。
策略的优势
- 更精细的风险管理: 通过将仓位与信号强度挂钩,避免了在信号刚出现或强度减弱时仍承担全额风险,有望降低波动和回撤。
- 潜在更高的风险调整后收益: 在信号明确且强的趋势中加大投入,在信号模糊或减弱时减少投入,理论上能更有效地捕捉趋势,提升夏普比率。
- 提高了入场信号质量: 双重过滤机制(低波+降噪)旨在避开部分容易失败的交易环境。
- 适应性潜力: U2P 机制作为一种信号强度量化和跟踪框架,理论上可以嫁接到其他类型的基础信号之上。
- 解释性相对较好: 相比纯黑箱模型,U2P 代表的概率差提供了一定的可解释性。
策略的劣势与挑战
- 复杂性增加: 引入了更多指标(TNR, ΔTNR, Lev_ATR, U2P)和参数,增加了理解、实现、调试和优化的难度。
- 参数敏感性高: EMA 周期、TNR 周期、ΔTNR 周期、ATR 周期、风险百分比、U2P 阈值、概率更新因子、最大杠杆等众多参数,使得策略对参数选择非常敏感,存在过拟合风险。需要严谨的回测和样本外验证。
- TNR/ΔTNR 的有效性: 这两个指标的有效性可能依赖于市场状态和参数设定,其作为过滤条件的普适性和稳健性需要大量数据检验。
- U2P 更新规则的启发性: 概率更新的具体数学形式 (
factor * (DownProb * Long - UpProb * Short)
) 具有一定的启发式成分,其能否精确反映未来概率值得商榷。更新因子的选择会影响策略的反应速度。 - 交易成本敏感性: 由于仓位是动态调整的,可能比传统 CTA 产生更多的交易次数,对交易成本(佣金和滑点)更敏感。虽然报告显示对成本不敏感,但实际运行中仍需关注。
- 实现细节: 精确的 VWAP 成交、动态的跨周期波动率目标调整(报告提到但代码简化了)等在实盘中会增加实现复杂度。
总结
该策略是传统趋势跟踪 CTA 的一次有意义的精细化升级。它试图通过量化信号强度和优化入场时机来解决传统方法的痛点。核心的 U2P 概率差机制 和 双重入场过滤 是其亮点,使得仓位管理更加动态和智能化。
然而,这种精细化也带来了更高的复杂性和参数依赖性。策略的长期稳健性高度依赖于参数的设定、对交易成本的控制以及其核心假设(如 TNR/ΔTNR 和 U2P 更新规则的有效性)在不同市场环境下的表现。
总体而言,这是一个理念先进、框架完整的量化策略,它代表了 CTA 策略从简单规则向更** nuanced(细致入微)** 的市场状态感知和风险管理演进的一个方向。其效果需要通过在更广泛数据和实际交易中的持续验证。