refactor: 以自实现指标替换 talib 与 technical 依赖

chanlun/indicators/ta.py 接口兼容 talib.abstract,实现代码实际用到的
SMA/MA/EMA/RSI/ATR/MACD/BBANDS;chanlun/pipeline/resample.py 替代
technical.util.resample_to_interval。调用点只改 import,逻辑未动。

暖机长度与平滑种子按 TA-Lib 的约定实现,差一根 K 线就会让下游所有
笔/线段/中枢整体位移。其中 MACD 需特别处理:TA-Lib 让快慢两条 EMA
在同一根 K 线出首值,因而快线的种子取 x[slow-fast:slow] 的均值,而非
从 fastperiod-1 一路递推——两者在百元价位上相差约 0.17。

BBANDS 是有意的分歧:TA-Lib 用 sumsq/n - mean² 求方差,短窗口远离零
时灾难性抵消(timeperiod=2 误差 8.7e-7),本实现用 rolling std,对 50
位精度基准误差为 0。项目实际使用的周期两者一致到 1e-10。

顺带清理 12 个文件中 16 处从未调用的 talib/technical 导入。

验证:9440 组随机对拨;真实 K 线端到端比对 add_indicators 全部 33 个
指标列,NaN 模式一致、MACD 柱符号 100% 相同;屏蔽两个包后 60 个模块
均可导入。新增 test_ta_compat.py 将输出逐 bar 钉在 TA-Lib 上,但该文件
在 TA-Lib 缺失时静默跳过,改动 ta.py 需在装有 TA-Lib 的环境复跑。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ubuntu
2026-08-27 02:01:43 +08:00
co-authored by Cursor
parent 7f393b93ed
commit 206b27fe72
17 changed files with 456 additions and 25 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Drop-in replacement for the `talib.abstract` calls this project makes.
Same call signatures, same column names, same NaN warm-up lengths, so call
sites only change their import line.
Only what the codebase actually uses is implemented: SMA, MA, EMA, RSI, ATR,
MACD, BBANDS. Numerical agreement with TA-Lib is enforced by
`chanlun/tests/test_ta_compat.py`, which skips when talib is absent.
The warm-up conventions below are TA-Lib's, not the textbook ones, and they
differ between functions — getting them wrong shifts every downstream Chan
structure by a bar:
SMA/BBANDS first value at index period-1
EMA seeded with the SMA of the first `period` values, at index period-1
RSI/ATR Wilder smoothing (alpha = 1/period), first value at index period
"""
from __future__ import annotations
import numpy as np
import pandas as pd
__all__ = ["SMA", "MA", "EMA", "RSI", "ATR", "MACD", "BBANDS"]
def _series(data, price: str = "close") -> pd.Series:
"""Accept the abstract-API shapes: DataFrame, Series, or ndarray."""
if isinstance(data, pd.DataFrame):
return data[price].astype(float)
if isinstance(data, pd.Series):
return data.astype(float)
return pd.Series(np.asarray(data, dtype=float))
def _recursive(values: np.ndarray, seed: float, start: int, alpha: float, n: int) -> np.ndarray:
"""out[start] = seed; out[i] = alpha*values[i] + (1-alpha)*out[i-1].
Delegates the recursion to pandas' C implementation rather than a Python
loop — `research/` runs this over long histories.
"""
out = np.full(n, np.nan)
if start >= n:
return out
tail = values[start:].astype(float).copy()
tail[0] = seed
out[start:] = pd.Series(tail).ewm(alpha=alpha, adjust=False).mean().to_numpy()
return out
def SMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
return s.rolling(window=timeperiod, min_periods=timeperiod).mean()
def MA(data, timeperiod: int = 30, matype: int = 0, price: str = "close") -> pd.Series:
if matype != 0:
raise NotImplementedError(f"MA matype={matype} is not used by this codebase")
return SMA(data, timeperiod, price=price)
def _ema(x: np.ndarray, period: int, start: int) -> np.ndarray:
"""EMA whose first output lands on `start`, seeded by the SMA of the
`period` values ending there.
`start` is a parameter because MACD needs the fast EMA to begin later than
it naturally would; see the note in MACD().
"""
n = x.size
if n <= start or start < period - 1:
return np.full(n, np.nan)
seed = x[start - period + 1: start + 1].mean()
return _recursive(x, seed, start, 2.0 / (period + 1.0), n)
def EMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
return pd.Series(_ema(x, timeperiod, timeperiod - 1), index=s.index)
def RSI(data, timeperiod: int = 14, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=s.index)
delta = np.diff(x)
gain = np.where(delta > 0.0, delta, 0.0)
loss = np.where(delta < 0.0, -delta, 0.0)
# delta[k] corresponds to bar k+1, so the first `timeperiod` deltas seed bar `timeperiod`.
alpha = 1.0 / timeperiod
avg_gain = _recursive(gain, gain[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
avg_loss = _recursive(loss, loss[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
ag = avg_gain[timeperiod - 1:]
al = avg_loss[timeperiod - 1:]
with np.errstate(divide="ignore", invalid="ignore"):
rsi = np.where(al == 0.0, 100.0, 100.0 - 100.0 / (1.0 + ag / al))
out[timeperiod:] = rsi
return pd.Series(out, index=s.index)
def ATR(data, timeperiod: int = 14) -> pd.Series:
if not isinstance(data, pd.DataFrame):
raise TypeError("ATR needs a DataFrame with high/low/close")
high = data["high"].to_numpy(dtype=float)
low = data["low"].to_numpy(dtype=float)
close = data["close"].to_numpy(dtype=float)
n = high.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=data.index)
prev_close = close[:-1]
tr = np.maximum.reduce([
high[1:] - low[1:],
np.abs(high[1:] - prev_close),
np.abs(low[1:] - prev_close),
])
# tr[k] is bar k+1; the first `timeperiod` true ranges seed bar `timeperiod`.
smoothed = _recursive(tr, tr[:timeperiod].mean(), timeperiod - 1, 1.0 / timeperiod, n - 1)
out[timeperiod:] = smoothed[timeperiod - 1:]
return pd.Series(out, index=data.index)
def MACD(
data,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
price: str = "close",
) -> pd.DataFrame:
if slowperiod < fastperiod:
fastperiod, slowperiod = slowperiod, fastperiod
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
macd = np.full(n, np.nan)
signal = np.full(n, np.nan)
hist = np.full(n, np.nan)
empty = pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
# Both EMAs emit their first value on the same bar. That makes the slow one
# ordinary, but re-seeds the fast one from the SMA of the `fastperiod`
# values ending there instead of carrying the recursion forward from bar
# fastperiod-1 — the two disagree by ~0.2 on a 100-priced series.
macd_start = slowperiod - 1
if n <= macd_start:
return empty
line = _ema(x, fastperiod, macd_start) - _ema(x, slowperiod, macd_start)
# The signal EMA runs over the MACD line, so everything shifts by another
# signalperiod-1 bars, and TA-Lib trims the MACD line to match.
valid = line[macd_start:]
if valid.size < signalperiod:
return empty
sig = _recursive(
valid, valid[:signalperiod].mean(), signalperiod - 1, 2.0 / (signalperiod + 1.0), valid.size
)
start = macd_start + signalperiod - 1
macd[start:] = line[start:]
signal[macd_start:] = sig
hist = macd - signal
return pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
def BBANDS(
data,
timeperiod: int = 5,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
matype: int = 0,
price: str = "close",
) -> pd.DataFrame:
if matype != 0:
raise NotImplementedError(f"BBANDS matype={matype} is not used by this codebase")
s = _series(data, price)
middle = s.rolling(window=timeperiod, min_periods=timeperiod).mean()
# TA-Lib uses the population standard deviation.
std = s.rolling(window=timeperiod, min_periods=timeperiod).std(ddof=0)
return pd.DataFrame(
{
"upperband": middle + nbdevup * std,
"middleband": middle,
"lowerband": middle - nbdevdn * std,
},
index=s.index,
)