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:
Justin Gu
2026-06-25 03:33:13 +08:00
parent 85e0f8a65f
commit f36e2d6a6c
14 changed files with 1597 additions and 4 deletions
+41
View File
@@ -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`)—— 上交所科创板 ETF588/589)、LOF560-563)、货币 ETF551)、普通 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`。
+88
View File
@@ -660,6 +660,87 @@ easy-tdx screen rank --from signals.json --sort sharpe --top 10 --table --names
| `--names` | 在线补齐股票名称(默认关闭,只查排名中的几十只) | | `--names` | 在线补齐股票名称(默认关闭,只查排名中的几十只) |
| `--count` | rank 使用最近 N 条 K 线(0=全部,默认 0) | | `--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 日涨幅及指数平滑判断短中长线趋势是否同向,用于筛选趋势刚启动的强势股。 捉妖大师是多周期涨幅共振指标,通过 20/60/120 日涨幅及指数平滑判断短中长线趋势是否同向,用于筛选趋势刚启动的强势股。
@@ -841,6 +922,13 @@ curl -X POST "http://localhost:8000/api/v1/quotes" \
# 市场统计 # 市场统计
curl "http://localhost:8000/api/v1/market/stat" 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" curl "http://localhost:8000/api/v1/block?filename=block_gn.dat"
+96
View File
@@ -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)`
+104
View File
@@ -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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.14.5" version = "1.15.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+13 -2
View File
@@ -30,6 +30,9 @@ def _detect_security_type(filename: str) -> str:
"""从文件名推断证券类型。 """从文件名推断证券类型。
文件名格式: {exchange}{code}.day,如 sh600000.day、sz000001.day 文件名格式: {exchange}{code}.day,如 sh600000.day、sz000001.day
依据上交所/深交所《证券代码段分配指南》判定。无法识别的代码段
返回 "UNKNOWN"(而非默认深市 A 股),避免把基金/ETF/债券误判为股票。
""" """
base = Path(filename).name.lower() base = Path(filename).name.lower()
exchange = base[:2] # "sh" or "sz" exchange = base[:2] # "sh" or "sz"
@@ -44,21 +47,29 @@ def _detect_security_type(filename: str) -> str:
return "SZ_INDEX" return "SZ_INDEX"
if code_head in ("15", "16"): if code_head in ("15", "16"):
return "SZ_FUND" return "SZ_FUND"
if code_head in ("17", "18"): # 封闭式基金 / LOF / ETF
return "SZ_FUND"
if code_head in ("10", "11", "12", "13", "14"): if code_head in ("10", "11", "12", "13", "14"):
return "SZ_BOND" return "SZ_BOND"
elif exchange == "sh": elif exchange == "sh":
if code_head == "60": if code_head == "60":
return "SH_A_STOCK" return "SH_A_STOCK"
if code_head == "68": # 科创板(688 开头)
return "SH_A_STOCK"
if code_head == "90": if code_head == "90":
return "SH_B_STOCK" return "SH_B_STOCK"
if code_head in ("00", "88", "99"): if code_head in ("00", "88", "99"):
return "SH_INDEX" 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" return "SH_FUND"
if code_head in ("01", "10", "11", "12", "13", "14"): if code_head in ("01", "10", "11", "12", "13", "14"):
return "SH_BOND" 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]: def read_daily_bars(filepath: str | Path) -> list[SecurityBar]:
+13
View File
@@ -4,6 +4,8 @@
1. scan: 用策略扫描全市场,找出触发买入信号的股票(纯离线) 1. scan: 用策略扫描全市场,找出触发买入信号的股票(纯离线)
2. rank: 对扫描结果做历史回测排名 2. rank: 对扫描结果做历史回测排名
另外提供 strength: 全市场强势股排名(按 5/20/60 日涨幅加权排序)。
用法:: 用法::
# Step 1: 信号扫描 # Step 1: 信号扫描
@@ -11,11 +13,22 @@
# Step 2: 回测排名 # Step 2: 回测排名
easy-tdx screen rank --from signals.json --sort sharpe --top 20 --table 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.scanner import ScanResult, SignalScanner # noqa: F401
from easy_tdx.screen.strength import ( # noqa: F401
STRENGTH_PRESETS,
StrengthRanker,
StrengthResult,
)
__all__ = [ __all__ = [
"SignalScanner", "SignalScanner",
"ScanResult", "ScanResult",
"StrengthRanker",
"StrengthResult",
"STRENGTH_PRESETS",
] ]
+170
View File
@@ -3,11 +3,13 @@
子命令: 子命令:
scan — 纯离线扫描信号 scan — 纯离线扫描信号
rank — 回测排名 rank — 回测排名
strength — 全市场强势股排名(5/20/60 日涨幅加权)
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
import click import click
@@ -219,6 +221,174 @@ def rank_cmd(
click.echo(ranker.to_json(entries, strategy_name, sort_by)) 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
# ── 辅助函数 ────────────────────────────────────────────────────────────────── # ── 辅助函数 ──────────────────────────────────────────────────────────────────
+10 -1
View File
@@ -191,6 +191,8 @@ class SignalRanker:
仅对排名中的股票查询,通常只有几十只。 仅对排名中的股票查询,通常只有几十只。
分批查询(每批最多 80 只),避免超出 MAC 协议单次报价上限导致末尾名字丢失。
Args: Args:
entries: 排名列表 entries: 排名列表
@@ -210,7 +212,14 @@ class SignalRanker:
client = MacClient.from_best_host() client = MacClient.from_best_host()
try: try:
client.connect() 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: finally:
client.close() client.close()
+489
View File
@@ -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)}")
+68
View File
@@ -98,3 +98,71 @@ async def history_fund_flow(
"""获取个股历史日线资金流向。""" """获取个股历史日线资金流向。"""
df = await client.get_history_fund_flow(market_from_str(market), code, start, count) df = await client.get_history_fund_flow(market_from_str(market), code, start, count)
return _df_response(df) 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))
+314
View File
@@ -342,6 +342,49 @@ class TestScanOne:
assert _detect_security_type("sz399001.day") == "SZ_INDEX" assert _detect_security_type("sz399001.day") == "SZ_INDEX"
assert _detect_security_type("sz159919.day") == "SZ_FUND" 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
# ── 深交所封闭式基金 / LOFsz184801 曾误判为 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): with pytest.raises(SystemExit):
_load_strategy(str(filepath)) _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"]
# 调整后 = 原始 / volvol < 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"