Skip to main content

把熔岩热度做成横截面因子:去极值、标准化与换手对照的 Python 走读

Updated 4 min readMarkdown

摘要

熔岩(Lava)的日频历史面板 GET /panel/daily(MCP 工具 get_stock_heat_panel)按自然日给出个股的 mentions、agencies、weighted_heat 与 is_first_mention_day。这些是原始计数,直接丢进多因子框架会遇到三个老问题:分布极度右偏、少数大票把截面拉成长尾、热度和换手天然相关。本文用一段可以逐格运行的 Python 伪客户端,把面板数据加工成「每个交易日一列、可横向比较」的热度因子:先取数、再变换、再去极值、再标准化,最后用你自己的换手率做对照残差。代码里的地址与令牌都是占位符,所有数字均为示例,不对应任何真实标的。

先说清楚:原料来自哪块数据

熔岩把消息面数据分成几个板块,因子加工只用到其中一部分,其余用来做口径核对:

熔岩数据板块 本文用到的接口 / MCP 工具 在因子流程中的角色
个股热度(日频历史) /panel/daily · get_stock_heat_panel 主原料:mentions、agencies、weighted_heat
全市场热度 /hot/stocks · get_market_hot_stocks 抽查:盘中截面是否与日频方向一致
概念板块 /concepts/by-stock · get_stock_concept_heat 可选:给股票打概念标签做组内标准化
机构/大V(含 PIT) /agencies/pit · get_agency_pit 口径提醒:面板的 weighted_heat 不是当日 PIT

面板参数按 OpenAPI:type(默认 stock,也可 concept)、codes(逗号分隔,单次最多 20 个代码)或 stock_name、from 与 to(自然日,to 默认今天,默认回看 90 天,区间最长 92 天)。超出上限会返回 400,不计费。换手率、成交额、交易日历都不在熔岩里,需要你自有的行情库提供。

第 1 格:占位客户端与分批取数

先写一个最小的只读客户端。BASE 与 TOKEN 用占位值,实际运行时从环境变量读取;成功调用按次计费(每次 0.005 元,非 2xx 不计费),所以分批循环里不要重复拉同一组。

# cell_1_client.py —— 示例,地址与令牌均为占位
import os, time, requests, pandas as pd

BASE = os.environ.get("LAVA_BASE", "<YOUR_LAVA_BASE>/subscribe/v1")
TOKEN = os.environ.get("LAVA_API_KEY", "<YOUR_TOKEN>")
H = {"Authorization": "Bearer " + TOKEN}

def get_panel(codes, start, end):
    """codes ≤ 20;start/end 为自然日,区间 ≤ 92 天"""
    assert len(codes) <= 20
    r = requests.get(
        BASE + "/panel/daily",
        params={"type": "stock", "codes": ",".join(codes),
                "from": start, "to": end},
        headers=H, timeout=60,
    ).json()
    if not r.get("ok"):
        raise RuntimeError(r.get("code"))
    rows = r["data"] if isinstance(r["data"], list) else r["data"].get("list", [])
    return pd.DataFrame(rows), r["meta"]["server_time"]

def pull_universe(codes, start, end):
    frames = []
    for i in range(0, len(codes), 20):
        df, ts = get_panel(codes[i:i + 20], start, end)
        df["fetched_at"] = ts
        frames.append(df)
        time.sleep(0.5)  # 礼貌间隔,示例值
    return pd.concat(frames, ignore_index=True)

两点注意。第一,返回行里日期与代码的列名以 OpenAPI 为准,下文统一假设为 date 与 ts_code,落地前请先打印一行核对。第二,to 若等于今天,当天还没结束,这一行是未完结日,做日频因子时应剔除。

第 2 格:自然日折到交易日

面板是自然日,因子表是交易日。周末与节假日的讨论如果直接按日期 join,会凭空丢掉;盘后讨论如果算进当天,又会提前使用信息。最简单的稳妥口径是「非交易日累加到下一个交易日」:

# cell_2_calendar.py —— 示例
def to_trade_day(panel, trade_days):
    """trade_days: 你自有的 A 股交易日列表(升序)"""
    td = pd.Series(pd.to_datetime(trade_days)).sort_values()
    panel = panel.copy()
    panel["date"] = pd.to_datetime(panel["date"])
    idx = td.searchsorted(panel["date"], side="left")
    panel = panel[idx < len(td)]
    panel["trade_day"] = td.values[idx[idx < len(td)]]
    agg = {"mentions": "sum", "agencies": "max", "weighted_heat": "sum"}
    return panel.groupby(["trade_day", "ts_code"], as_index=False).agg(agg)

agencies 用 max 而不是 sum,是因为跨日加总会重复计同一主体;若要精确的去重覆盖数,应改用 /coverage/stocks(get_agency_coverage)的 7/30 日去重计数,它只给计数,不给机构编号。若研究要求严格以当天 15:00 为界,另需逐小时或 PIT 截面,日频面板做不到小时级切分。

第 3 格:变换与去极值

提及数是计数型变量,截面上常见「中位数个位数、头部上千」的形态。先做 log1p,再去极值,顺序不能反:在原始尺度上按百分位截尾,会把大量中等热度的股票压成同一个值。

两种常用的去极值方法:

方法 规则(每个交易日截面内) 适用情形
百分位截尾 低于 1% 分位记为 1% 分位,高于 99% 分位记为 99% 分位 样本数较大、尾部不太离谱
MAD 截尾 以中位数 med 为中心,上下界为 med ± k × 1.4826 × MAD,k 常取 3~5 尾部很厚、零值很多
# cell_3_winsor.py —— 示例
import numpy as np

def mad_clip(s, k=5.0):
    med = s.median()
    mad = (s - med).abs().median()
    if mad == 0:                       # 大量零值时 MAD 可能为 0
        return s.clip(s.quantile(0.01), s.quantile(0.99))
    lo, hi = med - k * 1.4826 * mad, med + k * 1.4826 * mad
    return s.clip(lo, hi)

def transform(day_df, col):
    x = np.log1p(day_df[col].astype(float))
    return mad_clip(x)

mad == 0 的退路很重要。冷门股集中、讨论稀少的交易日里,超过一半股票的提及可能为 0,此时 MAD 为 0,上下界会塌成一个点。示例退路是改用百分位截尾,你也可以只在非零子样本上估计 MAD。

第 4 格:横截面标准化

去极值后在每个交易日内做 z 分:(x - mean) / std。也可以用排名分位代替,二者的取舍见下表。

# cell_4_zscore.py —— 示例
def xsection_z(df, col):
    out = []
    for d, g in df.groupby("trade_day"):
        x = transform(g, col)
        sd = x.std(ddof=0)
        z = (x - x.mean()) / sd if sd > 0 else x * 0.0
        out.append(pd.DataFrame({"trade_day": d, "ts_code": g["ts_code"],
                                 f"{col}_z": z.values}))
    return pd.concat(out, ignore_index=True)
做法 优点 代价
截面 z 分 保留距离信息,便于线性合成 对剩余尾部仍敏感
截面排名分位 对极值完全免疫 丢掉幅度,并列值多时分辨率低
组内 z 分(按概念或行业) 消除板块整体冷热 分组标签需本地维护,组小则不稳

注意,熔岩榜单接口自带的 heat_z20 是「相对自身近 20 日日均」的时间序列 z 分,回答的是「这只股票比它自己平常热多少」;本节做的是截面 z 分,回答的是「同一天里它比别的股票热多少」。两者都有用,但不能混成一列。若想做组内标准化,概念标签可以从 /concepts/by-stock 取,概念名需按返回值精确保存。

第 5 格:换手对照残差

热度高的股票往往换手也高,相关来源可能是同一事件,也可能只是大票本身流动性好。为了看「换手解释不了的那部分热度」,在每个截面上用换手 z 分对热度 z 分做一元回归,取残差:

# cell_5_resid.py —— 示例;turnover 来自你自有行情库
def resid_vs_turnover(heat_z, turn_z):
    df = heat_z.merge(turn_z, on=["trade_day", "ts_code"], how="inner")
    res = []
    for d, g in df.groupby("trade_day"):
        x, y = g["turnover_z"].values, g["mentions_z"].values
        if len(g) < 30 or x.std() == 0:
            continue                    # 样本太少不估,示例阈值
        b = np.cov(x, y, ddof=0)[0, 1] / x.var()
        a = y.mean() - b * x.mean()
        res.append(pd.DataFrame({"trade_day": d, "ts_code": g["ts_code"],
                                 "heat_resid": y - (a + b * x)}))
    return pd.concat(res, ignore_index=True)

示例:某交易日截面 800 只股票,mentions_z 与 turnover_z 的相关系数为 0.45(示例数字)。回归后残差与换手的截面相关接近 0,残差列保留的是「同等换手水平下讨论更多或更少」的部分。这只是一个统计分解,残差大不代表任何方向含义。

第 6 格:落盘与口径标签

最后把每一列的来源写进表头或元数据,而不是只存数字:

  • 原料:/panel/daily,type=stock,from/to,取数 meta.server_time;
  • 日期口径:自然日累加到下一交易日;当天未完结日已剔除;
  • 变换:log1p → MAD 截尾(k=5)→ 截面 z 分;
  • weighted_heat 标注「当前机构分口径,非 PIT」,严格回测改用 /hot/weighted 的 as_of_date 截面或 /agencies/pit 本地重算;
  • 换手对照:残差列注明回归样本数下限与行情来源。

有了这些标签,三个月后复查时才能说清某一天的因子值是怎么来的。

常见问题

Q:mentions、agencies、weighted_heat 应该各做一列,还是合成一列?
A:建议先各自完成去极值与标准化,再观察三者截面相关。weighted_heat 依赖机构分,且是当前分口径;在没有 PIT 版本之前,回测里单独成列并加标签更安全。

Q:面板缺行应该补 0 吗?
A:缺行与 0 是否等价以 OpenAPI 为准。稳妥做法是:只有当代码在请求列表内、且接口成功返回时,缺失日才按 0 处理,并单独记一列「补零标记」。

Q:截面样本太少时怎么办?
A:每次最多 20 个代码,股票池大时需要分批拉取。若某天成功返回的样本不足(示例阈值 30),宁可当天不出因子值,也不要在小样本上估计均值与标准差。

Q:可以直接用 heat_z20 代替截面 z 分吗?
A:不可以直接替代。heat_z20 是相对自身历史的偏离,截面 z 分是同日相对位置,研究问题不同。

结论

把熔岩热度做成横截面因子,核心步骤只有五步:按 20 个代码一批从 /panel/daily 取自然日数据,折到交易日并剔除未完结日,log1p 后用 MAD 或百分位去极值,在每个交易日内标准化,最后用自有换手率取对照残差。每一步都要把参数和口径标签一起落盘,尤其是 weighted_heat 的非 PIT 属性。这样得到的是一张可复现的统计特征表,后续怎样评估,由你自己的研究框架决定。

数据来自熔岩消息面热度监测,仅供研究参考,不构成投资建议。

只读监测与字段说明见:https://lava.ai-dolphin.com/research

No comments yet. Be the first to comment.

More from this blog