打开任何一款行情软件,K 线图下方几乎都趴着一张红红绿绿的柱状图——成交量。老股民聊天时也总绕不开它:「这两天放量了」「缩量反弹,不踏实」「量在价先」。价格告诉我们市场走到了哪里,成交量则告诉我们有多少人真金白银地参与了这场移动。只看价格不看量,就像只看比分不看比赛录像,很多细节会漏掉。
上一篇我们认识了 ATR 与 KDJ:ATR 量的是价格波动的幅度,KDJ 盯的是收盘价在近期区间里的位置——它们都是纯价格指标,成交量的信息一点没用上。本篇把「量」请回桌面:先弄懂成交量与换手率各自的含义,再手写一个经典的量价指标 OBV(能量潮),最后给出一个可以批量执行的量价背离识别思路。
这套东西不需要任何高级数学,核心代码加起来不到十行,但它能给你的眼睛装上「第二个维度」。读完本篇,你再打开 K 线图时,价格和成交量就不再是两条互不相干的线了。
本篇目标
- 能分别说出成交量与换手率各自回答的是什么问题,以及为什么跨公司比较必须用换手率。
- 能用「成交量 ÷ 流通股本」计算换手率,并用「成交额 ÷ 流通市值」做等价校验,说出这个教学近似的两处简化。
- 能手写实现 OBV(能量潮)并画出价格与 OBV 的上下对照图。
- 能用「20 日窗口内价格方向与 OBV 方向是否相反」的规则,批量找出量价背离嫌疑日。
核心概念
成交量:市场参与度的温度计
成交量(Volume)指一段时间内成交的股票数量,A 股行情接口里通常以「手」为单位,1 手 = 100 股。它回答的问题是:今天的行情,有多少人参与?
经验上(注意这只是教学化的经验描述,不是定律)量价搭配可以粗分成四种状态:放量上涨——买盘积极涌入,趋势走得健康;缩量上涨——参与者少,涨得勉强,动能存疑;放量下跌——恐慌性抛售,筹码大量换手;缩量下跌——抛压逐步衰竭,愿意卖的人越来越少。成交量本身没有多空方向,它必须和价格放在一起读,这正是「量价分析」(Price-Volume Analysis)这个名字的由来。
换手率:把「多少手」翻译成「多少比例」
大盘股和小盘股的成交量天然没有可比性:一个是日成交几亿股司空见惯,一个是日成交几百万股就算热闹。想跨公司比较,就要给成交量找一个「分母」——流通股本(Float,可以在市场上自由买卖的股本数量):
换手率(Turnover Rate) = 成交量 ÷ 流通股本
它回答的问题是:今天流通在外的筹码,有百分之几换了主人? A 股主板个股的日换手率多在 1%–3% 区间活动,放大到 7% 以上算高度活跃。注意:不同板块、不同股本结构差异很大,这个区间只是教学参考,不是判断标准。
本篇做一个必须交代清楚的教学简化:普通行情接口拿不到逐日变动的股本,我们用固定时点的流通股本回溯整段历史。真实的股本会因增发、解禁、回购逐年变化,所以历史换手率只是近似;更严格的做法是用自由流通股本(Free Float,剔除大股东锁定部分),但它不易获取,知道有这回事即可。另外还有一个等价校验路径:因为「成交额 = 成交量 × 成交均价」,所以 成交额 ÷ 流通市值(收盘价 × 流通股本) 在数学上和上式是同一个比值,代码里我们会用两条路径互相验证。
划重点:换手率的核心不是公式,而是「分母决定可比性」这个思想。以后你见到任何「某某率」,第一反应都应该是:分母是什么?分母不同,数字就不可比。
OBV:把成交量记成一条累计账
能量潮(On-Balance Volume,简称 OBV)是最经典的量价指标之一,思想朴素到可爱:把每天的成交量当成一笔账——价格上涨的日子记正账,价格下跌的日子记负账,平盘记零账,然后逐日累加:
OBV(今天) = OBV(昨天) + sign(收盘价变化) × 成交量
其中 sign 是符号函数:涨取 +1,跌取 −1,不变取 0。它的潜台词是:上涨有量的钱才算「真钱」,下跌有量说明筹码在真实换手。OBV 一路走高,说明买方持续吸筹;价格还在涨而 OBV 走平甚至下行,就要多留个心眼。手写它只需要三行核心代码,本篇就来实现。
量价背离:价格与量的方向打架
背离(Divergence)指价格与指标的方向出现矛盾。量价语境下最常见的两种:
- 顶背离嫌疑:价格还在涨,OBV 却不再创新高甚至往下走——上涨没有得到量能配合,追高的资金可能在退潮。
- 底背离嫌疑:价格还在跌,OBV 却不肯创新低——抛压衰竭,卖方的力量可能在枯竭。
教学上可以用固定窗口批量识别:取 20 个交易日,比较「价格涨跌幅的方向」与「OBV 增量的方向」,方向相反就记为背离嫌疑日。两个前提要记住:一是窗口长短会明显改变信号数量,它本质上是个参数;二是背离是预警不是按钮——它提示「动能可能出了问题」,不预测「明天一定反转」,任何基于背离的规则都要回到第 17 篇立的规矩里,用不含未来函数的回测验证之后才有资格谈使用。
完整代码
依赖安装:uv pip install pandas numpy akshare matplotlib。三个代码块按顺序放在同一个脚本里执行。
代码块 1:加载含成交量的行情数据(akshare 失败自动降级为固定种子的模拟数据,保证一定能跑):
import numpy as np
import pandas as pd
def load_data():
"""优先 akshare 真实行情(含成交量、成交额),失败退回固定种子模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权,保证价格连续
raw = raw.rename(columns={"日期": "date", "收盘": "close",
"成交量": "volume", "成交额": "amount"})
df = raw[["date", "close", "volume", "amount"]].copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
print(f"已加载 akshare 真实数据:000001,共 {len(df)} 个交易日")
except Exception as e:
print(f"akshare 不可用({e}),改用本地模拟数据")
rng = np.random.default_rng(15)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
close = 10 * np.cumprod(1 + rng.normal(0.0002, 0.015, len(idx)))
volume = rng.lognormal(mean=11, sigma=0.4, size=len(idx)).round() # 模拟成交量(手)
df = pd.DataFrame({"close": close, "volume": volume,
"amount": volume * 100 * close}, # 手 × 100 股 × 价格 ≈ 成交额
index=idx)
df.index.name = "date"
print(f"已生成模拟数据,共 {len(df)} 个交易日")
return df
df = load_data()
代码块 2:换手率计算与两条路径的交叉验证:
import numpy as np
import pandas as pd
# —— 换手率 = 成交量 ÷ 流通股本(教学简化:股本取固定值回溯全区间)——
FLOAT_SHARES = 1.9e9 # 教学假设的流通股本(股)
try:
import akshare as ak
info = ak.stock_individual_info_em(symbol="000001") # 个股资料表,含流通股字段
float_shares = float(info.loc[info["item"] == "流通股", "value"].iloc[0])
print(f"已从 akshare 获取流通股本:{float_shares / 1e8:.2f} 亿股")
except Exception:
float_shares = FLOAT_SHARES # 接口不可用就用假设值
print(f"股本接口不可用,改用教学假设值:{float_shares / 1e8:.2f} 亿股")
df["turnover_pct"] = df["volume"] * 100 / float_shares * 100 # 手→股,再转百分比
# 等价校验:成交额 ÷ 流通市值(收盘价 × 流通股本),两条路径应非常接近
df["turnover_mv"] = df["amount"] / (df["close"] * float_shares) * 100
df["gap"] = (df["turnover_pct"] - df["turnover_mv"]).abs() # 两条路径的差距
print("换手率描述统计(%):")
print(df["turnover_pct"].describe().round(2))
print(f"两条计算路径的平均差距:{df['gap'].mean():.4f} 个百分点")
print("\n换手率最高的 5 个交易日(%):")
print(df["turnover_pct"].sort_values(ascending=False).head().round(2))
代码块 3:手写 OBV 与量价背离识别:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] # 中文显示
plt.rcParams["axes.unicode_minus"] = False
# —— OBV:价涨日 +量、价跌日 −量、平盘记 0,逐日累加 ——
direction = np.sign(df["close"].diff()).fillna(0) # +1 / −1 / 0
df["obv"] = (direction * df["volume"]).cumsum() # 三行核心:符号 × 量,累加
# —— 量价背离识别:20 日窗口内价格方向与 OBV 方向相反 ——
window = 20
df["price_chg"] = df["close"].pct_change(window) # 20 日价格涨跌幅
df["obv_chg"] = df["obv"].diff(window) # 20 日 OBV 增量
top_div = df[(df["price_chg"] > 0) & (df["obv_chg"] < 0)] # 价涨量缩:顶背离嫌疑
bot_div = df[(df["price_chg"] < 0) & (df["obv_chg"] > 0)] # 价跌量不缩:底背离嫌疑
print(f"{window} 日窗口口径:顶背离嫌疑 {len(top_div)} 天,底背离嫌疑 {len(bot_div)} 天")
if len(top_div):
print("最近一次顶背离嫌疑日期:", top_div.index[-1].strftime("%Y-%m-%d"))
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True)
ax1.plot(df.index, df["close"], color="black", lw=1.2, label="收盘价")
ax1.set_ylabel("价格(前复权)")
ax1.legend(loc="upper left")
ax1.grid(alpha=0.3)
ax2.plot(df.index, df["obv"], color="steelblue", lw=1.2, label="OBV")
ax2.set_ylabel("OBV(手)")
ax2.legend(loc="upper left")
ax2.grid(alpha=0.3)
fig.suptitle("价格与 OBV 对照:肉眼检查量价背离(2020-2024)")
fig.tight_layout()
plt.savefig("obv_divergence.png", dpi=150)
print("已保存对照图 obv_divergence.png")
代码逐段解释
1. 块 1(取数):和系列其他篇同一套降级写法,akshare 拉 000001 的 2020–2024 前复权日线,这次多保留了「成交量」与「成交额」两列。注意单位:akshare 的成交量以手为单位,成交额以元为单位。降级方案里 amount 用「手 × 100 股 × 收盘价」近似生成,是因为模拟数据没有真实的分笔成交价,这只是教学口径。
2. 块 2(换手率):先拿流通股本——优先用 stock_individual_info_em 接口取最新值,接口字段或网络出问题就退回假设值,保证代码一定能跑。volume × 100 把手换算成股,除以股本再乘 100 得到百分比。turnover_mv 走的是「成交额 ÷ 流通市值」这条等价路径。两条路径的差 gap 通常只有百分之零点零几个百分点,来源是全天成交均价不等于收盘价——这正好验证了两个公式的等价关系。describe() 一眼看全分布:均值、中位数(50% 行)反映常态水平,max 告诉你最热闹的日子换手有多疯狂。
3. 块 3(OBV 与背离):np.sign(df["close"].diff()) 生成每日方向 +1/−1/0,fillna(0) 处理首行缺失,乘上成交量后 cumsum() 累加,OBV 就完成了。背离判定是两条布尔条件的交集:price_chg > 0 且 obv_chg < 0 记顶背离嫌疑,反之记底背离嫌疑。图分上下两栏共享时间轴,上栏价格、下栏 OBV,肉眼对照找「价格新高而 OBV 不新高」的位置。
示例输出(示意,数值随数据源、复权与股本口径变化,但形态如此):
已加载 akshare 真实数据:000001,共 1215 个交易日
已从 akshare 获取流通股本:194.06 亿股
换手率描述统计(%):
count 1215.00
mean 0.66
std 0.33
min 0.12
25% 0.42
50% 0.58
75% 0.83
max 2.71
两条计算路径的平均差距:0.0131 个百分点
换手率最高的 5 个交易日(%):
date
2020-07-07 2.71
2020-07-06 2.58
2020-02-25 2.44
2020-07-09 2.37
2020-09-01 2.29
Name: turnover_pct, dtype: float64
20 日窗口口径:顶背离嫌疑 173 天,底背离嫌疑 158 天
最近一次顶背离嫌疑日期: 2024-12-18
已保存对照图 obv_divergence.png
划重点:示例输出里的背离嫌疑天数看着不少(五年里一百多天),这是正常的——固定窗口的方向对撞本来就是低门槛筛子。它的价值在于把几百个交易日缩小成一张值得肉眼复查的候选清单,而不是直接给出买卖结论。
常见坑与小练习
坑 1:手和股不分。 现象是算出的换手率比行情软件显示的小 100 倍。原因:akshare 等接口的成交量单位是手,忘记乘 100。避免方式:任何涉及成交量的计算,先打印一行原始数据对照行情软件核对单位。
坑 2:用当前股本回溯历史还当真。 现象是历史换手率与当年软件截图对不上。原因:增发、解禁、回购会让股本逐年变化,本篇「固定股本回溯」是已声明的教学简化。避免方式:做严肃分析时使用带历史股本的数据源,或至少知道误差方向。
坑 3:把背离当反转按钮。 现象是看到顶背离就清仓、底背离就满仓,结果趋势行情里被连续背离打得反复止损。原因:背离只说明动能变化,不指示反转时点,强趋势里可以连续出现多次。避免方式:把背离当候选信号,先回测再使用,并配合趋势过滤。
坑 4:不复权数据污染方向符号。 现象是除权日附近 OBV 出现莫名其妙的巨幅波动。原因:不复权价格在除权日向下跳空,diff 的符号在那天是假的。避免方式:全程使用前复权(或后复权)数据,与第 18 篇的纪律一致。
坑 5:平盘日口径混用。 现象是自己算的 OBV 和软件上的对不上。原因:平盘日有的实现记 0(本篇做法),有的沿用昨日方向,横截面上数值会岔开。避免方式:跨软件对比前先确认口径,OBV 看形态与拐点,不抠绝对数值。
小练习 1:把 window 改成 10 和 60 各跑一遍,记录顶、底背离嫌疑天数和最近一次嫌疑日期的变化。
提示:窗口越短信号越多越毛躁,越长越迟钝——体会「窗口即参数」,为第 25 篇的参数优化打底。
小练习 2:找出 gap 最大的 5 个交易日,对照当日分时判断收盘价相对全天均价是偏高还是偏低。
提示:差距大说明收盘价与成交均价偏离明显,常见于尾盘集中拉升或砸盘的日子。
小练习 3:在 ax2 上加一条 df["obv"].rolling(20).mean() 的均线,观察价格创新高时 OBV 是否站上自己的均线。
提示:一行代码即可;这条均线相当于 OBV 的「趋势滤网」,能帮你更快肉眼定位背离区间。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。