股票相似度:从价格序列到向量检索

"走势相似"是量化分析中最朴素的假设之一。

如果两只股票的收益率序列在历史上高度相关,那么当其中一只出现异常波动时,另一只是否也会随之调整?这个假设构成了配对交易、风险平价、因子暴露分析的理论基础。

但"相似"究竟如何量化?简单的皮尔逊相关系数只能捕捉线性相关,无法处理非线性变换后的相似性。举例来说,茅台和五粮液的股价都随白酒板块涨跌,但它们的绝对价格水平、波动幅度完全不同。相关系数为 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 可以显著降低数据工程复杂度。


八、总结与下一步

核心方法论

  1. 收益率 Z-Score 标准化:消除价格量纲差异
  2. 多尺度特征提取:捕获统计特征和时变特性
  3. L2 归一化:统一向量空间,让相似度可比
  4. FAISS 倒排索引:支持大规模向量毫秒检索

应用场景

  • 配对交易:高相似度股票价差回归概率更高
  • 风险归因:组合波动来源可追溯至相似股票
  • 因子增强:相似度作为另类因子输入机器学习模型
  • 舆情映射:某股票异动时快速定位相似标的

下一步行动

如果你是个人开发者,想快速验证本文方法:

  1. 访问 tickdb.ai 注册获取免费 API Key
  2. 设置环境变量 TICKDB_API_KEY
  3. 复制本文代码,修改 symbols 参数即可运行

如果你需要多市场数据(港股、数字货币等):

  • TickDB 支持 US/HK/CRYPTO 等多市场,API 参数 market 可切换

如果你想进一步优化

  • 尝试 LSTM/Transformer 等深度学习模型生成序列 Embedding
  • 使用 Milvus/Qdrant 等向量数据库替代 FAISS,获得云原生支持

风险提示:本文不构成任何投资建议。股票相似度仅反映历史价格模式的统计关联,不代表未来走势预测,也不构成任何投资建议。市场有风险,投资需谨慎。


代码已通过生产级规范验证:指数退避重连、限频处理、超时设置、环境变量存储。如需进一步优化,建议在高频场景使用 aiohttp 实现异步并发请求。