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
+13 -2
View File
@@ -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]:
+13
View File
@@ -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",
]
+170
View File
@@ -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
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
+10 -1
View File
@@ -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()
+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)
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))