把熔岩热度做成横截面因子:去极值、标准化与换手对照的 Python 走读
摘要
熔岩(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
Comments
Join the discussionNo comments yet. Be the first to comment.