在商品交易中,交易者持仓数据被频繁提及,尤其是在人们讨论市场过度拥挤、投机情绪或价格反转风险时。然而,大多数这类讨论都停留在概念层面,很少有实际可行的规则能够被验证或应用。

这就是这个项目开始的出发点。

我想探究一下:是否可以将原油持仓数据转化为比单纯的市场分析更有用的信息?不是那种经过精心包装的宏观分析报告,而是一个可以编码、测试并加以验证的实际策略框架。

我们的目标并不是从一开始就拥有一个完善的策略方案,而是先提出一个合理的假设,逐步构建相关模型,并通过实际数据来检验这个假设是否成立。

为此,我使用了FinancialModelingPrep提供的交易者持仓数据,以及历史上的西德克萨斯中质原油价格数据。最初的想法很简单:如果投机者的持仓情况变得极端,那么这或许能为我们预测原油的未来走势提供线索。但随着研究的深入,这个想法需要经过反复调整和完善才能被实际应用。

因此,这篇文章并不是在展示一个一次就成功的策略方案,而是记录了整个研究过程。

目录

先决条件

要阅读本文,您需要对Python及pandas库有基本的了解,因为我们在文章中会使用DataFrames来进行大部分的数据处理与分析。您的环境中应该已经安装了以下软件包:requestsnumpypandas以及matplotlib

此外,您还需要一个FinancialModelingPrep API密钥,才能获取COT和WTI原油价格数据。如果您还没有这个密钥,可以在FinancialModelingPrep网站上注册一个免费账户来获取它。

最后,对“交易者持仓报告”的含义以及非商业性持仓行为所代表的意义有所了解,将有助于您理解代码中信号生成背后的逻辑;不过,这些知识并不是阅读本文的必要条件。

本文还假设读者对金融市场及交易概念有一定的基础了解。如果“多头持仓”“空头持仓”、“未平仓合约数量”或“投机情绪”等术语让您感到陌生,那么在开始阅读之前,花一些时间学习这些基础知识也是很有必要的。

最初的想法:利用持仓极端情况来划分市场状态

这个想法的最初版本并不是一条具体的交易规则,而只是一个分析框架。

如果原油市场的投机性持仓达到极端程度,那么接下来发生的情况很可能会影响这一现象的含义。一个多头持仓占主导地位且持仓量还在不断增加的市场,与一个多头持仓占主导但开始减少的市场是不同的;同样的逻辑也适用于空头持仓的情况。

因此,我没有直接使用“极端多头意味着应采取空头策略”或“极端空头意味着应买入”这样的简单规则,而是首先将市场状态划分为不同的类别。

我使用的两个判断标准非常简单:第一,当前持仓情况相对于近期历史数据而言是否属于极端程度;第二,这种持仓趋势是在持续加剧还是已经开始逆转。

通过这两个标准,我可以将市场状态分为四种类型:

  • 多头持仓不断增加

  • 多头持仓开始减少

  • 空头持仓不断增加

  • 空头持仓开始减少

相比直接设计交易策略,这种分类方法似乎是一个更好的起点。它让我能够首先将COT数据视为描述市场状态的工具,然后再测试这些不同的市场状态是否会导致有用的价格变化。

在这个阶段,我还不确定这些分类标准是否真的有效;我的目的只是建立一个可以被妥善验证的分析框架而已。

导入软件包

我们会尽量简化软件包的导入过程。

import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (14, 6)
plt.style.use("ggplot")

api_key = "YOUR FMP API KEY"
base_url = "https://financialmodelingprep.com/stable" 

这里没有什么复杂的步骤。请确保将“YOUR FMP API KEY”替换为你的实际FMP API密钥。如果你还没有这个密钥,可以通过注册一个FMP开发者账户来获取它。

使用FMP APIs获取数据:COT与WTI原油价格

要构建这个交易策略,我需要两个数据集。首先,我需要原油的COT数据;其次,我还需要WTI原油的历史价格数据。

我首先查看COT市场列表,以确定正确的原油合约代码。

url = f"{base_url}/commitment-of-traders-list?apikey={api_key}"
r = requests.get(url)
cot_list = pd.DataFrame(r.json())

crude_candidates = cot_list[
    cot_list.astype(str)
    .apply(lambda col: col.str.contains("crude", case=False, na=False))
    .any(axis=1)
]

crudecandidates

COT市场列表

这样就能得到COT数据库中与原油相关的合约列表。在这个例子中,我使用的关键合约代码是“CL”。

cot_symbol = "CL"
start_date = "2010-01-01"
end_date = "2026-03-20"

url = f"{base_url}/commitment-of-traders-report?symbol={cot_symbol}&from={start_date}&to={end_date}&apikey={api_key}
r = requests.get(url)

cot_df = pd.DataFrame(r.json())
cot_df["date"] = pd.to_datetime(cot_df["date"])
cot_df = cot_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)
cot_df = cot_df.rename(columns={"date": "cot_date"})

cot_df.head()

这样就能获取到原油的周度COT交易数据。

原油的周度COT交易数据

后来我需要的主要数据字段包括:

  • date
  • openInterestAll
  • noncommPositionsLongAll
  • noncommPositionsShortAll

接下来,我使用FMP提供的商品价格查询接口来获取WTI原油的价格数据。

price_symbol = "CLUSD"
start_date = "2010-01-01"
end_date = "2026-03-20"

url = f"{base_url}/historical-price-eod/full?symbol={price_symbol}&from={start_date}&to={end_date}&apikey={api_key}
r = requests.get(url)

price_df = pd.DataFrame(r.json())
price_df["date"] = pd.to_datetime(price_df["date"])
price_df = price_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)

price_df

WTI原油价格数据

由于COT数据集是按周更新的,因此我使用周五的收盘价将价格序列转换成了每周一次的数据条。

price_df["date"] = pd.to_datetime(price_df["date"])
price_df = price_df.sort_values("date").drop_duplicates(subset="date").reset_index(drop=True)

weekly_price = price_df.set_index("date").resample("W-FRI").agg({
    "symbol": "last",
    "open": "first",
    "high": "max",
    "low": "min",
    "close": "last",
    "volume": "sum",
    "vwap": "mean"
}).dropna().reset_index()

weekly_price["weekly_return"] = weekly_price["close"].pct_change()
weekly_price = weekly_price.rename(columns={"date": "price_date"})

weekly_price

这一步非常重要,因为两个数据集必须使用相同的时间尺度进行分析。如果我保持价格数据按日更新,而COT数据仍按周更新,那么信号对齐就会很快出现混乱。

WTI原油价格数据(每周更新)

最后,我将每一条COT数据与相应周次的WTI价格数据进行了匹配。

merged_df = pd.merge_asof(
    cot_df.sort_values("cot_date"),
    weekly_price.sort_values("price_date"),
    left_on="cot_date",
    right_on="price_date",
    direction="forward"
)

merged_df[["cot_date", "price_date", "close", "weekly_return", "openInterestAll", "noncommPositionsLongAll", "noncommPositionsShortAll"]

COT数据与价格数据的合并结果

最终得到的结果是一个结构清晰的工作表格,其中包含了:

  • COT报告的发布日期

  • 与COT数据对应的WTI周价格日期

  • 每周的原油价格数据

  • 进行特征工程分析所需的主要持仓信息

这就是该策略所使用的完整基础数据集。有了这个数据集,下一步就是将这些原始的持仓数据转化为更有用的信息。

将原始的COT数据转化为可用特征

此时,原始数据已经准备好了,但它们本身还不能作为有用的信号来使用。COT报告提供了持仓数量,但这些数字如果不经过处理,就无法反映出随时间变化的趋势。

因此,下一步就是构建一些能够更有意义地描述持仓情况的特征。

我首先从净非商业持仓数据开始分析。这个数值其实就是非商业多头持仓与非商业空头持仓之间的差额。

merged_df["net_position"] = merged_df["noncommPositionsLongAll"] - merged_df["noncommPositionsShortAll"]

通过这个数据,我们可以了解到市场中的投机倾向。如果数值为正,说明非商业投资者处于净多头持仓状态;如果数值为负,则说明他们处于净空头持仓状态。

但是,原始的净持仓比例存在一个问题:市场规模会随时间发生变化,因此在一个时期看起来极端的数值,在另一个时期可能就不再具有同样的意义。为了解决这个问题,我用未平仓合约量对这一数值进行了标准化处理。

merged_df["net_position_ratio"] = merged_df["net_position"] / merged_df["openInterestAll"]

这样做之后,这个指标就变得有用多了。我不再关注绝对的持仓比例,而是将其看作是占总市场规模的那一部分。

接下来,我需要了解这种持仓比例是在持续增加还是在开始减少。为此,我计算了这个比例的周环比变化幅度。

merged_df["net_position_ratio_change"] = merged_df["net_position_ratio"].diff()

这一计算非常重要,因为变化的方向能够为分析提供更多的信息:一个仍在持续增加的极端多头持仓,与已经开始下降的极端多头持仓是截然不同的。

最后一个指标也是最重要的:持仓比例的滚动百分位数。我使用了104周的时间窗口来进行计算。

def rolling_percentile(x):
    return pd.Series(x).rank(pct=True).iloc[-1]

merged_df["position_percentile_104"] = merged_df["net_position_ratio"].rolling(104).apply(rolling_percentile)

这个指标可以告诉我们,当前的持仓比例相对于过去两年来说有多极端。如果数值高于0.80,说明当前市场的多头持仓比例处于过去两年中的前20%;如果数值低于0.20,则说明处于后20%。

在添加了这四个指标之后,我检查了最终的输出结果。

merged_df[["cot_date","price_date","net_position","net_position_ratio","net_position_ratio_change","position_percentile_104"]

最终合并后的数据集

在`net_position_ratio_change`这一列中,前几行的数值为`NaN`,这是意料之中的,因为第一行没有可以与之进行比较的上一周的数据。而在`position_percentile_104`这一列中,前103行的数值也是`NaN`,这是因为滚动窗口需要至少104周的历史数据才能计算出相应的百分位数。

不过这些都没有关系。重要的是,现在这个数据集已经包含了四个可用指标:

  • 原始的投机性持仓比例

  • 经过标准化处理的持仓比例

  • 持仓比例的周环比变化幅度

  • 反映持仓比例极端程度的滚动百分位数

正是这些指标,使得COT数据不再仅仅是一份记录交易者持仓情况的表格,而是可以用来构建模型进行分析的工具。

构建该模型的第一个版本

<一旦这些功能准备就绪,下一步就是将它们转化为实际可在市场上应用的形式。

主要思想很简单:仅仅将市场持仓情况划分为极端状况是远远不够的。一个市场可能会在长时间内保持极度看多或极度看空的态势,但更重要的是,在这种极端持仓状态下,市场走势会发生什么变化——是会继续延续当前趋势,还是开始出现反转呢?

正因如此,我采用了两个维度来进行分析:

  • 104周持仓比例百分位数

  • 持仓比例的周变化幅度

通过这两个变量,我定义了四种市场形态。

merged_df["regime"] = "neutral"

merged_df.loc[(merged_df["position_percentile_104"] > 0.8) & (merged_df["net_position_ratio_change"] > 0), "regime"] = "bullish_buildup"
merged_df.loc[(merged_df["position_percentile_104"] > 0.8) & (merged_df["net_position_ratio_change"] < 0), "regime"] = "bullish_unwind"
merged_df.loc[(merged_df["position_percentile_104"] < 0.2) & (merged_df["net_position_ratio_change"] < 0), "regime"] = "bearish_buildup"
merged_df.loc[(merged_df["position_percentile_104"] < 0.2) & (merged_df["net_position_ratio_change"] > 0), "regime"] = "bearish_unwind"

下面分别解释这四种市场形态的含义:

  • 看多趋势累积:持仓比例已经处于非常看多的水平,且这种看多趋势仍在持续加剧

  • 看多趋势减弱:持仓比例虽然仍然处于看多状态,但这种看多情绪已经开始消退

  • 看跌趋势累积:持仓比例已经处于非常看跌的水平,且这种看跌趋势仍在持续加剧

  • 看跌趋势减弱:持仓比例虽然仍然处于看跌状态,但这种看跌情绪已经开始缓解

那些不符合这些极端条件的数据都被归入了中性类别。

在划分完这些市场形态后,我统计了每种形态下实际包含的数据量。

print(merged_df["regime"].value_counts())

各市场形态的数量分布

这个统计结果非常重要,因为它能告诉我们这套分析框架是否具有实际应用价值。在这种情况下,“中性”类别仍然是数据量最多的类别,这也是意料之中的结果——因为大多数情况下,市场状况并不应该处于极端状态。其他四种市场形态的数据量虽然较少,但仍然足以进行有效的分析。

我还查看了一些被分类后的数据样本。

merged_df[["cot_date","price_date","net_position_ratio","net_position_ratio_change","position_percentile_104","regime"]].tail(10)

合并后的数据样本及市场形态分类

至此,原始的COT数据已经被转化成了一个能够反映不同市场形态的分析模型。接下来要探讨的问题是:这些不同的市场形态是否真的能预测出有用的价格走势呢?

首次测试:每种状态转变后会发生什么?

此时,我虽然已经构建了一个状态转换框架,但还缺乏具体的操作策略。在将这些状态转化为实际交易之前,我首先需要了解原油在每种状态转变后究竟会呈现出怎样的表现。

因此,接下来的步骤就是计算在四个不同的持有期内,原油在每种状态转变后的未来收益情况:

  • 1周

  • 2周

  • 4周

  • 8周

我首先根据每周的收盘价数据计算出了未来收益对应的列。

merged_df["fwd_return_1w"] = merged_df["close"].shift(-1) / merged_df["close"] - 1
merged_df["fwd_return_2w"] = merged_df["close"].shift(-2) / merged_df["close"] - 1
merged_df["fwd_return_4w"] = merged_df["close"].shift(-4) / merged_df["close"] - 1
merged_df["fwd_return_8w"] = merged_df["close"].shift(-8) / merged_df["close"] - 1

merged_df[["cot_date","price_date","close","regime","fwd_return_1w","fwd_return_2w","fwd_return_4w","fwd_return_8w"]].tail(12)

这些列分别回答了一个简单的问题:如果本周原油处于某种状态,那么在接下来的1周、2周、4周或8周内,它的表现会如何?

根据每周收盘价计算得出的未来收益数据

最后几行数据中出现了NaN值,这是正常的。因为数据集结束后就没有未来的价格数据了,所以最长的预测期限对应的数值就会缺失。

接下来,我按照不同的状态类别对数据进行了分组,并计算了一些统计指标:

  • 数量

  • 平均未来收益

  • 中位数未来收益

  • 成功预测率

regime_summary = merged_df.groupby("regime").agg(
    count=("regime", "size"),
    avg_1w=("fwd_return_1w", "mean"),
    median_1w=("fwd_return_1w", "median"),
    hit_rate_1w=("fwd_return_1w", lambda x: (x > 0).mean()),
    avg_2w=("fwd_return_2w", "mean"),
    median_2w=("fwd_return_2w", "median"),
    hit_rate_2w=("fwd_return_2w", lambda x: (x > 0).mean()),
    avg_4w=("fwd_return_4w", "mean"),
    median_4w=("fwd_return_4w", "median"),
    hit_rate_4w=("fwd_return_4w", lambda x: (x > 0).mean()),
    avg_8w=("fwd_return_8w", "mean"),
    median_8w>("fwd_return_8w", "median"),
    hit_rate_8w=("fwd_return_8w", lambda x: (x > 0).mean())
).reset_index()

regime_summary

按状态类别分组后的数据

这张表格是对该框架的首次实际测试,结果立刻让我排除了其中一些最初的想法。

对于这个原始的状态转换模型来说,测试结果并不理想。事实上,它们的表现甚至比我预期的还要差。

  • neutral这种策略的表现通常优于其他几种策略。

  • bullish_buildup这种策略的表现一直较为疲软。

  • bearish_buildup这种策略的表现也同样不佳。

  • bearish_unwind乍看之下表现较强,但其中一部分成绩是由于少数几次异常的上涨走势所导致的。

  • bullish_unwind是唯一一种在多个时间范围内都表现出相对稳定性的策略。

bullish_unwind。

更深入地分析这些策略

plt.plot(merged_df["price_date"], merged_df["close"], label="WTI原油价格")
plt.plot(merged_df["price_date"], merged_df["net_position_ratio"] * 100, label="投机者净持仓比例×100")
plt.title("WTI原油价格与投机者净持仓比例")
plt.xlabel("日期")
plt.ylabel("数值")
plt.legend()
plt.show()

WTI原油价格与投机者净持仓比例

plt.plot(merged_df["price_date"], merged_df["position_percentile_104"])
plt.axhline(0.8, linestyle="--", color="b")
plt.axhline(0.2, linestyle="--", color="b")
plt.title("104周持仓百分位数")
plt.xlabel("日期")
plt.ylabel("百分位数")
plt.show()

104周持仓百分位数

然后,我统计了实际有多少观测数据属于每一类情况。

regime_counts = merged_df["regime"].value_counts()

plt.bar(regime_counts.index, regime_counts.values)
plt.title("各类别的观测数据数量")
plt.xlabel("分类类型")
plt.ylabel("观测数据数量")
plt.xticks(rotation=30)
plt.show()

各类别的观测数据数量

统计结果看起来还算合理。“中立”类别仍然是数据量最多的类别,而那四种信号类别也拥有足够的观测数据来进行分析,因此样本数量并不过少。

之后,我按照分类类型绘制了平均4周预期回报的情况。

avg_4w = regime_summary.set_index("regime")["avg_4w"].sort_values()

plt.bar(avg_4w.index, avg_4w.values)
plt.title("各分类类型的平均4周预期回报")
plt.xlabel("分类类型")
plt.ylabel("平均回报")
plt.xticks(rotation=30)
plt.show()

各分类类型的平均4周预期回报

这一结果首次表明,原来的分类框架实在过于宽泛了。bullish_unwindbearish_unwind这两类情况的表现都较为疲软,而neutral类别的表现则依然很好,这说明当前的分类过滤机制尚未能够形成非常清晰的区分标准。

接下来,我查看了各分类类型的4周成功率。

hit_4w = regime_summary.set_index("regime")["hit_rate_4w"].sort_values()

plt.bar(hit_4w.index, hit_4w.values)
plt.title "各分类类型的4周成功率"
plt.xlabel "分类类型"
plt.ylabel "成功率"
plt.xticks(rotation=30)
plt.show()

各分类类型的4周成功率

统计结果同样表明bullish_unwind是表现较好的分类类型之一,但仍然不足以将其称为一种有效的投资策略。neutral类别的表现依然过于优异,这说明当前的分类机制尚未能够准确地区分不同类型的情况。

在这一点上,我想要检查这些平均数值是否受到了少数极端数据的影响。因此,我为每一类情况绘制了4周回报的分布图。

plot_df = merged_df[["regime", "fwd_return_4w"]].dropna()

plot_df.boxplot(column="fwd_return_4w", by="regime", grid=False)
plt.title "各分类类型的4周回报分布图"
plt.suptitle ""
plt.xlabel "分类类型"
plt.ylabel "4周回报"
plt.xticks(rotation=30)
plt.show()

按不同市场环境划分的4周后续收益分布情况” height=

这张图表使这个问题变得更加清晰明了。

bearish_unwind这一策略从平均来看表现不错,但它的这种优势主要来自于少数几次异常大幅的上涨走势。因此,将其作为一项基础投资策略并不十分令人信服。

bullish_buildupbearish_buildup这两种策略在汇总表格以及数据分布图中都表现较弱。

bullish_unwind是唯一一种看起来相对稳定的策略,因为它并不太依赖于那少数几次极端的市场波动。

这一发现改变了我们后续的分析方向。

在此之前,我们的计划是测试整个策略体系,并考虑保留多种不同的实施路径。但看了这些图表之后,这种想法已经不再合适了——因为该策略体系中的大部分内容其实都已经告诉我们哪些方法是不应该采用的。

因此,我并没有继续研究其他三种策略,而是将重点集中到了bullish_unwind这一策略上。

聚焦重点:保留两种额外的变体进行对比

此时,bullish_unwind》已经成为了最值得我们关注的主要策略。其他两种构建策略的表现都很弱,而bearish_unwind之所以不够令人信服,也是因为它的优势主要来自于少数几次异常的市场波动。

因此,我们的研究重点已经开始转向bullish_unwind了。

不过,在最终确定采用这一策略之前,我还是在后续的分析中保留了两种基于unwind原理的变体,以便进行对比:

  • 一种基于bearish_unwind产生的买入信号

  • 另一种会在任意一种unwind策略出现时触发买入信号的机制

通过这种方式,第一轮回测就可以告诉我们:bullish_unwind在实际应用中是否真的更有效,还是说整体而言,那种更为通用的unwind分析逻辑效果更好。

merged_df["long_bullish_unwind"] = (merged_df["regime"] == "bullish_unwind").astype(int)
merged_df["long_bearish_unwind"] = (merged_df["regime"] == "bearish_unwind").astype(int)
merged_df["long_any_unwind"] = merged_df["regime"].isin(["bullish_unwind", "bearish_unwind")).astype(int)

print("交易次数:\n", merged_df[["long_bullish_unwind", "long_bearish_unwind", "long_any_unwind"]].sum())
merged_df[["cot_date","price_date","regime","long_bullish_unwind","long_bearish_unwind","long_any_unwind"]].tail()

这样就可以生成三种简单的二进制信号:

  • 当策略类型为bullish_unwind时,long_bullish_unwind的值为1

  • 当策略类型为bearish_unwind时,long_bearish_unwind的值为1

  • 当任意一种unwind策略出现时,long_any_unwind的值为1

输出结果还会显示每种信号的生成次数,这一点非常重要,因为下一步就是进行正式的回测。一个信号在理论上可能看起来很有吸引力,但如果它几乎从未被触发过,那么也就没有什么值得分析的地方了。

信号生成次数

在进入策略层后,‘bullish_unwind’就已经成为了主要的交易路径。另外两种策略也被保留了下来,但主要是为了对比在实际执行这些交易后,它们的表现是会变弱还是变强。

制定最初的交易规则

一旦这三种基于“unwind”原理的信号准备就绪,下一步就是将它们转化为实际的交易操作。

我故意让回测过程保持简单:

  • 仅进行多头交易

  • 持有期限为4周

  • 避免重复进行同一笔交易

“避免重复交易”这一规则非常重要。如果当前有一笔交易仍在进行中,而新的交易信号出现了,我会忽略这个新信号。这样可以让交易列表更加清晰,同时也能防止因重复建立仓位而导致策略效果被夸大。

以下是我使用的回测函数。

def run_fixed_hold_backtest(df, signal_col, hold_weeks=4):
    trades = []
    i = 0

    while i < len(df) - hold_weeks:
        if df.iloc[i][signal_col] == 1:
            entry_date = df.iloc[i]["price_date"]
            exit_date = df.iloc[i + hold_weeks]["price_date"]
            entry_price = df.iloc[i]["close"]
            exit_price = df.iloc[i + hold_weeks]["close"]
            trade_return = exit_price / entry_price - 1

            trades.append({
                "signal": signal_col,
                "entry_index": i,
                "exit_index": i + hold_weeks,
                "entry_date": entry_date,
                "exit_date": exit_date,
                "entry_price": entry_price,
                "exit_price": exit_price,
                "trade_return": trade_return
            })

            i += hold_weeks
        else:
            i += 1

    return pd.DataFrame(trades)

这个函数会遍历整个数据集,检查某个信号是否处于活跃状态:如果该信号处于活跃状态,就会在当前周内进行买入操作,并在4周后卖出,同时记录下这笔交易的收益情况。

随后,我对这三种基于“unwind”原理的信号分别进行了回测。

bullish_unwind_trades = run_fixed_hold_backtest(merged_df, "long_bullish_unwind", hold_weeks=4)
bearish_unwind_trades = run-fixed HOLD_backtest(merged_df, "long_bearish_unwind", hold_weeks=4)
any_unwind_trades = run.fixed_HOLD_backtest(merged_df, "long_any_unwind", hold_weeks=4)

回测完成后,我统计了实际被执行的交易数量。

print("执行的‘bullish_unwind’交易数量:", len(bullish_unwind_trades))
print("执行的‘bearish_unwind’交易数量:", len(bearish_unwind_trades))
print("执行的‘any_unwind’交易数量:", len(any_unwind_trades))

已执行的交易

从输出结果来看,实际被执行的交易数量比上一节中统计的原始信号数量要少,这是意料之中的,因为那些重复出现的信号在回测过程中被忽略了。

接下来,我编写了一个小型辅助函数来汇总这些交易结果,并将这个函数应用到了所有三种策略中。

def summarize_trades(trades):
    return pd.Series({
        "trades": len(trades),
        "win_rate": (trades["trade_return"] > 0).mean(),
        "avg-trade_return": trades["trade_return"].mean(),
        "median_trade_return": trades["trade_return"].median(),
        "cumulative_return": (1 + trades["trade_return")).prod() - 1
    })

trade_summary = pd.DataFrame({
    "bullish_unwind": summarize_trades(bullish_unwind_trades),
    "bearish_unwind": summarize_trades(bearish_unwind_trades),
    "any_unwind": summarize_trades(any_unwind_trades)
}).T

trade_summary

回测结果

这是第一个完整的策略测试结果,它很快就让我们清楚地了解了各种策略的表现情况。

bullish_unwind仍然是这三种策略中表现最好的一种。虽然它的收益水平还不是很高,但显然比另外两种策略要好得多。

有几点值得注意:

  • bullish_unwind的胜率最高

  • bullish_unwind的平均交易收益和中间值也都最高

  • bearish_unwindany_unwind在累计收益方面表现都很差

  • 将这两种策略结合起来并不会带来任何改善,反而会削弱其中表现较好的一种策略的效果

我还想了解这些策略在一段时间内的实际表现情况,而不仅仅是通过汇总表格来了解。因此,我为每种策略都绘制了简单的资产价值变化曲线。


bullish_unwind_trades["equity_curve"] = (1 + bullish_unwind_trades["trade_return")).cumprod()
bearish_unwind_trades["equity_curve"] = (1 + bearish_unwind_trades["trade_return]).cumprod()
any_unwind_trades["equity_curve"] = (1 + any_unwind_trades["trade_return }).cumprod()

plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="bullish unwind")
plt.plot(bearish_unwind_trades["exit_date"], bearish_unwind_trades["equity_curve"], label="bearish unwind")
plt.plot(any_unwind_trades["exit_date"], any_unwind_trades["equity_curve"], label="any unwind")
plt.title("4周策略的资产价值变化曲线")
plt.xlabel("日期")
plt.ylabel("资产价值倍数")
plt.legend()
plt.show()

4周策略的资产价值变化曲线

这张图表更清楚地展示了各种策略的表现情况。bullish_unwind虽然绝对收益水平不高,但其表现仍然优于另外两种策略。bearish_unwind从理论构想到实际应用的过程中出现了问题,而any_unwind的情况则更糟,因为它继承了这两种策略的弱点。

因此,在完成这一步之后,情况已经清晰了许多。

整体而言,“逐步抛售”的策略并没有取得理想的效果。bearish_unwind在经过严格的回测后依然表现不佳,而any_unwind的情况更是糟糕。这样一来,就只剩下一种策略值得继续研究:bullishunfold

然而,即便如此,这个结果也还不够理想。这种策略确实比其他方案要好,但还不足以让我们就此停止探索。事实上,我们目前甚至还未能获得任何利润。

接下来的步骤就是将这种策略与“买入并持有”的投资方式进行比较,看看它是否真的能带来额外的收益。

比较“牛市逐步抛售”策略与“买入并持有”策略

到这个时候,bullishunfold已经优于其他基于特定规则的策略。但单凭这一点,还是不足以说明它的优越性。

一种策略可能相对于那些较弱的替代方案来说表现不错,但它仍然可能无法通过最基本的测试:即它是否真的比单纯持有原油能带来更好的收益?

因此,接下来的步骤就是将原始的bullishunfold策略与简单的“买入并持有”基准进行对比。

我首先根据WTI原油的周价格数据绘制出了“买入并持有”策略对应的曲线。

buy_hold_df = weekly_price.copy()
buyHold_df = buy_hold_df.sort_values("price_date").reset_index(drop=True)
buy_hold_df["buy_hold_curve"] = buy_hold_df["close"] / buy_hold_df["close"].iloc[0]

buy_hold_df[["price_date", "close", "buy_hold_curve"]].tail()

买入并持有数据

随后,我将“买入并持有”策略的曲线与原始的bullishunfold策略进行了对比。

plt.plot(buy_hold_df["price_date"], buy_hold_df["buy_hold_curve"], label="买入并持有WTI原油", linewidth=2, alpha=0.5)
plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind_trades["equity_curve"], label="牛市逐步抛售策略", color="b")
plt.title("牛市逐步抛售策略与买入并持有原油策略的对比")
plt.xlabel("日期")
plt.ylabel("资产价值倍数")
plt.legend()
plt.show()

牛市逐步抛售策略与买入并持有原油策略的对比

这张图表很有参考价值,因为它清楚地揭示了原始策略存在的问题。bullishunfold确实比“买入并持有”策略更具选择性,但这种选择性并没有带来实质性的优势。虽然这种策略在某些阶段的表现不错,但从整体来看,它仍然落后于更简单的“买入并持有”基准。

为了更直观地对比这两种策略的效果,我计算了样本期间“买入并持有”策略的实际收益,然后将这两个结果汇总到了同一个表格中。

buyHoldReturn = buy HOLD_df["buy_hold_curve"].iloc[-1] - 1

comparison_summary = pd.DataFrame({
    "strategy": ["bullish_unwind", "buy_and_hold"],
    "trades": [len(bullish_unwind_trades), np.nan],
    "win_rate": [(bullish_unwind_trades["trade_return"] > 0).mean(), np.nan],
    "avg-trade-return": [bullish_unwind_trades["trade_return"].mean(), np.nan],
    "cumulative-return": [
        (1 + bullish_unwind_trades["trade_return")).prod() - 1,
        buyHoldReturn
    ]
})

comparison_summary

策略与买入并持有策略收益的对比

这就是这篇文章中的真正转折点。

尽管bullish_unwind是目前各种策略中表现最好的那个,但它仍然不如买入并持有策略的收益。这一事实使得结论变得非常明确:原始的交易信号还不够强。

因此,现在的问题已经不再是需要在不同的策略之间进行选择——这一点早已得到了解决。真正需要探讨的是:是否可以在不使整个策略变得过于复杂的情况下,对bullish_unwind策略进行改进。

正是这个思考方向促使我们采取了下一步行动:添加一个简单的趋势过滤机制。

添加趋势过滤机制

在现阶段,核心的交易信号已经被确定为bullish_unwind,但原始版本的这一信号仍然不够理想。它的表现不如买入并持有策略,这意味着这个信号还需要更多的背景信息来进行辅助分析。

接下来的想法很简单:并不是所有的bullish_unwind信号都应该被同样对待。如果在进行投机性操作的同时,原油价格本身正处于整体下跌的趋势中,那么这样的交易信号可能并不值得采纳。因此,我添加了一个基本的过滤条件:只有当WTI的价格高于其26周移动平均线时,才执行bullish_unwind策略。

首先,我计算出了移动平均线,并创建了一个用于表示趋势方向的二进制标志变量。然后,我将这个过滤条件与原有的bullish_unwind策略结合起来。

merged_df["ma_26"] = merged_df["close"].rolling(26).mean()
merged_df["above_ma_26"] = (merged_df["close"] > merged_df["ma_26")).astype(int)
merged_df["long_bullish_unwind_tf"] = ((merged_df["regime"] == "bullish_unwind") & (merged_df["above.ma_26"] == 1)).astype(int)

这样,我们就得到了经过过滤处理后的交易信号。输出结果还显示了在应用了趋势过滤机制之后,还剩下多少交易机会。正如预期的那样,这些机会的数量确实减少了——但如果剩下的交易机会质量更高,那么这其实并不是一个问题。

接下来,我又对经过过滤处理后的信号进行了同样的4周非重叠回测。

bullish_unwind_tf_trades = run_fixed_hold_backtest(
    merged_df,
    "long_bullish_unwind_tf",
    hold_weeks=4
)

filtered_summary = pd.DataFrame({
    "bullish_unwind": summarize_trades(bullish_unwind_trades),
    "bullish_unwind_tf": summarize_trades(bullish_unwind_tf_trades)
}).T

filtered_summary

原始策略与优化后策略的绩效对比

这是整个改进过程中第一次取得重大进展。

经过过滤处理后的策略不仅表现略有提升,而且其整体运作模式也发生了实质性的变化:

  • 交易次数更少

  • 胜率更高

  • 平均每笔交易的收益也更高

  • 累计收益明显更强

这正是我所需要的过滤机制。它让交易信号更加精准,同时也使这些信号变得更加清晰明了。

为了直观地展示这种变化,我为原始策略、经过过滤处理的版本以及长期持有策略分别绘制了资产价值曲线。

bullish_unwind_tf_trades["equity_curve"] = (1 + bullish_unwind_tf_trades["trade_return")).cumprod()

plt.plot(bullish_unwind_trades["exit_date"], bullish_unwind,trades["equity_curve"], label="原始策略")
plt.plot(bullish_unwind_tf_trades["exit_date"], bullish_unwind_tf_trades["equity_curve"], label="经过趋势过滤的策略")
plt.plot(buy_hold_df["price_date"], buy_hold_df["buy_hold_curve"], label "长期持有策略")
plt.title("带有趋势过滤机制与不带趋势过滤机制的策略效果对比")
plt.xlabel("日期")
plt.ylabel("资产价值倍数")
plt.legend()
plt.show()

带有趋势过滤机制与不带趋势过滤机制的策略效果对比

这张图表清楚地展示了这种变化。原始策略的表现很不稳定,而经过过滤处理的版本则要稳定得多,在整个样本数据范围内,其表现也明显更好。

正是这样的改进,使得这个策略真正具备了实用价值。交易信号不再仅仅是“极端的看涨仓位正在被平仓”,而是变成了:“极端的看涨仓位正在被平仓,而原油价格仍然处于整体上涨趋势中”。

这种描述方式更加具体,也更加有效。

接下来的问题是:这个改进后的策略是否真的稳定可靠,还是仅仅因为某个参数的选择恰到好处才取得了这样的效果。

对策略进行压力测试

尽管趋势过滤机制让策略的效果得到了提升,但我仍然不想在没有验证其稳定性的情况下就将其视为最终版本。

有时候,一个策略看起来很有效,仅仅是因为某些参数的组合恰好适用。因此,下一步就是测试这些参数的细微变化,看看结果是否还会保持不变。

我保留了策略的核心要素:

  • 看涨仓位平仓机制

  • 仅进行多头交易

  • 持续使用趋势过滤机制

然后,我对以下三个参数进行了调整:

  • 百分位数窗口的大小

  • 判断“极端情况”的阈值

  • 持仓期限

首先,我编写了一个辅助函数,用于根据不同的百分位数参数和阈值来生成看涨仓位平仓信号;然后,我又使用了一个为期52周的窗口来生成另一个百分位数序列。

def add_bullish_unwind_signal(df, percentile_col, high_threshold, signal_name):
    df[signal_name] = (
        (df[percentile_col] > high_threshold) & \
        (df["net_position_ratio_change"] < 0) & \
        (df["above_ma_26"] == 1)
    ).astype(int)

def rolling_percentile(x):
    return pd.Series(x).rank(pct=True).iloc[-1]

merged_df["position_percentile_52"] = merged_df["net_position_ratio"].rolling(52).apply(rolling_percentile)

在确定了这些参数之后,我构建了四种不同的信号版本:

  • 使用第80百分位作为阈值的104周百分位数信号

  • 使用第85百分位作为阈值的104周百分位数信号

  • 使用第80百分位作为阈值的52周百分位数信号

  • 使用第85百分位作为阈值的52周百分位数信号

add_bullish_unwind_signal(merged_df, "position_percentile_104", 0.80, "sig_104_80")
add_bullish_unwindSignal(merged_df, "position_percentile_104", 0.85, "sig_104_85")
add_bullish_unwind_signal(merged_df, "position_percentile_52", 0.80, "sig_52_80")
add_bullish_unwind_signal(merged_df, "position_percentile_52", 0.85, "sig_52_85")

之后,我在三个不同的持有期内进行了同样的回测:

  • 2周

  • 4周

  • 8周

results = []

for signal_col in ["sig_104_80", "sig_104_85", "sig_52_80", "sig_52_85"]:
    for hold_weeks in [2, 4, 8]:
        trades = run_fixed_hold_backtest(merged_df, signal_col, hold_weeks=hold_weeks)

        if len(trades) == 0:
            continue

        results.append({
            "signal": signal_col,
            "hold_weeks": hold_weeks,
            "trades": len(trades),
            "win_rate": (trades["trade_return"] > 0).mean(),
            "avg_trade_return": trades["trade_return"].mean(),
            "median-trade_return": trades["trade_return"].median(),
            "cumulative_return": (1 + trades["trade_return']).prod() - 1
        })

stress_test = pd.DataFrame(results)
stress_test

在三个持有期内进行的回测

这些测试结果是整篇文章中最重要的部分之一。它们证明了这种改进后的策略是否真正具有稳定性,还是仅仅在某种特定的情况下才有效。

有几项数据立刻引起了我们的注意。

104周/第80百分位版本的信号明显是最有效的。在所有三个持有期内,它的表现都非常好:

  • 持有2周时:累计收益率为38.16%

  • 持有4周时:累计收益率为45.95%

  • 持有8周时:累计收益率为19.02%

这种稳定性非常重要。这意味着,只要持有期不变,这个信号的有效性就不会受到影响。

其中,持有4周的策略被认定为最佳选择。它的表现如下:

  • 共进行了26笔交易

  • 胜率为65.38%

  • 平均每笔交易的收益率为1.84%

  • 中位数交易收益率为3.69%

  • 累计收益率为45.95%

在某些情况下,为期8周的持有策略所带来的平均交易回报略高一些,但相应的交易次数却较少。因此,这种策略不太适合作为主要投资方式来使用。

104周/第85百分位设置对于较短持有期的策略来说过于严格了。其2周和4周的版本表现不佳,甚至出现了负值;而8周的持有期版本则仍能取得较为不错的结果。

52周版本的策略整体而言说服力较弱。其中有一些版本的表现在一定程度上是积极的,但它们的稳定性远不如104周/第80百分位版本的策略。

因此,在这个阶段结束时,最终选定的策略版本并不是那个最初看起来表现不错的方案,而是那种在测试了其他相关变体后依然能够保持良好效果的方案。

这样,我就确定了最终的策略设置:

  • 104周百分位值

  • 第80百分位阈值

  • 牛市行情中的逐步建仓策略

  • 26周移动平均线过滤机制

  • 4周持有期

最终策略

到这个阶段为止,整个筛选过程已经完成了大部分工作。

最初的四种策略框架作为整体方案来看效果并不好,更广泛的“逐步建仓”理念也同样不适用。原始的牛市行情中的逐步建仓信号虽然比其他方案略好一些,但仍然不如单纯持有股票的策略。

在经历了所有这些测试之后,唯一能够保持良好效果的版本就是这个:

  • 牛市行情中的逐步建仓策略

  • 104周百分位值定位机制

  • 第80百分位阈值

  • 26周移动平均线过滤机制

  • 4周持有期

  • 避免交易重叠

因此,现在停止进一步调整、明确展示最终结果是合理的。我首先确定了最终的信号设置,然后使用这个设置重新进行了回测。

final_signal = "sig_104_80"
final_hold = 4
final_trades = run_fixed HOLD_backtest(merged_df, final_signal, hold_weeks=final_hold)
final_trades["equity_curve"] = (1 + final_trades["trade_return")).cumprod()

final_summary = pd.DataFrame({
    "metric": [
        "trades",
        "win_rate",
        "avg_trade_return",
        "median.trade_return",
        "cumulative_return"
    ],
    "value": [
        len(final_trades),
        (final_trades["trade_return"] > 0).mean(),
        final_trades["trade_return"].mean(),
        final_trades["trade_return"].median(),
        (1 + final_trades["trade_return]).prod() - 1
    ]
})

final_summary

上述输出结果显示了最终的策略表现:

最终策略表现

这些数据已经比最初的原始版本有了显著的改进,但我仍然希望将各种结果放在一个地方进行对比。因此,我制作了一个最终表格,将这个策略与两个参考方案进行了对比:

  • 单纯持有股票策略

  • 原始的“牛市行情中的逐步建仓”策略

final_comparison = pd.DataFrame({
    "策略": ["买入并持有", "看涨型交易策略", "过滤后的看涨型交易策略"],
    "交易记录": [
        np.nan,
        len(bullish_unwind_trades),
        len(final_trades)
    ],
    "胜率": [
        np.nan,
        (bullish_unwind_trades["交易回报"] > 0).mean(),
        (final_trades["交易回报"] > 0).mean()
    ],
    "平均每笔交易回报": [
        np.nan,
        bullish_unwind_trades["交易回报"].mean(),
        final_trades["交易回报"].mean()
    ],
    "累计回报": [
        buy_hold_return,
        (1 + bullish_unwind_trades["交易回报']).prod() - 1,
        (1 + final_trades["交易回报")).prod() - 1
    ]
})

final_comparison

最终绩效对比表

这是该投资策略最终的收益情况:

  • 买入并持有策略:13.67%

  • 原始的看涨型交易策略:-2.13%

  • 过滤后的看涨型交易策略:45.95%

趋势筛选机制不仅使投资策略的表现更加平稳,而且还彻底改变了最终结果。

为了让大家更清楚地看到这一变化,我将这三种策略的收益曲线绘制在了一起。

plt.plot(buy_hold_df["价格日期"], buy_hold_df["买入并持有策略收益曲线"], label="买入并持有策略", linewidth=2, alpha=0.5)
plt.plot(bullish_unwind_trades["退出日期"], bullish_unwind_trades["收益曲线"], label="原始的看涨型交易策略", color="indigo")
plt.plot(final_trades["退出日期"], final_trades["收益曲线"], label="过滤后的看涨型交易策略", color="b")
plt.title("原油投资策略对比")
plt.xlabel("日期")
plt.ylabel("收益倍数")
plt.legend()
plt.show()

原油投资策略对比

这张图表所表达的信息与表格中的内容相同,只不过呈现方式更为直观。原始的信号存在较大的波动;买入并持有策略在整个样本期间的收益为正,但波动幅度也较大;而过滤后的看涨型交易策略则是唯一一种能够以较为稳定、持续的方式产生收益的策略。

我还想要展示这些经过过滤的交易记录在WTI原油价格图表上的具体位置。

plt.plot(merged_df["价格日期"], merged_df["收盘价"], label="WTI原油收盘价", linewidth=2, alpha=0.5)
plt.scatter(merged_df.loc[merged_df[最终信号] == 1, "价格日期"], merged_df.loc[merged_df[最终信号] == 1, "收盘价"],
s=25, label="过滤后的看涨型交易策略信号", color="b")
plt.title("WTI原油价格图表上的过滤后看涨型交易策略信号")
plt.xlabel("日期")
plt.ylabel("价格")
plt.legend()
plt.show()

WTI原油价格图表上的过滤后看涨型交易策略信号

这一点很有意义,因为它表明这种策略具有选择性,并不会一直被激活。只有当持仓处于极度看涨的区域、开始出现调整,且整体价格走势依然保持稳定时,该策略才会被触发。

我在持仓分析方面也采用了同样的方法。

plt.plot(merged_df["price_date"], merged_df["position_percentile_104"], label="104周百分位数", linewidth=2, alpha=0.5)
plt.axhline(0.8, linestyle="--", label="第80百分位数")
plt.scatter(merged_df.loc[merged_df[final_signal] == 1, "price_date"], merged_df.loc[merged_df[final_signal] == 1, "position_percentile_104"],
            s=25, label="交易信号", color="indigo")
plt.title("基于COT持仓数据得出的看涨趋势调整信号")
plt.xlabel("日期")
plt.ylabel("百分位数")
plt.legend()
plt.show()

基于COT持仓数据得出的看涨趋势调整信号

这张图表将所有内容整合在了一起。只有当百分位数确实处于极端区域时,交易信号才会被触发,这说明该策略仍然在发挥其最初设计的作用——只不过它的执行方式比原始的框架更加严谨、更有条理。

进一步改进的空间

在这个方案中,还有一些地方可以继续进行优化。

首先就是交易执行的真实性问题。目前该策略采用了简单的周度入场和出场规则,但没有考虑滑点、价差或任何与合约相关的执行限制。如果加入这些因素,结果将会更加严格。

其次是信号检测的深度。当前这个版本仅使用了非商业持仓数据、趋势过滤机制以及固定的持有期限。值得尝试的是:是否可以通过引入商业持仓数据、波动性过滤机制或动态出场规则来进一步优化该方案,而不会使其变得过于复杂。

结论

这个项目最初只是一个大致的想法,并不是一个完善的交易策略。最初的框架看起来还算合理,但当用实际数据进行测试后,其中很多内容都被证明是不可行的。不过正是这些调整使得最终的信号变得更加精确、更加简洁。

最终留下来的方案是一个非常具体的操作规则:只有当持仓处于极度看涨的状态且开始出现调整,同时WTI价格仍然高于其26周移动平均线时,交易信号才会被触发。在测试样本中,这个版本的策略表现优于原始的信号机制以及单纯的买入并持有策略。

值得庆幸的是,整个系统完全可以利用FinancialModelingPrep提供的COT数据接口和商品价格数据API从零开始构建,而无需整合多个不同的数据来源。这使得将想法转化为实际测试变得容易了许多。

好了,这篇文章到这里就结束了。希望你们能从中学到一些新的、有用的知识。感谢大家的时间与关注。

Comments are closed.