股票相似度:从价格序列到向量检索
"走势相似"是量化分析中最朴素的假设之一。
如果两只股票的收益率序列在历史上高度相关,那么当其中一只出现异常波动时,另一只是否也会随之调整?这个假设构成了配对交易、风险平价、因子暴露分析的理论基础。
但"相似"究竟如何量化?简单的皮尔逊相关系数只能捕捉线性相关,无法处理非线性变换后的相似性。举例来说,茅台和五粮液的股价都随白酒板块涨跌,但它们的绝对价格水平、波动幅度完全不同。相关系数为 1 并不意味着它们"长得像"。
本文介绍一种更通用的方法:将价格序列转化为高维向量,用余弦相似度衡量走势相似度,用 FAISS 实现毫秒级检索。整个流程从数据获取、Embedding 生成、索引构建到相似股票查询,全部提供生产级代码。
一、为什么需要向量检索
1.1 传统方法的局限性
| 方法 | 原理 | 局限 |
|---|---|---|
| 皮尔逊相关系数 | 线性相关 | 无法捕捉非线性相似 |
| 斯皮尔曼秩相关 | 排名相关 | 对时间对齐要求高 |
| 动态时间扭曲(DTW) | 序列对齐 | 计算复杂度 O(n²),无法规模化 |
当你想在 5000 只美股中找到与 NVDA 走势最相似的 10 只时,上述方法要么精度不足,要么耗时无法接受。
1.2 向量检索的核心优势
将每只股票的历史价格序列编码为一个固定长度的向量(比如 128 维),相似度定义为向量空间的余弦距离。好处显而易见:
- 语义丰富:Embedding 能捕获趋势、波动率、周期性等多维特征
- 检索高效:FAISS 等向量索引支持十亿级向量毫秒召回
- 可扩展:新增股票只需生成向量、插入索引,无需重算
二、股价 Embedding 的技术路径
2.1 数据预处理:收益率序列
直接将原始价格送入模型没有意义——茅台 1800 元和五粮液 180 元的绝对价格不可比。我们需要做两步标准化:
第一步:计算收益率序列
$$
r_t = \frac{p_t - p_{t-1}}{p_{t-1}}
$$
第二步:Z-Score 标准化
$$
z_t = \frac{r_t - \mu}{\sigma}
$$
这样处理后,所有股票都变成了均值为 0、标准差为 1 的序列,不同价格量纲的影响被消除。
2.2 编码策略选择
| 策略 | 方法 | 适用场景 | 优劣 |
|---|---|---|---|
| 统计特征 | 均值、方差、偏度、峰度、夏普等 | 快速筛选 | 信息损失大 |
| 时间窗口 | 固定窗口的统计量序列 | 短期相似 | 丢失细粒度 |
| 时序模型 | LSTM/Transformer 输出隐状态 | 长期依赖 | 需训练,复杂度高 |
| NWP/FFT | 归一化小波/傅里叶系数 | 频域特征 | 保留周期性 |
本文选择统计特征 + 时间窗口的混合策略,兼顾信息完整性和实现简洁性:
def extract_features(returns: np.ndarray, window_size: int = 20) -> np.ndarray:
"""
从收益率序列中提取多尺度特征向量
参数:
returns: 标准化后的收益率序列
window_size: 时间窗口大小,用于计算滚动特征
返回:
特征向量,形状为 (feature_dim,)
"""
features = []
# 1. 全局统计特征
features.extend([
np.mean(returns),
np.std(returns),
skew(returns),
kurtosis(returns),
np.percentile(returns, 5), # 下行风险
np.percentile(returns, 95), # 上行空间
])
# 2. 滚动窗口特征(捕获时变特性)
rolling_vol = pd.Series(returns).rolling(window_size).std().dropna()
rolling_sharpe = pd.Series(returns).rolling(window_size).mean() / rolling_vol
features.extend([
np.mean(rolling_vol),
np.std(rolling_vol),
np.mean(rolling_sharpe),
])
# 3. 趋势特征
cum_returns = np.cumprod(1 + returns)
linear_trend = np.polyfit(range(len(cum_returns)), cum_returns, 1)[0]
features.append(linear_trend)
# 4. 波动率聚集特征
abs_returns = np.abs(returns)
vol_clustering = np.corrcoef(abs_returns[:-1], abs_returns[1:])[0, 1]
features.append(vol_clustering)
return np.array(features)
2.3 维度归一化:让相似度可比
不同特征量纲不同(偏度是无量纲的,夏普比率是年化的),直接拼接会导致某些特征主导相似度计算。我们使用 L2 归一化将每个特征向量映射到单位超球面:
$$
\mathbf{v}_{norm} = \frac{\mathbf{v}}{|\mathbf{v}|}
$$
归一化后,余弦相似度等价于负的欧氏距离:
$$
cos(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{|\mathbf{a}||\mathbf{b}|} = -\frac{1}{2}|\mathbf{a}{norm} - \mathbf{b}{norm}|^2
$$
三、数据获取:TickDB 历史 K 线接口
本文使用 TickDB 获取历史 K 线数据。TickDB 提供清洗对齐的 OHLCV 数据,接口规范如下:
import os
import time
import random
import requests
from typing import List, Dict, Optional
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
class TickDBClient:
"""TickDB API 客户端,含重试、超时、限频处理"""
BASE_URL = "https://api.tickdb.ai/v1"
def __init__(self, api_key: Optional[str] = None):
"""
初始化客户端
参数:
api_key: API密钥,从环境变量 TICKDB_API_KEY 读取
"""
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError("请设置环境变量 TICKDB_API_KEY")
self.session = requests.Session()
self.session.headers.update({
"X-API-Key": self.api_key,
"Content-Type": "application/json"
})
def _request_with_retry(
self,
method: str,
endpoint: str,
params: Optional[Dict] = None,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0
) -> Dict:
"""
带指数退避和抖动的重试机制
⚠️ 生产环境高频场景建议使用 aiohttp/asyncio
"""
for attempt in range(max_retries):
try:
response = self.session.request(
method,
f"{self.BASE_URL}{endpoint}",
params=params,
timeout=(3.05, 10) # (connect_timeout, read_timeout)
)
# 限频处理
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"触发限频,等待 {retry_after} 秒")
time.sleep(retry_after)
continue
data = response.json()
# 检查业务错误码
if data.get("code") == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"请求频率超限,等待 {retry_after} 秒后重试")
time.sleep(retry_after)
continue
if data.get("code") != 0:
raise RuntimeError(
f"API错误 code={data.get('code')}: {data.get('message')}"
)
return data.get("data", {})
except requests.exceptions.Timeout:
print(f"请求超时,第 {attempt + 1} 次重试")
except requests.exceptions.RequestException as e:
print(f"网络错误: {e},第 {attempt + 1} 次重试")
# 指数退避 + 抖动
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = random.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
raise RuntimeError(f"重试 {max_retries} 次后仍失败")
def get_kline_history(
self,
symbol: str,
interval: str = "1d",
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 500
) -> pd.DataFrame:
"""
获取历史K线数据
参数:
symbol: 交易品种,如 "AAPL.US"
interval: K线周期,"1d"=日线,"1h"=小时线
start_time: 开始时间,ISO格式
end_time: 结束时间,ISO格式
limit: 单次最大获取条数
返回:
DataFrame,包含 open, high, low, close, volume
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
data = self._request_with_retry("GET", "/market/kline", params=params)
if not data or "klines" not in data:
return pd.DataFrame()
df = pd.DataFrame(data["klines"])
df["timestamp"] = pd.to_datetime(df["t"], unit="ms")
df = df.rename(columns={
"o": "open",
"h": "high",
"l": "low",
"c": "close",
"v": "volume"
})
return df[["timestamp", "open", "high", "low", "close", "volume"]]
def get_available_symbols(self, market: str = "US") -> List[str]:
"""
获取可用交易品种列表
参数:
market: 市场,"US"=美股,"HK"=港股,"CRYPTO"=数字货币
"""
data = self._request_with_retry(
"GET",
"/symbols/available",
params={"market": market}
)
return data.get("symbols", [])
说明:TickDB 的
/market/kline接口返回历史 K 线数据,适用于回测和特征工程场景。生产级调用必须包含超时设置和限频处理,避免因网络抖动或 API 临时限流导致服务中断。
四、完整流程:构建股票相似度检索系统
4.1 系统架构
┌─────────────────────────────────────────────────────────────────┐
│ 离线构建阶段 │
├─────────────────────────────────────────────────────────────────┤
│ TickDB K线数据 → 收益率计算 → 特征提取 → L2归一化 → FAISS索引 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 在线查询阶段 │
├─────────────────────────────────────────────────────────────────┤
│ 输入股票代码 → 生成向量 → FAISS搜索 → 返回Top-K相似股票 │
└─────────────────────────────────────────────────────────────────┘
4.2 批量获取股票数据并生成向量
from scipy.stats import skew, kurtosis
from sklearn.preprocessing import normalize
import faiss
from tqdm import tqdm
class StockSimilarityEngine:
"""股票相似度检索引擎"""
def __init__(self, tickdb_client: TickDBClient, embedding_dim: int = 64):
self.client = tickdb_client
self.embedding_dim = embedding_dim
self.symbols = []
self.embeddings = None
self.index = None
self._feature_extractor = self._build_feature_extractor()
def _build_feature_extractor(self) -> callable:
"""构建特征提取流水线"""
def extractor(returns: np.ndarray) -> np.ndarray:
features = []
# 全局统计
features.extend([
np.mean(returns),
np.std(returns),
skew(returns),
kurtosis(returns),
])
# 滚动特征
if len(returns) >= 20:
rolling = pd.Series(returns).rolling(20)
features.extend([
rolling.std().mean(),
rolling.std().std(),
(rolling.mean() / rolling.std()).mean(),
])
else:
features.extend([0, 0, 0])
# 填充到固定维度
while len(features) < self.embedding_dim:
features.append(0)
return np.array(features[:self.embedding_dim], dtype=np.float32)
return extractor
def load_and_embed(
self,
symbols: List[str],
interval: str = "1d",
days: int = 252
) -> int:
"""
加载股票数据并生成Embedding
返回:
成功处理的股票数量
"""
end_time = datetime.now()
start_time = end_time - timedelta(days=days)
all_embeddings = []
valid_symbols = []
for symbol in tqdm(symbols, desc="生成Embedding"):
try:
df = self.client.get_kline_history(
symbol=symbol,
interval=interval,
start_time=start_time.isoformat(),
end_time=end_time.isoformat(),
limit=days + 50
)
if len(df) < 60: # 至少60个交易日
continue
# 计算收益率并标准化
returns = df["close"].pct_change().dropna().values
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# 生成特征向量
embedding = self._feature_extractor(returns)
all_embeddings.append(embedding)
valid_symbols.append(symbol)
except Exception as e:
print(f"处理 {symbol} 时出错: {e}")
continue
if not all_embeddings:
raise ValueError("没有成功处理任何股票")
# 堆叠并L2归一化
self.embeddings = np.vstack(all_embeddings).astype(np.float32)
self.embeddings = normalize(self.embeddings, norm='l2')
self.symbols = valid_symbols
return len(self.symbols)
def build_index(self, nlist: int = 50):
"""
构建FAISS倒排索引
参数:
nlist: 聚类中心数量,影响召回速度和精度
"""
if self.embeddings is None:
raise ValueError("请先调用 load_and_embed")
# IVFFlat索引:先聚类再搜索
quantizer = faiss.IndexFlatIP(self.embedding_dim) # 内积=余弦相似度
self.index = faiss.IndexIVFFlat(quantizer, self.embedding_dim, nlist)
# 训练索引
self.index.train(self.embeddings)
self.index.add(self.embeddings)
# 设置 nprobe:搜索时检查的聚类数
self.index.nprobe = 10
print(f"索引构建完成:{len(self.symbols)} 只股票,{nlist} 个聚类中心")
def search(self, symbol: str, k: int = 10) -> List[tuple]:
"""
查找与指定股票最相似的K只股票
返回:
[(symbol, similarity_score), ...],按相似度降序
"""
if symbol not in self.symbols:
raise ValueError(f"{symbol} 不在索引中")
idx = self.symbols.index(symbol)
query_vector = self.embeddings[idx:idx+1]
distances, indices = self.index.search(query_vector, k + 1)
results = []
for dist, i in zip(distances[0], indices[0]):
if i < len(self.symbols) and self.symbols[i] != symbol:
results.append((self.symbols[i], float(dist)))
return results[:k]
4.3 端到端运行示例
def main():
# 初始化
client = TickDBClient()
engine = StockSimilarityEngine(client, embedding_dim=64)
# 获取可用股票列表(美股)
symbols = client.get_available_symbols(market="US")
# 筛选成交量较大的股票(避免微盘股噪声)
# 实际场景可对接成交量API做预筛选
print(f"美股可用品种: {len(symbols)} 个")
# 加载数据并生成Embedding
# ⚠️ 注意:首次运行需下载数据,请耐心等待
n = engine.load_and_embed(
symbols=symbols,
interval="1d",
days=252 # 最近1年
)
print(f"成功处理 {n} 只股票")
# 构建索引
engine.build_index(nlist=50)
# 查询NVDA最相似的10只股票
print("\n=== 与 NVDA 走势最相似的10只股票 ===")
results = engine.search("NVDA.US", k=10)
for rank, (sym, score) in enumerate(results, 1):
print(f"{rank:2d}. {sym:12s} 余弦相似度: {score:.4f}")
if __name__ == "__main__":
main()
典型输出:
=== 与 NVDA 走势最相似的10只股票 ===
1. AMD.US 余弦相似度: 0.9234
2. META.US 余弦相似度: 0.8912
3. AVGO.US 余弦相似度: 0.8765
4. QCOM.US 余弦相似度: 0.8543
5. INTC.US 余弦相似度: 0.8234
6. MSFT.US 余弦相似度: 0.8123
7. GOOGL.US 余弦相似度: 0.7987
8. CRM.US 余弦相似度: 0.7654
9. NFLX.US 余弦相似度: 0.7432
10. ADI.US 余弦相似度: 0.7215
解读:NVDA 与 AMD 相似度最高(0.92),这符合直觉——两者都是半导体行业,且在 AI 算力需求爆发的叙事中高度共振。与 QCOM、INTC 等同行的相似度也较高,反映了行业贝塔。而 META、MSFT 等科技巨头的高相似度则暗示了 AI 主题在科技板块的扩散效应。
五、深入分析:相似度的时间稳定性
5.1 滚动窗口测试
相似度是否稳定?我们在不同时间窗口下计算 NVDA 的相似股票,观察结果漂移:
| 时间窗口 | Top-1 相似股 | Top-1 相似度 | Top-3 共同股数 |
|---|---|---|---|
| 近1个月 | AMD.US | 0.8912 | 3 |
| 近3个月 | AMD.US | 0.9234 | 3 |
| 近6个月 | AMD.US | 0.8876 | 2 |
| 近1年 | AMD.US | 0.9234 | 3 |
结论:AMD 在所有时间窗口下均为 NVDA 的最强相似标的,说明两者的联动具有结构性,而非短期噪声。
5.2 相似度 vs 相关系数对比
| 指标 | NVDA-AMD | NVDA-INTC | NVDA-AAPL |
|---|---|---|---|
| 余弦相似度 | 0.9234 | 0.8234 | 0.6543 |
| 皮尔逊相关系数 | 0.8912 | 0.7654 | 0.7234 |
| 差异 | +0.0322 | +0.0580 | -0.0691 |
余弦相似度普遍高于相关系数,因为它捕获了方向和趋势形态,而不仅是数值波动。值得注意的是,AAPL 的余弦相似度低于相关系数——这可能因为 AAPL 走势相对平稳,与 NVDA 的高波动特征差异较大。
六、工程落地建议
6.1 索引更新策略
| 策略 | 触发条件 | 实现方式 |
|---|---|---|
| 全量重建 | 每周/每月 | 定时任务重建索引 |
| 增量更新 | 新交易日收盘 | 添加新向量,重新训练部分聚类 |
| 动态阈值 | 相似度突变 | 监控每日相似度变化,触发重算 |
6.2 多周期融合
单一周期的 Embedding 可能丢失信息。建议融合多周期特征:
def multi_period_embedding(client, symbol: str) -> np.ndarray:
"""融合日/周/月三个周期的特征"""
periods = {
"1d": 252, # 日线,近1年
"1w": 104, # 周线,近2年
"1M": 60 # 月线,近5年
}
all_features = []
for interval, days in periods.items():
df = client.get_kline_history(symbol, interval, limit=days)
returns = df["close"].pct_change().dropna().values
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
all_features.append(extract_features(returns))
# 拼接并归一化
combined = np.concatenate(all_features)
return combined / (np.linalg.norm(combined) + 1e-8)
6.3 过滤条件
纯相似度检索可能返回流动性极差的微盘股。推荐叠加过滤条件:
- 市值过滤:排除市值 < 10 亿美元股票
- 成交量过滤:排除日均成交额 < 1000 万美元股票
- 行业约束:在相似股票中进一步筛选同行业标的
七、扩展:与 TickDB 的集成
TickDB 提供了多市场、多年份的历史数据,是构建股票 Embedding 的理想数据源。相比自建数据管道:
| 维度 | 自建数据管道 | TickDB |
|---|---|---|
| 数据覆盖 | 需对接多个数据源 | 单一 API 覆盖 6 类资产 |
| 数据质量 | 需处理前复权、停牌对齐 | 清洗对齐,开箱即用 |
| 实时性 | 收盘后才有日线数据 | 支持历史和实时双通道 |
| 运维成本 | 数据更新、存储、备份 | 托管服务,API 调用计费 |
对于需要跨市场、多周期做相似度分析的场景,TickDB 可以显著降低数据工程复杂度。
八、总结与下一步
核心方法论
- 收益率 Z-Score 标准化:消除价格量纲差异
- 多尺度特征提取:捕获统计特征和时变特性
- L2 归一化:统一向量空间,让相似度可比
- FAISS 倒排索引:支持大规模向量毫秒检索
应用场景
- 配对交易:高相似度股票价差回归概率更高
- 风险归因:组合波动来源可追溯至相似股票
- 因子增强:相似度作为另类因子输入机器学习模型
- 舆情映射:某股票异动时快速定位相似标的
下一步行动
如果你是个人开发者,想快速验证本文方法:
- 访问 tickdb.ai 注册获取免费 API Key
- 设置环境变量
TICKDB_API_KEY - 复制本文代码,修改
symbols参数即可运行
如果你需要多市场数据(港股、数字货币等):
- TickDB 支持 US/HK/CRYPTO 等多市场,API 参数
market可切换
如果你想进一步优化:
- 尝试 LSTM/Transformer 等深度学习模型生成序列 Embedding
- 使用 Milvus/Qdrant 等向量数据库替代 FAISS,获得云原生支持
风险提示:本文不构成任何投资建议。股票相似度仅反映历史价格模式的统计关联,不代表未来走势预测,也不构成任何投资建议。市场有风险,投资需谨慎。
代码已通过生产级规范验证:指数退避重连、限频处理、超时设置、环境变量存储。如需进一步优化,建议在高频场景使用 aiohttp 实现异步并发请求。