☰
从API到策略:手写RSI、MACD、MA指标的完整指南与避坑实录
2026/10/15 4:59:52 网站建设 项目流程

做量化的朋友应该都有过类似的经历:行情数据拿到手了,K线画出来了,但RSI、MACD、MA这几个最常用的技术指标却总是“差一点意思”——要么和行情软件对不上,要么边界值一堆NaN,要么不知道选什么参数才合理。我一直主张一个原则:技术指标能用现成的库就用现成的,但如果你要深入理解指标背后的含义,或者要落地到自己的策略里,最好还是把计算逻辑亲手写一遍。这篇文章就从股票数据API讲起,把RSI、MACD、MA三大指标的计算细节、选参逻辑、以及我在实际代码里踩过的坑,完整拆开来讲。

适合刚接触量化、拿到数据但不知道怎么算指标的初学者,也适合已经跑通策略、但发现指标结果和参照平台对不齐的老手。文章里所有代码我都按可以直接复现的标准写,数据接口用一个常见的开源行情源示例,你换成自己手里的任意API都成立。读完你至少能解决三件事:知道每类指标公式背后的真正含义、能在本地算出和主流行情软件误差可控的结果、能自己排查“指标算错”的常见原因。

1. 先把架子搭起来:从行情API到本地DataFrame

1.1 为什么绕开现成指标库,执意要从API自己算

很多新手第一反应是:直接用某个量化库自带的talib、stockstats不香吗?香,但会掩盖很多问题。比如几个常见的坑:

第一,现成库和你的数据格式不一定匹配。有的指标库要求输入数组是float32,有的要求时间索引升序,你拿到的数据但凡多几根停牌K线、少几列复权因子,结果就会错位。

第二,指标库默认的算法不一定符合你用的行情软件。国内行情软件和海外常用库在MACD柱状图系数、RSI平滑方式上都有细微差异,直接调用默认实现很容易出现“代码没错、结果和软件对不上”的诡异情况。

第三,策略调试时你要的不是一个“魔法函数”,而是可解释的中间值。比如你想知道某一天的DIF是多少、DEA是多少,而不是一个封装好的histogram数组,拆开手写更方便你做信号诊断。

所以我强烈建议:API只负责把原始K线抓回来,指标计算自己动手写。这样数据流是清晰的:API -> 清洗后的DataFrame -> 指标计算 -> 信号逻辑。

1.2 数据源选型要考虑的四件事

市面上的股票数据API五花八门,免费的有开源数据接口,付费的有专业级行情服务商。选型时我建议重点盯四个维度:

  • 复权支持:API必须能提供前复权、后复权或不复权三种模式的日线数据,而且最好能区分“复权因子”和“已经复权好的价格”。很多指标在计算前要统一步调,比如MACD的EMA序列对价格断裂非常敏感,除权除息日如果不做复权处理,指标会莫名其妙地跳变。
  • 历史长度与频率:日线至少要有3年以上数据,因为MACD的EMA26需要至少约半年的数据来“热机”,如果数据太短,计算出的前段指标失真严重。想测试多周期策略,还要确认API支持周线、月线。
  • 字段完整性:除了OHLC(开高低收),最好还有成交量、成交额、涨跌幅、复权因子。成交量在指标里不直接参与RSI/MACD/MA计算,但后期做量价背离验证时会用到。
  • 接口稳定性与限流:免费API一般有请求频率限制。我实际测下来,如果只是算日线指标,一天几千次请求够用了;但如果要做分钟线批量回测,最好选付费服务或者本地缓存,否则很容易触发限流。

拿开源数据接口举例,它的日线数据接口返回是标准的pandas DataFrame,列名通常是date、open、close、high、low、volume、amount、factor,日期格式是标准的时间戳。这类数据基本能满足80%的指标计算需求。

1.3 拿到原始数据后第一件事:清洗与对齐

无论用哪个API,原始数据都不能直接拿去算指标。我踩过最典型的坑是三种:

第一,数据乱序。有的接口返回的数据是倒序的(最新日期在最上面),如果你不排序就计算rolling窗口,结果就是倒着算的指标,信号完全反了。所以第一步永远是df.sort_index(ascending=True)。

第二,停牌导致的NaN。股票停牌时,日线接口一般返回NaN或者干脆缺行。如果返回的是NaN,计算rolling均值时会被当作缺失处理,但计算EMA时pandas的ewm会自动跳过NaN,导致肯能指标结果正常但实际数据是不连续的。处理方案是:先看缺口有多长,短停牌可以直接ffill,长时间停牌建议在指标计算前单独标记,或者在信号判断时剔除停牌日。

第三,复权因子不一致。很多免费API提供的“交易日K线”和“复权后K线”是两个字段,如果你把复权后的价格与未复权的技术指标混着用,比如用复权收盘价算MACD、再用未复权价格算回测收益,就会出问题。

我自己常用的清洗流程固定为四步:排序、去重、处理缺失值、统一数值类型(float32)。清洗完的DataFrame长这样:

import pandas as pd import numpy as np def load_clean_data(api_data: pd.DataFrame) -> pd.DataFrame: # 1. 解析日期成索引 df = api_data.copy() df['date'] = pd.to_datetime(df['date']) df.set_index('date', inplace=True) # 2. 升序排序,这是所有计算的前提 df.sort_index(inplace=True) # 3. 去重 df = df[~df.index.duplicated(keep='first')] # 4. 统一数值类型 for col in ['open', 'high', 'low', 'close', 'volume']: df[col] = pd.to_numeric(df[col], errors='coerce').astype(np.float32) # 5. 停牌处理:短的缺口用前值填充 df['close'] = df['close'].ffill().bfill() # volume最好别填充,保持原样 return df

这个函数我基本上每个项目都会先跑一遍,省掉了后面90%的“为什么指标算出来不对”的排查时间。

2. 移动平均线MA:最基础也是最容易被忽略的“坐标系”

2.1 MA家族:SMA、WMA、EMA分别解决什么问题

简单移动平均线(SMA)就是过去N天收盘价的算术平均,它是最朴素的趋势描述工具。但它有个天然问题:对最近一天的价格变化不敏感,而且N越大滞后越明显。WMA(加权移动平均)和EMA(指数移动平均)都是为了解决“滞后”问题的,区别在于WMA的权重是线性递减,EMA的权重是呈指数衰减。

实际项目中我的选择逻辑很直接:

  • 判断中长期趋势用SMA,参数常取20、60、250。因为SMA计算简单、含义清晰,而且机构研报里说的“股价站上60日线”基本上指的就是SMA60。
  • 做短线策略,尤其在波动大的标的上,我会用EMA替代SMA。EMA对近期价格敏感度高,拐点出现得更早,但缺点也明显——假信号变多。
  • WMA用得不多,但如果你的策略里需要“更平滑但又不太滞后”的曲线,可以试一下WMA,它介于两者之间。

2.2 用pandas一行写出MA,但要注意两个隐藏细节

计算SMA的代码非常简单:

df['MA20'] = df['close'].rolling(window=20, min_periods=20).mean() df['MA60'] = df['close'].rolling(window=60, min_periods=60).mean()

但有两个细节你未必注意到了。

第一个是min_periods参数。默认pandas的rolling.mean()会在窗口未满时返回NaN,这其实是对的行为——前19天不应该计算出MA20的值。但如果你在信号逻辑里用if df['MA20'].iloc[i] > df['MA20'].iloc[i-1],就会在前N天直接报错或跳过,容易防不胜防。更好的做法是保留NaN,然后在策略循环里明确跳过NaN行。

第二个是价格复权的选择。很多量化新手拿着“前复权”价格算MA,发现历史上某些跳空处均线走势很“陡峭”,以为是数据错乱。其实这是正常的——前复权价格是动态值,它的历史价格会随新股发行、分红而变动,所以基于前复权算出的历史MA也会跟着变。如果只想观察当前时刻的均线位置关系,前复权没问题;如果要做历史回测,建议使用不复权价格并结合除权除息日期单独处理。

EMA代码也不复杂:

df['EMA20'] = df['close'].ewm(span=20, adjust=False).mean()

这里的adjust=False很关键,它表示从第一条数据开始就迭代计算,公式是:EMA_today = (close_today * weight) + (EMA_yesterday * (1 - weight))其中weight = 2 / (span + 1),20日EMA对应的权重是2/21 ≈ 0.0952。

2.3 为什么我用MA而不是其他均线体系做趋势参照

不是别的均线体系不好,而是MA在A股的实际语义最清晰。比如“金叉”“死叉”这个词,默认指的就是MA5上穿MA10或MA20上穿MA60。我实际复盘时发现,很多量价关系、背离形态都是以MA20为中枢展开的。所以我会把均线族当成一套“坐标系”,而不是独立的指标:先确认价格在MA20上方还是下方,再决定RSI和MACD的信号是否有效。

一个很实用的改法是“均线斜率过滤”。单纯的价格站上均线经常被震荡打脸,但如果加一个MA20的斜率判断——只做MA20上行时的金叉,胜率会好不少。斜率的计算可以用均线一阶差分再做平滑:

df['MA20_slope'] = df['MA20'].diff() df['MA20_slope_smoothed'] = df['MA20_slope'].rolling(5).mean()

这样就把传统均线从“画线工具”升级成了“状态过滤器”。新手可以先从这种简单的过滤开始,后面再叠加RSI、MACD信号。

3. RSI:别再用错算数了,这里面有一个平滑细节

3.1 RSI公式拆开讲:涨跌幅与平均值的博弈

RSI(相对强弱指标)在教科书里的公式是:

RSI = 100 - [100 / (1 + RS)] RS = 平均上涨幅度 / 平均下跌幅度

但这里的“平均”到底怎么定义,就是各软件之间差异最大的地方。经典版本用的是 Wilder 平滑(也叫相对强弱平均法),不是简单的算术平均。很多人拿现成代码直接算RSI,结果和同花顺、通达信对不上,十有八九就是没注意这个细节。

如果按简单的算术平均思路算RSI14,那就是把过去14天的涨幅加起来除以14,跌幅绝对值加起来除以14,再直接套RS公式。这样算出来的RSI曲线非常钝,而且一旦某天跌幅巨大,RSI会剧烈下探,缺乏平滑感。而Wilder平滑法的核心思想是:用前一天的“平均涨跌幅”作为基数,再做一次递推平滑,让RSI的记忆更持久。

具体公式是:

  • 当日涨跌:delta = close_today - close_yesterday
  • 上涨部分:gain = max(delta, 0),下跌部分:loss = max(-delta, 0)
  • 第一次计算时,用前14天的gain和loss做算术平均作为初始值
  • 之后每一天:avg_gain = (prev_avg_gain * (period - 1) + gain_today) / period
  • 同理:avg_loss = (prev_avg_loss * (period - 1) + loss_today) / period
  • RS = avg_gain / avg_loss,RSI = 100 - 100 / (1 + RS)

这个递推过程本质上就是一个以alpha = 1/period的指数移动平均。所以在pandas里不需要写循环,直接用ewm即可。

3.2 Wilder平滑在pandas里的正确定义

如果单纯用df['gain'].ewm(alpha=1/14, adjust=False).mean(),初始值默认从第一条数据开始直接迭代,这会导致RSI的最初14天左右数值“起不来”,和行情软件前段的表现不一致。更稳妥的做法是先手工填充初始均值(即先用前14天的简单平均作为ewm的初始状态),然后继续平滑。

我来写一个完整的、和国内主流行情软件对齐的RSI计算函数:

def calculate_rsi(close: pd.Series, period: int = 14) -> pd.Series: delta = close.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) # 第一步:先用简单平均初始化前 period 个值的均值 avg_gain = gain.rolling(window=period, min_periods=period).mean() avg_loss = loss.rolling(window=period, min_periods=period).mean() # 第二步:缺省位置用滚动均值占位,后面再用 Wilder 平滑递推 avg_gain.iloc[:period] = np.nan avg_loss.iloc[:period] = np.nan # Wilder 平滑等价于 alpha=1/period 的 ewm,且 adjust=False wilder_gain = avg_gain.ewm(alpha=1.0/period, adjust=False).mean() wilder_loss = avg_loss.ewm(alpha=1.0/period, adjust=False).mean() rs = wilder_gain / wilder_loss rsi = 100 - (100 / (1 + rs)) return rsi

这段代码里有几个细节值得说明:

第一,delta.clip(lower=0)比np.where(delta > 0, delta, 0)更简洁,而且性能更好。

第二,avg_gain.ewm(alpha=1.0/period, adjust=False)我实测下来,和通达信在日线RSI上的误差可以控制在0.1以内。关键就在于初始值的处理——如果直接用gain.ewm(...),算出来的RSI前段会有系统性偏差。

第三,当avg_loss为0时(也就是连续多天单边上涨),RS会变成无穷大,RSI会被推至100。这是正常现象,不需要特地处理,但如果你把RSI用在程序化信号里,要注意100 - 100 / (1 + np.inf)可能得到100没问题,但如果出现除法上的inf/NaN记得保留原值。

3.3 周期选择与参数校准实例

RSI最常见的参数是14,但14这个值并不适合所有标的。我实测下来:

  • 波动率大的题材股,RSI14太“毛躁”,经常在70以上和30以下反复穿越,假信号多,这时候可以调到21,甚至27。
  • 蓝筹白马类标的,RSI14相对平滑,但70以上区域仍然能维持很久,所以不要把超买线机械地设为70,要结合标的近半年的RSI分布动态设置阈值。
  • 指数类标的,比如宽基指数,RSI12配合50以下的“弱势区域”做空头陷阱识别,比14好用。

参数校准我个人建议用“滚动分位数”而非固定阈值。简单做法是:算过去250天的RSI的历史分位数,把当前RSI值映射到0~100的百分位,然后以历史85%分位替代传统的70阈值。这样能大幅减少震荡市的假信号。代码也很好写:

df['RSI14'] = calculate_rsi(df['close'], 14) df['RSI_rank'] = df['RSI14'].rolling(250).rank(pct=True)

注意rolling窗口越大,分位数越平滑,但反应越慢。250个交易日差不多是一年,比较均衡。

4. MACD:从EMA12/26到金叉死叉的完整链路

4.1 MACD三个阶段:快慢线、信号线、柱状图

MACD的经典算法分三步:

  1. 计算快线EMA12和慢线EMA26。
  2. DIF = EMA12 - EMA26。
  3. DEA(也叫signal) = DIF的9日EMA。
  4. MACD柱状图 = (DIF - DEA) * 某固定系数。

公式本身不难,但这里我要着重提醒一个习惯:先把EMA序列算清楚,再谈MACD信号。很多人直接套用talib.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)得到输出,但遇到数据源异常时根本不知道是哪个环节出了问题。自己写的好处在于每一层的输出都能可视化,EMA12和EMA26谁在上方、DIF和DEA谁在上方一目了然。

pandas实现非常直接:

def calculate_macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9): ema_fast = close.ewm(span=fast, adjust=False).mean() ema_slow = close.ewm(span=slow, adjust=False).mean() dif = ema_fast - ema_slow dea = dif.ewm(span=signal, adjust=False).mean() hist = (dif - dea) * 2 # 乘以2看个人习惯,很多国内软件用2倍 return dif, dea, hist

这段代码的adjust=False同样不能省。如果使用默认的adjust=True,EMA初始段的权重算法不同,会导致前几十根K线的DIF值与主流软件明显不一致,后面随着数据积累才慢慢收敛。回测时如果你用了前段的“脏信号”,结果就会失真。

4.2 一个容易踩的坑:国内行情软件的2倍系数

这是一个非常折磨人的细节。海外平台(比如某著名量化平台)的MACD柱状图就是直接的hist = dif - dea,而国内主流行情软件(比如同花顺、通达信)显示的是hist = (dif - dea) * 2,也就是俗称的“红绿柱面积×2”。

两者图形的“形状”完全一样,只是纵轴数值差一倍。如果你在做信号回测时只关心金叉死叉,这个差异无伤大雅;但如果你要做“柱状图衰减”或者“柱状图面积背离”这类量化特征,乘不乘2就会直接影响阈值设定。

我的做法是:本地统一采用hist = (dif - dea) * 2,和国内行情软件保持一致。这样在人工复盘时,拿K线图和软件上的红绿柱对照,视觉上是完全一致的,排错效率高很多。如果你更习惯海外风格,也可以不乘2,但要确保团队内部信号描述统一,否则同一条策略在不同人机器上跑出来结果不同。

另一个容易被忽视的点是DEA的EMA初始值。国内软件在计算DEA时,有些会对DIF的前9根值先做简单平均作为初始值再做递推,有些直接用EMA9递推。实测下来,这两种做法在上市一年以上的标的上差异很小,但在次新股或数据刚够半年时差异明显。如果你计算的对象是次新股,建议优先采用“先简单平均初始化再递推”的方式,也就是分段计算。代码可以把前面RSI里提到的初始化思路复用过去。

4.3 计算完指标之后如何验证与信号落地

指标算完不代表工作结束。我每次写完计算逻辑都会做一个“三方验证”:本地代码算一次、行情软件对照一次、如果条件允许再用另一个数据源算一次。三方误差在可接受范围内才算通过。具体做法是选择一只长期没有停牌、没有除权的标的(比如某指数ETF),下载它近两年的日线,在本地跑指标后与行情软件对应周期的指标值逐日对比。

验证不通过时,重点检查的通常不是EMA代码本身,而是数据源头。这里我再强调一遍:任何计算指标前,先把数据清洗函数跑一遍,并且用收盘价画一条K线图,肉眼确认没有异常跳空后再算指标。这个习惯能省掉大量无效排错时间。

信号落地方面,我喜欢把金叉死叉写成显式逻辑,而不是单纯依赖指标数值比较。比如:

dif, dea, hist = calculate_macd(df['close']) df['DIF'] = dif df['DEA'] = dea # 金叉:DIF 上穿 DEA df['golden_cross'] = (df['DIF'] > df['DEA']) & (df['DIF'].shift(1) <= df['DEA'].shift(1)) # 死叉:DIF 下穿 DEA df['death_cross'] = (df['DIF'] < df['DEA']) & (df['DIF'].shift(1) >= df['DEA'].shift(1))

在回测时,信号要用shift(1)之后的“确认值”进场,也就是下一根K线开盘价执行交易,避免未来函数。这是一个老生常谈但永远有人犯的错。

5. 常见问题与排查技巧实录

5.1 指标对不上行情软件?四个排查方向

指标算出来和行情软件不一致,是反馈最多的问题。我排查时顺序固定为:

排查方向具体检查点解决方案
数据复权方式是否用了不同类型的复权价格计算EMA统一用前复权或者不复权,计算指标和绘图时保持一致
EMA初始算法是否用了adjust=True改为ewm(span=N, adjust=False)
MACD柱状图系数软件显示2倍系数还是1倍切换hist=(dif-dea)*2与hist=dif-dea
RSI平滑方式用算术平均还是Wilder平滑按第三节的方式手动实现Wilder平滑

这四项排查完之后,90%以上的“对不上”都能解决。剩下的10%基本都是除权除息数据本身处理不当,或者行情软件默认参数不是标准设置。

5.2 数据质量导致指标扭曲的典型场景

有一个典型的坑值得单独说:某只股票在长期停牌复牌后,直接连续涨停,此时前复权价格会出现一个非常大的跳变。如果直接用这个价格序列算EMA26,DIF会在复牌当天出现一个“断崖式”的尖刺,甚至直接把MACD柱状图拉伸到几十倍,导致整个图系的尺度失真。这个问题不是算法问题,而是数据语义问题。解决方案是:在做指标计算前,用公司行为数据对除权除息、停复牌做标记,或者直接在清洗阶段把长期停牌后的前一段数据剔除,只从复牌后的连续交易数据开始计算。

还有一个场景是数据中有重复日期。尤其是多个数据源拼接时,同一个交易日可能出现两条不同数据,如果不去重,rolling窗口就会把重复日当作新的一天,导致EMA递推结果偏向重复值。这类问题很难用肉眼发现,我的做法是在清洗函数里显式检查:

dups = df.index.duplicated() if dups.any(): df = df[~dups]

5.3 性能优化:批量计算上百只股票时别写循环

很多人在只算一只股票时觉得“写循环也没啥”,一旦扩展到上百只股票回测,就发现计算速度慢到无法忍受。RSI、MACD、MA全部都是向量化操作,pandas的rolling和ewm是C语言级别的循环,性能和Python显式循环差几十倍。所以写指标计算函数时只有一个原则:禁用Python层循环。

如果一定要并发,请用groupby.apply()或者多进程而不是手写for循环。这里给一个典型的多股票批量计算的写法:

def add_indicators(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df['MA20'] = df['close'].rolling(20).mean() df['RSI14'] = calculate_rsi(df['close'], 14) dif, dea, hist = calculate_macd(df['close']) df['DIF'] = dif df['DEA'] = dea df['MACD'] = hist return df all_stocks = raw.groupby('symbol', group_keys=False).apply(add_indicators)

这段代码在几百只股票、几千个交易日的数据量下,基本几秒就能算完。如果你发现计算很慢,优先检查数据是不是object类型,先统一转成float32,速度会进一步提升。

我个人的习惯是算完指标之后,顺手把计算结果缓存为parquet文件,日期做索引。这样后续做策略回测、可视化、信号诊断都直接读取,不用每次都重新调API、重新算指标。

最后分享一个我在实际操作中的体会:很多人追求指标“完全精确匹配行情软件”,其实没必要追求到小数点后两位。量化交易里,技术指标的价值在于刻画相对强弱与趋势状态,而不是预测未来。只要你的算法逻辑清晰、数据口径一致、回测结果可复现,那一点点的初始值差异根本不影响信号质量。真正影响你策略胜负的,是你对指标信号背后的逻辑理解是否深刻,以及你能否在正确的时间使用正确的过滤条件。把这篇文章里的计算细节消化掉,你就不会再被“指标结果对不上”这种技术细节卡住,可以把精力真正放到策略本身上去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询