mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
feat(screen): v1.15.0 — 强势股排名 + 修复证券类型识别与名称分批查询
新增:强势股排名(screen strength) - 全市场按 5/20/60 日涨幅加权合成强势分,纯离线扫描 - 三种预设:steady(稳健)/breakout(妖股)/balanced(均衡) - CLI: easy-tdx screen strength --preset steady --top 50 --table - Web API: GET /api/v1/market/strength - 支持自定义权重、成交额过滤、并发扫描 修复: - _detect_security_type 代码段不全,ETF/基金/科创板/逆回购被误判为 A 股 - screen strength/rank 名称补齐超 80 只时末尾被丢弃(分批查询) 详见 CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 更新日志
|
||||
|
||||
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
||||
|
||||
## [1.15.0] — 2026-06-25
|
||||
|
||||
### 新增
|
||||
|
||||
- **强势股排名(strength)** — 全市场按 5/20/60 日涨幅加权合成强势分,选出"最近最强"的股票。
|
||||
- 新增核心引擎 `easy_tdx.screen.strength.StrengthRanker`,纯离线读取本地 `.day` 文件,复用 `SignalScanner` 的并发/进度回调架构。
|
||||
- 新增 CLI 子命令 `easy-tdx screen strength`,支持表格 / JSON 输出。
|
||||
- 新增 Web API 端点 `GET /api/v1/market/strength`,通过线程池执行避免阻塞事件循环。
|
||||
- **三种预设模式**:
|
||||
- `steady`(默认):中长期稳健,60 日权重主导 + 波动率惩罚,选出"稳着涨"的票。
|
||||
- `breakout`:近期妖股爆发,5 日权重主导,纯加权涨幅(不除波动率),选出短期最猛的票。
|
||||
- `balanced`:三周期均衡 + 波动率调整。
|
||||
- 支持自定义权重(自动归一化)、成交额过滤、上市天数过滤、并发扫描。
|
||||
- 输出含 `data_date` / `last_date` 字段,标注数据截止日,便于判断时效。
|
||||
- 示例代码见 `examples/23_screen_strength/`。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`_detect_security_type` 代码段判定不全**(`offline/daily_bar.py`)—— 上交所科创板 ETF(588/589)、LOF(560-563)、货币 ETF(551)、普通 ETF(520-530)等代码段,以及深交所封闭式基金/LOF(17/18 开头)、国债逆回购(204 开头)被默认返回值误判为深市 A 股,导致 `screen strength` / `screen scan` 把基金和 ETF 混入股票排名。修复后补全所有已知代码段,默认返回 `UNKNOWN`(不再误判成 A 股)。
|
||||
- **`screen strength` / `screen rank` 名称补齐分批 bug**(`screen/cli.py`、`screen/ranker.py`)—— `MacClient.get_stock_quotes` 单次最多 80 只,传入超过 80 只时末尾名称被服务器静默丢弃。修复后改为 80 只/批分页查询。
|
||||
|
||||
### 变更
|
||||
|
||||
- `easy_tdx.screen.__init__` 导出 `StrengthRanker`、`StrengthResult`、`STRENGTH_PRESETS`。
|
||||
- README 增加「强势股排名(strength)」章节及 Web API 调用示例。
|
||||
|
||||
## [1.14.5] — 2026-06-12
|
||||
|
||||
- feat(chanlun): 分钟级别日期自适应输出时分 YYYY-MM-DD HH:MM
|
||||
- release: v1.14.4 — 修复 cmd_chanlun.py ruff format CI 失败
|
||||
- release: v1.14.3 — 缠论 CLI table 模式补日期(中枢/买卖点/背驰)
|
||||
- feat(chanlun): CLI table 模式 zss/mmds/bcs 显示日期字段
|
||||
- release: v1.14.2 — 缠论 JSON 可视化字段增强(中枢/买卖点/背驰补日期)
|
||||
|
||||
---
|
||||
|
||||
> 历史版本变更请参考 `git log`。
|
||||
@@ -660,6 +660,87 @@ easy-tdx screen rank --from signals.json --sort sharpe --top 10 --table --names
|
||||
| `--names` | 在线补齐股票名称(默认关闭,只查排名中的几十只) |
|
||||
| `--count` | rank 使用最近 N 条 K 线(0=全部,默认 0) |
|
||||
|
||||
#### 强势股排名(strength)
|
||||
|
||||
按 **5 / 20 / 60 日涨幅加权**合成强势分,从全市场选出"最近最强"的股票。**纯离线数据**,读取本地通达信 `.day` 文件,全市场约 30-60 秒(并发可压到 10 秒内)。
|
||||
|
||||
**三种预设模式:**
|
||||
|
||||
| 模式 | 性格 | 权重 (w5/w20/w60) | 波动率惩罚 | 适合 |
|
||||
|------|------|-------------------|-----------|------|
|
||||
| `steady`(默认) | 中长期稳健 | 0.2 / 0.3 / 0.5 | ✅ 除以 vol_20 | 选"稳着涨"的票,妖股被高波动压低 |
|
||||
| `breakout` | 近期妖股爆发 | 0.6 / 0.3 / 0.1 | ❌ 纯加权涨幅 | 选"短期最猛"的票,妖股本身就是高波动 |
|
||||
| `balanced` | 三周期均衡 | 等权 + vol 调整 | ✅ 除以 vol_20 | 不确定时的安全默认 |
|
||||
|
||||
> 💡 **为什么 breakout 不除波动率?** 妖股本质高波动,除以 vol 会把它压下去,与"找妖股"目标矛盾。steady 除以 vol 是为了奖励"稳着涨"的票(vol 小,score 放大)。
|
||||
|
||||
```bash
|
||||
# 中长期稳健强势 Top 50(默认 steady 模式)
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
|
||||
# 近期妖股爆发 Top 20(补齐股票名称)
|
||||
easy-tdx screen strength --preset breakout --top 20 --names --table
|
||||
|
||||
# 三周期均衡
|
||||
easy-tdx screen strength --preset balanced --top 30 --table
|
||||
|
||||
# 自定义权重(自动归一化,5:3:2 = 0.5:0.3:0.2)
|
||||
easy-tdx screen strength --w5 0.5 --w20 0.3 --w60 0.2 --top 30 --table
|
||||
|
||||
# 并发扫描(推荐 4-8 进程)
|
||||
easy-tdx screen strength --preset steady --top 100 --workers 4 --table
|
||||
|
||||
# 过滤低流动性(最近 5 日日均成交额 ≥ 5000 万)
|
||||
easy-tdx screen strength --preset breakout --top 30 --min-amount 50000000 --table
|
||||
|
||||
# 缩小范围 + 输出到文件
|
||||
easy-tdx screen strength --universe sz --top 30 --output sz_strength.json
|
||||
```
|
||||
|
||||
输出示例(`--table`):
|
||||
|
||||
```
|
||||
[*] 强势股排名 [steady] 共 50 只
|
||||
数据截止: 2026-06-24 | 中长期稳健强势:权重偏 60 日,波动率惩罚,选出稳着涨的票
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
排名 代码 名称 现价 5日 20日 60日 波动率 强势分
|
||||
*1 SZ300308 中际旭创 85.20 8.12% 15.34% 30.21% 0.0180 9.52
|
||||
*2 SH600519 贵州茅台 1800.00 3.25% 5.10% 10.05% 0.0120 6.21
|
||||
```
|
||||
|
||||
输出示例(JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_time": "2026-06-25T10:30:00",
|
||||
"preset": "steady",
|
||||
"preset_desc": "中长期稳健强势:权重偏 60 日,波动率惩罚...",
|
||||
"data_date": 20260624,
|
||||
"total_ranked": 50,
|
||||
"ranking": [
|
||||
{"rank": 1, "code": "300308", "market": "SZ", "name": "中际旭创",
|
||||
"last_close": 85.20, "last_date": 20260624,
|
||||
"ret_5": 0.0812, "ret_20": 0.1534, "ret_60": 0.3021,
|
||||
"vol_20": 0.0180, "strength": 9.52}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--preset` | 预设模式:`steady`(默认)/ `breakout` / `balanced` |
|
||||
| `--w5` `--w20` `--w60` | 自定义三周期权重(覆盖预设,自动归一化) |
|
||||
| `--vol-adjusted` / `--no-vol-adjusted` | 波动率惩罚开关(覆盖预设) |
|
||||
| `--top` | 返回前 N 名(默认 50) |
|
||||
| `--universe` | `all`(默认)/ `sh` / `sz` / 文件路径 |
|
||||
| `--min-listed-days` | 最小上市天数(默认 65,保证能算 60 日涨幅) |
|
||||
| `--min-amount` | 最近 5 日日均成交额下限(元,默认 0 不过滤) |
|
||||
| `--workers` | 并发进程数:`0` 串行 / `4+` 并发(推荐 4-8) |
|
||||
| `--names` | 在线补齐股票名称(默认关闭) |
|
||||
| `--output` | 输出 JSON 文件(默认 stdout) |
|
||||
|
||||
> ⚠️ **数据时效**:strength 依赖本地 `.day` 文件。输出中的 `data_date` / `last_date` 字段标注数据截止日,请先用 `easy-tdx offline sync` 同步最新数据。
|
||||
|
||||
### 捉妖大师(重点)
|
||||
|
||||
捉妖大师是多周期涨幅共振指标,通过 20/60/120 日涨幅及指数平滑判断短中长线趋势是否同向,用于筛选趋势刚启动的强势股。
|
||||
@@ -841,6 +922,13 @@ curl -X POST "http://localhost:8000/api/v1/quotes" \
|
||||
# 市场统计
|
||||
curl "http://localhost:8000/api/v1/market/stat"
|
||||
|
||||
# 全市场强势股排名(基于本地 vipdoc 数据,扫描约 30-60 秒)
|
||||
# steady = 中长期稳健 / breakout = 近期妖股 / balanced = 均衡
|
||||
curl "http://localhost:8000/api/v1/market/strength?preset=breakout&top_n=20"
|
||||
|
||||
# 自定义权重 + 过滤低流动性(日均成交额 ≥ 5000 万)
|
||||
curl "http://localhost:8000/api/v1/market/strength?w5=0.5&w20=0.3&w60=0.2&min_amount=50000000&top_n=30"
|
||||
|
||||
# 板块信息(标准协议)
|
||||
curl "http://localhost:8000/api/v1/block?filename=block_gn.dat"
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 23. 强势股排名(screen strength)
|
||||
|
||||
按 **5 / 20 / 60 日涨幅加权**合成强势分,从全市场选出"最近最强"的股票。
|
||||
|
||||
## 三种预设模式
|
||||
|
||||
| 模式 | 性格 | 适合 |
|
||||
|------|------|------|
|
||||
| `steady` | 中长期稳健(60日主导 + 波动率惩罚) | 选"稳着涨"的票 |
|
||||
| `breakout` | 近期妖股爆发(5日主导,纯涨幅) | 选"短期最猛"的票 |
|
||||
| `balanced` | 三周期均衡 + 波动率调整 | 不确定时的安全默认 |
|
||||
|
||||
## 前提条件
|
||||
|
||||
需要本地通达信 `.day` 日线数据(扫描纯离线,无网络请求):
|
||||
|
||||
```bash
|
||||
# 同步最新日线数据
|
||||
easy-tdx offline sync
|
||||
|
||||
# 或用通达信客户端下载日线数据到 vipdoc/{sh,sz}/lday/
|
||||
```
|
||||
|
||||
## 示例文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `strength_api.py` | Python API 调用(`StrengthRanker` 类) |
|
||||
| `strength_cli.sh` | CLI 命令示例(`easy-tdx screen strength`) |
|
||||
| `strength_web_api.py` | Web API 调用(`GET /api/v1/market/strength`) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
ranker = StrengthRanker(preset="steady")
|
||||
results = ranker.rank(top_n=20)
|
||||
for r in results[:5]:
|
||||
print(f"#{r.rank} {r.market}{r.code} 强势分={r.strength:.2f}")
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# 表格输出
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
|
||||
# 近期妖股 + 补齐名称
|
||||
easy-tdx screen strength --preset breakout --top 20 --names --table
|
||||
|
||||
# 自定义权重(自动归一化)
|
||||
easy-tdx screen strength --w5 0.5 --w20 0.3 --w60 0.2 --top 30 --table
|
||||
```
|
||||
|
||||
### Web API
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
easy-tdx serve
|
||||
|
||||
# 调用接口
|
||||
curl "http://localhost:8000/api/v1/market/strength?preset=breakout&top_n=20"
|
||||
```
|
||||
|
||||
## 输出字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rank` | int | 排名 |
|
||||
| `code` | str | 6 位股票代码 |
|
||||
| `market` | str | 市场(SZ/SH) |
|
||||
| `name` | str | 股票名称(需 `--names` 开启) |
|
||||
| `last_close` | float | 最新收盘价 |
|
||||
| `last_date` | int | 数据截止日(YYYYMMDD) |
|
||||
| `ret_5` | float | 5 日涨幅 |
|
||||
| `ret_20` | float | 20 日涨幅 |
|
||||
| `ret_60` | float | 60 日涨幅 |
|
||||
| `vol_20` | float | 20 日波动率(对数收益率标准差) |
|
||||
| `strength` | float | 强势综合分(排序依据) |
|
||||
|
||||
## 公式
|
||||
|
||||
```
|
||||
ret_5 = close[-1] / close[-6] - 1
|
||||
ret_20 = close[-1] / close[-21] - 1
|
||||
ret_60 = close[-1] / close[-61] - 1
|
||||
vol_20 = std(log_return, 20)[-1]
|
||||
|
||||
strength = (w5·ret_5 + w20·ret_20 + w60·ret_60) / vol_20 # vol_adjusted=True
|
||||
strength = w5·ret_5 + w20·ret_20 + w60·ret_60 # vol_adjusted=False
|
||||
```
|
||||
|
||||
权重自动归一化:`w = w / (w5 + w20 + w60)`。
|
||||
@@ -0,0 +1,104 @@
|
||||
"""强势股排名 — Python API 示例。
|
||||
|
||||
本示例演示如何用 StrengthRanker 扫描全市场,按 5/20/60 日涨幅加权选出强势股。
|
||||
|
||||
运行前提:
|
||||
1. 本地安装通达信,且 vipdoc/{sh,sz}/lday/*.day 数据已同步(含最新交易日)。
|
||||
2. 可通过 easy-tdx offline sync 命令同步数据。
|
||||
3. pip install easy-tdx
|
||||
|
||||
运行方式:
|
||||
python examples/23_screen_strength/strength_api.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS, StrengthRanker
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# ── 1. 查看所有预设模式 ──────────────────────────────────────────────
|
||||
print("=" * 60)
|
||||
print("可用预设模式:")
|
||||
print("=" * 60)
|
||||
for name, cfg in STRENGTH_PRESETS.items():
|
||||
print(f" {name:10} w5={cfg['w5']:.2f} w20={cfg['w20']:.2f} "
|
||||
f"w60={cfg['w60']:.2f} vol_adjusted={cfg['vol_adjusted']}")
|
||||
print(f" {cfg['desc']}")
|
||||
print()
|
||||
|
||||
# ── 2. steady 模式:中长期稳健强势 Top 20 ────────────────────────────
|
||||
print("=" * 60)
|
||||
print("[steady] 中长期稳健强势 Top 20")
|
||||
print("=" * 60)
|
||||
ranker = StrengthRanker(preset="steady")
|
||||
|
||||
# 进度回调(扫描 ~5000 只约 30-60 秒)
|
||||
def on_progress(current: int, total: int, name: str) -> None:
|
||||
if name == "done":
|
||||
print(f"\r扫描完成: {total} 只")
|
||||
else:
|
||||
pct = current * 100 // total if total > 0 else 0
|
||||
print(f"\r[{current}/{total}] {pct}% scanning {name}", end="")
|
||||
|
||||
results = ranker.rank(top_n=20, progress_callback=on_progress)
|
||||
data_date = results[0].last_date if results else 0
|
||||
print()
|
||||
print(ranker.to_table(results, "steady", data_date))
|
||||
print()
|
||||
|
||||
# ── 3. breakout 模式:近期妖股爆发 Top 10 ───────────────────────────
|
||||
print("=" * 60)
|
||||
print("[breakout] 近期妖股爆发 Top 10")
|
||||
print("=" * 60)
|
||||
breakout_ranker = StrengthRanker(preset="breakout")
|
||||
results = breakout_ranker.rank(top_n=10)
|
||||
data_date = results[0].last_date if results else 0
|
||||
print(breakout_ranker.to_table(results, "breakout", data_date))
|
||||
print()
|
||||
|
||||
# ── 4. 自定义权重 + 成交额过滤 ──────────────────────────────────────
|
||||
print("=" * 60)
|
||||
print("[自定义] 5:3:2 权重 + 日均成交额 ≥ 5000 万")
|
||||
print("=" * 60)
|
||||
custom_ranker = StrengthRanker(
|
||||
w5=0.5, w20=0.3, w60=0.2,
|
||||
vol_adjusted=False, # 纯加权涨幅
|
||||
min_amount=50_000_000, # 最近 5 日日均成交额 ≥ 5000 万
|
||||
)
|
||||
results = custom_ranker.rank(top_n=15)
|
||||
data_date = results[0].last_date if results else 0
|
||||
print(custom_ranker.to_table(results, "custom", data_date))
|
||||
print()
|
||||
|
||||
# ── 5. 并发扫描 + JSON 输出到文件 ───────────────────────────────────
|
||||
print("=" * 60)
|
||||
print("[并发] balanced 模式 + 4 进程 + 输出 JSON")
|
||||
print("=" * 60)
|
||||
parallel_ranker = StrengthRanker(preset="balanced")
|
||||
results = parallel_ranker.rank(
|
||||
top_n=50,
|
||||
workers=4, # 4 进程并发,速度提升约 4 倍
|
||||
progress_callback=on_progress,
|
||||
)
|
||||
data_date = results[0].last_date if results else 0
|
||||
json_str = parallel_ranker.to_json(results, "balanced", data_date)
|
||||
|
||||
output_file = "strength_balanced.json"
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(json_str)
|
||||
print(f"\n排名: {len(results)} 只 → {output_file}")
|
||||
|
||||
# ── 6. 编程式访问排名数据 ───────────────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("[编程式访问] 遍历前 5 名")
|
||||
print("=" * 60)
|
||||
for r in results[:5]:
|
||||
print(f" #{r.rank} {r.market}{r.code} 现价={r.last_close:.2f} "
|
||||
f"5日={r.ret_5:+.2%} 20日={r.ret_20:+.2%} 60日={r.ret_60:+.2%} "
|
||||
f"强势分={r.strength:.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# easy-tdx 强势股排名 — CLI 使用示例
|
||||
#
|
||||
# 前提:本地通达信 vipdoc/{sh,sz}/lday/*.day 已同步最新数据
|
||||
# 可用 `easy-tdx offline sync` 同步
|
||||
#
|
||||
# 三种预设模式:
|
||||
# steady — 中长期稳健(60日主导 + 波动率惩罚),选稳着涨的票
|
||||
# breakout — 近期妖股爆发(5日主导,纯涨幅),选最猛的票
|
||||
# balanced — 三周期均衡 + 波动率调整
|
||||
#
|
||||
# 用法:去掉命令前的 # 即可实际执行。
|
||||
|
||||
echo "================================================================"
|
||||
echo "1. steady 模式 — 中长期稳健强势 Top 50(表格输出)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --preset steady --top 50 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "2. breakout 模式 — 近期妖股爆发 Top 20(补齐股票名称)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --preset breakout --top 20 --names --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "3. balanced 模式 — 三周期均衡 Top 30"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --preset balanced --top 30 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "4. 自定义权重(自动归一化,5:3:2 = 0.5:0.3:0.2)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --w5 0.5 --w20 0.3 --w60 0.2 --top 30 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "5. 自定义权重 + 关闭波动率惩罚(纯加权涨幅)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --w5 0.6 --w20 0.3 --w60 0.1 --no-vol-adjusted --top 20 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "6. 并发扫描(4 进程,速度提升约 4 倍)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --preset steady --top 100 --workers 4 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "7. 过滤低流动性(最近 5 日日均成交额 ≥ 5000 万)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --preset breakout --top 30 --min-amount 50000000 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "8. 缩小范围(仅深圳)+ 输出到 JSON 文件"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --universe sz --top 30 --output sz_strength.json
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "9. 仅上海 + 最小上市天数 120 日(过滤次新股)"
|
||||
echo "================================================================"
|
||||
# easy-tdx screen strength --universe sh --min-listed-days 120 --top 30 --table
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo "10. 对比三种预设(同一批股票,不同视角)"
|
||||
echo "================================================================"
|
||||
echo "--- steady(稳健)---"
|
||||
# easy-tdx screen strength --preset steady --top 10 --table
|
||||
echo ""
|
||||
echo "--- breakout(妖股)---"
|
||||
# easy-tdx screen strength --preset breakout --top 10 --table
|
||||
echo ""
|
||||
echo "--- balanced(均衡)---"
|
||||
# easy-tdx screen strength --preset balanced --top 10 --table
|
||||
@@ -0,0 +1,112 @@
|
||||
"""强势股排名 — Web API 调用示例。
|
||||
|
||||
演示如何通过 HTTP 调用 easy-tdx 的 REST API 获取强势股排名。
|
||||
|
||||
前提:
|
||||
1. 启动 Web API 服务:easy-tdx serve --port 8000
|
||||
2. 本地 vipdoc 数据已同步(扫描依赖本地 .day 文件)
|
||||
3. pip install requests
|
||||
|
||||
运行方式:
|
||||
python examples/23_screen_strength/strength_web_api.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8000/api/v1"
|
||||
|
||||
|
||||
def fetch_strength(
|
||||
preset: str = "steady",
|
||||
top_n: int = 20,
|
||||
universe: str = "all",
|
||||
min_amount: float = 0.0,
|
||||
) -> dict:
|
||||
"""调用 GET /market/strength 获取强势股排名。
|
||||
|
||||
Args:
|
||||
preset: 预设模式 steady / breakout / balanced
|
||||
top_n: 返回前 N 名
|
||||
universe: 范围 all / sh / sz
|
||||
min_amount: 日均成交额下限(元)
|
||||
|
||||
Returns:
|
||||
{"data": [...], "count": N}
|
||||
"""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/market/strength",
|
||||
params={
|
||||
"preset": preset,
|
||||
"top_n": top_n,
|
||||
"universe": universe,
|
||||
"min_amount": min_amount,
|
||||
},
|
||||
timeout=120, # 扫描全市场可能需要 30-60 秒
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_strength_custom_weights(
|
||||
w5: float = 0.5,
|
||||
w20: float = 0.3,
|
||||
w60: float = 0.2,
|
||||
top_n: int = 30,
|
||||
) -> dict:
|
||||
"""自定义权重调用(覆盖预设)。"""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/market/strength",
|
||||
params={
|
||||
"w5": w5, "w20": w20, "w60": w60,
|
||||
"top_n": top_n,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def print_ranking(result: dict, title: str) -> None:
|
||||
"""格式化打印排名结果。"""
|
||||
print(f"\n{'=' * 70}")
|
||||
print(f" {title}")
|
||||
print(f"{'=' * 70}")
|
||||
data = result.get("data", [])
|
||||
if not data:
|
||||
print(" 无数据")
|
||||
return
|
||||
|
||||
print(f" {'排名':>4} {'代码':<10} {'现价':>10} "
|
||||
f"{'5日':>8} {'20日':>8} {'60日':>8} {'强势分':>8}")
|
||||
print(f" {'-' * 66}")
|
||||
for row in data:
|
||||
print(f" {row['rank']:>4} {row['market']}{row['code']:<9} "
|
||||
f"{row['last_close']:>9.2f} "
|
||||
f"{row['ret_5']:>7.2%} {row['ret_20']:>7.2%} "
|
||||
f"{row['ret_60']:>7.2%} {row['strength']:>8.2f}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# ── 1. steady 模式:中长期稳健 Top 20 ───────────────────────────────
|
||||
result = fetch_strength(preset="steady", top_n=20)
|
||||
print_ranking(result, "[steady] 中长期稳健强势 Top 20")
|
||||
|
||||
# ── 2. breakout 模式:近期妖股 Top 10 ───────────────────────────────
|
||||
result = fetch_strength(preset="breakout", top_n=10)
|
||||
print_ranking(result, "[breakout] 近期妖股爆发 Top 10")
|
||||
|
||||
# ── 3. 自定义权重 + 成交额过滤 ──────────────────────────────────────
|
||||
result = fetch_strength_custom_weights(w5=0.5, w20=0.3, w60=0.2, top_n=15)
|
||||
print_ranking(result, "[自定义 5:3:2] Top 15")
|
||||
|
||||
# ── 4. 过滤低流动性(日均成交额 ≥ 5000 万)─────────────────────────
|
||||
result = fetch_strength(
|
||||
preset="breakout", top_n=20, min_amount=50_000_000
|
||||
)
|
||||
print_ranking(result, "[breakout + 流动性过滤] Top 20")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.14.5"
|
||||
version = "1.15.0"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -30,6 +30,9 @@ def _detect_security_type(filename: str) -> str:
|
||||
"""从文件名推断证券类型。
|
||||
|
||||
文件名格式: {exchange}{code}.day,如 sh600000.day、sz000001.day
|
||||
|
||||
依据上交所/深交所《证券代码段分配指南》判定。无法识别的代码段
|
||||
返回 "UNKNOWN"(而非默认深市 A 股),避免把基金/ETF/债券误判为股票。
|
||||
"""
|
||||
base = Path(filename).name.lower()
|
||||
exchange = base[:2] # "sh" or "sz"
|
||||
@@ -44,21 +47,29 @@ def _detect_security_type(filename: str) -> str:
|
||||
return "SZ_INDEX"
|
||||
if code_head in ("15", "16"):
|
||||
return "SZ_FUND"
|
||||
if code_head in ("17", "18"): # 封闭式基金 / LOF / ETF
|
||||
return "SZ_FUND"
|
||||
if code_head in ("10", "11", "12", "13", "14"):
|
||||
return "SZ_BOND"
|
||||
elif exchange == "sh":
|
||||
if code_head == "60":
|
||||
return "SH_A_STOCK"
|
||||
if code_head == "68": # 科创板(688 开头)
|
||||
return "SH_A_STOCK"
|
||||
if code_head == "90":
|
||||
return "SH_B_STOCK"
|
||||
if code_head in ("00", "88", "99"):
|
||||
return "SH_INDEX"
|
||||
if code_head in ("50", "51"):
|
||||
if code_head in ("50", "51", "52", "53", "55", "56", "58"):
|
||||
# 501 LOF / 510-519 ETF / 520-529 ETF / 530-539 ETF
|
||||
# 550-556 货币ETF / 560-563 LOF / 588-589 科创板ETF
|
||||
return "SH_FUND"
|
||||
if code_head in ("01", "10", "11", "12", "13", "14"):
|
||||
return "SH_BOND"
|
||||
if code_head == "20": # 国债逆回购(204xxx)
|
||||
return "SH_BOND"
|
||||
|
||||
return "SZ_A_STOCK" # 默认按 A 股处理
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def read_daily_bars(filepath: str | Path) -> list[SecurityBar]:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
1. scan: 用策略扫描全市场,找出触发买入信号的股票(纯离线)
|
||||
2. rank: 对扫描结果做历史回测排名
|
||||
|
||||
另外提供 strength: 全市场强势股排名(按 5/20/60 日涨幅加权排序)。
|
||||
|
||||
用法::
|
||||
|
||||
# Step 1: 信号扫描
|
||||
@@ -11,11 +13,22 @@
|
||||
|
||||
# Step 2: 回测排名
|
||||
easy-tdx screen rank --from signals.json --sort sharpe --top 20 --table
|
||||
|
||||
# 强势股排名
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
"""
|
||||
|
||||
from easy_tdx.screen.scanner import ScanResult, SignalScanner # noqa: F401
|
||||
from easy_tdx.screen.strength import ( # noqa: F401
|
||||
STRENGTH_PRESETS,
|
||||
StrengthRanker,
|
||||
StrengthResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SignalScanner",
|
||||
"ScanResult",
|
||||
"StrengthRanker",
|
||||
"StrengthResult",
|
||||
"STRENGTH_PRESETS",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
子命令:
|
||||
scan — 纯离线扫描信号
|
||||
rank — 回测排名
|
||||
strength — 全市场强势股排名(5/20/60 日涨幅加权)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
@@ -219,6 +221,174 @@ def rank_cmd(
|
||||
click.echo(ranker.to_json(entries, strategy_name, sort_by))
|
||||
|
||||
|
||||
# ── strength 子命令 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@screen.command("strength")
|
||||
@click.option(
|
||||
"--preset",
|
||||
default="steady",
|
||||
type=click.Choice(["steady", "breakout", "balanced"]),
|
||||
help="预设模式: steady(中长期稳健,默认) / breakout(近期妖股) / balanced(均衡)",
|
||||
)
|
||||
@click.option("--w5", default=None, type=float, help="自定义 5 日权重(覆盖预设)")
|
||||
@click.option("--w20", default=None, type=float, help="自定义 20 日权重(覆盖预设)")
|
||||
@click.option("--w60", default=None, type=float, help="自定义 60 日权重(覆盖预设)")
|
||||
@click.option(
|
||||
"--vol-adjusted/--no-vol-adjusted",
|
||||
default=None,
|
||||
help="是否波动率惩罚(覆盖预设)",
|
||||
)
|
||||
@click.option("--top", "top_n", default=50, type=int, help="返回前 N 名(默认 50)")
|
||||
@click.option("--universe", default="all", help="范围: all/sh/sz/<文件路径>")
|
||||
@click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)")
|
||||
@click.option("--min-listed-days", default=65, type=int, help="最小上市天数(默认 65)")
|
||||
@click.option(
|
||||
"--min-amount",
|
||||
default=0.0,
|
||||
type=float,
|
||||
help="最近 5 日日均成交额下限(元,默认不过滤)",
|
||||
)
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=0,
|
||||
type=int,
|
||||
help="并发进程数: 0=串行(默认),4-8 推荐",
|
||||
)
|
||||
@click.option("--output", "output_file", default=None, help="输出 JSON 文件(默认 stdout)")
|
||||
@click.option("--table", "use_table", is_flag=True, help="表格输出")
|
||||
@click.option("--names/--no-names", default=False, help="在线查询股票名称(默认关闭)")
|
||||
def strength_cmd(
|
||||
preset: str,
|
||||
w5: float | None,
|
||||
w20: float | None,
|
||||
w60: float | None,
|
||||
vol_adjusted: bool | None,
|
||||
top_n: int,
|
||||
universe: str,
|
||||
vipdoc: str | None,
|
||||
min_listed_days: int,
|
||||
min_amount: float,
|
||||
workers: int,
|
||||
output_file: str | None,
|
||||
use_table: bool,
|
||||
names: bool,
|
||||
) -> None:
|
||||
"""全市场强势股排名 — 按 5/20/60 日涨幅加权排序。
|
||||
|
||||
三种预设:
|
||||
|
||||
steady — 中长期稳健(60日主导 + 波动率惩罚),选稳着涨的票
|
||||
|
||||
breakout — 近期妖股爆发(5日主导,纯涨幅),选最猛的票
|
||||
|
||||
balanced — 三周期均衡 + 波动率调整
|
||||
|
||||
示例:
|
||||
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
|
||||
easy-tdx screen strength --preset breakout --top 20 --names --table
|
||||
|
||||
easy-tdx screen strength --w5 0.5 --w20 0.3 --w60 0.2 --top 30
|
||||
"""
|
||||
from .strength import StrengthRanker
|
||||
|
||||
click.echo(f"模式: {preset}", err=True)
|
||||
click.echo(f"范围: {universe} | Top: {top_n}", err=True)
|
||||
if workers > 0:
|
||||
click.echo(f"并发: {workers} 进程", err=True)
|
||||
|
||||
ranker = StrengthRanker(
|
||||
vipdoc_path=vipdoc,
|
||||
preset=preset,
|
||||
w5=w5,
|
||||
w20=w20,
|
||||
w60=w60,
|
||||
vol_adjusted=vol_adjusted,
|
||||
min_listed_days=min_listed_days,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
|
||||
def on_progress(current: int, total: int, name: str) -> None:
|
||||
if name == "done":
|
||||
click.echo(f"\r扫描完成: {total} 只", err=True)
|
||||
else:
|
||||
pct = current * 100 // total if total > 0 else 0
|
||||
click.echo(f"\r[{current}/{total}] {pct}% {name}", nl=False, err=True)
|
||||
|
||||
results = ranker.rank(
|
||||
universe=universe,
|
||||
top_n=top_n,
|
||||
workers=workers,
|
||||
progress_callback=on_progress,
|
||||
)
|
||||
|
||||
# 数据截止日期(取排名第一的 last_date)
|
||||
data_date = results[0].last_date if results else 0
|
||||
|
||||
# 可选补齐名称
|
||||
if names and results:
|
||||
click.echo("\n获取股票名称...", err=True)
|
||||
results = _enrich_strength_names(results)
|
||||
|
||||
if use_table:
|
||||
click.echo(ranker.to_table(results, preset, data_date))
|
||||
else:
|
||||
json_str = ranker.to_json(results, preset, data_date)
|
||||
if output_file:
|
||||
Path(output_file).write_text(json_str, encoding="utf-8")
|
||||
click.echo(f"排名: {len(results)} 只 → {output_file}")
|
||||
else:
|
||||
click.echo(json_str)
|
||||
|
||||
|
||||
def _enrich_strength_names(
|
||||
results: list[Any],
|
||||
) -> list[Any]:
|
||||
"""在线查询补齐股票名称(复用 ranker 的逻辑)。
|
||||
|
||||
分批查询(每批最多 80 只),避免超出 MAC 协议单次报价上限导致末尾名字丢失。
|
||||
"""
|
||||
try:
|
||||
from easy_tdx.cli.parsers import parse_market
|
||||
from easy_tdx.mac.client import MacClient
|
||||
|
||||
pairs = [(parse_market(r.market), r.code) for r in results]
|
||||
client = MacClient.from_best_host()
|
||||
try:
|
||||
client.connect()
|
||||
# 分批查询:MAC 协议单次最多 80 只,超出部分会被服务器丢弃
|
||||
import pandas as pd
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for i in range(0, len(pairs), 80):
|
||||
batch = pairs[i : i + 80]
|
||||
frames.append(client.get_stock_quotes(batch))
|
||||
quotes_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if quotes_df.empty or "name" not in quotes_df.columns:
|
||||
return results
|
||||
|
||||
_market_map = {0: "SZ", 1: "SH"}
|
||||
name_map: dict[str, str] = {}
|
||||
for _, row in quotes_df.iterrows():
|
||||
mkt_int = row.get("market", -1)
|
||||
mkt_str = _market_map.get(mkt_int, str(mkt_int))
|
||||
key = f"{mkt_str}{row.get('code', '')}"
|
||||
name_map[key] = str(row.get("name", ""))
|
||||
|
||||
for r in results:
|
||||
r.name = name_map.get(f"{r.market}{r.code}", "")
|
||||
except Exception:
|
||||
# 名称查询失败不影响主流程
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +191,8 @@ class SignalRanker:
|
||||
|
||||
仅对排名中的股票查询,通常只有几十只。
|
||||
|
||||
分批查询(每批最多 80 只),避免超出 MAC 协议单次报价上限导致末尾名字丢失。
|
||||
|
||||
Args:
|
||||
entries: 排名列表
|
||||
|
||||
@@ -210,7 +212,14 @@ class SignalRanker:
|
||||
client = MacClient.from_best_host()
|
||||
try:
|
||||
client.connect()
|
||||
quotes_df = client.get_stock_quotes(pairs)
|
||||
# 分批查询:MAC 协议单次最多 80 只,超出部分会被服务器丢弃
|
||||
import pandas as pd
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for i in range(0, len(pairs), 80):
|
||||
batch = pairs[i : i + 80]
|
||||
frames.append(client.get_stock_quotes(batch))
|
||||
quotes_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"""强势股排名引擎 — 全市场多周期涨幅加权排序。
|
||||
|
||||
核心流程:
|
||||
1. 扫描 vipdoc/{sh,sz}/lday/*.day 获取 A 股文件列表
|
||||
2. 每只股票:read_daily_bars() → 计算 ret_5/ret_20/ret_60/vol_20
|
||||
3. 按预设模式加权合成 strength 分数
|
||||
4. 排序输出
|
||||
|
||||
三种预设:
|
||||
steady — 中长期稳健(w60 主导 + 波动率惩罚),选出稳着涨的票
|
||||
breakout — 近期妖股爆发(w5 主导,纯涨幅),选出短期最猛的票
|
||||
balanced — 三周期均衡(等权 + 波动率惩罚)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.offline.daily_bar import _detect_security_type, read_daily_bars
|
||||
from easy_tdx.offline.paths import resolve_vipdoc
|
||||
|
||||
_A_STOCK_TYPES = frozenset({"SH_A_STOCK", "SZ_A_STOCK"})
|
||||
|
||||
# ── 预设模式 ──────────────────────────────────────────────────────────────
|
||||
|
||||
STRENGTH_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"steady": {
|
||||
"w5": 0.2,
|
||||
"w20": 0.3,
|
||||
"w60": 0.5,
|
||||
"vol_adjusted": True,
|
||||
"desc": "中长期稳健强势:权重偏 60 日,波动率惩罚,选出稳着涨的票",
|
||||
},
|
||||
"breakout": {
|
||||
"w5": 0.6,
|
||||
"w20": 0.3,
|
||||
"w60": 0.1,
|
||||
"vol_adjusted": False,
|
||||
"desc": "近期妖股爆发:权重偏 5 日,无波动率惩罚,选出短期最猛的票",
|
||||
},
|
||||
"balanced": {
|
||||
"w5": 0.34,
|
||||
"w20": 0.33,
|
||||
"w60": 0.33,
|
||||
"vol_adjusted": True,
|
||||
"desc": "均衡强势:三周期等权,波动率调整",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrengthResult:
|
||||
"""单只股票的强势分结果。
|
||||
|
||||
Attributes:
|
||||
rank: 排名(排序后赋值)
|
||||
code: 6 位股票代码
|
||||
market: 市场(SZ/SH)
|
||||
name: 股票名称(可选,需在线查询补齐)
|
||||
last_close: 最新收盘价
|
||||
last_date: 最新交易日(YYYYMMDD 整数)
|
||||
ret_5: 5 日涨幅
|
||||
ret_20: 20 日涨幅
|
||||
ret_60: 60 日涨幅
|
||||
vol_20: 20 日波动率(对数收益率标准差)
|
||||
strength: 强势综合分
|
||||
"""
|
||||
|
||||
rank: int = 0
|
||||
code: str = ""
|
||||
market: str = ""
|
||||
name: str = ""
|
||||
last_close: float = 0.0
|
||||
last_date: int = 0
|
||||
ret_5: float = 0.0
|
||||
ret_20: float = 0.0
|
||||
ret_60: float = 0.0
|
||||
vol_20: float = 0.0
|
||||
strength: float = 0.0
|
||||
|
||||
|
||||
def compute_strength_metrics(
|
||||
closes: pd.Series,
|
||||
w5: float,
|
||||
w20: float,
|
||||
w60: float,
|
||||
vol_adjusted: bool,
|
||||
) -> dict[str, float] | None:
|
||||
"""纯计算函数:给定收盘价序列,返回强势指标字典。
|
||||
|
||||
Args:
|
||||
closes: 收盘价 Series(按时间升序)
|
||||
w5/w20/w60: 三周期权重(自动归一化)
|
||||
vol_adjusted: 是否除以波动率
|
||||
|
||||
Returns:
|
||||
{"ret_5", "ret_20", "ret_60", "vol_20", "strength"} 或 None(数据不足)
|
||||
"""
|
||||
n = len(closes)
|
||||
if n < 65: # 至少需要 61 日算 ret_60,留余量
|
||||
return None
|
||||
|
||||
# 权重归一化
|
||||
w_sum = w5 + w20 + w60
|
||||
if w_sum <= 0:
|
||||
return None
|
||||
w5, w20, w60 = w5 / w_sum, w20 / w_sum, w60 / w_sum
|
||||
|
||||
last = closes.iloc[-1]
|
||||
ret_5 = last / closes.iloc[-6] - 1
|
||||
ret_20 = last / closes.iloc[-21] - 1
|
||||
ret_60 = last / closes.iloc[-61] - 1
|
||||
|
||||
# 20 日波动率(对数收益率标准差)
|
||||
log_ret = np.log(closes / closes.shift(1))
|
||||
vol_20 = float(log_ret.rolling(20).std().iloc[-1])
|
||||
|
||||
if vol_20 <= 0 or np.isnan(vol_20):
|
||||
return None
|
||||
|
||||
raw = w5 * ret_5 + w20 * ret_20 + w60 * ret_60
|
||||
strength = raw / vol_20 if vol_adjusted else raw
|
||||
|
||||
if np.isnan(strength):
|
||||
return None
|
||||
|
||||
return {
|
||||
"ret_5": float(ret_5),
|
||||
"ret_20": float(ret_20),
|
||||
"ret_60": float(ret_60),
|
||||
"vol_20": vol_20,
|
||||
"strength": float(strength),
|
||||
}
|
||||
|
||||
|
||||
class StrengthRanker:
|
||||
"""全市场强势股排名器。
|
||||
|
||||
用法::
|
||||
|
||||
ranker = StrengthRanker(preset="steady")
|
||||
results = ranker.rank(top_n=50)
|
||||
for r in results[:5]:
|
||||
print(f"#{r.rank} {r.market}{r.code} strength={r.strength:.2f}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vipdoc_path: str | Path | None = None,
|
||||
preset: str = "steady",
|
||||
w5: float | None = None,
|
||||
w20: float | None = None,
|
||||
w60: float | None = None,
|
||||
vol_adjusted: bool | None = None,
|
||||
min_listed_days: int = 65,
|
||||
min_amount: float = 0.0,
|
||||
) -> None:
|
||||
"""初始化排名器。
|
||||
|
||||
Args:
|
||||
vipdoc_path: vipdoc 目录路径,None 则自动检测
|
||||
preset: 预设模式 steady/breakout/balanced
|
||||
w5/w20/w60: 自定义权重(非 None 时覆盖预设)
|
||||
vol_adjusted: 自定义波动率惩罚开关(非 None 时覆盖预设)
|
||||
min_listed_days: 最小上市天数(默认 65,保证能算 60 日涨幅)
|
||||
min_amount: 最近 5 日日均成交额下限(默认 0 不过滤,单位:元)
|
||||
"""
|
||||
if preset not in STRENGTH_PRESETS:
|
||||
raise ValueError(f"未知预设 '{preset}',可选: {list(STRENGTH_PRESETS.keys())}")
|
||||
cfg = STRENGTH_PRESETS[preset]
|
||||
self._preset = preset
|
||||
self._w5 = w5 if w5 is not None else cfg["w5"]
|
||||
self._w20 = w20 if w20 is not None else cfg["w20"]
|
||||
self._w60 = w60 if w60 is not None else cfg["w60"]
|
||||
self._vol_adjusted = vol_adjusted if vol_adjusted is not None else cfg["vol_adjusted"]
|
||||
self._min_listed_days = min_listed_days
|
||||
self._min_amount = min_amount
|
||||
self._vipdoc = resolve_vipdoc(vipdoc_path)
|
||||
|
||||
@property
|
||||
def preset(self) -> str:
|
||||
"""当前预设名称。"""
|
||||
return self._preset
|
||||
|
||||
def rank(
|
||||
self,
|
||||
universe: str = "all",
|
||||
top_n: int = 50,
|
||||
workers: int = 0,
|
||||
progress_callback: Any = None,
|
||||
) -> list[StrengthResult]:
|
||||
"""扫描全市场并返回强势股排名。
|
||||
|
||||
Args:
|
||||
universe: all/sh/sz/<文件路径>
|
||||
top_n: 返回前 N 名,0=全部
|
||||
workers: 并发进程数(0=串行,4-8 推荐)
|
||||
progress_callback: 回调(current, total, name)
|
||||
|
||||
Returns:
|
||||
按 strength 降序排列的 StrengthResult 列表
|
||||
"""
|
||||
files = self._collect_files(universe)
|
||||
if not files:
|
||||
return []
|
||||
total = len(files)
|
||||
|
||||
if workers <= 0:
|
||||
results = self._rank_serial(files, total, progress_callback)
|
||||
else:
|
||||
results = self._rank_parallel(files, total, workers, progress_callback)
|
||||
|
||||
# 排序 + 赋名次
|
||||
results.sort(key=lambda r: r.strength, reverse=True)
|
||||
for i, r in enumerate(results):
|
||||
r.rank = i + 1
|
||||
|
||||
if top_n > 0:
|
||||
results = results[:top_n]
|
||||
return results
|
||||
|
||||
def _collect_files(self, universe: str) -> list[tuple[Path, str, str]]:
|
||||
"""收集 A 股 .day 文件列表(复用 scanner 的逻辑)。"""
|
||||
exchanges: list[str] = []
|
||||
if universe in ("all", "sz"):
|
||||
exchanges.append("sz")
|
||||
if universe in ("all", "sh"):
|
||||
exchanges.append("sh")
|
||||
|
||||
# 从文件列表模式读取
|
||||
if universe not in ("all", "sh", "sz"):
|
||||
return self._collect_from_file(universe)
|
||||
|
||||
files: list[tuple[Path, str, str]] = []
|
||||
for exchange in exchanges:
|
||||
lday_dir = self._vipdoc / exchange / "lday"
|
||||
if not lday_dir.is_dir():
|
||||
continue
|
||||
for filepath in sorted(lday_dir.glob("*.day")):
|
||||
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
|
||||
continue
|
||||
code = filepath.name.lower()[2:8]
|
||||
files.append((filepath, exchange.upper(), code))
|
||||
return files
|
||||
|
||||
def _collect_from_file(self, filepath: str) -> list[tuple[Path, str, str]]:
|
||||
"""从文件读取股票列表(每行 "市场 代码")。"""
|
||||
path = Path(filepath)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"股票列表文件不存在: {filepath}")
|
||||
|
||||
files: list[tuple[Path, str, str]] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
market_str = parts[0].upper()
|
||||
code = parts[1]
|
||||
else:
|
||||
continue
|
||||
exchange = market_str.lower()
|
||||
day_file = self._vipdoc / exchange / "lday" / f"{exchange}{code}.day"
|
||||
if day_file.is_file():
|
||||
files.append((day_file, market_str, code))
|
||||
return files
|
||||
|
||||
def _rank_serial(
|
||||
self,
|
||||
files: list[tuple[Path, str, str]],
|
||||
total: int,
|
||||
progress_callback: Any,
|
||||
) -> list[StrengthResult]:
|
||||
"""串行扫描。"""
|
||||
results: list[StrengthResult] = []
|
||||
for idx, (filepath, market, code) in enumerate(files):
|
||||
if progress_callback:
|
||||
progress_callback(idx, total, filepath.name)
|
||||
try:
|
||||
r = self._compute_one(filepath, market, code)
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "done")
|
||||
return results
|
||||
|
||||
def _rank_parallel(
|
||||
self,
|
||||
files: list[tuple[Path, str, str]],
|
||||
total: int,
|
||||
workers: int,
|
||||
progress_callback: Any,
|
||||
) -> list[StrengthResult]:
|
||||
"""并发扫描(ProcessPoolExecutor)。"""
|
||||
import concurrent.futures
|
||||
|
||||
tasks = [
|
||||
(
|
||||
str(fp),
|
||||
mkt,
|
||||
code,
|
||||
self._w5,
|
||||
self._w20,
|
||||
self._w60,
|
||||
self._vol_adjusted,
|
||||
self._min_listed_days,
|
||||
self._min_amount,
|
||||
)
|
||||
for fp, mkt, code in files
|
||||
]
|
||||
|
||||
results: list[StrengthResult] = []
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
future_map = {ex.submit(_compute_strength_one, *t): i for i, t in enumerate(tasks)}
|
||||
done = 0
|
||||
for fut in concurrent.futures.as_completed(future_map):
|
||||
done += 1
|
||||
idx = future_map[fut]
|
||||
if progress_callback:
|
||||
progress_callback(done, total, files[idx][0].name)
|
||||
try:
|
||||
r = fut.result()
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "done")
|
||||
return results
|
||||
|
||||
def _compute_one(self, filepath: Path, market: str, code: str) -> StrengthResult | None:
|
||||
"""计算单只股票的强势分。"""
|
||||
bars = read_daily_bars(filepath)
|
||||
if len(bars) < self._min_listed_days:
|
||||
return None
|
||||
|
||||
closes = pd.Series([b.close for b in bars])
|
||||
|
||||
# 成交额过滤(最近 5 日平均值)
|
||||
if self._min_amount > 0:
|
||||
recent_amount = float(np.mean([b.amount for b in bars[-5:]]))
|
||||
if recent_amount < self._min_amount:
|
||||
return None
|
||||
|
||||
metrics = compute_strength_metrics(
|
||||
closes, self._w5, self._w20, self._w60, self._vol_adjusted
|
||||
)
|
||||
if metrics is None:
|
||||
return None
|
||||
|
||||
last_bar = bars[-1]
|
||||
return StrengthResult(
|
||||
code=code,
|
||||
market=market,
|
||||
last_close=last_bar.close,
|
||||
last_date=last_bar.year * 10000 + last_bar.month * 100 + last_bar.day,
|
||||
ret_5=metrics["ret_5"],
|
||||
ret_20=metrics["ret_20"],
|
||||
ret_60=metrics["ret_60"],
|
||||
vol_20=metrics["vol_20"],
|
||||
strength=metrics["strength"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_json(results: list[StrengthResult], preset: str, data_date: int) -> str:
|
||||
"""将排名结果序列化为 JSON 字符串。"""
|
||||
data = {
|
||||
"scan_time": datetime.now().isoformat(timespec="seconds"),
|
||||
"preset": preset,
|
||||
"preset_desc": STRENGTH_PRESETS.get(preset, {}).get("desc", ""),
|
||||
"data_date": data_date,
|
||||
"total_ranked": len(results),
|
||||
"ranking": [
|
||||
{
|
||||
"rank": r.rank,
|
||||
"code": r.code,
|
||||
"market": r.market,
|
||||
"name": r.name,
|
||||
"last_close": r.last_close,
|
||||
"last_date": r.last_date,
|
||||
"ret_5": r.ret_5,
|
||||
"ret_20": r.ret_20,
|
||||
"ret_60": r.ret_60,
|
||||
"vol_20": r.vol_20,
|
||||
"strength": r.strength,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
return json.dumps(data, ensure_ascii=False, indent=2, default=_json_default)
|
||||
|
||||
@staticmethod
|
||||
def to_table(results: list[StrengthResult], preset: str, data_date: int) -> str:
|
||||
"""将排名结果格式化为表格字符串。"""
|
||||
if not results:
|
||||
return "无有效排名结果"
|
||||
|
||||
desc = STRENGTH_PRESETS.get(preset, {}).get("desc", "")
|
||||
lines = [
|
||||
f"[*] 强势股排名 [{preset}] 共 {len(results)} 只",
|
||||
f" 数据截止: {_fmt_date(data_date)} | {desc}",
|
||||
"═" * 96,
|
||||
f"{'排名':>4} {'代码':<10} {'名称':<8} {'现价':>10} "
|
||||
f"{'5日':>8} {'20日':>8} {'60日':>8} {'波动率':>8} {'强势分':>8}",
|
||||
"─" * 96,
|
||||
]
|
||||
|
||||
for r in results:
|
||||
medal = (
|
||||
" *1*"
|
||||
if r.rank == 1
|
||||
else " *2*"
|
||||
if r.rank == 2
|
||||
else " *3*"
|
||||
if r.rank == 3
|
||||
else " "
|
||||
)
|
||||
name = r.name[:6] if r.name else ""
|
||||
lines.append(
|
||||
f"{medal}{r.rank:>2} {r.market}{r.code:<9} {name:<8} "
|
||||
f"{r.last_close:>9.2f} {r.ret_5:>7.2%} {r.ret_20:>7.2%} "
|
||||
f"{r.ret_60:>7.2%} {r.vol_20:>7.4f} {r.strength:>8.2f}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _compute_strength_one(
|
||||
filepath: str,
|
||||
market: str,
|
||||
code: str,
|
||||
w5: float,
|
||||
w20: float,
|
||||
w60: float,
|
||||
vol_adjusted: bool,
|
||||
min_listed_days: int,
|
||||
min_amount: float,
|
||||
) -> StrengthResult | None:
|
||||
"""顶层函数(供 ProcessPoolExecutor 调用)。"""
|
||||
bars = read_daily_bars(filepath)
|
||||
if len(bars) < min_listed_days:
|
||||
return None
|
||||
|
||||
closes = pd.Series([b.close for b in bars])
|
||||
|
||||
if min_amount > 0:
|
||||
recent = float(np.mean([b.amount for b in bars[-5:]]))
|
||||
if recent < min_amount:
|
||||
return None
|
||||
|
||||
metrics = compute_strength_metrics(closes, w5, w20, w60, vol_adjusted)
|
||||
if metrics is None:
|
||||
return None
|
||||
|
||||
last = bars[-1]
|
||||
return StrengthResult(
|
||||
code=code,
|
||||
market=market,
|
||||
last_close=last.close,
|
||||
last_date=last.year * 10000 + last.month * 100 + last.day,
|
||||
ret_5=metrics["ret_5"],
|
||||
ret_20=metrics["ret_20"],
|
||||
ret_60=metrics["ret_60"],
|
||||
vol_20=metrics["vol_20"],
|
||||
strength=metrics["strength"],
|
||||
)
|
||||
|
||||
|
||||
def _fmt_date(d: int) -> str:
|
||||
"""YYYYMMDD 整数 → YYYY-MM-DD 字符串。"""
|
||||
s = str(d)
|
||||
return f"{s[:4]}-{s[4:6]}-{s[6:]}" if len(s) == 8 else str(d)
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""JSON 序列化辅助(numpy 标量等)。"""
|
||||
if hasattr(obj, "item"):
|
||||
return obj.item()
|
||||
raise TypeError(f"无法序列化 {type(obj)}")
|
||||
@@ -98,3 +98,71 @@ async def history_fund_flow(
|
||||
"""获取个股历史日线资金流向。"""
|
||||
df = await client.get_history_fund_flow(market_from_str(market), code, start, count)
|
||||
return _df_response(df)
|
||||
|
||||
|
||||
@router.get("/market/strength", response_model=DataFrameResponse)
|
||||
async def market_strength(
|
||||
preset: str = Query(
|
||||
"steady",
|
||||
description="预设模式: steady(中长期稳健) / breakout(近期妖股) / balanced(均衡)",
|
||||
),
|
||||
w5: float | None = Query(None, description="自定义 5 日权重(覆盖预设)"),
|
||||
w20: float | None = Query(None, description="自定义 20 日权重(覆盖预设)"),
|
||||
w60: float | None = Query(None, description="自定义 60 日权重(覆盖预设)"),
|
||||
vol_adjusted: bool | None = Query(None, description="波动率惩罚开关(覆盖预设)"),
|
||||
top_n: int = Query(50, ge=1, le=5000, description="返回前 N 名"),
|
||||
universe: str = Query("all", description="范围: all/sh/sz"),
|
||||
min_listed_days: int = Query(65, ge=30, description="最小上市天数"),
|
||||
min_amount: float = Query(0.0, ge=0, description="最近 5 日日均成交额下限(元)"),
|
||||
vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"),
|
||||
) -> DataFrameResponse:
|
||||
"""全市场强势股排名(基于本地通达信 .day 日线文件)。
|
||||
|
||||
按 5/20/60 日涨幅加权合成强势分。三种预设:
|
||||
|
||||
- **steady**: 中长期稳健(60日主导 + 波动率惩罚),选出稳着涨的票
|
||||
- **breakout**: 近期妖股爆发(5日主导,纯涨幅),选出短期最猛的票
|
||||
- **balanced**: 三周期均衡 + 波动率调整
|
||||
|
||||
注意:需要本地 vipdoc 数据,扫描 ~5000 只约 30-60 秒。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
ranker = StrengthRanker(
|
||||
vipdoc_path=vipdoc,
|
||||
preset=preset,
|
||||
w5=w5,
|
||||
w20=w20,
|
||||
w60=w60,
|
||||
vol_adjusted=vol_adjusted,
|
||||
min_listed_days=min_listed_days,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
|
||||
# Web 端用线程池执行,避免阻塞事件循环(扫描全市场耗时较长)
|
||||
# 注:在协程内用 get_running_loop() 而非 get_event_loop(),
|
||||
# 后者在 Python 3.12+ 已弃用。
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None, lambda: ranker.rank(universe=universe, top_n=top_n)
|
||||
)
|
||||
|
||||
records = [
|
||||
{
|
||||
"rank": r.rank,
|
||||
"code": r.code,
|
||||
"market": r.market,
|
||||
"name": r.name,
|
||||
"last_close": r.last_close,
|
||||
"last_date": r.last_date,
|
||||
"ret_5": r.ret_5,
|
||||
"ret_20": r.ret_20,
|
||||
"ret_60": r.ret_60,
|
||||
"vol_20": r.vol_20,
|
||||
"strength": r.strength,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
return DataFrameResponse(data=records, count=len(records))
|
||||
|
||||
@@ -342,6 +342,49 @@ class TestScanOne:
|
||||
assert _detect_security_type("sz399001.day") == "SZ_INDEX"
|
||||
assert _detect_security_type("sz159919.day") == "SZ_FUND"
|
||||
|
||||
def test_detect_security_type_etf_and_funds(self) -> None:
|
||||
"""ETF / 基金 / 科创板 / 国债逆回购不应被误判为 A 股。
|
||||
|
||||
回归测试:修复前 sh588710/sh562590/sz184801/sh204001 等被
|
||||
_detect_security_type 默认返回值误判为 SZ_A_STOCK。
|
||||
"""
|
||||
from easy_tdx.offline.daily_bar import _detect_security_type
|
||||
|
||||
# ── 真 A 股(必须正确识别)──
|
||||
assert _detect_security_type("sh600000.day") == "SH_A_STOCK"
|
||||
assert _detect_security_type("sh601869.day") == "SH_A_STOCK" # 长飞光纤
|
||||
assert _detect_security_type("sh688146.day") == "SH_A_STOCK" # 科创板
|
||||
assert _detect_security_type("sz000001.day") == "SZ_A_STOCK"
|
||||
assert _detect_security_type("sz300489.day") == "SZ_A_STOCK" # 创业板
|
||||
|
||||
# ── 上交所 ETF / LOF / 货币基金(曾经误判为 SZ_A_STOCK)──
|
||||
assert _detect_security_type("sh588710.day") == "SH_FUND" # 科创板ETF
|
||||
assert _detect_security_type("sh588000.day") == "SH_FUND"
|
||||
assert _detect_security_type("sh589000.day") == "SH_FUND" # 科创板行业ETF
|
||||
assert _detect_security_type("sh562590.day") == "SH_FUND" # 科创板LOF
|
||||
assert _detect_security_type("sh563000.day") == "SH_FUND"
|
||||
assert _detect_security_type("sh520500.day") == "SH_FUND" # ETF
|
||||
assert _detect_security_type("sh530000.day") == "SH_FUND"
|
||||
assert _detect_security_type("sh551000.day") == "SH_FUND" # 货币ETF
|
||||
assert _detect_security_type("sh501000.day") == "SH_FUND" # LOF
|
||||
assert _detect_security_type("sh510300.day") == "SH_FUND" # 沪深300ETF
|
||||
|
||||
# ── 深交所封闭式基金 / LOF(sz184801 曾误判为 SZ_A_STOCK)──
|
||||
assert _detect_security_type("sz184801.day") == "SZ_FUND"
|
||||
assert _detect_security_type("sz150200.day") == "SZ_FUND" # 分级基金
|
||||
assert _detect_security_type("sz161725.day") == "SZ_FUND" # LOF
|
||||
|
||||
# ── 国债逆回购(债券类)──
|
||||
assert _detect_security_type("sh204001.day") == "SH_BOND" # GC001
|
||||
|
||||
# ── 指数 ──
|
||||
assert _detect_security_type("sh000001.day") == "SH_INDEX" # 上证综指
|
||||
assert _detect_security_type("sz399001.day") == "SZ_INDEX" # 深证成指
|
||||
|
||||
# ── 未知代码段不应被默认成 A 股 ──
|
||||
assert _detect_security_type("sh777777.day") == "UNKNOWN"
|
||||
assert _detect_security_type("sz777777.day") == "UNKNOWN"
|
||||
|
||||
|
||||
# ── 策略加载测试 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -382,3 +425,274 @@ class DummyStrategy(Strategy):
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
_load_strategy(str(filepath))
|
||||
|
||||
|
||||
# ── 强势股排名测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStrengthPresets:
|
||||
"""测试预设模式配置。"""
|
||||
|
||||
def test_preset_keys(self) -> None:
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS
|
||||
|
||||
assert set(STRENGTH_PRESETS.keys()) == {"steady", "breakout", "balanced"}
|
||||
|
||||
def test_steady_config(self) -> None:
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS
|
||||
|
||||
cfg = STRENGTH_PRESETS["steady"]
|
||||
assert cfg["w60"] > cfg["w5"] # 60 日主导
|
||||
assert cfg["vol_adjusted"] is True
|
||||
|
||||
def test_breakout_config(self) -> None:
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS
|
||||
|
||||
cfg = STRENGTH_PRESETS["breakout"]
|
||||
assert cfg["w5"] > cfg["w60"] # 5 日主导
|
||||
assert cfg["vol_adjusted"] is False # 妖股不惩罚波动
|
||||
|
||||
def test_balanced_config(self) -> None:
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS
|
||||
|
||||
cfg = STRENGTH_PRESETS["balanced"]
|
||||
# 三周期接近等权
|
||||
assert abs(cfg["w5"] - cfg["w20"]) < 0.05
|
||||
assert abs(cfg["w20"] - cfg["w60"]) < 0.05
|
||||
assert cfg["vol_adjusted"] is True
|
||||
|
||||
def test_all_presets_have_desc(self) -> None:
|
||||
from easy_tdx.screen.strength import STRENGTH_PRESETS
|
||||
|
||||
for name, cfg in STRENGTH_PRESETS.items():
|
||||
assert "desc" in cfg, f"预设 {name} 缺少 desc"
|
||||
assert isinstance(cfg["desc"], str) and len(cfg["desc"]) > 0
|
||||
|
||||
|
||||
class TestComputeStrengthMetrics:
|
||||
"""测试纯计算函数 compute_strength_metrics。"""
|
||||
|
||||
def test_data_too_short(self) -> None:
|
||||
"""少于 65 根 K 线返回 None。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.1 for i in range(30)])
|
||||
assert compute_strength_metrics(closes, 0.3, 0.3, 0.4, True) is None
|
||||
|
||||
def test_steady_uptrend(self) -> None:
|
||||
"""稳定上涨的票,steady 模式应有正分。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.05 for i in range(70)]) # 稳定上涨
|
||||
m = compute_strength_metrics(closes, 0.2, 0.3, 0.5, True)
|
||||
assert m is not None
|
||||
assert m["ret_5"] > 0
|
||||
assert m["ret_20"] > 0
|
||||
assert m["ret_60"] > 0
|
||||
assert m["strength"] > 0
|
||||
|
||||
def test_weight_normalization(self) -> None:
|
||||
"""权重应自动归一化(同比例权重结果相同)。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.1 for i in range(70)])
|
||||
m1 = compute_strength_metrics(closes, 0.3, 0.3, 0.4, False)
|
||||
m2 = compute_strength_metrics(closes, 3.0, 3.0, 4.0, False) # 10 倍
|
||||
assert m1 is not None and m2 is not None
|
||||
assert abs(m1["strength"] - m2["strength"]) < 1e-10
|
||||
|
||||
def test_vol_adjusted_differences(self) -> None:
|
||||
"""vol_adjusted True/False 应给出不同分。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.1 for i in range(70)])
|
||||
m_raw = compute_strength_metrics(closes, 0.3, 0.3, 0.4, False)
|
||||
m_adj = compute_strength_metrics(closes, 0.3, 0.3, 0.4, True)
|
||||
assert m_raw is not None and m_adj is not None
|
||||
assert m_raw["strength"] != m_adj["strength"]
|
||||
# 调整后 = 原始 / vol,vol < 1 时调整后更大
|
||||
assert m_adj["strength"] > m_raw["strength"]
|
||||
|
||||
def test_flat_price_zero_vol(self) -> None:
|
||||
"""价格不变时 vol=0,应返回 None。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0] * 70)
|
||||
assert compute_strength_metrics(closes, 0.3, 0.3, 0.4, True) is None
|
||||
|
||||
def test_downtrend_negative_strength(self) -> None:
|
||||
"""下跌趋势的票应有负分。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([20.0 - i * 0.05 for i in range(70)]) # 稳定下跌
|
||||
m = compute_strength_metrics(closes, 0.3, 0.3, 0.4, False)
|
||||
assert m is not None
|
||||
assert m["ret_5"] < 0
|
||||
assert m["strength"] < 0
|
||||
|
||||
def test_all_weights_zero(self) -> None:
|
||||
"""权重全为 0 返回 None。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.1 for i in range(70)])
|
||||
assert compute_strength_metrics(closes, 0.0, 0.0, 0.0, True) is None
|
||||
|
||||
def test_metrics_keys(self) -> None:
|
||||
"""返回的字典应包含所有字段。"""
|
||||
from easy_tdx.screen.strength import compute_strength_metrics
|
||||
|
||||
closes = pd.Series([10.0 + i * 0.1 for i in range(70)])
|
||||
m = compute_strength_metrics(closes, 0.3, 0.3, 0.4, True)
|
||||
assert m is not None
|
||||
assert set(m.keys()) == {
|
||||
"ret_5",
|
||||
"ret_20",
|
||||
"ret_60",
|
||||
"vol_20",
|
||||
"strength",
|
||||
}
|
||||
|
||||
|
||||
class TestStrengthResult:
|
||||
"""测试 StrengthResult 数据结构。"""
|
||||
|
||||
def test_creation(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthResult
|
||||
|
||||
r = StrengthResult(code="000001", market="SZ", strength=1.5)
|
||||
assert r.code == "000001"
|
||||
assert r.market == "SZ"
|
||||
assert r.rank == 0 # 默认
|
||||
assert r.strength == 1.5
|
||||
|
||||
def test_full_creation(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthResult
|
||||
|
||||
r = StrengthResult(
|
||||
rank=1,
|
||||
code="600519",
|
||||
market="SH",
|
||||
name="贵州茅台",
|
||||
last_close=1800.0,
|
||||
last_date=20260624,
|
||||
ret_5=0.05,
|
||||
ret_20=0.12,
|
||||
ret_60=0.25,
|
||||
vol_20=0.015,
|
||||
strength=8.5,
|
||||
)
|
||||
assert r.rank == 1
|
||||
assert r.name == "贵州茅台"
|
||||
assert r.last_date == 20260624
|
||||
|
||||
|
||||
class TestStrengthRankerOutput:
|
||||
"""测试 StrengthRanker 的 JSON/表格输出(不触及文件 IO)。"""
|
||||
|
||||
def _make_results(self) -> list[Any]:
|
||||
from easy_tdx.screen.strength import StrengthResult
|
||||
|
||||
return [
|
||||
StrengthResult(
|
||||
rank=1,
|
||||
code="000001",
|
||||
market="SZ",
|
||||
name="平安银行",
|
||||
last_close=12.5,
|
||||
last_date=20260624,
|
||||
ret_5=0.08,
|
||||
ret_20=0.15,
|
||||
ret_60=0.30,
|
||||
vol_20=0.018,
|
||||
strength=9.5,
|
||||
),
|
||||
StrengthResult(
|
||||
rank=2,
|
||||
code="600519",
|
||||
market="SH",
|
||||
name="",
|
||||
last_close=1800.0,
|
||||
last_date=20260624,
|
||||
ret_5=0.03,
|
||||
ret_20=0.05,
|
||||
ret_60=0.10,
|
||||
vol_20=0.012,
|
||||
strength=6.2,
|
||||
),
|
||||
]
|
||||
|
||||
def test_to_json(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
results = self._make_results()
|
||||
json_str = StrengthRanker.to_json(results, "steady", 20260624)
|
||||
data = json.loads(json_str)
|
||||
|
||||
assert data["preset"] == "steady"
|
||||
assert "preset_desc" in data
|
||||
assert data["data_date"] == 20260624
|
||||
assert data["total_ranked"] == 2
|
||||
assert data["ranking"][0]["rank"] == 1
|
||||
assert data["ranking"][0]["code"] == "000001"
|
||||
assert data["ranking"][1]["code"] == "600519"
|
||||
|
||||
def test_to_table(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
results = self._make_results()
|
||||
table = StrengthRanker.to_table(results, "breakout", 20260624)
|
||||
|
||||
assert "强势股排名" in table
|
||||
assert "breakout" in table
|
||||
assert "数据截止: 2026-06-24" in table
|
||||
assert "SZ000001" in table
|
||||
assert "平安银行" in table
|
||||
|
||||
def test_to_table_empty(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
table = StrengthRanker.to_table([], "steady", 20260624)
|
||||
assert "无有效排名结果" in table
|
||||
|
||||
def test_to_json_includes_all_metrics(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
results = self._make_results()
|
||||
json_str = StrengthRanker.to_json(results, "balanced", 20260624)
|
||||
data = json.loads(json_str)
|
||||
|
||||
entry = data["ranking"][0]
|
||||
for key in ("ret_5", "ret_20", "ret_60", "vol_20", "strength", "last_close", "last_date"):
|
||||
assert key in entry, f"排名条目缺少字段 {key}"
|
||||
|
||||
|
||||
class TestStrengthRankerInit:
|
||||
"""测试 StrengthRanker 初始化(不触及文件 IO)。"""
|
||||
|
||||
def test_invalid_preset_raises(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
# resolve_vipdoc 在 __init__ 中调用,需要 mock 掉
|
||||
with patch("easy_tdx.screen.strength.resolve_vipdoc", return_value=Path("/fake")):
|
||||
with pytest.raises(ValueError, match="未知预设"):
|
||||
StrengthRanker(preset="invalid")
|
||||
|
||||
def test_custom_weights_override_preset(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
with patch("easy_tdx.screen.strength.resolve_vipdoc", return_value=Path("/fake")):
|
||||
ranker = StrengthRanker(
|
||||
preset="steady", w5=0.5, w20=0.3, w60=0.2, vol_adjusted=False
|
||||
)
|
||||
assert ranker._w5 == 0.5
|
||||
assert ranker._w20 == 0.3
|
||||
assert ranker._w60 == 0.2
|
||||
assert ranker._vol_adjusted is False
|
||||
|
||||
def test_preset_property(self) -> None:
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
with patch("easy_tdx.screen.strength.resolve_vipdoc", return_value=Path("/fake")):
|
||||
ranker = StrengthRanker(preset="breakout")
|
||||
assert ranker.preset == "breakout"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user