diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eef5eb4 --- /dev/null +++ b/CHANGELOG.md @@ -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`。 diff --git a/README.md b/README.md index e009a25..e0c5d4c 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/examples/23_screen_strength/README.md b/examples/23_screen_strength/README.md new file mode 100644 index 0000000..6d5126b --- /dev/null +++ b/examples/23_screen_strength/README.md @@ -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)`。 diff --git a/examples/23_screen_strength/strength_api.py b/examples/23_screen_strength/strength_api.py new file mode 100644 index 0000000..3f69b82 --- /dev/null +++ b/examples/23_screen_strength/strength_api.py @@ -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() diff --git a/examples/23_screen_strength/strength_cli.sh b/examples/23_screen_strength/strength_cli.sh new file mode 100644 index 0000000..3b35279 --- /dev/null +++ b/examples/23_screen_strength/strength_cli.sh @@ -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 diff --git a/examples/23_screen_strength/strength_web_api.py b/examples/23_screen_strength/strength_web_api.py new file mode 100644 index 0000000..d1ea99a --- /dev/null +++ b/examples/23_screen_strength/strength_web_api.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 2df395d..7a68475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/offline/daily_bar.py b/src/easy_tdx/offline/daily_bar.py index 776f186..b195bcb 100644 --- a/src/easy_tdx/offline/daily_bar.py +++ b/src/easy_tdx/offline/daily_bar.py @@ -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]: diff --git a/src/easy_tdx/screen/__init__.py b/src/easy_tdx/screen/__init__.py index 27bd3d1..31bc2a4 100644 --- a/src/easy_tdx/screen/__init__.py +++ b/src/easy_tdx/screen/__init__.py @@ -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", ] diff --git a/src/easy_tdx/screen/cli.py b/src/easy_tdx/screen/cli.py index c2f4308..9f412dd 100644 --- a/src/easy_tdx/screen/cli.py +++ b/src/easy_tdx/screen/cli.py @@ -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 + + # ── 辅助函数 ────────────────────────────────────────────────────────────────── diff --git a/src/easy_tdx/screen/ranker.py b/src/easy_tdx/screen/ranker.py index 5d358f1..55ee03e 100644 --- a/src/easy_tdx/screen/ranker.py +++ b/src/easy_tdx/screen/ranker.py @@ -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() diff --git a/src/easy_tdx/screen/strength.py b/src/easy_tdx/screen/strength.py new file mode 100644 index 0000000..9582e2e --- /dev/null +++ b/src/easy_tdx/screen/strength.py @@ -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)}") diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index 6e11d4b..1ce495d 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -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)) diff --git a/tests/unit/test_screen.py b/tests/unit/test_screen.py index 33dda38..c3ef703 100644 --- a/tests/unit/test_screen.py +++ b/tests/unit/test_screen.py @@ -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" +