Compare commits
2
Commits
7f393b93ed
...
a243edd2d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a243edd2d3 | ||
|
|
206b27fe72 |
@@ -0,0 +1,130 @@
|
||||
# Chan — 缠论分析引擎
|
||||
|
||||
把 OHLCV K 线拆成缠论结构(K 线单元 → 合并 K 线 → 分型 → 笔 → 线段 → 中枢 → 买卖点),
|
||||
配一个 TradingView Charting Library 的 Web 界面,外加一套验证信号有效性的回测脚本。
|
||||
|
||||
标的不限:加密永续(ccxt)与 A 股(akshare)都走同一条分析链路。
|
||||
|
||||
```
|
||||
chanlun/ 缠论引擎,纯 pandas/numpy,无外部指标库依赖
|
||||
web/ Flask API + 图表界面
|
||||
research/ 信号有效性验证脚本(step1 ~ step30)
|
||||
data/ 本地 K 线(freqtrade 的 feather 格式)
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
需要 Python ≥ 3.11(pandas 3.x / numpy 2.x 的要求,不是本项目代码的限制)。
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt # 核心运行时,8 个包
|
||||
.venv/bin/pip install -r requirements-dev.txt # 另加测试与 research/ 所需
|
||||
```
|
||||
|
||||
## 跑 Web
|
||||
|
||||
```bash
|
||||
cd web
|
||||
../.venv/bin/python app.py # 默认 http://0.0.0.0:8128
|
||||
```
|
||||
|
||||
从仓库根跑 `.venv/bin/python web/app.py` 也可以——Python 会把脚本所在目录放进 `sys.path`。
|
||||
但**不能用 `python -m web.app`**,也不能 `import web.app`:`web/` 内部是无前缀导入
|
||||
(`import config`、`from api.analyze import bp`),`-m` 方式下 `sys.path` 里是仓库根而不是
|
||||
`web/`,会 `ModuleNotFoundError: No module named 'config'`。
|
||||
|
||||
配置全部走环境变量,见 `web/config.py`:
|
||||
|
||||
| 变量 | 默认值 | 用途 |
|
||||
|------|--------|------|
|
||||
| `FLASK_HOST` / `FLASK_PORT` | `0.0.0.0` / `8128` | 监听地址 |
|
||||
| `DATA_SERVICE_URL` | `https://provider.jackyu66.com` | 行情 REST 源 |
|
||||
| `DATA_SERVICE_WS_URL` | `wss://jackyu66.com/ws` | 行情 WebSocket 源 |
|
||||
| `ASHARE_DP_URL` | `http://103.179.242.166:8000` | A 股数据源 |
|
||||
| `CHAN_HTTP_PROXY` | 未设置则不走代理 | ccxt / HTTP 代理 |
|
||||
| `MACD_FACTOR` / `MACD_SMOOTH` | `1` / `1` | MACD 周期倍数,默认 12/26/9 |
|
||||
|
||||
主要接口:`GET /api/analyze` 返回某标的某周期的完整缠论结构,`/api/klines/recent`
|
||||
取最新 K 线,`/api/trend_filter` 与 `/api/trend_detail` 做多周期趋势筛选,
|
||||
`/api/symbols`、`/api/search_stock`、`/api/sectors` 等负责标的检索。页面在 `/` 与 `/chan_tv`。
|
||||
|
||||
## 作为库使用
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from chanlun import TF_DF
|
||||
|
||||
# 需要 date/open/high/low/close/volume 六列,date 为 datetime
|
||||
df = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1h-futures.feather")
|
||||
|
||||
tf = TF_DF(df, interval=1, timeframe="1h")
|
||||
|
||||
len(tf.klu_list) # K 线单元
|
||||
len(tf.klc_list) # 合并 K 线(处理包含关系后)
|
||||
len(tf.bi_list) # 笔
|
||||
len(tf.seg_list) # 线段
|
||||
len(tf.zs_list) # 中枢
|
||||
tf.bi_list[-1].dir # Chan_BI_DIR.DOWN
|
||||
tf.chanmacd # MACD 结构分析(背驰判定用)
|
||||
```
|
||||
|
||||
**`interval` 的单位是分钟**,对传入的 df 做重采样;`interval=1` 是特例,表示原样使用、
|
||||
不重采样。所以拿 1h 的 feather 要传 `interval=1`,拿 1m 数据想看 1h 才传 `interval=60`:
|
||||
|
||||
```python
|
||||
df1m = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1m-futures.feather")
|
||||
TF_DF(df1m, interval=5, timeframe="5m")
|
||||
TF_DF(df1m, interval=60, timeframe="1h")
|
||||
```
|
||||
|
||||
传错不会报错,只会静默给出错误周期的结构——1h 数据配 `interval=4` 相当于按 4 分钟
|
||||
重采样,结果与 `interval=1` 完全相同。
|
||||
|
||||
## 分析流程
|
||||
|
||||
`TF_DF.init_TF_DF()` 按顺序做这几步,每步的实现在 `chanlun/pipeline/builders/` 下同名文件:
|
||||
|
||||
1. `resample_to_interval` — 重采样(`interval != 1` 时)
|
||||
2. `add_indicators` — 追加 33 列指标(MACD / BBANDS / EMA / RSI / ATR 等)
|
||||
3. `cal_kl_data` → `klu_list` — K 线单元
|
||||
4. `get_klc_list` → `klc_list` — 按包含关系合并 K 线,并标记分型
|
||||
5. `cal_bi_list` → `bi_list` — 笔
|
||||
6. `cal_bi_zs_list_pure` → `bi_zs_list` — 笔中枢
|
||||
7. `get_seg_list` → `seg_list` — 线段
|
||||
8. `get_zs_list` / `get_big_zs_list` — 中枢与大级别中枢
|
||||
9. `ChanMACD(klu_list)` — MACD 段 / 柱堆结构,供背驰判定
|
||||
|
||||
## 数据
|
||||
|
||||
`data/<交易所>/futures/<SYMBOL>-<周期>-futures.feather`,即 freqtrade 的下载格式,
|
||||
如 `data/binance/futures/BTC_USDT_USDT-1h-futures.feather`。
|
||||
`research/lib/data.py` 负责定位:`BTC/USDT:USDT` + `1h` 会解析到上面这个路径,
|
||||
找不到本地文件则回落到远端拉取。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest chanlun/tests web/tests -q
|
||||
```
|
||||
|
||||
`chanlun/tests/test_ta_compat.py` 有个**需要注意的陷阱**:它把 `chanlun/indicators/ta.py`
|
||||
的输出逐 bar 钉在 TA-Lib 上,但 **TA-Lib 不存在时会静默跳过**。也就是说改了 `ta.py`
|
||||
之后在没装 TA-Lib 的环境里跑,测试会显示通过,其实一项都没验证。改动那个文件时请先装:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libta-lib0 ta-lib-dev
|
||||
.venv/bin/pip install TA-Lib technical
|
||||
```
|
||||
|
||||
## 已知问题
|
||||
|
||||
- **`web/DEPLOY_GUIDE.md` 已失效**:它引用的 `deploy_venv.sh`、`stop_venv.sh`、
|
||||
`status_venv.sh` 等 6 个脚本都在 `7f393b9` 精简提交里删掉了,目前没有部署脚本。
|
||||
两个 systemd unit 文件(`web/chanlun-web*.service`)仍可参考,但它们用 gunicorn
|
||||
且写死端口 8123,与 `config.py` 默认的 8128 不一致,gunicorn 也不在依赖清单里。
|
||||
- **`web/README.txt` 已过时**:它说的 `web/requirements.txt` 不存在,依赖清单在仓库根目录。
|
||||
- **四个零引用的死文件**:`chanlun/analysis/` 下的 `ChanPY.py`、`ChanLun_Classifier.py`、
|
||||
`Find_Trend.py`、`ChanHeng.py` 全项目无人引用。`ChanPY.py` 依赖未安装的外部 chan.py 库,
|
||||
另外三个需要 matplotlib / mplfinance / xgboost / scikit-learn——这些都**不在**依赖清单里,
|
||||
是有意为之。要用得自行安装。
|
||||
@@ -14,12 +14,10 @@ from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
import talib.abstract as ta
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.dates import DateFormatter, date2num
|
||||
import matplotlib.patches as patches
|
||||
from technical.util import resample_to_interval
|
||||
from decimal import Decimal
|
||||
from chanlun.pipeline.orchestrator import ChanLun
|
||||
import xgboost as xgb
|
||||
|
||||
@@ -2,7 +2,7 @@ import ccxt
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import mplfinance as mpf
|
||||
from talib import MACD, SMA
|
||||
from chanlun.indicators import ta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import datetime as dt
|
||||
@@ -249,8 +249,9 @@ def analyze_higher_timeframe(df_30m):
|
||||
# 8. Back-divergence detection (enhanced)
|
||||
def detect_back_divergence(df, strokes, higher_trend):
|
||||
try:
|
||||
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
sma20 = SMA(df['Close'], timeperiod=20)
|
||||
macd_df = ta.MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
macd, hist = macd_df['macd'], macd_df['macdhist']
|
||||
sma20 = ta.SMA(df['Close'], timeperiod=20)
|
||||
df['macd'] = macd
|
||||
df['hist'] = hist
|
||||
df['sma20'] = sma20
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -6,9 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -6,9 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
from chanlun.pipeline.resample import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
|
||||
@@ -6,9 +6,8 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from chanlun.indicators import ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -6,9 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -6,9 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -6,9 +6,7 @@ from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -17,9 +17,7 @@ from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
import talib.abstract as ta
|
||||
import pandas as pd
|
||||
from technical.util import resample_to_interval
|
||||
from decimal import Decimal
|
||||
import numpy as np
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""OHLCV resampling — replaces `technical.util.resample_to_interval`.
|
||||
|
||||
That was the only symbol this project imported from `technical`, which in turn
|
||||
pulled in the freqtrade dependency chain. Behaviour is preserved exactly,
|
||||
including the left-labelled bins (rows are candle *open* times) and the
|
||||
`dropna()` that drops empty intervals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
__all__ = ["TICKER_INTERVAL_MINUTES", "resample_to_interval"]
|
||||
|
||||
TICKER_INTERVAL_MINUTES: dict[str, int] = {
|
||||
"1m": 1,
|
||||
"5m": 5,
|
||||
"15m": 15,
|
||||
"30m": 30,
|
||||
"1h": 60,
|
||||
"60m": 60,
|
||||
"2h": 120,
|
||||
"4h": 240,
|
||||
"6h": 360,
|
||||
"12h": 720,
|
||||
"1d": 1440,
|
||||
"1w": 10080,
|
||||
}
|
||||
|
||||
_OHLC_AGG = {
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
"low": "min",
|
||||
"close": "last",
|
||||
"volume": "sum",
|
||||
}
|
||||
|
||||
|
||||
def resample_to_interval(dataframe: pd.DataFrame, interval: int | str) -> pd.DataFrame:
|
||||
"""Resample OHLCV rows to `interval` minutes (or a timeframe string).
|
||||
|
||||
Merging the result back onto a finer frame requires care to avoid lookahead
|
||||
bias; this function only resamples.
|
||||
"""
|
||||
if isinstance(interval, str):
|
||||
interval = TICKER_INTERVAL_MINUTES[interval]
|
||||
|
||||
df = dataframe.copy()
|
||||
df = df.set_index(pd.DatetimeIndex(df["date"]))
|
||||
df = df.resample(f"{interval}min", label="left").agg(_OHLC_AGG).dropna()
|
||||
df.reset_index(inplace=True)
|
||||
return df
|
||||
@@ -2,9 +2,8 @@ from datetime import timedelta
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
from chanlun.pipeline.resample import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Pin chanlun.indicators.ta to TA-Lib's output, bar for bar.
|
||||
|
||||
These indicators feed the Chan structure builders, so a one-bar shift in the
|
||||
warm-up or a different smoothing seed silently changes every downstream
|
||||
bi/seg/zs. Equality against the reference implementation is the only check
|
||||
that catches that.
|
||||
|
||||
Skipped when talib is unavailable — which is the point of the replacement, so
|
||||
the suite still has to pass without it. Run in an environment that has talib
|
||||
whenever chanlun/indicators/ta.py changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from chanlun.indicators import ta # noqa: E402
|
||||
from chanlun.pipeline.resample import resample_to_interval # noqa: E402
|
||||
|
||||
try:
|
||||
import talib.abstract as reference
|
||||
except ImportError: # pragma: no cover
|
||||
reference = None
|
||||
|
||||
requires_talib = unittest.skipIf(reference is None, "talib not installed")
|
||||
|
||||
|
||||
def make_ohlcv(n: int = 900, seed: int = 7) -> pd.DataFrame:
|
||||
"""Random walk with enough range for BBANDS(365) and EMA(208) to warm up."""
|
||||
rng = np.random.default_rng(seed)
|
||||
close = 100.0 + np.cumsum(rng.normal(0.0, 1.0, n))
|
||||
spread = np.abs(rng.normal(0.0, 0.6, n)) + 0.05
|
||||
high = close + spread
|
||||
low = close - spread
|
||||
open_ = np.concatenate([[close[0]], close[:-1]])
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"date": pd.date_range("2024-01-01", periods=n, freq="1min", tz="UTC"),
|
||||
"open": open_,
|
||||
"high": np.maximum.reduce([high, open_, close]),
|
||||
"low": np.minimum.reduce([low, open_, close]),
|
||||
"close": close,
|
||||
"volume": rng.uniform(1.0, 100.0, n),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TAEquivalence(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.df = make_ohlcv()
|
||||
|
||||
def assertSameSeries(self, got, expected, label: str) -> None:
|
||||
g = np.asarray(got, dtype=float)
|
||||
e = np.asarray(expected, dtype=float)
|
||||
self.assertEqual(g.shape, e.shape, f"{label}: shape")
|
||||
np.testing.assert_array_equal(
|
||||
np.isnan(g), np.isnan(e), err_msg=f"{label}: NaN warm-up differs"
|
||||
)
|
||||
mask = ~np.isnan(e)
|
||||
np.testing.assert_allclose(
|
||||
g[mask], e[mask], rtol=1e-9, atol=1e-8, err_msg=f"{label}: values differ"
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_sma(self) -> None:
|
||||
for period in (5, 20, 90, 250):
|
||||
self.assertSameSeries(
|
||||
ta.SMA(self.df, timeperiod=period),
|
||||
reference.SMA(self.df, timeperiod=period),
|
||||
f"SMA({period})",
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_ma(self) -> None:
|
||||
for period in (5, 10, 250):
|
||||
self.assertSameSeries(
|
||||
ta.MA(self.df, timeperiod=period),
|
||||
reference.MA(self.df, timeperiod=period),
|
||||
f"MA({period})",
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_ema(self) -> None:
|
||||
for period in (5, 7, 10, 13, 24, 26, 30, 52, 104, 156, 208):
|
||||
self.assertSameSeries(
|
||||
ta.EMA(self.df, timeperiod=period),
|
||||
reference.EMA(self.df, timeperiod=period),
|
||||
f"EMA({period})",
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_rsi(self) -> None:
|
||||
for period in (7, 14, 21):
|
||||
self.assertSameSeries(
|
||||
ta.RSI(self.df, timeperiod=period),
|
||||
reference.RSI(self.df, timeperiod=period),
|
||||
f"RSI({period})",
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_atr(self) -> None:
|
||||
for period in (7, 14, 30):
|
||||
self.assertSameSeries(
|
||||
ta.ATR(self.df, timeperiod=period),
|
||||
reference.ATR(self.df, timeperiod=period),
|
||||
f"ATR({period})",
|
||||
)
|
||||
|
||||
@requires_talib
|
||||
def test_macd(self) -> None:
|
||||
for fast, slow, signal in ((12, 26, 9), (26, 52, 9), (5, 35, 5)):
|
||||
got = ta.MACD(self.df, fastperiod=fast, slowperiod=slow, signalperiod=signal)
|
||||
exp = reference.MACD(self.df, fastperiod=fast, slowperiod=slow, signalperiod=signal)
|
||||
for col in ("macd", "macdsignal", "macdhist"):
|
||||
self.assertSameSeries(got[col], exp[col], f"MACD({fast},{slow},{signal}).{col}")
|
||||
|
||||
@requires_talib
|
||||
def test_bbands(self) -> None:
|
||||
cases = (
|
||||
(365, 3.0, 3.0),
|
||||
(120, 3.0, 3.0),
|
||||
(41, 2.3, 2.3),
|
||||
(41, 2.0, 2.0),
|
||||
(26, 3.0, 3.0),
|
||||
(20, 2.0, 2.0),
|
||||
(14, 2.0, 2.0),
|
||||
)
|
||||
for period, up, dn in cases:
|
||||
got = ta.BBANDS(self.df, timeperiod=period, nbdevup=up, nbdevdn=dn, matype=0)
|
||||
exp = reference.BBANDS(self.df, timeperiod=period, nbdevup=up, nbdevdn=dn, matype=0)
|
||||
for col in ("upperband", "middleband", "lowerband"):
|
||||
self.assertSameSeries(got[col], exp[col], f"BBANDS({period},{up},{dn}).{col}")
|
||||
|
||||
@requires_talib
|
||||
def test_bbands_is_more_accurate_than_talib_on_tiny_windows(self) -> None:
|
||||
"""A deliberate divergence, documented so nobody "fixes" it back.
|
||||
|
||||
TA-Lib derives the variance from sumsq/n - mean**2, which cancels
|
||||
catastrophically when the window is short and prices are far from zero;
|
||||
at timeperiod=2 it drifts ~1e-6. Rolling std is accurate there, so the
|
||||
two disagree. No timeperiod below 14 is used in this codebase, and the
|
||||
periods that are used agree to ~1e-10 (covered by test_bbands).
|
||||
"""
|
||||
got = ta.BBANDS(self.df, timeperiod=2, nbdevup=1.0, nbdevdn=1.0, matype=0)["upperband"]
|
||||
exp = reference.BBANDS(self.df, timeperiod=2, nbdevup=1.0, nbdevdn=1.0, matype=0)["upperband"]
|
||||
|
||||
window = self.df["close"].rolling(2)
|
||||
truth = window.mean() + window.std(ddof=0)
|
||||
ours = np.nanmax(np.abs((got - truth).to_numpy()))
|
||||
theirs = np.nanmax(np.abs((exp - truth).to_numpy()))
|
||||
self.assertLess(ours, 1e-9)
|
||||
self.assertLess(ours, theirs)
|
||||
|
||||
@requires_talib
|
||||
def test_matches_on_real_price_scale(self) -> None:
|
||||
"""Guard against tolerances that only hold near 100."""
|
||||
df = self.df.copy()
|
||||
for col in ("open", "high", "low", "close"):
|
||||
df[col] *= 900.0
|
||||
self.assertSameSeries(
|
||||
ta.ATR(df, timeperiod=14), reference.ATR(df, timeperiod=14), "ATR@scale"
|
||||
)
|
||||
self.assertSameSeries(
|
||||
ta.RSI(df, timeperiod=14), reference.RSI(df, timeperiod=14), "RSI@scale"
|
||||
)
|
||||
|
||||
|
||||
class ResampleEquivalence(unittest.TestCase):
|
||||
@unittest.skipIf(
|
||||
__import__("importlib").util.find_spec("technical") is None,
|
||||
"technical not installed",
|
||||
)
|
||||
def test_matches_technical(self) -> None:
|
||||
from technical.util import resample_to_interval as ref_resample
|
||||
|
||||
df = make_ohlcv(600)
|
||||
for interval in (5, 15, 60, "5m", "1h"):
|
||||
got = resample_to_interval(df, interval)
|
||||
exp = ref_resample(df, interval)
|
||||
pd.testing.assert_frame_equal(got, exp, check_exact=False, rtol=1e-12)
|
||||
|
||||
def test_shapes_without_reference(self) -> None:
|
||||
df = make_ohlcv(120)
|
||||
out = resample_to_interval(df, 5)
|
||||
self.assertEqual(list(out.columns), ["date", "open", "high", "low", "close", "volume"])
|
||||
self.assertLessEqual(len(out), 120 // 5 + 1)
|
||||
self.assertTrue((out["high"] >= out["low"]).all())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
# Tests and research/ — everything beyond the core runtime.
|
||||
#
|
||||
# .venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
||||
# .venv/bin/python -m pytest chanlun/tests web/tests
|
||||
|
||||
-r requirements.txt
|
||||
|
||||
# --- tests -----------------------------------------------------------------
|
||||
pytest>=8.0
|
||||
|
||||
# --- research/ -------------------------------------------------------------
|
||||
# pyarrow reads the data/binance/*.feather klines (research/lib/data.py) and
|
||||
# writes the parquet cache. Needed for research/, not by the web app.
|
||||
pyarrow>=15.0
|
||||
|
||||
# Only research/step10_direct_return_label.py and step11_breakout_follow.py.
|
||||
# Neither is installed by default; skip this pair unless running those steps.
|
||||
scikit-learn>=1.4
|
||||
lightgbm>=4.3
|
||||
|
||||
# --- indicator parity check (optional) -------------------------------------
|
||||
# chanlun/indicators/ta.py replaced TA-Lib and technical, so nothing here needs
|
||||
# them at runtime. chanlun/tests/test_ta_compat.py pins our output against
|
||||
# TA-Lib bar for bar and SKIPS SILENTLY when they are absent — meaning a change
|
||||
# to ta.py can look tested when it was not. Install these and re-run that file
|
||||
# whenever ta.py changes.
|
||||
#
|
||||
# TA-Lib needs the C library first:
|
||||
# sudo apt-get install -y libta-lib0 ta-lib-dev
|
||||
#
|
||||
# TA-Lib>=0.6
|
||||
# technical>=1.7
|
||||
@@ -0,0 +1,29 @@
|
||||
# Core runtime — what `web/` and `chanlun/` need to run.
|
||||
#
|
||||
# python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
#
|
||||
# Tests and research/ pull in more; see requirements-dev.txt.
|
||||
#
|
||||
# Requires Python >= 3.11 (imposed by pandas 3.x / numpy 2.x, not by our code).
|
||||
#
|
||||
# Lower bounds are the oldest versions believed safe. Verified set as of
|
||||
# 2026-08-27 on Python 3.14.4:
|
||||
# pandas 3.0.5 · numpy 2.5.2 · Flask 3.1.3 · requests 2.34.2
|
||||
# python-dateutil 2.9.0 · pytz 2026.3 · ccxt 4.5.75 · akshare 1.18.94
|
||||
|
||||
# chanlun/ — kline pipeline and indicators.
|
||||
# pandas >= 2.2 for the "5min" offset alias used by chanlun/pipeline/resample.py.
|
||||
pandas>=2.2
|
||||
numpy>=1.26
|
||||
|
||||
# web/ — Flask API and templates.
|
||||
Flask>=3.0
|
||||
requests>=2.31
|
||||
python-dateutil>=2.9
|
||||
pytz>=2024.1
|
||||
|
||||
# Market data. ccxt drives the live websocket/REST state in
|
||||
# web/services/runtime/state.py; akshare only backs the A-share endpoints in
|
||||
# web/services/cn_stock.py.
|
||||
ccxt>=4.4
|
||||
akshare>=1.16
|
||||
@@ -574,7 +574,7 @@ class ChinaStockData:
|
||||
def add_indicators(self, df):
|
||||
"""添加技术指标"""
|
||||
try:
|
||||
import talib.abstract as ta
|
||||
from chanlun.indicators import ta
|
||||
import numpy as np
|
||||
|
||||
# MACD指标
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import talib.abstract as ta
|
||||
from chanlun.indicators import ta
|
||||
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_KLC_FX, Chan_FX_TYPE
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import talib.abstract as ta
|
||||
from chanlun.indicators import ta
|
||||
from . import state
|
||||
|
||||
def add_indicators(df):
|
||||
|
||||
Reference in New Issue
Block a user