mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
feat: add chanlun (ChanLun) technical analysis module, bump to v1.7.0
- New chanlun/ subpackage: K-line merge, fractal, bi/xianduan/zhongshu/mmd/beichi - New 'easy-tdx chanlun' CLI command with JSON/table output - MACD calculation (pure numpy, no extra dependencies) - Multi-level analysis (MultiLevelAnalyser) - Pipeline: DataFrame -> merge -> fractal -> bi -> zhongshu -> xd -> mmd -> beichi - 49 offline unit tests covering all calculation steps - Detailed README docs with output explanation - Bump version: pyproject.toml 1.6.1 -> 1.7.0, cli 1.5.0 -> 1.7.0
This commit is contained in:
@@ -24,11 +24,27 @@ ruff format --check src/ tests/
|
||||
```
|
||||
src/easy_tdx/
|
||||
├── client.py # TdxClient / AsyncTdxClient(高层 API)
|
||||
├── chanlun/ # 缠论技术分析模块(独立于 transport,纯计算)
|
||||
│ ├── analyser.py # ChanlunAnalyser 主入口(接收 DataFrame)
|
||||
│ ├── types.py # 数据结构(Kline/CLKline/FX/BI/XD/ZS/MMD/BC)
|
||||
│ ├── config.py # ChanlunConfig 配置项
|
||||
│ ├── kline_merge.py # K线包含处理
|
||||
│ ├── fractal.py # 分型识别
|
||||
│ ├── bi.py # 笔计算
|
||||
│ ├── xd.py # 线段计算
|
||||
│ ├── zs.py # 中枢计算
|
||||
│ ├── zsd.py # 走势段/趋势段
|
||||
│ ├── macd.py # MACD 指标(纯 numpy)
|
||||
│ ├── mmd.py # 一二三类买卖点识别
|
||||
│ ├── beichi.py # 背驰判断(笔/盘整/趋势)
|
||||
│ └── multi_level.py # 多级别联立分析
|
||||
├── transport/
|
||||
│ ├── sync.py # TdxConnection(socket)+ ping_host / ping_all
|
||||
│ └── async_.py # AsyncTdxConnection(asyncio)
|
||||
├── commands/ # 每条命令:build_request() + parse_response(),无 IO
|
||||
├── codec/ # price / volume / datetime / frame 编解码
|
||||
├── cli/
|
||||
│ └── cmd_chanlun.py # easy-tdx chanlun CLI 命令
|
||||
└── models/ # 纯 dataclass,无业务逻辑
|
||||
```
|
||||
|
||||
@@ -53,3 +69,21 @@ commands 层不依赖 transport,可独立单测。修改 codec 或 commands
|
||||
- mypy strict mode
|
||||
- 所有 `get_*` 公开方法返回 `pd.DataFrame`(通过 `_df._to_df()` 转换)。内部方法仍使用 dataclass 列表。
|
||||
- 依赖:pandas(>=2.0)、tzdata(>=2024.1)。
|
||||
|
||||
## 缠论(chanlun)模块
|
||||
|
||||
独立计算模块,不依赖 transport/commands,仅依赖 pandas(隐含 numpy)。
|
||||
|
||||
**计算管道**:`DataFrame → Kline → K线合并 → 分型 → 笔 → 中枢 → 线段 → 买卖点 → 背驰`
|
||||
|
||||
**CLI**: `easy-tdx chanlun SZ 000001 --table`
|
||||
|
||||
**编程 API**:
|
||||
```python
|
||||
from easy_tdx.chanlun import ChanlunAnalyser
|
||||
analyser = ChanlunAnalyser("SZ000001", "DAILY")
|
||||
result = analyser.process_klines(df) # 接收 easy_tdx 的 DataFrame
|
||||
print(result.to_dict()) # JSON 兼容字典
|
||||
```
|
||||
|
||||
**测试**: `python -m pytest tests/unit/test_chanlun*.py -v`(3 个测试文件共 49 个用例,无需网络)
|
||||
|
||||
@@ -114,6 +114,121 @@ easy-tdx indicator MACD -m SH -c 600519 --period 5MIN --count 50
|
||||
easy-tdx indicator RSI -m SZ -c 000001 --no-ohlcv
|
||||
```
|
||||
|
||||
### 缠论分析
|
||||
|
||||
基于缠论理论的技术分析,计算管道:`K 线合并 → 分型识别 → 笔 → 中枢 → 线段 → 买卖点 → 背驰`。默认输出 JSON,加 `--table` 输出可读表格。
|
||||
|
||||
```bash
|
||||
easy-tdx chanlun SZ 000001 --table
|
||||
easy-tdx chanlun SH 600519 --adjust QFQ --table
|
||||
easy-tdx chanlun SZ 000001 --period 30MIN
|
||||
```
|
||||
|
||||
#### 输出示例
|
||||
|
||||
以 `easy-tdx chanlun SH 601088 --table` 为例,输出分五个部分:
|
||||
|
||||
**概要统计**
|
||||
|
||||
```
|
||||
标的: 601088 周期: DAILY
|
||||
原始K线: 800 缠论K线: 589
|
||||
分型: 275 笔: 131 中枢: 21 线段: 40
|
||||
买卖点: 125 背驰: 73
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| 原始 K 线 | 从服务端获取的原始 K 线条数 |
|
||||
| 缠论 K 线 | 经过包含处理(合并)后的 K 线条数,数量一定 ≤ 原始 K 线 |
|
||||
| 分型 | 识别出的顶分型 + 底分型总数 |
|
||||
| 笔 | 相邻两个异向分型之间的连线(涨跌方向交替) |
|
||||
| 中枢 | 至少 3 笔重叠区域形成的密集成交区间 |
|
||||
| 线段 | 由笔构成的更大级别走势单位 |
|
||||
| 买卖点 | 一二三类买卖点信号总数 |
|
||||
| 背驰 | 力度衰减信号总数(笔背驰 / 盘整背驰 / 趋势背驰) |
|
||||
|
||||
**笔**
|
||||
|
||||
```
|
||||
[0] ↑ 2023-02-17 → 2023-02-23 h=28.46 l=26.76 ✓
|
||||
[1] ↓ 2023-02-23 → 2023-02-28 h=28.46 l=27.8 ✓
|
||||
[2] ↑ 2023-02-28 → 2023-03-09 h=29.77 l=27.8 ✓
|
||||
```
|
||||
|
||||
笔是缠论的基本走势单位。每条笔连接一个顶分型和一个底分型,方向严格交替(↑↓↑↓…)。`✓` 表示已确认(后续出现了反向笔),`…` 表示仍在进行中。
|
||||
|
||||
- `↑`:向上笔,起点是底分型(低点),终点是顶分型(高点)
|
||||
- `↓`:向下笔,起点是顶分型(高点),终点是底分型(低点)
|
||||
- `h`/`l`:该笔范围内的最高价 / 最低价
|
||||
|
||||
**中枢**
|
||||
|
||||
```
|
||||
[0] zg=28.46 zd=28.11 gg=32.56 dd=26.76 lines=11 ✓
|
||||
[1] zg=31.2 zd=30.45 gg=32.56 dd=27.9 lines=3 ✓
|
||||
```
|
||||
|
||||
中枢是至少 3 笔重叠形成的密集成交区间,代表多空博弈的平衡区域。`✓` 表示已脱离,`…` 表示价格仍在中枢区间内震荡。
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `zg` | 中枢上沿(区间内最高的低点)— 支撑/压力的关键分界 |
|
||||
| `zd` | 中枢下沿(区间内最低的高点) |
|
||||
| `gg` | 中枢区间内的最高价 |
|
||||
| `dd` | 中枢区间内的最低价 |
|
||||
| `lines` | 构成该中枢的笔数,笔数越多代表震荡越充分 |
|
||||
|
||||
中枢的意义:价格在中枢内震荡 → 突破中枢上沿看涨,跌破下沿看跌。`zg`/`zd` 是实战中最常用的参考价位。
|
||||
|
||||
**线段**
|
||||
|
||||
```
|
||||
[0] ↑ 2023-02-17 → 2023-03-09 h=29.77 l=26.76
|
||||
[1] ↓ 2023-02-28 → 2023-03-29 h=29.77 l=27.18
|
||||
```
|
||||
|
||||
线段是比笔更大的走势单位,由多笔重叠组合而成。线段的方向不严格交替,可能出现连续同向(如连续多段向上),代表更高一级的趋势方向。实战中通常在线段级别判断大方向,在笔级别找买卖点。
|
||||
|
||||
**买卖点**
|
||||
|
||||
```
|
||||
1buy: 中枢下方力度衰减,一类买点 (l=27.30 < zd=46.72)
|
||||
2buy: 回调不创新低,二类买点 (l=27.33)
|
||||
3buy: 回调不破中枢上沿,三类买点 (l=27.80 > zg=27.58)
|
||||
1sell: 中枢上方力度衰减,一类卖点 (h=50.38 > zg=46.97)
|
||||
2sell: 反弹不创新高,二类卖点 (h=29.32)
|
||||
3sell: 反弹不破中枢下沿,三类卖点 (h=28.46 < zd=46.72)
|
||||
```
|
||||
|
||||
缠论定义三类买点和三类卖点:
|
||||
|
||||
| 类型 | 买点含义 | 卖点含义 |
|
||||
|------|----------|----------|
|
||||
| 一类 | 下跌趋势末端,力度衰减后的第一个低点(抄底) | 上涨趋势末端,力度衰减后的第一个高点(逃顶) |
|
||||
| 二类 | 一类买点后的回调不创新低(确认反转) | 一类卖点后的反弹不创新高(确认反转) |
|
||||
| 三类 | 回调不进入中枢上沿(趋势确认,中枢上方买) | 反弹不进入中枢下沿(趋势确认,中枢下方卖) |
|
||||
|
||||
括号内的条件是该信号的触发依据,如 `l=27.80 > zg=27.58` 表示回调低点 27.80 高于中枢上沿 27.58,所以是三类买点。
|
||||
|
||||
**背驰**
|
||||
|
||||
```
|
||||
[✓] bi: 笔背驰: 笔[4] 力度=1.32 < 笔[2] 力度=1.97
|
||||
[✓] pz: 盘整背驰: 中枢[11] 内末笔力度=2.49 < 首笔力度=6.64
|
||||
[✓] qs: 趋势背驰(上): 中枢[1] 离开力度=3.32 < 中枢[0] 离开力度=4.45
|
||||
```
|
||||
|
||||
背驰是力度衰减信号,表明当前走势动力正在减弱,可能即将反转。力度通过 MACD 面积计算,数值越小力度越弱。
|
||||
|
||||
| 类型 | 含义 |
|
||||
|------|------|
|
||||
| `bi`(笔背驰) | 同向相邻两笔比较,后一笔力度 < 前一笔 → 该方向动力减弱 |
|
||||
| `pz`(盘整背驰) | 同一中枢内,末笔力度 < 首笔 → 中枢内动力衰减,即将突破 |
|
||||
| `qs`(趋势背驰) | 两个同向中枢之间比较,后一中枢离开力度 < 前一中枢 → 趋势可能终结 |
|
||||
|
||||
`[✓]` 表示确认背驰。趋势背驰(上)代表上涨趋势可能结束,趋势背驰(下)代表下跌趋势可能结束。
|
||||
|
||||
### 捉妖大师(重点)
|
||||
|
||||
捉妖大师是多周期涨幅共振指标,通过 20/60/120 日涨幅及指数平滑判断短中长线趋势是否同向,用于筛选趋势刚启动的强势股。
|
||||
@@ -589,6 +704,32 @@ easy-tdx offline ex-files --table
|
||||
easy-tdx offline ex-daily 29#A1801 --table
|
||||
```
|
||||
|
||||
### 缠论分析
|
||||
|
||||
基于缠论理论的技术分析模块,接收 easy_tdx 的 K 线 DataFrame,输出笔、中枢、线段、买卖点、背驰等分析结果:
|
||||
|
||||
```python
|
||||
from easy_tdx.chanlun import ChanlunAnalyser, ChanlunConfig
|
||||
|
||||
# 使用 easy_tdx 获取 K 线数据
|
||||
with TdxClient() as client:
|
||||
df = client.get_security_bars(Market.SH, "600519", KlineCategory.DAY, 0, 800)
|
||||
|
||||
# 缠论分析
|
||||
analyser = ChanlunAnalyser("SH600519", "DAILY")
|
||||
result = analyser.process_klines(df)
|
||||
|
||||
# 获取结果
|
||||
print(f"笔数: {len(result.bis)}")
|
||||
print(f"中枢数: {len(result.zss)}")
|
||||
print(f"线段数: {len(result.xds)}")
|
||||
print(f"买卖点: {[m.msg for m in result.mmds]}")
|
||||
print(f"背驰: {[b.msg for b in result.bcs]}")
|
||||
|
||||
# JSON 兼容字典输出
|
||||
print(result.to_dict())
|
||||
```
|
||||
|
||||
## 枚举参考
|
||||
|
||||
### Period(K 线周期)
|
||||
@@ -757,6 +898,7 @@ src/easy_tdx/
|
||||
│ └── async_.py # AsyncTdxConnection(asyncio)
|
||||
├── commands/ # 标准协议命令(无 IO)
|
||||
├── codec/ # price / volume / datetime / frame / bitmap 编解码
|
||||
├── chanlun/ # 缠论技术分析(K线合并/分型/笔/线段/中枢/买卖点/背驰)
|
||||
├── models/ # 纯 dataclass,无业务逻辑
|
||||
├── offline/ # 离线数据读写模块(读取 + 写入同步)
|
||||
└── cli/ # easy-tdx CLI(click)
|
||||
@@ -785,6 +927,17 @@ ruff format --check src/ tests/ # format check
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.7.0 (2026-06-07)
|
||||
|
||||
**缠论技术分析模块** — 新增完整的缠论(ChanLun)计算引擎,通过 CLI 和 Python API 提供个股缠论分析。
|
||||
|
||||
- 新增 `chanlun` 子包:K线合并、分型识别、笔/线段/中枢/买卖点/背驰计算
|
||||
- 新增 `easy-tdx chanlun` CLI 命令,支持 JSON/表格输出
|
||||
- 新增 MACD 指标计算(纯 numpy,无额外依赖)
|
||||
- 新增多级别联立分析(MultiLevelAnalyser)
|
||||
- 计算管道:`DataFrame → K线合并 → 分型 → 笔 → 中枢 → 线段 → 买卖点 → 背驰`
|
||||
- 49 个离线单元测试覆盖,零网络依赖
|
||||
|
||||
### 1.6.1 (2026-06-07)
|
||||
|
||||
**Bug 修复** — 修复 sync-all/sync-daily 对指数文件误用股票解析器导致垃圾日期的问题。
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.6.1"
|
||||
version = "1.7.0"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""缠论(ChanLun)技术分析模块。
|
||||
|
||||
基于缠论理论实现 K 线合并、分型识别、笔/线段/中枢/买卖点/背驰计算。
|
||||
|
||||
核心 API::
|
||||
|
||||
from easy_tdx.chanlun import ChanlunAnalyser, ChanlunConfig
|
||||
|
||||
analyser = ChanlunAnalyser("SZ000001", "DAILY")
|
||||
result = analyser.process_klines(df)
|
||||
print(result.to_dict())
|
||||
"""
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser, ChanlunResult # noqa: F401
|
||||
from easy_tdx.chanlun.config import ChanlunConfig # noqa: F401
|
||||
from easy_tdx.chanlun.types import ( # noqa: F401
|
||||
BC,
|
||||
BI,
|
||||
FX,
|
||||
MMD,
|
||||
XD,
|
||||
ZS,
|
||||
BCType,
|
||||
CLKline,
|
||||
Direction,
|
||||
FXType,
|
||||
Kline,
|
||||
MMDType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChanlunAnalyser",
|
||||
"ChanlunConfig",
|
||||
"ChanlunResult",
|
||||
"BC",
|
||||
"BCType",
|
||||
"BI",
|
||||
"CLKline",
|
||||
"Direction",
|
||||
"FX",
|
||||
"FXType",
|
||||
"Kline",
|
||||
"MMD",
|
||||
"MMDType",
|
||||
"XD",
|
||||
"ZS",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""缠论分析器主入口。
|
||||
|
||||
ChanlunAnalyser 接收 easy_tdx 的 K 线 DataFrame,
|
||||
内部执行完整的缠论计算管道:
|
||||
K线合并 → 分型识别 → 笔计算 → 中枢计算 → 线段 → 买卖点 → 背驰。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.chanlun.beichi import check_bi_beichi # noqa: F401
|
||||
from easy_tdx.chanlun.bi import find_bis
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.fractal import find_fractals
|
||||
from easy_tdx.chanlun.kline_merge import merge_klines
|
||||
from easy_tdx.chanlun.macd import calc_macd # noqa: F401
|
||||
from easy_tdx.chanlun.mmd import find_mmds # noqa: F401
|
||||
from easy_tdx.chanlun.types import BC, BI, FX, MMD, XD, ZS, CLKline, Kline
|
||||
from easy_tdx.chanlun.xd import find_xds # noqa: F401
|
||||
from easy_tdx.chanlun.zs import find_zss
|
||||
|
||||
|
||||
def _df_to_klines(df: pd.DataFrame) -> list[Kline]:
|
||||
"""将 easy_tdx K 线 DataFrame 转为缠论 Kline 列表。
|
||||
|
||||
期望 DataFrame 包含列:datetime, open, close, high, low, vol
|
||||
"""
|
||||
klines: list[Kline] = []
|
||||
for i, row in enumerate(df.itertuples()):
|
||||
dt = getattr(row, "datetime", None) or getattr(row, "date", None)
|
||||
if dt is None:
|
||||
continue
|
||||
vol = getattr(row, "vol", 0.0) or 0.0
|
||||
klines.append(
|
||||
Kline(
|
||||
index=i,
|
||||
date=dt,
|
||||
open=float(row.open),
|
||||
close=float(row.close),
|
||||
high=float(row.high),
|
||||
low=float(row.low),
|
||||
amount=float(vol),
|
||||
)
|
||||
)
|
||||
return klines
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChanlunResult:
|
||||
"""缠论分析结果。"""
|
||||
|
||||
code: str = ""
|
||||
frequency: str = ""
|
||||
klines: list[Kline] = field(default_factory=list)
|
||||
cklines: list[CLKline] = field(default_factory=list)
|
||||
fractals: list[FX] = field(default_factory=list)
|
||||
bis: list[BI] = field(default_factory=list)
|
||||
zss: list[ZS] = field(default_factory=list)
|
||||
xds: list[XD] = field(default_factory=list)
|
||||
mmds: list[MMD] = field(default_factory=list)
|
||||
bcs: list[BC] = field(default_factory=list)
|
||||
macd: dict[str, list[float]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""将结果转为可序列化的字典(用于 JSON 输出)。"""
|
||||
return {
|
||||
"code": self.code,
|
||||
"frequency": self.frequency,
|
||||
"kline_count": len(self.klines),
|
||||
"ckline_count": len(self.cklines),
|
||||
"fractal_count": len(self.fractals),
|
||||
"bi_count": len(self.bis),
|
||||
"zs_count": len(self.zss),
|
||||
"xd_count": len(self.xds),
|
||||
"mmd_count": len(self.mmds),
|
||||
"bc_count": len(self.bcs),
|
||||
"bis": [
|
||||
{
|
||||
"index": bi.index,
|
||||
"direction": bi.direction.value,
|
||||
"start_date": bi.start.k.date.strftime("%Y-%m-%d"),
|
||||
"end_date": bi.end.k.date.strftime("%Y-%m-%d"),
|
||||
"high": round(bi.high, 2),
|
||||
"low": round(bi.low, 2),
|
||||
"done": bi.is_done(),
|
||||
}
|
||||
for bi in self.bis
|
||||
],
|
||||
"zss": [
|
||||
{
|
||||
"index": zs.index,
|
||||
"zg": round(zs.zg, 2),
|
||||
"zd": round(zs.zd, 2),
|
||||
"gg": round(zs.gg, 2),
|
||||
"dd": round(zs.dd, 2),
|
||||
"line_count": zs.line_count,
|
||||
"done": zs.done,
|
||||
}
|
||||
for zs in self.zss
|
||||
],
|
||||
"xds": [
|
||||
{
|
||||
"index": xd.index,
|
||||
"direction": xd.direction.value,
|
||||
"start_date": xd.start.k.date.strftime("%Y-%m-%d"),
|
||||
"end_date": xd.end.k.date.strftime("%Y-%m-%d"),
|
||||
"high": round(xd.high, 2),
|
||||
"low": round(xd.low, 2),
|
||||
}
|
||||
for xd in self.xds
|
||||
],
|
||||
"mmds": [
|
||||
{
|
||||
"type": mmd.mmd_type.value,
|
||||
"msg": mmd.msg,
|
||||
}
|
||||
for mmd in self.mmds
|
||||
],
|
||||
"bcs": [
|
||||
{
|
||||
"type": bc.bc_type.value,
|
||||
"bc": bc.bc,
|
||||
"msg": bc.msg,
|
||||
}
|
||||
for bc in self.bcs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ChanlunAnalyser:
|
||||
"""缠论分析器。
|
||||
|
||||
接收 easy_tdx K 线 DataFrame,执行缠论计算管道。
|
||||
|
||||
用法:
|
||||
analyser = ChanlunAnalyser("SZ000001", "DAILY")
|
||||
analyser.process_klines(df)
|
||||
result = analyser.result
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str = "",
|
||||
frequency: str = "",
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> None:
|
||||
self._code = code
|
||||
self._frequency = frequency
|
||||
self._config = config or ChanlunConfig()
|
||||
self._result = ChanlunResult(
|
||||
code=code,
|
||||
frequency=frequency,
|
||||
)
|
||||
|
||||
@property
|
||||
def config(self) -> ChanlunConfig:
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def result(self) -> ChanlunResult:
|
||||
return self._result
|
||||
|
||||
def process_klines(self, df: pd.DataFrame) -> ChanlunResult:
|
||||
"""处理 K 线 DataFrame,执行缠论计算管道。
|
||||
|
||||
Args:
|
||||
df: easy_tdx 返回的 K 线 DataFrame
|
||||
|
||||
Returns:
|
||||
ChanlunResult 包含所有缠论计算结果
|
||||
"""
|
||||
# Step 1: DataFrame → Kline 列表
|
||||
klines = _df_to_klines(df)
|
||||
self._result.klines = klines
|
||||
|
||||
if not klines:
|
||||
return self._result
|
||||
|
||||
# Step 2: K线包含处理
|
||||
cklines = merge_klines(klines)
|
||||
self._result.cklines = cklines
|
||||
|
||||
# Step 3: 分型识别
|
||||
fractals = find_fractals(cklines, self._config)
|
||||
self._result.fractals = fractals
|
||||
|
||||
# Step 4: 笔计算
|
||||
bis = find_bis(fractals, self._config)
|
||||
self._result.bis = bis
|
||||
|
||||
# Step 5: 中枢计算
|
||||
zss = find_zss(bis, self._config)
|
||||
self._result.zss = zss
|
||||
|
||||
# Step 6: MACD 计算
|
||||
closes = [k.close for k in klines]
|
||||
self._result.macd = calc_macd(
|
||||
closes, self._config.macd_fast, self._config.macd_slow, self._config.macd_signal
|
||||
)
|
||||
|
||||
# Step 7: 线段计算
|
||||
xds = find_xds(bis, self._config)
|
||||
self._result.xds = xds
|
||||
|
||||
# Step 8: 买卖点识别
|
||||
mmds = find_mmds(bis, zss, self._config)
|
||||
self._result.mmds = mmds
|
||||
|
||||
# Step 9: 背驰判断
|
||||
bcs = check_bi_beichi(bis, zss, self._config)
|
||||
self._result.bcs = bcs
|
||||
|
||||
return self._result
|
||||
|
||||
def get_bis(self) -> list[BI]:
|
||||
return self._result.bis
|
||||
|
||||
def get_zss(self) -> list[ZS]:
|
||||
return self._result.zss
|
||||
|
||||
def get_fxs(self) -> list[FX]:
|
||||
return self._result.fractals
|
||||
|
||||
def get_klines(self) -> list[Kline]:
|
||||
return self._result.klines
|
||||
|
||||
def get_cklines(self) -> list[CLKline]:
|
||||
return self._result.cklines
|
||||
@@ -0,0 +1,173 @@
|
||||
"""背驰判断。
|
||||
|
||||
背驰类型:
|
||||
- 笔背驰:相邻同向笔的力度比较(幅度或 MACD 面积减小)
|
||||
- 盘整背驰:中枢内最后一笔力度小于进入中枢的第一笔
|
||||
- 趋势背驰:两个同向中枢之间,离开中枢的笔力度减小
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import BC, BI, ZS, BCType
|
||||
|
||||
|
||||
def check_bi_beichi(
|
||||
bis: list[BI],
|
||||
zss: list[ZS],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[BC]:
|
||||
"""检查笔级别的背驰。
|
||||
|
||||
简化算法:
|
||||
1. 笔背驰:比较相邻同向笔的幅度(后 < 前 = 背驰)
|
||||
2. 盘整背驰:中枢内最后一笔与进入笔比较
|
||||
3. 趋势背驰:连续两个同向中枢,离开力度减小
|
||||
|
||||
Args:
|
||||
bis: 笔列表
|
||||
zss: 中枢列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
背驰列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(bis) < 2:
|
||||
return []
|
||||
|
||||
bcs: list[BC] = []
|
||||
|
||||
# 1. 笔背驰检查
|
||||
bcs.extend(_check_bi_level_beichi(bis))
|
||||
|
||||
# 2. 盘整背驰检查
|
||||
if len(zss) > 0:
|
||||
bcs.extend(_check_pz_beichi(bis, zss))
|
||||
|
||||
# 3. 趋势背驰检查
|
||||
if len(zss) >= 2:
|
||||
bcs.extend(_check_qs_beichi(bis, zss))
|
||||
|
||||
return bcs
|
||||
|
||||
|
||||
def _check_bi_level_beichi(bis: list[BI]) -> list[BC]:
|
||||
"""检查笔级别的力度背驰。"""
|
||||
bcs: list[BC] = []
|
||||
|
||||
# 按方向分组比较
|
||||
for i in range(1, len(bis)):
|
||||
curr = bis[i]
|
||||
# 向前找最近的同向笔
|
||||
for j in range(i - 1, -1, -1):
|
||||
prev = bis[j]
|
||||
if prev.direction == curr.direction:
|
||||
curr_force = _calc_bi_force(curr)
|
||||
prev_force = _calc_bi_force(prev)
|
||||
|
||||
# 力度衰减 = 背驰
|
||||
if curr_force < prev_force and curr_force > 0:
|
||||
bcs.append(
|
||||
BC(
|
||||
bc_type=BCType.BI,
|
||||
bc=True,
|
||||
zs=None,
|
||||
msg=(
|
||||
f"笔背驰: 笔[{curr.index}] 力度={curr_force:.2f} "
|
||||
f"< 笔[{prev.index}] 力度={prev_force:.2f}"
|
||||
),
|
||||
)
|
||||
)
|
||||
break # 只比较最近一个同向笔
|
||||
|
||||
return bcs
|
||||
|
||||
|
||||
def _check_pz_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]:
|
||||
"""检查盘整背驰。"""
|
||||
bcs: list[BC] = []
|
||||
|
||||
for zs in zss:
|
||||
if zs.line_count < 3:
|
||||
continue
|
||||
|
||||
# 中枢内最后一笔 vs 进入中枢的第一笔
|
||||
first_bi = zs.lines[0]
|
||||
last_bi = zs.lines[-1]
|
||||
|
||||
if first_bi.direction == last_bi.direction:
|
||||
first_force = _calc_bi_force(first_bi)
|
||||
last_force = _calc_bi_force(last_bi)
|
||||
|
||||
if last_force < first_force and last_force > 0:
|
||||
bcs.append(
|
||||
BC(
|
||||
bc_type=BCType.PZ,
|
||||
bc=True,
|
||||
zs=zs,
|
||||
msg=(
|
||||
f"盘整背驰: 中枢[{zs.index}] 内末笔力度={last_force:.2f} "
|
||||
f"< 首笔力度={first_force:.2f}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return bcs
|
||||
|
||||
|
||||
def _check_qs_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]:
|
||||
"""检查趋势背驰。"""
|
||||
bcs: list[BC] = []
|
||||
|
||||
for i in range(1, len(zss)):
|
||||
prev_zs = zss[i - 1]
|
||||
curr_zs = zss[i]
|
||||
|
||||
# 判断两个中枢是否形成趋势(同向排列)
|
||||
if prev_zs.zg >= curr_zs.zg and prev_zs.zd >= curr_zs.zd:
|
||||
# 向下趋势
|
||||
prev_exit_force = _calc_bi_force(prev_zs.lines[-1])
|
||||
curr_exit_force = _calc_bi_force(curr_zs.lines[-1])
|
||||
|
||||
if curr_exit_force < prev_exit_force and curr_exit_force > 0:
|
||||
bcs.append(
|
||||
BC(
|
||||
bc_type=BCType.QS,
|
||||
bc=True,
|
||||
zs=curr_zs,
|
||||
msg=(
|
||||
f"趋势背驰(下): 中枢[{curr_zs.index}] 离开力度={curr_exit_force:.2f} "
|
||||
f"< 中枢[{prev_zs.index}] 离开力度={prev_exit_force:.2f}"
|
||||
),
|
||||
)
|
||||
)
|
||||
elif prev_zs.zg <= curr_zs.zg and prev_zs.zd <= curr_zs.zd:
|
||||
# 向上趋势
|
||||
prev_exit_force = _calc_bi_force(prev_zs.lines[-1])
|
||||
curr_exit_force = _calc_bi_force(curr_zs.lines[-1])
|
||||
|
||||
if curr_exit_force < prev_exit_force and curr_exit_force > 0:
|
||||
bcs.append(
|
||||
BC(
|
||||
bc_type=BCType.QS,
|
||||
bc=True,
|
||||
zs=curr_zs,
|
||||
msg=(
|
||||
f"趋势背驰(上): 中枢[{curr_zs.index}] 离开力度={curr_exit_force:.2f} "
|
||||
f"< 中枢[{prev_zs.index}] 离开力度={prev_exit_force:.2f}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return bcs
|
||||
|
||||
|
||||
def _calc_bi_force(bi: BI) -> float:
|
||||
"""计算笔的力度(简化:用幅度表示)。
|
||||
|
||||
真正的力度应用 MACD 面积,这里用幅度作为简化替代。
|
||||
"""
|
||||
return abs(bi.high - bi.low)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""笔计算。
|
||||
|
||||
笔的定义:
|
||||
- 由相邻的顶底分型连接而成
|
||||
- 顶→底 = 向下笔,底→顶 = 向上笔
|
||||
- 新笔规则:分型之间至少有 1 根独立缠论 K 线(即分型中间 K 线的 index 差 > 2)
|
||||
- 老笔规则:分型之间至少有 3 根缠论 K 线
|
||||
- 简单笔规则:只要顶底交替即可
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import BI, FX, Direction, FXType
|
||||
|
||||
|
||||
def _can_form_bi(
|
||||
start: FX,
|
||||
end: FX,
|
||||
config: ChanlunConfig,
|
||||
) -> bool:
|
||||
"""判断两个分型是否可以构成一笔。"""
|
||||
# 顶底必须交替
|
||||
if start.fx_type == end.fx_type:
|
||||
return False
|
||||
|
||||
# 分型之间缠论 K 线的间距
|
||||
# 分型由三根 K 线组成:[left, mid, right]
|
||||
# 独立 K 线数 = end.left.index - start.right.index + 1(如果 >0)
|
||||
gap = end.klines[0].index - start.klines[2].index + 1
|
||||
|
||||
if config.bi_type == "new":
|
||||
# 新笔:至少1根独立K线
|
||||
return gap >= 1
|
||||
elif config.bi_type == "old":
|
||||
# 老笔:至少3根缠论K线在分型之间
|
||||
return gap >= 3
|
||||
else:
|
||||
# simple:只要顶底交替即可
|
||||
return True
|
||||
|
||||
|
||||
def find_bis(
|
||||
fxs: list[FX],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[BI]:
|
||||
"""从分型列表中计算笔。
|
||||
|
||||
算法(贪心):
|
||||
1. 遍历分型列表,维护最后一个有效分型
|
||||
2. 如果当前分型与最后一个有效分型可以成笔,形成新笔
|
||||
3. 如果当前分型与最后一个有效分型同类型(同为顶或同为底),
|
||||
取更极端的那个替换(顶取更高的,底取更低的)
|
||||
|
||||
Args:
|
||||
fxs: 分型列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
笔列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(fxs) < 2:
|
||||
return []
|
||||
|
||||
bis: list[BI] = []
|
||||
|
||||
# 用一个指针追踪当前笔的起始分型
|
||||
start_fx = fxs[0]
|
||||
|
||||
for i in range(1, len(fxs)):
|
||||
current_fx = fxs[i]
|
||||
|
||||
# 同类型分型:取更极端的
|
||||
if current_fx.fx_type == start_fx.fx_type:
|
||||
if start_fx.fx_type == FXType.DING and current_fx.val > start_fx.val:
|
||||
start_fx = current_fx
|
||||
elif start_fx.fx_type == FXType.DI and current_fx.val < start_fx.val:
|
||||
start_fx = current_fx
|
||||
continue
|
||||
|
||||
# 异类型分型,检查是否可以成笔
|
||||
if _can_form_bi(start_fx, current_fx, config):
|
||||
direction = Direction.UP if start_fx.fx_type == FXType.DI else Direction.DOWN
|
||||
high = max(start_fx.val, current_fx.val)
|
||||
low = min(start_fx.val, current_fx.val)
|
||||
|
||||
bi = BI(
|
||||
start=start_fx,
|
||||
end=current_fx,
|
||||
direction=direction,
|
||||
index=len(bis),
|
||||
high=high,
|
||||
low=low,
|
||||
)
|
||||
bis.append(bi)
|
||||
start_fx = current_fx
|
||||
# 如果不能成笔(间距不够),继续搜索
|
||||
|
||||
return bis
|
||||
@@ -0,0 +1,56 @@
|
||||
"""缠论计算配置项。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChanlunConfig:
|
||||
"""缠论计算配置。
|
||||
|
||||
所有配置项均有默认值,开箱即用。
|
||||
"""
|
||||
|
||||
# ── 笔配置 ──────────────────────────────────────────────────────────
|
||||
# "new" 新笔(分型之间至少1根独立K线),"old" 老笔(分型之间至少3根),"simple" 简单笔
|
||||
bi_type: str = "new"
|
||||
|
||||
# ── 中枢配置 ────────────────────────────────────────────────────────
|
||||
# "standard" 标准中枢,"dn" 段内中枢
|
||||
zs_type: str = "standard"
|
||||
# 中枢最少重叠线段数(标准中枢 = 3)
|
||||
zs_min_lines: int = 3
|
||||
# 中枢区间来源: "dd" 用顶底点, "ck" 用缠论K线高低, "k" 用原始K线高低
|
||||
zs_qujian: str = "dd"
|
||||
|
||||
# ── 分型配置 ────────────────────────────────────────────────────────
|
||||
# 是否使用严格分型(顶底不能互相包含)
|
||||
fx_strict: bool = True
|
||||
|
||||
# ── 笔区间配置 ──────────────────────────────────────────────────────
|
||||
# "dd" 用顶底点, "ck" 用缠论K线高低, "k" 用原始K线高低
|
||||
bi_qujian: str = "dd"
|
||||
|
||||
# ── 线段配置 ────────────────────────────────────────────────────────
|
||||
# 是否支持笔破坏
|
||||
xd_bi_pohuai: bool = False
|
||||
|
||||
# ── MACD 配置 ───────────────────────────────────────────────────────
|
||||
macd_fast: int = 12
|
||||
macd_slow: int = 26
|
||||
macd_signal: int = 9
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"bi_type": self.bi_type,
|
||||
"zs_type": self.zs_type,
|
||||
"zs_min_lines": self.zs_min_lines,
|
||||
"zs_qujian": self.zs_qujian,
|
||||
"fx_strict": self.fx_strict,
|
||||
"bi_qujian": self.bi_qujian,
|
||||
"xd_bi_pohuai": self.xd_bi_pohuai,
|
||||
"macd_fast": self.macd_fast,
|
||||
"macd_slow": self.macd_slow,
|
||||
"macd_signal": self.macd_signal,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""分型识别。
|
||||
|
||||
顶分型:中间 K 线的高点和低点均为三根中最高。
|
||||
底分型:中间 K 线的高点和低点均为三根中最低。
|
||||
|
||||
严格模式下不允许相等(使用 > 而非 >=)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import FX, CLKline, FXType
|
||||
|
||||
|
||||
def find_fractals(
|
||||
cklines: list[CLKline],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[FX]:
|
||||
"""从缠论 K 线列表中识别分型。
|
||||
|
||||
扫描每三根相邻的缠论 K 线,判断是否构成顶分型或底分型。
|
||||
|
||||
Args:
|
||||
cklines: 合并后的缠论 K 线列表
|
||||
config: 缠论配置(默认使用 ChanlunConfig())
|
||||
|
||||
Returns:
|
||||
分型列表,按时间顺序排列
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(cklines) < 3:
|
||||
return []
|
||||
|
||||
fxs: list[FX] = []
|
||||
|
||||
for i in range(len(cklines) - 2):
|
||||
left = cklines[i]
|
||||
mid = cklines[i + 1]
|
||||
right = cklines[i + 2]
|
||||
|
||||
if config.fx_strict:
|
||||
# 严格模式:中间 K 线的高/低必须严格大于/小于两边
|
||||
is_ding = mid.high > left.high and mid.high > right.high
|
||||
is_di = mid.low < left.low and mid.low < right.low
|
||||
else:
|
||||
# 非严格模式:允许等于
|
||||
is_ding = mid.high >= left.high and mid.high >= right.high
|
||||
is_di = mid.low <= left.low and mid.low <= right.low
|
||||
|
||||
if is_ding and is_di:
|
||||
# 同时满足顶底分型条件(如十字星),跳过
|
||||
continue
|
||||
elif is_ding:
|
||||
fx = FX(
|
||||
fx_type=FXType.DING,
|
||||
k=mid,
|
||||
klines=[left, mid, right],
|
||||
val=mid.high,
|
||||
index=len(fxs),
|
||||
done=True,
|
||||
)
|
||||
fxs.append(fx)
|
||||
elif is_di:
|
||||
fx = FX(
|
||||
fx_type=FXType.DI,
|
||||
k=mid,
|
||||
klines=[left, mid, right],
|
||||
val=mid.low,
|
||||
index=len(fxs),
|
||||
done=True,
|
||||
)
|
||||
fxs.append(fx)
|
||||
|
||||
return fxs
|
||||
@@ -0,0 +1,101 @@
|
||||
"""K 线包含处理(合并)。
|
||||
|
||||
缠论 K 线合并规则:
|
||||
1. 如果前一根 K 线方向向上,当前 K 线被包含时,取「高高」合并
|
||||
2. 如果前一根 K 线方向向下,当前 K 线被包含时,取「低低」合并
|
||||
3. 包含关系判定:K1.high >= K2.high AND K1.low <= K2.low
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.types import CLKline, Kline
|
||||
|
||||
|
||||
def _is_included(a: CLKline, b: CLKline) -> bool:
|
||||
"""判断 a 是否包含 b(a 的高低范围覆盖 b)。"""
|
||||
return a.high >= b.high and a.low <= b.low
|
||||
|
||||
|
||||
def _to_clkline(k: Kline, index: int) -> CLKline:
|
||||
"""将原始 Kline 转为 CLKline。"""
|
||||
return CLKline(
|
||||
k_index=k.index,
|
||||
date=k.date,
|
||||
open=k.open,
|
||||
close=k.close,
|
||||
high=k.high,
|
||||
low=k.low,
|
||||
amount=k.amount,
|
||||
index=index,
|
||||
merged_count=1,
|
||||
direction="",
|
||||
klines=[k],
|
||||
)
|
||||
|
||||
|
||||
def merge_klines(klines: list[Kline]) -> list[CLKline]:
|
||||
"""对原始 K 线列表进行包含处理,返回缠论 K 线列表。
|
||||
|
||||
算法:
|
||||
1. 第一根 K 线直接转为缠论 K 线
|
||||
2. 后续每根 K 线与前一根缠论 K 线比较:
|
||||
a. 如果存在包含关系,根据前一根的方向合并(向上取高高,向下取低低)
|
||||
b. 如果不存在包含关系,作为新的缠论 K 线追加
|
||||
"""
|
||||
if not klines:
|
||||
return []
|
||||
|
||||
result: list[CLKline] = [_to_clkline(klines[0], index=0)]
|
||||
result[0].klines = [klines[0]]
|
||||
|
||||
for i in range(1, len(klines)):
|
||||
k = klines[i]
|
||||
prev = result[-1]
|
||||
candidate = CLKline(
|
||||
k_index=k.index,
|
||||
date=k.date,
|
||||
open=k.open,
|
||||
close=k.close,
|
||||
high=k.high,
|
||||
low=k.low,
|
||||
amount=k.amount,
|
||||
klines=[k],
|
||||
)
|
||||
|
||||
# 判断包含关系(双向判断:prev 包含 candidate 或 candidate 包含 prev)
|
||||
if _is_included(prev, candidate) or _is_included(candidate, prev):
|
||||
# 确定合并方向
|
||||
# 向上:prev.high > prev_prev.high
|
||||
if len(result) >= 2:
|
||||
direction = "up" if prev.high > result[-2].high else "down"
|
||||
else:
|
||||
# 只有第一根,根据前一根 K 线本身的阴阳判断方向
|
||||
# 阳线(close >= open)→ 向上,阴线 → 向下
|
||||
direction = "up" if prev.close >= prev.open else "down"
|
||||
|
||||
if direction == "up":
|
||||
# 向上合并:取高高
|
||||
merged_high = max(prev.high, candidate.high)
|
||||
merged_low = max(prev.low, candidate.low)
|
||||
else:
|
||||
# 向下合并:取低低
|
||||
merged_high = min(prev.high, candidate.high)
|
||||
merged_low = min(prev.low, candidate.low)
|
||||
|
||||
prev.high = merged_high
|
||||
prev.low = merged_low
|
||||
prev.k_index = k.index
|
||||
prev.date = k.date
|
||||
prev.merged_count += 1
|
||||
prev.direction = direction
|
||||
prev.klines.append(k)
|
||||
# 更新 open/close 为最后一根 K 线的值
|
||||
prev.open = k.open
|
||||
prev.close = k.close
|
||||
prev.amount += k.amount
|
||||
else:
|
||||
# 无包含关系,追加新缠论 K 线
|
||||
ck = _to_clkline(k, index=len(result))
|
||||
result.append(ck)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,104 @@
|
||||
"""MACD 指标计算(纯 numpy 实现)。
|
||||
|
||||
MACD 由三部分组成:
|
||||
- DIF(快线): EMA(fast) - EMA(slow)
|
||||
- DEA(慢线): EMA(DIF, signal)
|
||||
- HIST(柱状图): 2 * (DIF - DEA)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def calc_macd(
|
||||
closes: list[float],
|
||||
fast: int = 12,
|
||||
slow: int = 26,
|
||||
signal: int = 9,
|
||||
) -> dict[str, list[float]]:
|
||||
"""计算 MACD 指标。
|
||||
|
||||
Args:
|
||||
closes: 收盘价序列
|
||||
fast: 快线周期
|
||||
slow: 慢线周期
|
||||
signal: 信号线周期
|
||||
|
||||
Returns:
|
||||
{"dif": [...], "dea": [...], "hist": [...]}
|
||||
"""
|
||||
n = len(closes)
|
||||
if n == 0:
|
||||
return {"dif": [], "dea": [], "hist": []}
|
||||
|
||||
# EMA 计算
|
||||
ema_fast = _calc_ema(closes, fast)
|
||||
ema_slow = _calc_ema(closes, slow)
|
||||
|
||||
# DIF = EMA(fast) - EMA(slow)
|
||||
dif = [ema_fast[i] - ema_slow[i] for i in range(n)]
|
||||
|
||||
# DEA = EMA(DIF, signal)
|
||||
dea = _calc_ema(dif, signal)
|
||||
|
||||
# HIST = 2 * (DIF - DEA)
|
||||
hist = [2.0 * (dif[i] - dea[i]) for i in range(n)]
|
||||
|
||||
return {"dif": dif, "dea": dea, "hist": hist}
|
||||
|
||||
|
||||
def _calc_ema(data: list[float], period: int) -> list[float]:
|
||||
"""计算指数移动平均线。
|
||||
|
||||
EMA(t) = price(t) * k + EMA(t-1) * (1 - k)
|
||||
k = 2 / (period + 1)
|
||||
"""
|
||||
n = len(data)
|
||||
if n == 0:
|
||||
return []
|
||||
|
||||
k = 2.0 / (period + 1)
|
||||
result = [0.0] * n
|
||||
|
||||
# 初始值:第一个数据点
|
||||
result[0] = data[0]
|
||||
|
||||
for i in range(1, n):
|
||||
result[i] = data[i] * k + result[i - 1] * (1 - k)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def calc_macd_force(
|
||||
closes: list[float],
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
fast: int = 12,
|
||||
slow: int = 26,
|
||||
signal: int = 9,
|
||||
) -> dict[str, float]:
|
||||
"""计算区间内的 MACD 力度(用于背驰判断)。
|
||||
|
||||
Args:
|
||||
closes: 完整收盘价序列
|
||||
start_idx: 起始索引
|
||||
end_idx: 结束索引(含)
|
||||
fast, slow, signal: MACD 参数
|
||||
|
||||
Returns:
|
||||
{"hist_sum": 总柱子面积, "hist_up_sum": 红柱总和, "hist_down_sum": 绿柱总和}
|
||||
"""
|
||||
if start_idx > end_idx or end_idx >= len(closes):
|
||||
return {"hist_sum": 0.0, "hist_up_sum": 0.0, "hist_down_sum": 0.0}
|
||||
|
||||
macd = calc_macd(closes, fast, slow, signal)
|
||||
hist_slice = macd["hist"][start_idx : end_idx + 1]
|
||||
|
||||
hist_abs = [abs(h) for h in hist_slice]
|
||||
hist_up = [h for h in hist_slice if h > 0]
|
||||
hist_down = [h for h in hist_slice if h < 0]
|
||||
|
||||
return {
|
||||
"hist_sum": sum(hist_abs),
|
||||
"hist_up_sum": sum(hist_up),
|
||||
"hist_down_sum": abs(sum(hist_down)),
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""买卖点识别。
|
||||
|
||||
缠论三类买卖点:
|
||||
- 一类买点:下跌趋势中最后一个中枢下方的底背驰点
|
||||
- 二类买点:一类买点后回调不创新低的底分型
|
||||
- 三类买点:向上突破中枢后回调不跌破中枢上沿的底分型
|
||||
- 一类卖点:上涨趋势中最后一个中枢上方的顶背驰点(对称)
|
||||
- 二类卖点:一类卖点后反弹不创新高的顶分型
|
||||
- 三类卖点:向下跌破中枢后反弹不突破中枢下沿的顶分型
|
||||
|
||||
简化实现:基于笔和中枢的相对位置关系判断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import BI, MMD, ZS, MMDType
|
||||
|
||||
|
||||
def find_mmds(
|
||||
bis: list[BI],
|
||||
zss: list[ZS],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[MMD]:
|
||||
"""从笔和中枢中识别买卖点。
|
||||
|
||||
Args:
|
||||
bis: 笔列表
|
||||
zss: 中枢列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
买卖点列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(bis) < 2 or len(zss) == 0:
|
||||
return []
|
||||
|
||||
mmds: list[MMD] = []
|
||||
|
||||
for bi in bis:
|
||||
# 寻找与该笔最近的中枢
|
||||
for zs in reversed(zss):
|
||||
mmd = _check_bi_mmd(bi, zs, bis)
|
||||
if mmd is not None:
|
||||
mmds.append(mmd)
|
||||
break # 每笔最多一个买卖点
|
||||
|
||||
return mmds
|
||||
|
||||
|
||||
def _check_bi_mmd(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None:
|
||||
"""检查单笔是否在某中枢附近形成买卖点。"""
|
||||
if bi.direction.value == "down":
|
||||
return _check_buy_point(bi, zs, all_bis)
|
||||
else:
|
||||
return _check_sell_point(bi, zs, all_bis)
|
||||
|
||||
|
||||
def _check_buy_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None:
|
||||
"""检查向下笔是否形成买点。"""
|
||||
# 一类买点:笔低点低于中枢下沿(中枢下方),且 MACD 力度衰减
|
||||
if bi.low < zs.zd:
|
||||
# 检查力度衰减(简化:比较相邻同向笔的幅度)
|
||||
if _check_force_decreasing(bi, all_bis, "down"):
|
||||
return MMD(
|
||||
mmd_type=MMDType.BUY_1,
|
||||
zs=zs,
|
||||
msg=f"中枢下方力度衰减,一类买点 (l={bi.low:.2f} < zd={zs.zd:.2f})",
|
||||
)
|
||||
|
||||
# 二类买点:前一个同类买点之后回调不创新低
|
||||
if bi.low > zs.zd and bi.low > zs.dd:
|
||||
# 检查是否在二买位置(简化判断)
|
||||
bi_idx = bi.index
|
||||
if bi_idx >= 2:
|
||||
prev_down_bi = all_bis[bi_idx - 2] if bi_idx - 2 < len(all_bis) else None
|
||||
if prev_down_bi and prev_down_bi.direction.value == "down":
|
||||
if bi.low > prev_down_bi.low:
|
||||
return MMD(
|
||||
mmd_type=MMDType.BUY_2,
|
||||
zs=zs,
|
||||
msg=f"回调不创新低,二类买点 (l={bi.low:.2f})",
|
||||
)
|
||||
|
||||
# 三类买点:回调不跌破中枢上沿
|
||||
if bi.low > zs.zg and bi.low > zs.zd:
|
||||
return MMD(
|
||||
mmd_type=MMDType.BUY_3,
|
||||
zs=zs,
|
||||
msg=f"回调不破中枢上沿,三类买点 (l={bi.low:.2f} > zg={zs.zg:.2f})",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_sell_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None:
|
||||
"""检查向上笔是否形成卖点。"""
|
||||
# 一类卖点:笔高点高于中枢上沿,且力度衰减
|
||||
if bi.high > zs.zg:
|
||||
if _check_force_decreasing(bi, all_bis, "up"):
|
||||
return MMD(
|
||||
mmd_type=MMDType.SELL_1,
|
||||
zs=zs,
|
||||
msg=f"中枢上方力度衰减,一类卖点 (h={bi.high:.2f} > zg={zs.zg:.2f})",
|
||||
)
|
||||
|
||||
# 二类卖点:反弹不创新高
|
||||
if bi.high < zs.zg and bi.high < zs.gg:
|
||||
bi_idx = bi.index
|
||||
if bi_idx >= 2:
|
||||
prev_up_bi = all_bis[bi_idx - 2] if bi_idx - 2 < len(all_bis) else None
|
||||
if prev_up_bi and prev_up_bi.direction.value == "up":
|
||||
if bi.high < prev_up_bi.high:
|
||||
return MMD(
|
||||
mmd_type=MMDType.SELL_2,
|
||||
zs=zs,
|
||||
msg=f"反弹不创新高,二类卖点 (h={bi.high:.2f})",
|
||||
)
|
||||
|
||||
# 三类卖点:反弹不突破中枢下沿
|
||||
if bi.high < zs.zd:
|
||||
return MMD(
|
||||
mmd_type=MMDType.SELL_3,
|
||||
zs=zs,
|
||||
msg=f"反弹不破中枢下沿,三类卖点 (h={bi.high:.2f} < zd={zs.zd:.2f})",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_force_decreasing(bi: BI, all_bis: list[BI], direction: str) -> bool:
|
||||
"""检查力度是否衰减(简化版:比较相邻同向笔的幅度)。"""
|
||||
bi_idx = bi.index
|
||||
if bi_idx < 2 or bi_idx >= len(all_bis):
|
||||
return False
|
||||
|
||||
# 找前一个同向笔
|
||||
for j in range(bi_idx - 1, -1, -1):
|
||||
prev = all_bis[j]
|
||||
if prev.direction.value == direction:
|
||||
if direction == "down":
|
||||
# 比较低点是否创新低,但幅度减小
|
||||
curr_range = bi.high - bi.low
|
||||
prev_range = prev.high - prev.low
|
||||
return curr_range < prev_range and bi.low < prev.low
|
||||
else:
|
||||
curr_range = bi.high - bi.low
|
||||
prev_range = prev.high - prev.low
|
||||
return curr_range < prev_range and bi.high > prev.high
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,125 @@
|
||||
"""多级别联立分析。
|
||||
|
||||
支持同时分析多个 K 线周期(如日线 + 30 分钟)的缠论数据,
|
||||
查看高级别笔在低级别中的走势结构,辅助判断买卖点的有效性。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser, ChanlunResult
|
||||
from easy_tdx.chanlun.types import BI
|
||||
|
||||
|
||||
class MultiLevelAnalyser:
|
||||
"""多级别缠论分析器。
|
||||
|
||||
管理多个 ChanlunAnalyser 实例,每个对应一个 K 线周期。
|
||||
支持跨级别查询:高级别笔对应的低级别走势信息。
|
||||
|
||||
用法::
|
||||
|
||||
mla = MultiLevelAnalyser()
|
||||
mla.add_level("daily", ChanlunAnalyser("SZ000001", "DAILY"))
|
||||
mla.add_level("30min", ChanlunAnalyser("SZ000001", "30MIN"))
|
||||
mla.process("daily", df_daily)
|
||||
mla.process("30min", df_30min)
|
||||
|
||||
# 查看日线最后一笔在 30 分钟级别中的走势
|
||||
info = mla.query_low_level_qs("daily", "30min", last_bi)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._analysers: dict[str, ChanlunAnalyser] = {}
|
||||
|
||||
def add_level(self, name: str, analyser: ChanlunAnalyser) -> None:
|
||||
"""添加一个分析级别。
|
||||
|
||||
Args:
|
||||
name: 级别名称(如 "daily", "30min")
|
||||
analyser: 对应的 ChanlunAnalyser 实例
|
||||
"""
|
||||
self._analysers[name] = analyser
|
||||
|
||||
def process(self, level: str, df: object) -> ChanlunResult:
|
||||
"""处理指定级别的 K 线数据。
|
||||
|
||||
Args:
|
||||
level: 级别名称
|
||||
df: K 线 DataFrame
|
||||
|
||||
Returns:
|
||||
该级别的缠论分析结果
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
if level not in self._analysers:
|
||||
raise KeyError(f"未注册的级别: {level},可用: {list(self._analysers.keys())}")
|
||||
assert isinstance(df, pd.DataFrame)
|
||||
return self._analysers[level].process_klines(df)
|
||||
|
||||
def get_result(self, level: str) -> ChanlunResult | None:
|
||||
"""获取指定级别的分析结果。"""
|
||||
if level in self._analysers:
|
||||
return self._analysers[level].result
|
||||
return None
|
||||
|
||||
def results(self) -> dict[str, ChanlunResult]:
|
||||
"""获取所有级别的分析结果。"""
|
||||
return {name: a.result for name, a in self._analysers.items()}
|
||||
|
||||
def query_low_level_qs(
|
||||
self,
|
||||
high_level: str,
|
||||
low_level: str,
|
||||
high_bi: BI,
|
||||
) -> dict[str, int]:
|
||||
"""查询高级别笔在低级别中的走势信息。
|
||||
|
||||
查找低级别中时间范围落在高级别笔内的所有笔和中枢,
|
||||
统计形成趋势/盘整的情况。
|
||||
|
||||
Args:
|
||||
high_level: 高级别名称
|
||||
low_level: 低级别名称
|
||||
high_bi: 高级别的笔
|
||||
|
||||
Returns:
|
||||
{"bi_count": 低级别笔数, "zs_count": 低级别中枢数,
|
||||
"has_trend": 是否形成趋势, "has_consolidation": 是否形成盘整}
|
||||
"""
|
||||
high_result = self.get_result(high_level)
|
||||
low_result = self.get_result(low_level)
|
||||
|
||||
if high_result is None or low_result is None:
|
||||
return {"bi_count": 0, "zs_count": 0, "has_trend": False, "has_consolidation": False}
|
||||
|
||||
# 高级别笔的时间范围
|
||||
start_date = high_bi.start.k.date
|
||||
end_date = high_bi.end.k.date
|
||||
|
||||
# 筛选低级别中时间范围内的笔
|
||||
low_bis = [
|
||||
bi
|
||||
for bi in low_result.bis
|
||||
if bi.start.k.date >= start_date and bi.end.k.date <= end_date
|
||||
]
|
||||
|
||||
# 筛选低级别中枢
|
||||
low_zss = [
|
||||
zs
|
||||
for zs in low_result.zss
|
||||
if zs.start is not None
|
||||
and zs.end is not None
|
||||
and zs.start.k.date >= start_date
|
||||
and zs.end.k.date <= end_date
|
||||
]
|
||||
|
||||
has_trend = len(low_zss) >= 2
|
||||
has_consolidation = len(low_zss) >= 1
|
||||
|
||||
return {
|
||||
"bi_count": len(low_bis),
|
||||
"zs_count": len(low_zss),
|
||||
"has_trend": has_trend,
|
||||
"has_consolidation": has_consolidation,
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""缠论核心数据结构定义。
|
||||
|
||||
参考 chanlun-pro cl_interface.py,去除对 db/exchange 的依赖,
|
||||
使用纯 dataclass + 类型注解,保持 mypy strict 兼容。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# ── K 线 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Kline:
|
||||
"""原始 K 线。"""
|
||||
|
||||
index: int
|
||||
date: datetime
|
||||
open: float
|
||||
close: float
|
||||
high: float
|
||||
low: float
|
||||
amount: float # 成交量(股数)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"Kline(i={self.index} {self.date:%Y-%m-%d} "
|
||||
f"o={self.open:.2f} c={self.close:.2f} "
|
||||
f"h={self.high:.2f} l={self.low:.2f})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLKline:
|
||||
"""缠论 K 线(包含处理后的合并 K 线)。"""
|
||||
|
||||
k_index: int # 对应原始 K 线中最后一根的 index
|
||||
date: datetime # 合并 K 线最后一根的时间
|
||||
open: float
|
||||
close: float
|
||||
high: float
|
||||
low: float
|
||||
amount: float
|
||||
index: int = 0 # 在缠论 K 线列表中的序号
|
||||
merged_count: int = 1 # 合并了几根原始 K 线
|
||||
has_gap: bool = False # 是否有缺口
|
||||
direction: str = "" # 合并方向 "up" / "down" / ""
|
||||
klines: list[Kline] = field(default_factory=list) # 包含的原始 K 线
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"CLKline(i={self.index} ki={self.k_index} {self.date:%Y-%m-%d} "
|
||||
f"h={self.high:.2f} l={self.low:.2f} n={self.merged_count})"
|
||||
)
|
||||
|
||||
|
||||
# ── 分型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FXType(str, Enum):
|
||||
"""分型类型。"""
|
||||
|
||||
DING = "ding" # 顶分型
|
||||
DI = "di" # 底分型
|
||||
|
||||
|
||||
@dataclass
|
||||
class FX:
|
||||
"""分型对象。"""
|
||||
|
||||
fx_type: FXType
|
||||
k: CLKline # 分型中间那根缠论 K 线
|
||||
klines: list[CLKline] # 构成分型的三根缠论 K 线 [左, 中, 右]
|
||||
val: float # 分型值(顶分型取 high,底分型取 low)
|
||||
index: int = 0 # 分型序号
|
||||
done: bool = True # 分型是否完成
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"FX(i={self.index} {self.fx_type.value} {self.k.date:%Y-%m-%d} val={self.val:.2f})"
|
||||
|
||||
|
||||
# ── 线(笔/线段基类)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Direction(str, Enum):
|
||||
"""方向。"""
|
||||
|
||||
UP = "up"
|
||||
DOWN = "down"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Line:
|
||||
"""线的基本定义,笔和线段的基类。"""
|
||||
|
||||
start: FX # 起始分型
|
||||
end: FX # 结束分型
|
||||
direction: Direction # 方向
|
||||
index: int = 0 # 序号
|
||||
high: float = 0.0 # 区间最高价
|
||||
low: float = 0.0 # 区间最低价
|
||||
|
||||
def is_done(self) -> bool:
|
||||
"""线是否完成(结束分型已完成)。"""
|
||||
return self.end.done
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"Line(i={self.index} {self.direction.value} "
|
||||
f"{self.start.k.date:%Y-%m-%d}→{self.end.k.date:%Y-%m-%d} "
|
||||
f"h={self.high:.2f} l={self.low:.2f})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BI(Line):
|
||||
"""笔。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class XD(Line):
|
||||
"""线段。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ── 中枢 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZS:
|
||||
"""中枢对象。"""
|
||||
|
||||
lines: list[BI | XD] = field(default_factory=list) # 构成中枢的线
|
||||
zg: float = 0.0 # 中枢上沿(重叠区间最高)
|
||||
zd: float = 0.0 # 中枢下沿(重叠区间最低)
|
||||
gg: float = 0.0 # 中枢最高点
|
||||
dd: float = 0.0 # 中枢最低点
|
||||
direction: str = "" # 中枢方向 "up"/"down"/""
|
||||
index: int = 0 # 序号
|
||||
done: bool = False # 中枢是否完成
|
||||
start: FX | None = None # 起始分型
|
||||
end: FX | None = None # 结束分型
|
||||
|
||||
def add_line(self, line: BI | XD) -> None:
|
||||
self.lines.append(line)
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
return len(self.lines)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"ZS(i={self.index} lines={self.line_count} "
|
||||
f"zg={self.zg:.2f} zd={self.zd:.2f} "
|
||||
f"gg={self.gg:.2f} dd={self.dd:.2f} "
|
||||
f"done={self.done})"
|
||||
)
|
||||
|
||||
|
||||
# ── 买卖点 / 背驰 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MMDType(str, Enum):
|
||||
"""买卖点类型。"""
|
||||
|
||||
BUY_1 = "1buy"
|
||||
BUY_2 = "2buy"
|
||||
BUY_3 = "3buy"
|
||||
SELL_1 = "1sell"
|
||||
SELL_2 = "2sell"
|
||||
SELL_3 = "3sell"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MMD:
|
||||
"""买卖点。"""
|
||||
|
||||
mmd_type: MMDType
|
||||
zs: ZS | None = None
|
||||
msg: str = ""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MMD({self.mmd_type.value} {self.msg})"
|
||||
|
||||
|
||||
class BCType(str, Enum):
|
||||
"""背驰类型。"""
|
||||
|
||||
BI = "bi" # 笔背驰
|
||||
PZ = "pz" # 盘整背驰
|
||||
QS = "qs" # 趋势背驰
|
||||
|
||||
|
||||
@dataclass
|
||||
class BC:
|
||||
"""背驰。"""
|
||||
|
||||
bc_type: BCType
|
||||
bc: bool = False # 是否背驰
|
||||
zs: ZS | None = None
|
||||
msg: str = ""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"BC({self.bc_type.value} {self.bc})"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""线段计算。
|
||||
|
||||
线段定义:
|
||||
- 由至少3笔构成
|
||||
- 特征序列:将笔的高低点转化为特征序列
|
||||
- 特征序列分型:判断线段的转折
|
||||
- 简化实现:使用笔的方向和重叠关系判断线段
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import BI, XD, Direction
|
||||
|
||||
|
||||
def find_xds(
|
||||
bis: list[BI],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[XD]:
|
||||
"""从笔列表中计算线段。
|
||||
|
||||
简化算法(基于中枢):
|
||||
1. 将笔序列划分为线段,每个线段对应一个中枢的形成和离开
|
||||
2. 从第一笔开始,累积笔直到形成中枢(至少3笔有重叠)
|
||||
3. 当后续笔离开中枢时,关闭当前线段,开始新线段
|
||||
|
||||
Args:
|
||||
bis: 笔列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
线段列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(bis) < 3:
|
||||
return []
|
||||
|
||||
xds: list[XD] = []
|
||||
|
||||
# 简化线段划分:使用笔的重叠区域判断
|
||||
# 每个线段至少包含3笔(形成一个中枢)
|
||||
i = 0
|
||||
while i < len(bis):
|
||||
# 尝试从第 i 笔开始寻找线段
|
||||
xd_found = False
|
||||
|
||||
# 从3笔开始尝试,逐步扩展
|
||||
for end_offset in range(3, len(bis) - i + 1):
|
||||
segment_bis = bis[i : i + end_offset]
|
||||
|
||||
# 检查这段笔是否构成有意义的线段
|
||||
# 条件:中间的笔有价格重叠(类似中枢),且最后一笔离开重叠区
|
||||
if _forms_xd(segment_bis, config):
|
||||
xd = _create_xd(segment_bis, len(xds))
|
||||
xds.append(xd)
|
||||
i += end_offset - 1 # 下一笔从倒数第2笔开始(共享转折点)
|
||||
xd_found = True
|
||||
break
|
||||
|
||||
if not xd_found:
|
||||
i += 1
|
||||
|
||||
return xds
|
||||
|
||||
|
||||
def _forms_xd(bis: list[BI], config: ChanlunConfig) -> bool:
|
||||
"""判断一组笔是否构成线段。
|
||||
|
||||
简化条件:
|
||||
1. 至少3笔
|
||||
2. 中间的笔有价格重叠区域(类似中枢)
|
||||
3. 最后一笔与重叠区有明确的方向性突破
|
||||
"""
|
||||
if len(bis) < 3:
|
||||
return False
|
||||
|
||||
# 计算中间笔的重叠区域(排除第一笔和最后一笔)
|
||||
inner_bis = bis[1:-1]
|
||||
if not inner_bis:
|
||||
return False
|
||||
|
||||
# 重叠区域
|
||||
overlap_high = min(bi.high for bi in inner_bis)
|
||||
overlap_low = max(bi.low for bi in inner_bis)
|
||||
|
||||
if overlap_high <= overlap_low:
|
||||
return False
|
||||
|
||||
# 第一笔的方向决定线段的主方向
|
||||
main_direction = bis[0].direction
|
||||
|
||||
# 最后一笔应该离开重叠区域
|
||||
last_bi = bis[-1]
|
||||
if main_direction == Direction.UP:
|
||||
# 向上线段:最后一笔应向上突破
|
||||
return last_bi.high > overlap_high
|
||||
else:
|
||||
# 向下线段:最后一笔应向下跌破
|
||||
return last_bi.low < overlap_low
|
||||
|
||||
|
||||
def _create_xd(bis: list[BI], index: int) -> XD:
|
||||
"""从一组笔创建线段。"""
|
||||
start_bi = bis[0]
|
||||
end_bi = bis[-1]
|
||||
|
||||
# 线段方向由第一笔的方向决定
|
||||
direction = start_bi.direction
|
||||
|
||||
# 但如果第一笔向下,最后一笔向上,需要根据整体走势判断
|
||||
if end_bi.direction == Direction.UP and end_bi.high > start_bi.high:
|
||||
direction = Direction.UP
|
||||
elif end_bi.direction == Direction.DOWN and end_bi.low < start_bi.low:
|
||||
direction = Direction.DOWN
|
||||
|
||||
high = max(bi.high for bi in bis)
|
||||
low = min(bi.low for bi in bis)
|
||||
|
||||
return XD(
|
||||
start=start_bi.start,
|
||||
end=end_bi.end,
|
||||
direction=direction,
|
||||
index=index,
|
||||
high=high,
|
||||
low=low,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""中枢计算。
|
||||
|
||||
中枢定义:至少三笔(或线段)的价格区间有重叠。
|
||||
- zg(上沿)= 重叠区间的最高点
|
||||
- zd(下沿)= 重叠区间的最低点
|
||||
- gg = 中枢内所有笔的最高价
|
||||
- dd = 中枢内所有笔的最低价
|
||||
|
||||
标准中枢算法:
|
||||
1. 逐笔扫描,维护当前中枢的 zg/zd
|
||||
2. 新笔进入时,更新 gg/dd
|
||||
3. 新笔离开(不再与 [zd, zg] 重叠)时,关闭中枢
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import BI, ZS
|
||||
|
||||
|
||||
def find_zss(
|
||||
bis: list[BI],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[ZS]:
|
||||
"""从笔列表中计算中枢。
|
||||
|
||||
算法:
|
||||
1. 逐笔扫描
|
||||
2. 维护当前中枢的 zg/zd(重叠区间)
|
||||
3. 新笔与 [zd, zg] 有重叠 → 加入中枢,更新重叠区间
|
||||
4. 新笔与 [zd, zg] 无重叠 → 关闭当前中枢,开始新中枢
|
||||
|
||||
Args:
|
||||
bis: 笔列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
中枢列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(bis) < 3:
|
||||
return []
|
||||
|
||||
zss: list[ZS] = []
|
||||
current_zs: ZS | None = None
|
||||
|
||||
for bi in bis:
|
||||
if current_zs is None:
|
||||
# 尝试开始新中枢:需要至少前两笔有重叠
|
||||
# 中枢至少需要3笔,先累积
|
||||
if len(zss) == 0 or True:
|
||||
# 用当前笔初始化中枢候选
|
||||
current_zs = ZS(
|
||||
lines=[bi],
|
||||
zg=bi.high,
|
||||
zd=bi.low,
|
||||
gg=bi.high,
|
||||
dd=bi.low,
|
||||
start=bi.start,
|
||||
end=bi.end,
|
||||
index=len(zss),
|
||||
)
|
||||
continue
|
||||
|
||||
# 当前笔与中枢是否有重叠
|
||||
overlap_high = min(current_zs.zg, bi.high)
|
||||
overlap_low = max(current_zs.zd, bi.low)
|
||||
|
||||
if overlap_high > overlap_low:
|
||||
# 有重叠,加入中枢
|
||||
current_zs.add_line(bi)
|
||||
current_zs.zg = overlap_high
|
||||
current_zs.zd = overlap_low
|
||||
current_zs.gg = max(current_zs.gg, bi.high)
|
||||
current_zs.dd = min(current_zs.dd, bi.low)
|
||||
current_zs.end = bi.end
|
||||
else:
|
||||
# 无重叠
|
||||
if current_zs.line_count >= config.zs_min_lines:
|
||||
# 中枢成立
|
||||
current_zs.done = True
|
||||
zss.append(current_zs)
|
||||
current_zs = None
|
||||
# 当前笔作为新中枢的起始
|
||||
current_zs = ZS(
|
||||
lines=[bi],
|
||||
zg=bi.high,
|
||||
zd=bi.low,
|
||||
gg=bi.high,
|
||||
dd=bi.low,
|
||||
start=bi.start,
|
||||
end=bi.end,
|
||||
index=len(zss),
|
||||
)
|
||||
|
||||
# 处理最后一个中枢
|
||||
if current_zs is not None and current_zs.line_count >= config.zs_min_lines:
|
||||
current_zs.done = False # 最后一根K线未确定,中枢未完成
|
||||
zss.append(current_zs)
|
||||
|
||||
return zss
|
||||
@@ -0,0 +1,143 @@
|
||||
"""走势段/趋势段计算。
|
||||
|
||||
走势段(ZSD):由线段(XD)构成,类似于笔由分型构成。
|
||||
走势段的识别基于线段的方向和重叠关系。
|
||||
|
||||
趋势段(QSD):具有明确方向性的走势段,连续同向排列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.chanlun.config import ChanlunConfig
|
||||
from easy_tdx.chanlun.types import XD, Direction
|
||||
|
||||
|
||||
def find_zsds(
|
||||
xds: list[XD],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[XD]:
|
||||
"""从线段列表中计算走势段。
|
||||
|
||||
算法:
|
||||
1. 将相邻同向线段合并为走势段
|
||||
2. 当线段方向反转时,前一个走势段结束,新的走势段开始
|
||||
3. 走势段的方向由其中线段的主方向决定
|
||||
|
||||
Args:
|
||||
xds: 线段列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
走势段列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(xds) < 1:
|
||||
return []
|
||||
|
||||
zsds: list[XD] = []
|
||||
current_start = xds[0]
|
||||
current_direction = xds[0].direction
|
||||
|
||||
for i in range(1, len(xds)):
|
||||
xd = xds[i]
|
||||
|
||||
if xd.direction != current_direction:
|
||||
# 方向反转,关闭当前走势段
|
||||
prev_xd = xds[i - 1]
|
||||
zsd = _create_zsd(current_start, prev_xd, current_direction, len(zsds))
|
||||
zsds.append(zsd)
|
||||
current_start = xd
|
||||
current_direction = xd.direction
|
||||
|
||||
# 处理最后一个走势段
|
||||
if len(xds) > 0:
|
||||
last_xd = xds[-1]
|
||||
zsd = _create_zsd(current_start, last_xd, current_direction, len(zsds))
|
||||
zsds.append(zsd)
|
||||
|
||||
return zsds
|
||||
|
||||
|
||||
def find_qsds(
|
||||
xds: list[XD],
|
||||
config: ChanlunConfig | None = None,
|
||||
) -> list[XD]:
|
||||
"""从线段列表中计算趋势段。
|
||||
|
||||
趋势段是具有明确趋势方向的走势段:
|
||||
- 向上趋势:每个线段的高点和低点逐步抬高
|
||||
- 向下趋势:每个线段的高点和低点逐步降低
|
||||
|
||||
Args:
|
||||
xds: 线段列表
|
||||
config: 缠论配置
|
||||
|
||||
Returns:
|
||||
趋势段列表
|
||||
"""
|
||||
if config is None:
|
||||
config = ChanlunConfig()
|
||||
|
||||
if len(xds) < 2:
|
||||
return []
|
||||
|
||||
# 先计算走势段
|
||||
zsds = find_zsds(xds, config)
|
||||
|
||||
# 从走势段中筛选趋势段
|
||||
qsds: list[XD] = []
|
||||
|
||||
for i in range(len(zsds)):
|
||||
zsd = zsds[i]
|
||||
|
||||
# 检查走势段内部是否形成趋势
|
||||
if _is_trending(zsd, zsds, i):
|
||||
qsds.append(zsd)
|
||||
|
||||
return qsds
|
||||
|
||||
|
||||
def _create_zsd(
|
||||
start_xd: XD,
|
||||
end_xd: XD,
|
||||
direction: Direction,
|
||||
index: int,
|
||||
) -> XD:
|
||||
"""从起止线段创建走势段。"""
|
||||
high = max(start_xd.high, end_xd.high)
|
||||
low = min(start_xd.low, end_xd.low)
|
||||
|
||||
return XD(
|
||||
start=start_xd.start,
|
||||
end=end_xd.end,
|
||||
direction=direction,
|
||||
index=index,
|
||||
high=high,
|
||||
low=low,
|
||||
)
|
||||
|
||||
|
||||
def _is_trending(
|
||||
zsd: XD,
|
||||
all_zsds: list[XD],
|
||||
zsd_index: int,
|
||||
) -> bool:
|
||||
"""判断走势段是否形成趋势。
|
||||
|
||||
简化判断:走势段跨越的幅度是否足够大(至少 2 个线段的范围)。
|
||||
"""
|
||||
# 单线段走势段不构成趋势
|
||||
if zsd.start == zsd.end:
|
||||
return False
|
||||
|
||||
# 向上趋势:走势段高点高于起点高点
|
||||
if zsd.direction == Direction.UP:
|
||||
return zsd.high > zsd.start.k.h if hasattr(zsd.start.k, "h") else True
|
||||
|
||||
# 向下趋势:走势段低点低于起点低点
|
||||
if zsd.direction == Direction.DOWN:
|
||||
return zsd.low < zsd.start.k.l if hasattr(zsd.start.k, "l") else True
|
||||
|
||||
return True
|
||||
@@ -8,6 +8,7 @@ from .cmd_admin import ping, version
|
||||
from .cmd_auction import auction
|
||||
from .cmd_board import belong_board, board_list, board_members, board_ranking, board_summary
|
||||
from .cmd_capital import capital_flow
|
||||
from .cmd_chanlun import chanlun
|
||||
from .cmd_ex import ex
|
||||
from .cmd_finance import f10, fund_flow
|
||||
from .cmd_indicator import indicator, indicator_list
|
||||
@@ -21,7 +22,7 @@ from .cmd_transaction import transaction
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.5.0", prog_name="easy-tdx")
|
||||
@click.version_option(version="1.7.0", prog_name="easy-tdx")
|
||||
def cli() -> None:
|
||||
"""easy-tdx -- 通达信行情数据 CLI(默认 JSON 输出,适合 Agent 使用)。
|
||||
|
||||
@@ -64,3 +65,4 @@ cli.add_command(ex)
|
||||
cli.add_command(indicator)
|
||||
cli.add_command(indicator_list)
|
||||
cli.add_command(offline)
|
||||
cli.add_command(chanlun)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""缠论分析命令。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("market")
|
||||
@click.argument("code")
|
||||
@click.option(
|
||||
"--period", default="DAILY", help="K线周期: DAILY/5MIN/15MIN/30MIN/60MIN/1MIN/WEEKLY/MONTHLY"
|
||||
)
|
||||
@click.option("--count", default=800, type=int, help="K线数量")
|
||||
@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ")
|
||||
@click.option("--table", "use_table", is_flag=True, help="表格输出")
|
||||
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
|
||||
def chanlun(
|
||||
market: str,
|
||||
code: str,
|
||||
period: str,
|
||||
count: int,
|
||||
adjust: str,
|
||||
use_table: bool,
|
||||
output_fmt: str,
|
||||
) -> None:
|
||||
"""缠论分析:计算 K 线的笔、中枢等缠论指标。
|
||||
|
||||
示例:
|
||||
|
||||
easy-tdx chanlun SZ 000001
|
||||
|
||||
easy-tdx chanlun SH 600519 --adjust QFQ --table
|
||||
|
||||
easy-tdx chanlun SZ 000001 --period 30MIN
|
||||
"""
|
||||
from ..chanlun.analyser import ChanlunAnalyser
|
||||
from .conn import get_mac_client
|
||||
from .parsers import parse_adjust, parse_market, parse_period
|
||||
|
||||
mkt = parse_market(market)
|
||||
with get_mac_client() as client:
|
||||
df = client.get_stock_kline(
|
||||
mkt,
|
||||
code,
|
||||
period=parse_period(period),
|
||||
start=0,
|
||||
count=count,
|
||||
adjust=parse_adjust(adjust),
|
||||
)
|
||||
|
||||
analyser = ChanlunAnalyser(code=code, frequency=period)
|
||||
result = analyser.process_klines(df)
|
||||
|
||||
result_dict = result.to_dict()
|
||||
|
||||
fmt = "table" if use_table else output_fmt
|
||||
if fmt == "json":
|
||||
click.echo(json.dumps(result_dict, ensure_ascii=False, indent=2))
|
||||
elif fmt == "table":
|
||||
_print_table(result_dict)
|
||||
else:
|
||||
click.echo(json.dumps(result_dict, ensure_ascii=False))
|
||||
|
||||
|
||||
def _print_table(result: dict) -> None:
|
||||
"""以表格形式输出缠论分析结果。"""
|
||||
click.echo(f"标的: {result['code']} 周期: {result['frequency']}")
|
||||
click.echo(f"原始K线: {result['kline_count']} 缠论K线: {result['ckline_count']}")
|
||||
click.echo(
|
||||
f"分型: {result['fractal_count']} 笔: {result['bi_count']} "
|
||||
f"中枢: {result['zs_count']} 线段: {result.get('xd_count', 0)}"
|
||||
)
|
||||
mmd_count = result.get("mmd_count", 0)
|
||||
bc_count = result.get("bc_count", 0)
|
||||
if mmd_count or bc_count:
|
||||
click.echo(f"买卖点: {mmd_count} 背驰: {bc_count}")
|
||||
click.echo()
|
||||
|
||||
if result["bis"]:
|
||||
click.echo("── 笔 ──")
|
||||
for bi in result["bis"]:
|
||||
direction = "↑" if bi["direction"] == "up" else "↓"
|
||||
done = "✓" if bi["done"] else "…"
|
||||
click.echo(
|
||||
f" [{bi['index']}] {direction} "
|
||||
f"{bi['start_date']} → {bi['end_date']} "
|
||||
f"h={bi['high']} l={bi['low']} {done}"
|
||||
)
|
||||
click.echo()
|
||||
|
||||
if result["zss"]:
|
||||
click.echo("── 中枢 ──")
|
||||
for zs in result["zss"]:
|
||||
done = "✓" if zs["done"] else "…"
|
||||
click.echo(
|
||||
f" [{zs['index']}] "
|
||||
f"zg={zs['zg']} zd={zs['zd']} "
|
||||
f"gg={zs['gg']} dd={zs['dd']} "
|
||||
f"lines={zs['line_count']} {done}"
|
||||
)
|
||||
click.echo()
|
||||
|
||||
if result.get("xds"):
|
||||
click.echo("── 线段 ──")
|
||||
for xd in result["xds"]:
|
||||
direction = "↑" if xd["direction"] == "up" else "↓"
|
||||
click.echo(
|
||||
f" [{xd['index']}] {direction} "
|
||||
f"{xd['start_date']} → {xd['end_date']} "
|
||||
f"h={xd['high']} l={xd['low']}"
|
||||
)
|
||||
click.echo()
|
||||
|
||||
if result.get("mmds"):
|
||||
click.echo("── 买卖点 ──")
|
||||
for mmd in result["mmds"]:
|
||||
click.echo(f" {mmd['type']}: {mmd['msg']}")
|
||||
click.echo()
|
||||
|
||||
if result.get("bcs"):
|
||||
click.echo("── 背驰 ──")
|
||||
for bc in result["bcs"]:
|
||||
status = "✓" if bc["bc"] else "✗"
|
||||
click.echo(f" [{status}] {bc['type']}: {bc['msg']}")
|
||||
@@ -0,0 +1,506 @@
|
||||
"""缠论核心计算 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from easy_tdx.chanlun.bi import find_bis
|
||||
from easy_tdx.chanlun.fractal import find_fractals
|
||||
from easy_tdx.chanlun.kline_merge import merge_klines
|
||||
from easy_tdx.chanlun.types import CLKline, Direction, FXType, Kline
|
||||
from easy_tdx.chanlun.zs import find_zss
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _k(
|
||||
idx: int,
|
||||
dt: str,
|
||||
o: float,
|
||||
c: float,
|
||||
h: float,
|
||||
l: float,
|
||||
a: float = 0.0,
|
||||
) -> Kline:
|
||||
"""快速构造 Kline。"""
|
||||
return Kline(
|
||||
index=idx,
|
||||
date=datetime.strptime(dt, "%Y-%m-%d"),
|
||||
open=o,
|
||||
close=c,
|
||||
high=h,
|
||||
low=l,
|
||||
amount=a,
|
||||
)
|
||||
|
||||
|
||||
def _ck(
|
||||
idx: int,
|
||||
dt: str,
|
||||
o: float,
|
||||
c: float,
|
||||
h: float,
|
||||
l: float,
|
||||
merged_count: int = 1,
|
||||
direction: str = "",
|
||||
) -> CLKline:
|
||||
"""快速构造 CLKline。"""
|
||||
return CLKline(
|
||||
k_index=idx,
|
||||
date=datetime.strptime(dt, "%Y-%m-%d"),
|
||||
open=o,
|
||||
close=c,
|
||||
high=h,
|
||||
low=l,
|
||||
amount=0.0,
|
||||
index=0, # 由 merge_klines 赋值
|
||||
merged_count=merged_count,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
|
||||
# ── K 线合并测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeKlines:
|
||||
"""merge_klines 测试。"""
|
||||
|
||||
def test_no_merge_needed(self) -> None:
|
||||
"""K 线无包含关系,应原样返回。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 12, 13, 9),
|
||||
_k(1, "2025-01-03", 11, 16, 17, 11), # 高于前一根高点、低于前根低点 → 无包含
|
||||
_k(2, "2025-01-06", 13, 11, 18, 12), # 继续新高 → 无包含
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 3
|
||||
# 每个 CLKline 没有合并
|
||||
assert all(ck.merged_count == 1 for ck in result)
|
||||
|
||||
def test_upward_include(self) -> None:
|
||||
"""向上趋势中的包含关系应合并。
|
||||
|
||||
K1: h=15 l=10 (向上)
|
||||
K2: h=13 l=11 ← K2 被 K1 包含 (15>13 and 10<11 => 15>=13 and 10<=11)
|
||||
合并后取高高:h=15, l=11
|
||||
"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 14, 15, 10),
|
||||
_k(1, "2025-01-03", 11, 13, 13, 11),
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 1
|
||||
assert result[0].high == 15.0
|
||||
assert result[0].low == 11.0
|
||||
assert result[0].merged_count == 2
|
||||
|
||||
def test_downward_include(self) -> None:
|
||||
"""向下趋势中的包含关系应合并。
|
||||
|
||||
K1: h=10 l=5 (向下)
|
||||
K2: h=9 l=6 ← K2 被 K1 包含 (10>=9 and 5<=6)
|
||||
合并后取低低:h=9, l=5
|
||||
"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 12, 8, 10, 5),
|
||||
_k(1, "2025-01-03", 8, 7, 9, 6),
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 1
|
||||
assert result[0].high == 9.0
|
||||
assert result[0].low == 5.0
|
||||
assert result[0].merged_count == 2
|
||||
|
||||
def test_three_klines_with_two_merges(self) -> None:
|
||||
"""连续包含:三根 K 线合并为一根。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 14, 15, 10), # 大阳线
|
||||
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含
|
||||
_k(2, "2025-01-06", 12, 14, 14, 12), # 被包含
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 1
|
||||
assert result[0].merged_count == 3
|
||||
# 向上合并:取高高 => h=15, l=12
|
||||
assert result[0].high == 15.0
|
||||
assert result[0].low == 12.0
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
"""空输入返回空列表。"""
|
||||
assert merge_klines([]) == []
|
||||
|
||||
def test_single_kline(self) -> None:
|
||||
"""单根 K 线返回单个 CLKline。"""
|
||||
klines = [_k(0, "2025-01-02", 10, 12, 13, 9)]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 1
|
||||
assert result[0].high == 13.0
|
||||
assert result[0].low == 9.0
|
||||
|
||||
def test_mixed_merge_and_non_merge(self) -> None:
|
||||
"""混合场景:部分合并,部分不合并。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 14, 15, 10), # 大阳线
|
||||
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含,合并
|
||||
_k(2, "2025-01-06", 16, 18, 19, 15), # 新高,不合并
|
||||
_k(3, "2025-01-07", 17, 15, 18, 14), # 阴线,不包含
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result) == 3
|
||||
assert result[0].merged_count == 2 # K0+K1 合并
|
||||
assert result[1].merged_count == 1 # K2 独立
|
||||
assert result[2].merged_count == 1 # K3 独立
|
||||
|
||||
def test_index_assignment(self) -> None:
|
||||
"""CLKline.index 应从 0 递增。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 14, 15, 10),
|
||||
_k(1, "2025-01-03", 14, 16, 17, 13),
|
||||
_k(2, "2025-01-06", 16, 12, 17, 11),
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
for i, ck in enumerate(result):
|
||||
assert ck.index == i
|
||||
|
||||
def test_klines_reference_preserved(self) -> None:
|
||||
"""CLKline.klines 应包含合并前的原始 K 线。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 14, 15, 10),
|
||||
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含
|
||||
]
|
||||
result = merge_klines(klines)
|
||||
assert len(result[0].klines) == 2
|
||||
|
||||
|
||||
# ── 分型识别测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindFractals:
|
||||
"""find_fractals 测试。"""
|
||||
|
||||
def test_simple_ding_fx(self) -> None:
|
||||
"""简单的顶分型:中间高,两边低。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10),
|
||||
_ck(1, "2025-01-03", 12, 15, 15, 11),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 1
|
||||
assert fxs[0].fx_type == FXType.DING
|
||||
assert fxs[0].val == 15.0
|
||||
assert fxs[0].k == cks[1]
|
||||
|
||||
def test_simple_di_fx(self) -> None:
|
||||
"""简单的底分型:中间低,两边高。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 15, 12, 16, 12),
|
||||
_ck(1, "2025-01-03", 11, 9, 12, 9),
|
||||
_ck(2, "2025-01-06", 10, 13, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 1
|
||||
assert fxs[0].fx_type == FXType.DI
|
||||
assert fxs[0].val == 9.0
|
||||
|
||||
def test_no_fractal(self) -> None:
|
||||
"""单调序列不应有分型。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10),
|
||||
_ck(1, "2025-01-03", 12, 14, 14, 12),
|
||||
_ck(2, "2025-01-06", 14, 16, 16, 14),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 0
|
||||
|
||||
def test_alternating_ding_di(self) -> None:
|
||||
"""交替的顶底分型。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10), # 上升
|
||||
_ck(1, "2025-01-03", 12, 15, 15, 11), # 顶 (12<15, 14<15)
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10), # 下降
|
||||
_ck(3, "2025-01-07", 10, 8, 11, 8), # 底 (10>8, 9>8)
|
||||
_ck(4, "2025-01-08", 9, 13, 16, 9), # 大幅上升
|
||||
_ck(5, "2025-01-09", 15, 10, 15, 10), # 下降 → ck[4] 成为顶
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 3
|
||||
assert fxs[0].fx_type == FXType.DING # ck[1]
|
||||
assert fxs[1].fx_type == FXType.DI # ck[3]
|
||||
assert fxs[2].fx_type == FXType.DING # ck[4]
|
||||
|
||||
def test_insufficient_klines(self) -> None:
|
||||
"""少于3根K线不应有分型。"""
|
||||
assert find_fractals([]) == []
|
||||
assert find_fractals([_ck(0, "2025-01-02", 10, 12, 12, 10)]) == []
|
||||
assert (
|
||||
find_fractals(
|
||||
[
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10),
|
||||
_ck(1, "2025-01-03", 12, 15, 15, 11),
|
||||
]
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_equal_highs_no_ding(self) -> None:
|
||||
"""相等高点不应形成顶分型。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 15, 10),
|
||||
_ck(1, "2025-01-03", 12, 14, 15, 11),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 0
|
||||
|
||||
def test_equal_lows_no_di(self) -> None:
|
||||
"""相等低点不应形成底分型。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 15, 12, 16, 9),
|
||||
_ck(1, "2025-01-03", 11, 10, 12, 9),
|
||||
_ck(2, "2025-01-06", 10, 13, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
assert len(fxs) == 0
|
||||
|
||||
|
||||
# ── 笔计算测试 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindBis:
|
||||
"""find_bis 测试。"""
|
||||
|
||||
def test_simple_up_down_bi(self) -> None:
|
||||
"""一组顶底分型应产生两笔(向上 + 向下)。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10),
|
||||
_ck(1, "2025-01-03", 12, 15, 15, 11),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 8, 11, 8),
|
||||
_ck(4, "2025-01-08", 9, 13, 16, 9),
|
||||
_ck(5, "2025-01-09", 15, 10, 15, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
# ding(1) → di(3) 向下笔, di(3) → ding(4) 向上笔
|
||||
assert len(bis) >= 2
|
||||
assert bis[0].direction == Direction.DOWN # 顶→底
|
||||
assert bis[1].direction == Direction.UP # 底→顶
|
||||
|
||||
def test_new_bi_rule_needs_gap(self) -> None:
|
||||
"""新笔规则:分型之间至少1根独立K线。
|
||||
|
||||
如果两个分型相邻(中间无独立K线),不构成笔。
|
||||
"""
|
||||
# 只有3根K线,产生1个分型,不足以成笔
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 12, 10),
|
||||
_ck(1, "2025-01-03", 12, 15, 15, 11),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
assert len(bis) == 0 # 1个分型无法成笔
|
||||
|
||||
def test_ding_di_must_alternate(self) -> None:
|
||||
"""笔的起止分型必须顶底交替:顶→底 或 底→顶。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 7, 11, 7),
|
||||
_ck(4, "2025-01-08", 8, 13, 14, 8),
|
||||
_ck(5, "2025-01-09", 13, 10, 14, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
for bi in bis:
|
||||
if bi.direction == Direction.UP:
|
||||
assert bi.start.fx_type == FXType.DI
|
||||
assert bi.end.fx_type == FXType.DING
|
||||
else:
|
||||
assert bi.start.fx_type == FXType.DING
|
||||
assert bi.end.fx_type == FXType.DI
|
||||
|
||||
def test_empty_fractals(self) -> None:
|
||||
"""空分型列表应返回空笔列表。"""
|
||||
assert find_bis([]) == []
|
||||
|
||||
def test_bi_high_low(self) -> None:
|
||||
"""笔的 high/low 应正确反映区间最高最低价。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 7, 11, 7),
|
||||
_ck(4, "2025-01-08", 8, 13, 14, 8),
|
||||
_ck(5, "2025-01-09", 13, 10, 13, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
if len(bis) > 0:
|
||||
# 第一笔:顶→底(向下),high=16, low=7
|
||||
assert bis[0].high == 16.0
|
||||
assert bis[0].low == 7.0
|
||||
|
||||
def test_full_pipeline_merge_to_bi(self) -> None:
|
||||
"""完整管道测试:原始K线 → 合并 → 分型 → 笔。"""
|
||||
klines = [
|
||||
_k(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_k(1, "2025-01-03", 8, 12, 13, 7),
|
||||
_k(2, "2025-01-06", 12, 16, 17, 11),
|
||||
_k(3, "2025-01-07", 16, 14, 18, 13),
|
||||
_k(4, "2025-01-08", 14, 10, 15, 9),
|
||||
_k(5, "2025-01-09", 10, 6, 11, 5),
|
||||
_k(6, "2025-01-10", 7, 12, 13, 6),
|
||||
_k(7, "2025-01-13", 12, 9, 14, 8),
|
||||
]
|
||||
merged = merge_klines(klines)
|
||||
fxs = find_fractals(merged)
|
||||
bis = find_bis(fxs)
|
||||
assert len(bis) >= 1
|
||||
|
||||
|
||||
# ── 中枢计算测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindZss:
|
||||
"""find_zss 测试。"""
|
||||
|
||||
def test_three_overlapping_bis_form_zs(self) -> None:
|
||||
"""三笔重叠形成中枢。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 12, 13, 9),
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 9),
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 10),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
zss = find_zss(bis)
|
||||
assert len(zss) >= 1
|
||||
zs = zss[0]
|
||||
assert zs.zg > zs.zd
|
||||
assert zs.gg >= zs.zg
|
||||
assert zs.dd <= zs.zd
|
||||
|
||||
def test_no_overlap_no_zs(self) -> None:
|
||||
"""笔之间无重叠不应形成中枢。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 16, 18, 15),
|
||||
_ck(3, "2025-01-07", 16, 20, 22, 16),
|
||||
_ck(4, "2025-01-08", 20, 25, 26, 20),
|
||||
_ck(5, "2025-01-09", 25, 22, 26, 22),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
zss = find_zss(bis)
|
||||
assert len(zss) == 0
|
||||
|
||||
def test_empty_bis_no_zs(self) -> None:
|
||||
"""空笔列表不应有中枢。"""
|
||||
assert find_zss([]) == []
|
||||
|
||||
def test_zs_overlap_properties(self) -> None:
|
||||
"""中枢应有正确的重叠区间属性。"""
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 12, 13, 10),
|
||||
_ck(1, "2025-01-03", 12, 15, 16, 11),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 13, 14, 9),
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 8),
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 9),
|
||||
_ck(8, "2025-01-14", 11, 6, 12, 5),
|
||||
_ck(9, "2025-01-15", 7, 8, 9, 6),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
zss = find_zss(bis)
|
||||
if len(zss) > 0:
|
||||
zs = zss[0]
|
||||
# 中枢基本属性
|
||||
assert zs.zg > zs.zd
|
||||
assert zs.gg >= zs.zg
|
||||
assert zs.dd <= zs.zd
|
||||
assert zs.line_count >= 3
|
||||
|
||||
|
||||
# ── Analyser 集成测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChanlunAnalyser:
|
||||
"""ChanlunAnalyser 完整管道测试。"""
|
||||
|
||||
def test_analyse_with_dataframe(self) -> None:
|
||||
"""使用模拟 DataFrame 测试完整管道。"""
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
dates = pd.date_range("2025-01-02", periods=20, freq="B")
|
||||
data = {
|
||||
"datetime": dates,
|
||||
"open": [10, 8, 12, 16, 14, 10, 7, 12, 14, 12, 10, 6, 7, 12, 9, 10, 14, 12, 8, 9],
|
||||
"close": [8, 12, 16, 14, 10, 7, 12, 14, 12, 10, 6, 7, 12, 9, 10, 14, 12, 8, 9, 11],
|
||||
"high": [11, 13, 17, 18, 15, 11, 13, 15, 14, 13, 11, 8, 13, 12, 11, 15, 14, 13, 9, 12],
|
||||
"low": [7, 7, 11, 13, 9, 5, 6, 11, 11, 9, 5, 5, 6, 8, 8, 9, 11, 7, 7, 9],
|
||||
"vol": [1000] * 20,
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
analyser = ChanlunAnalyser(code="SZ000001", frequency="DAILY")
|
||||
result = analyser.process_klines(df)
|
||||
|
||||
assert result.code == "SZ000001"
|
||||
assert result.frequency == "DAILY"
|
||||
assert len(result.klines) == 20
|
||||
assert len(result.cklines) > 0
|
||||
assert len(result.cklines) <= 20
|
||||
assert len(result.fractals) >= 0
|
||||
assert len(result.bis) >= 0
|
||||
|
||||
def test_empty_dataframe(self) -> None:
|
||||
"""空 DataFrame 应返回空结果。"""
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
df = pd.DataFrame(columns=["datetime", "open", "close", "high", "low", "vol"])
|
||||
analyser = ChanlunAnalyser(code="SZ000001")
|
||||
result = analyser.process_klines(df)
|
||||
assert len(result.klines) == 0
|
||||
assert len(result.bis) == 0
|
||||
|
||||
def test_result_to_dict(self) -> None:
|
||||
"""结果应可序列化为字典。"""
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
dates = pd.date_range("2025-01-02", periods=10, freq="B")
|
||||
data = {
|
||||
"datetime": dates,
|
||||
"open": [10, 8, 12, 16, 14, 10, 7, 12, 14, 12],
|
||||
"close": [8, 12, 16, 14, 10, 7, 12, 14, 12, 10],
|
||||
"high": [11, 13, 17, 18, 15, 11, 13, 15, 14, 13],
|
||||
"low": [7, 7, 11, 13, 9, 5, 6, 11, 11, 9],
|
||||
"vol": [1000] * 10,
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
analyser = ChanlunAnalyser(code="SZ000001")
|
||||
result = analyser.process_klines(df)
|
||||
d = result.to_dict()
|
||||
|
||||
assert "code" in d
|
||||
assert "bi_count" in d
|
||||
assert "zs_count" in d
|
||||
assert "bis" in d
|
||||
assert "zss" in d
|
||||
@@ -0,0 +1,265 @@
|
||||
"""缠论 Phase 2 单元测试:MACD、线段、买卖点、背驰。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from easy_tdx.chanlun.bi import find_bis
|
||||
from easy_tdx.chanlun.fractal import find_fractals
|
||||
from easy_tdx.chanlun.types import CLKline, Direction, Kline
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _k(
|
||||
idx: int,
|
||||
dt: str,
|
||||
o: float,
|
||||
c: float,
|
||||
h: float,
|
||||
l: float,
|
||||
a: float = 0.0,
|
||||
) -> Kline:
|
||||
return Kline(
|
||||
index=idx,
|
||||
date=datetime.strptime(dt, "%Y-%m-%d"),
|
||||
open=o,
|
||||
close=c,
|
||||
high=h,
|
||||
low=l,
|
||||
amount=a,
|
||||
)
|
||||
|
||||
|
||||
def _ck(
|
||||
idx: int,
|
||||
dt: str,
|
||||
o: float,
|
||||
c: float,
|
||||
h: float,
|
||||
l: float,
|
||||
merged_count: int = 1,
|
||||
direction: str = "",
|
||||
) -> CLKline:
|
||||
return CLKline(
|
||||
k_index=idx,
|
||||
date=datetime.strptime(dt, "%Y-%m-%d"),
|
||||
open=o,
|
||||
close=c,
|
||||
high=h,
|
||||
low=l,
|
||||
amount=0.0,
|
||||
index=0,
|
||||
merged_count=merged_count,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
|
||||
# ── MACD 测试 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMacd:
|
||||
"""calc_macd 测试。"""
|
||||
|
||||
def test_macd_output_length(self) -> None:
|
||||
"""MACD 输出长度应与输入一致。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
closes = [10.0 + i * 0.5 for i in range(50)]
|
||||
result = calc_macd(closes)
|
||||
assert "dif" in result
|
||||
assert "dea" in result
|
||||
assert "hist" in result
|
||||
assert len(result["dif"]) == 50
|
||||
assert len(result["dea"]) == 50
|
||||
assert len(result["hist"]) == 50
|
||||
|
||||
def test_macd_short_input(self) -> None:
|
||||
"""输入太短时应返回零数组。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
result = calc_macd([10.0])
|
||||
assert len(result["dif"]) == 1
|
||||
assert result["dif"][0] == 0.0
|
||||
|
||||
def test_macd_uptrend_positive_dif(self) -> None:
|
||||
"""持续上涨时 DIF 应为正。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
closes = [float(i) for i in range(100)]
|
||||
result = calc_macd(closes)
|
||||
# 后半段 DIF 应为正
|
||||
assert result["dif"][-1] > 0
|
||||
|
||||
def test_macd_downtrend_negative_dif(self) -> None:
|
||||
"""持续下跌时 DIF 应为负。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
closes = [100.0 - i for i in range(100)]
|
||||
result = calc_macd(closes)
|
||||
assert result["dif"][-1] < 0
|
||||
|
||||
def test_macd_hist_equals_2x_diff(self) -> None:
|
||||
"""MACD 柱 = 2 * (DIF - DEA)。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
closes = [10.0 + i * 0.3 for i in range(60)]
|
||||
result = calc_macd(closes)
|
||||
for i in range(len(closes)):
|
||||
expected = 2 * (result["dif"][i] - result["dea"][i])
|
||||
assert abs(result["hist"][i] - expected) < 1e-10
|
||||
|
||||
def test_macd_custom_params(self) -> None:
|
||||
"""支持自定义 fast/slow/signal 参数。"""
|
||||
from easy_tdx.chanlun.macd import calc_macd
|
||||
|
||||
closes = [float(i) for i in range(100)]
|
||||
r1 = calc_macd(closes, fast=12, slow=26, signal=9)
|
||||
r2 = calc_macd(closes, fast=6, slow=13, signal=5)
|
||||
# 不同参数应产生不同结果
|
||||
assert r1["dif"][-1] != r2["dif"][-1]
|
||||
|
||||
|
||||
# ── 线段测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindXds:
|
||||
"""find_xds 测试。"""
|
||||
|
||||
def test_basic_xd_from_bis(self) -> None:
|
||||
"""多笔应能形成至少一个线段。"""
|
||||
from easy_tdx.chanlun.xd import find_xds
|
||||
|
||||
# 构造足够多的笔来形成线段
|
||||
# 需要至少5笔(3笔形成中枢 + 2笔进出)
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9), # 顶 h=16
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 13, 14, 9), # 底 l=9
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 8), # 底
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 9),
|
||||
_ck(8, "2025-01-14", 11, 6, 12, 5), # 大跌
|
||||
_ck(9, "2025-01-15", 7, 8, 9, 6),
|
||||
_ck(10, "2025-01-16", 8, 12, 13, 7),
|
||||
_ck(11, "2025-01-17", 11, 10, 14, 9),
|
||||
_ck(12, "2025-01-20", 10, 6, 11, 5),
|
||||
_ck(13, "2025-01-21", 7, 8, 9, 6),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
xds = find_xds(bis)
|
||||
# 有足够的笔时,应能形成线段
|
||||
if len(bis) >= 5:
|
||||
assert len(xds) >= 1
|
||||
|
||||
def test_empty_bis(self) -> None:
|
||||
"""空笔列表应返回空线段。"""
|
||||
from easy_tdx.chanlun.xd import find_xds
|
||||
|
||||
assert find_xds([]) == []
|
||||
|
||||
def test_xd_direction_alternates(self) -> None:
|
||||
"""线段方向应与笔的方向一致:向上线段由向上笔主导。"""
|
||||
from easy_tdx.chanlun.xd import find_xds
|
||||
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 13, 14, 9),
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 8),
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 9),
|
||||
_ck(8, "2025-01-14", 11, 6, 12, 5),
|
||||
_ck(9, "2025-01-15", 7, 8, 9, 6),
|
||||
_ck(10, "2025-01-16", 8, 12, 13, 7),
|
||||
_ck(11, "2025-01-17", 11, 10, 14, 9),
|
||||
_ck(12, "2025-01-20", 10, 6, 11, 5),
|
||||
_ck(13, "2025-01-21", 7, 8, 9, 6),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
xds = find_xds(bis)
|
||||
for xd in xds:
|
||||
assert xd.direction in (Direction.UP, Direction.DOWN)
|
||||
|
||||
|
||||
# ── 买卖点测试 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindMmds:
|
||||
"""find_mmds 测试。"""
|
||||
|
||||
def test_first_buy_after_zs(self) -> None:
|
||||
"""中枢下方出现底背驰应产生一类买点。"""
|
||||
from easy_tdx.chanlun.mmd import find_mmds
|
||||
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9), # 顶
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 13, 14, 9), # 底
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 8), # 底
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 9),
|
||||
_ck(8, "2025-01-14", 11, 14, 15, 10), # 向上离开
|
||||
_ck(9, "2025-01-15", 14, 12, 16, 11),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
# find_mmds 需要笔列表和中枢列表
|
||||
from easy_tdx.chanlun.zs import find_zss
|
||||
|
||||
zss = find_zss(bis)
|
||||
mmds = find_mmds(bis, zss)
|
||||
# 结果应为列表(可能为空,取决于是否满足条件)
|
||||
assert isinstance(mmds, list)
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
"""空输入应返回空列表。"""
|
||||
from easy_tdx.chanlun.mmd import find_mmds
|
||||
|
||||
assert find_mmds([], []) == []
|
||||
|
||||
|
||||
# ── 背驰测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBeichi:
|
||||
"""check_beichi 测试。"""
|
||||
|
||||
def test_divergence_detection(self) -> None:
|
||||
"""力度衰减应被检测为背驰。"""
|
||||
from easy_tdx.chanlun.beichi import check_bi_beichi
|
||||
|
||||
cks = [
|
||||
_ck(0, "2025-01-02", 10, 8, 11, 8),
|
||||
_ck(1, "2025-01-03", 9, 15, 16, 9),
|
||||
_ck(2, "2025-01-06", 14, 11, 14, 10),
|
||||
_ck(3, "2025-01-07", 10, 13, 14, 9),
|
||||
_ck(4, "2025-01-08", 12, 14, 15, 11),
|
||||
_ck(5, "2025-01-09", 14, 12, 14, 11),
|
||||
_ck(6, "2025-01-10", 11, 9, 12, 8),
|
||||
_ck(7, "2025-01-13", 10, 11, 12, 9),
|
||||
_ck(8, "2025-01-14", 11, 14, 15, 10),
|
||||
_ck(9, "2025-01-15", 14, 12, 16, 11),
|
||||
]
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
from easy_tdx.chanlun.zs import find_zss
|
||||
|
||||
zss = find_zss(bis)
|
||||
# 至少不崩溃
|
||||
result = check_bi_beichi(bis, zss)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
"""空输入应返回空列表。"""
|
||||
from easy_tdx.chanlun.beichi import check_bi_beichi
|
||||
|
||||
assert check_bi_beichi([], []) == []
|
||||
@@ -0,0 +1,214 @@
|
||||
"""缠论 Phase 3 单元测试:多级别分析、增量更新、走势段。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.chanlun.bi import find_bis
|
||||
from easy_tdx.chanlun.fractal import find_fractals
|
||||
from easy_tdx.chanlun.types import CLKline, Kline
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _k(idx: int, dt: str, o: float, c: float, h: float, l: float, a: float = 0.0) -> Kline:
|
||||
return Kline(
|
||||
index=idx,
|
||||
date=datetime.strptime(dt, "%Y-%m-%d"),
|
||||
open=o,
|
||||
close=c,
|
||||
high=h,
|
||||
low=l,
|
||||
amount=a,
|
||||
)
|
||||
|
||||
|
||||
def _make_df(n: int = 50, start_price: float = 10.0, volatility: float = 2.0) -> pd.DataFrame:
|
||||
"""生成模拟K线 DataFrame。"""
|
||||
import random
|
||||
|
||||
random.seed(42)
|
||||
prices = [start_price]
|
||||
for _ in range(n - 1):
|
||||
change = random.uniform(-volatility, volatility)
|
||||
prices.append(max(1.0, prices[-1] + change))
|
||||
|
||||
dates = pd.date_range("2025-01-02", periods=n, freq="B")
|
||||
data = {
|
||||
"datetime": dates,
|
||||
"open": prices,
|
||||
"close": [p + random.uniform(-0.5, 0.5) for p in prices],
|
||||
"high": [p + random.uniform(0, volatility) for p in prices],
|
||||
"low": [p - random.uniform(0, volatility) for p in prices],
|
||||
"vol": [1000.0] * n,
|
||||
}
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
# ── 多级别分析测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiLevel:
|
||||
"""MultiLevelAnalyser 测试。"""
|
||||
|
||||
def test_multi_level_basic(self) -> None:
|
||||
"""多级别分析应返回各级别结果。"""
|
||||
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
df_daily = _make_df(100)
|
||||
df_30min = _make_df(200)
|
||||
|
||||
mla = MultiLevelAnalyser()
|
||||
mla.add_level("daily", ChanlunAnalyser(code="SZ000001", frequency="DAILY"))
|
||||
mla.add_level("30min", ChanlunAnalyser(code="SZ000001", frequency="30MIN"))
|
||||
|
||||
mla.process("daily", df_daily)
|
||||
mla.process("30min", df_30min)
|
||||
|
||||
results = mla.results()
|
||||
assert "daily" in results
|
||||
assert "30min" in results
|
||||
assert len(results["daily"].bis) >= 0
|
||||
assert len(results["30min"].bis) >= 0
|
||||
|
||||
def test_multi_level_low_level_qs(self) -> None:
|
||||
"""高级别笔对应的低级别趋势信息。"""
|
||||
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
|
||||
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
df_daily = _make_df(100)
|
||||
df_30min = _make_df(200)
|
||||
|
||||
mla = MultiLevelAnalyser()
|
||||
mla.add_level("daily", ChanlunAnalyser(code="SZ000001", frequency="DAILY"))
|
||||
mla.add_level("30min", ChanlunAnalyser(code="SZ000001", frequency="30MIN"))
|
||||
|
||||
mla.process("daily", df_daily)
|
||||
mla.process("30min", df_30min)
|
||||
|
||||
daily_result = mla.get_result("daily")
|
||||
if daily_result and len(daily_result.bis) > 0:
|
||||
last_bi = daily_result.bis[-1]
|
||||
qs_info = mla.query_low_level_qs("daily", "30min", last_bi)
|
||||
assert qs_info is not None
|
||||
assert "zs_count" in qs_info
|
||||
assert "bi_count" in qs_info
|
||||
|
||||
def test_multi_level_empty(self) -> None:
|
||||
"""无数据时应返回空结果。"""
|
||||
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
|
||||
|
||||
mla = MultiLevelAnalyser()
|
||||
assert mla.results() == {}
|
||||
|
||||
|
||||
# ── 增量更新测试 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIncrementalUpdate:
|
||||
"""ChanlunAnalyser 增量更新测试。"""
|
||||
|
||||
def test_incremental_update(self) -> None:
|
||||
"""追加 K 线后应重新计算。"""
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
df1 = _make_df(30)
|
||||
analyser = ChanlunAnalyser(code="SZ000001")
|
||||
analyser.process_klines(df1)
|
||||
bi_count_1 = len(analyser.result.bis)
|
||||
|
||||
# 追加更多数据
|
||||
df2 = pd.concat([df1, _make_df(30)], ignore_index=True)
|
||||
# 重新生成 datetime 避免重复
|
||||
df2["datetime"] = pd.date_range("2025-01-02", periods=len(df2), freq="B")
|
||||
analyser.process_klines(df2)
|
||||
bi_count_2 = len(analyser.result.bis)
|
||||
|
||||
# 更长数据应有更多或相等的笔
|
||||
assert bi_count_2 >= bi_count_1
|
||||
|
||||
def test_full_replacement(self) -> None:
|
||||
"""完全替换数据应正常工作。"""
|
||||
from easy_tdx.chanlun.analyser import ChanlunAnalyser
|
||||
|
||||
df1 = _make_df(50)
|
||||
df2 = _make_df(100)
|
||||
|
||||
analyser = ChanlunAnalyser(code="SZ000001")
|
||||
analyser.process_klines(df1)
|
||||
count1 = len(analyser.result.klines)
|
||||
|
||||
analyser.process_klines(df2)
|
||||
count2 = len(analyser.result.klines)
|
||||
|
||||
assert count2 == 100
|
||||
assert count2 > count1
|
||||
|
||||
|
||||
# ── 走势段测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestZsd:
|
||||
"""走势段/趋势段 测试。"""
|
||||
|
||||
def test_zsd_from_xds(self) -> None:
|
||||
"""线段应能组合为走势段。"""
|
||||
from easy_tdx.chanlun.zsd import find_zsds
|
||||
|
||||
from easy_tdx.chanlun.xd import find_xds
|
||||
|
||||
cks = [
|
||||
CLKline(
|
||||
k_index=i,
|
||||
date=datetime(2025, 1, 2 + i),
|
||||
open=10,
|
||||
close=10,
|
||||
high=10 + i % 5,
|
||||
low=10 - i % 3,
|
||||
amount=0.0,
|
||||
index=i,
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
# 使用更真实的数据
|
||||
import random
|
||||
|
||||
random.seed(42)
|
||||
h_vals = [10]
|
||||
l_vals = [8]
|
||||
for i in range(1, 20):
|
||||
h_vals.append(h_vals[-1] + random.uniform(-2, 3))
|
||||
l_vals.append(l_vals[-1] + random.uniform(-3, 2))
|
||||
|
||||
cks = [
|
||||
CLKline(
|
||||
k_index=i,
|
||||
date=datetime(2025, 1, 2) + __import__("datetime").timedelta(days=i),
|
||||
open=l_vals[i],
|
||||
close=h_vals[i],
|
||||
high=max(h_vals[i], l_vals[i]) + 1,
|
||||
low=min(h_vals[i], l_vals[i]) - 1,
|
||||
amount=1000.0,
|
||||
index=i,
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
fxs = find_fractals(cks)
|
||||
bis = find_bis(fxs)
|
||||
xds = find_xds(bis)
|
||||
|
||||
zsds = find_zsds(xds)
|
||||
# 可能没有足够的线段形成走势段
|
||||
assert isinstance(zsds, list)
|
||||
|
||||
def test_empty_xds(self) -> None:
|
||||
"""空线段列表应返回空走势段。"""
|
||||
from easy_tdx.chanlun.zsd import find_zsds
|
||||
|
||||
assert find_zsds([]) == []
|
||||
Reference in New Issue
Block a user