mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +08:00
feat(screen): v1.15.0 — 强势股排名 + 修复证券类型识别与名称分批查询
新增:强势股排名(screen strength) - 全市场按 5/20/60 日涨幅加权合成强势分,纯离线扫描 - 三种预设:steady(稳健)/breakout(妖股)/balanced(均衡) - CLI: easy-tdx screen strength --preset steady --top 50 --table - Web API: GET /api/v1/market/strength - 支持自定义权重、成交额过滤、并发扫描 修复: - _detect_security_type 代码段不全,ETF/基金/科创板/逆回购被误判为 A 股 - screen strength/rank 名称补齐超 80 只时末尾被丢弃(分批查询) 详见 CHANGELOG.md
This commit is contained in:
@@ -30,6 +30,9 @@ def _detect_security_type(filename: str) -> str:
|
||||
"""从文件名推断证券类型。
|
||||
|
||||
文件名格式: {exchange}{code}.day,如 sh600000.day、sz000001.day
|
||||
|
||||
依据上交所/深交所《证券代码段分配指南》判定。无法识别的代码段
|
||||
返回 "UNKNOWN"(而非默认深市 A 股),避免把基金/ETF/债券误判为股票。
|
||||
"""
|
||||
base = Path(filename).name.lower()
|
||||
exchange = base[:2] # "sh" or "sz"
|
||||
@@ -44,21 +47,29 @@ def _detect_security_type(filename: str) -> str:
|
||||
return "SZ_INDEX"
|
||||
if code_head in ("15", "16"):
|
||||
return "SZ_FUND"
|
||||
if code_head in ("17", "18"): # 封闭式基金 / LOF / ETF
|
||||
return "SZ_FUND"
|
||||
if code_head in ("10", "11", "12", "13", "14"):
|
||||
return "SZ_BOND"
|
||||
elif exchange == "sh":
|
||||
if code_head == "60":
|
||||
return "SH_A_STOCK"
|
||||
if code_head == "68": # 科创板(688 开头)
|
||||
return "SH_A_STOCK"
|
||||
if code_head == "90":
|
||||
return "SH_B_STOCK"
|
||||
if code_head in ("00", "88", "99"):
|
||||
return "SH_INDEX"
|
||||
if code_head in ("50", "51"):
|
||||
if code_head in ("50", "51", "52", "53", "55", "56", "58"):
|
||||
# 501 LOF / 510-519 ETF / 520-529 ETF / 530-539 ETF
|
||||
# 550-556 货币ETF / 560-563 LOF / 588-589 科创板ETF
|
||||
return "SH_FUND"
|
||||
if code_head in ("01", "10", "11", "12", "13", "14"):
|
||||
return "SH_BOND"
|
||||
if code_head == "20": # 国债逆回购(204xxx)
|
||||
return "SH_BOND"
|
||||
|
||||
return "SZ_A_STOCK" # 默认按 A 股处理
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def read_daily_bars(filepath: str | Path) -> list[SecurityBar]:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
1. scan: 用策略扫描全市场,找出触发买入信号的股票(纯离线)
|
||||
2. rank: 对扫描结果做历史回测排名
|
||||
|
||||
另外提供 strength: 全市场强势股排名(按 5/20/60 日涨幅加权排序)。
|
||||
|
||||
用法::
|
||||
|
||||
# Step 1: 信号扫描
|
||||
@@ -11,11 +13,22 @@
|
||||
|
||||
# Step 2: 回测排名
|
||||
easy-tdx screen rank --from signals.json --sort sharpe --top 20 --table
|
||||
|
||||
# 强势股排名
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
"""
|
||||
|
||||
from easy_tdx.screen.scanner import ScanResult, SignalScanner # noqa: F401
|
||||
from easy_tdx.screen.strength import ( # noqa: F401
|
||||
STRENGTH_PRESETS,
|
||||
StrengthRanker,
|
||||
StrengthResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SignalScanner",
|
||||
"ScanResult",
|
||||
"StrengthRanker",
|
||||
"StrengthResult",
|
||||
"STRENGTH_PRESETS",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
子命令:
|
||||
scan — 纯离线扫描信号
|
||||
rank — 回测排名
|
||||
strength — 全市场强势股排名(5/20/60 日涨幅加权)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
@@ -219,6 +221,174 @@ def rank_cmd(
|
||||
click.echo(ranker.to_json(entries, strategy_name, sort_by))
|
||||
|
||||
|
||||
# ── strength 子命令 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@screen.command("strength")
|
||||
@click.option(
|
||||
"--preset",
|
||||
default="steady",
|
||||
type=click.Choice(["steady", "breakout", "balanced"]),
|
||||
help="预设模式: steady(中长期稳健,默认) / breakout(近期妖股) / balanced(均衡)",
|
||||
)
|
||||
@click.option("--w5", default=None, type=float, help="自定义 5 日权重(覆盖预设)")
|
||||
@click.option("--w20", default=None, type=float, help="自定义 20 日权重(覆盖预设)")
|
||||
@click.option("--w60", default=None, type=float, help="自定义 60 日权重(覆盖预设)")
|
||||
@click.option(
|
||||
"--vol-adjusted/--no-vol-adjusted",
|
||||
default=None,
|
||||
help="是否波动率惩罚(覆盖预设)",
|
||||
)
|
||||
@click.option("--top", "top_n", default=50, type=int, help="返回前 N 名(默认 50)")
|
||||
@click.option("--universe", default="all", help="范围: all/sh/sz/<文件路径>")
|
||||
@click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)")
|
||||
@click.option("--min-listed-days", default=65, type=int, help="最小上市天数(默认 65)")
|
||||
@click.option(
|
||||
"--min-amount",
|
||||
default=0.0,
|
||||
type=float,
|
||||
help="最近 5 日日均成交额下限(元,默认不过滤)",
|
||||
)
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=0,
|
||||
type=int,
|
||||
help="并发进程数: 0=串行(默认),4-8 推荐",
|
||||
)
|
||||
@click.option("--output", "output_file", default=None, help="输出 JSON 文件(默认 stdout)")
|
||||
@click.option("--table", "use_table", is_flag=True, help="表格输出")
|
||||
@click.option("--names/--no-names", default=False, help="在线查询股票名称(默认关闭)")
|
||||
def strength_cmd(
|
||||
preset: str,
|
||||
w5: float | None,
|
||||
w20: float | None,
|
||||
w60: float | None,
|
||||
vol_adjusted: bool | None,
|
||||
top_n: int,
|
||||
universe: str,
|
||||
vipdoc: str | None,
|
||||
min_listed_days: int,
|
||||
min_amount: float,
|
||||
workers: int,
|
||||
output_file: str | None,
|
||||
use_table: bool,
|
||||
names: bool,
|
||||
) -> None:
|
||||
"""全市场强势股排名 — 按 5/20/60 日涨幅加权排序。
|
||||
|
||||
三种预设:
|
||||
|
||||
steady — 中长期稳健(60日主导 + 波动率惩罚),选稳着涨的票
|
||||
|
||||
breakout — 近期妖股爆发(5日主导,纯涨幅),选最猛的票
|
||||
|
||||
balanced — 三周期均衡 + 波动率调整
|
||||
|
||||
示例:
|
||||
|
||||
easy-tdx screen strength --preset steady --top 50 --table
|
||||
|
||||
easy-tdx screen strength --preset breakout --top 20 --names --table
|
||||
|
||||
easy-tdx screen strength --w5 0.5 --w20 0.3 --w60 0.2 --top 30
|
||||
"""
|
||||
from .strength import StrengthRanker
|
||||
|
||||
click.echo(f"模式: {preset}", err=True)
|
||||
click.echo(f"范围: {universe} | Top: {top_n}", err=True)
|
||||
if workers > 0:
|
||||
click.echo(f"并发: {workers} 进程", err=True)
|
||||
|
||||
ranker = StrengthRanker(
|
||||
vipdoc_path=vipdoc,
|
||||
preset=preset,
|
||||
w5=w5,
|
||||
w20=w20,
|
||||
w60=w60,
|
||||
vol_adjusted=vol_adjusted,
|
||||
min_listed_days=min_listed_days,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
|
||||
def on_progress(current: int, total: int, name: str) -> None:
|
||||
if name == "done":
|
||||
click.echo(f"\r扫描完成: {total} 只", err=True)
|
||||
else:
|
||||
pct = current * 100 // total if total > 0 else 0
|
||||
click.echo(f"\r[{current}/{total}] {pct}% {name}", nl=False, err=True)
|
||||
|
||||
results = ranker.rank(
|
||||
universe=universe,
|
||||
top_n=top_n,
|
||||
workers=workers,
|
||||
progress_callback=on_progress,
|
||||
)
|
||||
|
||||
# 数据截止日期(取排名第一的 last_date)
|
||||
data_date = results[0].last_date if results else 0
|
||||
|
||||
# 可选补齐名称
|
||||
if names and results:
|
||||
click.echo("\n获取股票名称...", err=True)
|
||||
results = _enrich_strength_names(results)
|
||||
|
||||
if use_table:
|
||||
click.echo(ranker.to_table(results, preset, data_date))
|
||||
else:
|
||||
json_str = ranker.to_json(results, preset, data_date)
|
||||
if output_file:
|
||||
Path(output_file).write_text(json_str, encoding="utf-8")
|
||||
click.echo(f"排名: {len(results)} 只 → {output_file}")
|
||||
else:
|
||||
click.echo(json_str)
|
||||
|
||||
|
||||
def _enrich_strength_names(
|
||||
results: list[Any],
|
||||
) -> list[Any]:
|
||||
"""在线查询补齐股票名称(复用 ranker 的逻辑)。
|
||||
|
||||
分批查询(每批最多 80 只),避免超出 MAC 协议单次报价上限导致末尾名字丢失。
|
||||
"""
|
||||
try:
|
||||
from easy_tdx.cli.parsers import parse_market
|
||||
from easy_tdx.mac.client import MacClient
|
||||
|
||||
pairs = [(parse_market(r.market), r.code) for r in results]
|
||||
client = MacClient.from_best_host()
|
||||
try:
|
||||
client.connect()
|
||||
# 分批查询:MAC 协议单次最多 80 只,超出部分会被服务器丢弃
|
||||
import pandas as pd
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for i in range(0, len(pairs), 80):
|
||||
batch = pairs[i : i + 80]
|
||||
frames.append(client.get_stock_quotes(batch))
|
||||
quotes_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if quotes_df.empty or "name" not in quotes_df.columns:
|
||||
return results
|
||||
|
||||
_market_map = {0: "SZ", 1: "SH"}
|
||||
name_map: dict[str, str] = {}
|
||||
for _, row in quotes_df.iterrows():
|
||||
mkt_int = row.get("market", -1)
|
||||
mkt_str = _market_map.get(mkt_int, str(mkt_int))
|
||||
key = f"{mkt_str}{row.get('code', '')}"
|
||||
name_map[key] = str(row.get("name", ""))
|
||||
|
||||
for r in results:
|
||||
r.name = name_map.get(f"{r.market}{r.code}", "")
|
||||
except Exception:
|
||||
# 名称查询失败不影响主流程
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +191,8 @@ class SignalRanker:
|
||||
|
||||
仅对排名中的股票查询,通常只有几十只。
|
||||
|
||||
分批查询(每批最多 80 只),避免超出 MAC 协议单次报价上限导致末尾名字丢失。
|
||||
|
||||
Args:
|
||||
entries: 排名列表
|
||||
|
||||
@@ -210,7 +212,14 @@ class SignalRanker:
|
||||
client = MacClient.from_best_host()
|
||||
try:
|
||||
client.connect()
|
||||
quotes_df = client.get_stock_quotes(pairs)
|
||||
# 分批查询:MAC 协议单次最多 80 只,超出部分会被服务器丢弃
|
||||
import pandas as pd
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
for i in range(0, len(pairs), 80):
|
||||
batch = pairs[i : i + 80]
|
||||
frames.append(client.get_stock_quotes(batch))
|
||||
quotes_df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"""强势股排名引擎 — 全市场多周期涨幅加权排序。
|
||||
|
||||
核心流程:
|
||||
1. 扫描 vipdoc/{sh,sz}/lday/*.day 获取 A 股文件列表
|
||||
2. 每只股票:read_daily_bars() → 计算 ret_5/ret_20/ret_60/vol_20
|
||||
3. 按预设模式加权合成 strength 分数
|
||||
4. 排序输出
|
||||
|
||||
三种预设:
|
||||
steady — 中长期稳健(w60 主导 + 波动率惩罚),选出稳着涨的票
|
||||
breakout — 近期妖股爆发(w5 主导,纯涨幅),选出短期最猛的票
|
||||
balanced — 三周期均衡(等权 + 波动率惩罚)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.offline.daily_bar import _detect_security_type, read_daily_bars
|
||||
from easy_tdx.offline.paths import resolve_vipdoc
|
||||
|
||||
_A_STOCK_TYPES = frozenset({"SH_A_STOCK", "SZ_A_STOCK"})
|
||||
|
||||
# ── 预设模式 ──────────────────────────────────────────────────────────────
|
||||
|
||||
STRENGTH_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"steady": {
|
||||
"w5": 0.2,
|
||||
"w20": 0.3,
|
||||
"w60": 0.5,
|
||||
"vol_adjusted": True,
|
||||
"desc": "中长期稳健强势:权重偏 60 日,波动率惩罚,选出稳着涨的票",
|
||||
},
|
||||
"breakout": {
|
||||
"w5": 0.6,
|
||||
"w20": 0.3,
|
||||
"w60": 0.1,
|
||||
"vol_adjusted": False,
|
||||
"desc": "近期妖股爆发:权重偏 5 日,无波动率惩罚,选出短期最猛的票",
|
||||
},
|
||||
"balanced": {
|
||||
"w5": 0.34,
|
||||
"w20": 0.33,
|
||||
"w60": 0.33,
|
||||
"vol_adjusted": True,
|
||||
"desc": "均衡强势:三周期等权,波动率调整",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrengthResult:
|
||||
"""单只股票的强势分结果。
|
||||
|
||||
Attributes:
|
||||
rank: 排名(排序后赋值)
|
||||
code: 6 位股票代码
|
||||
market: 市场(SZ/SH)
|
||||
name: 股票名称(可选,需在线查询补齐)
|
||||
last_close: 最新收盘价
|
||||
last_date: 最新交易日(YYYYMMDD 整数)
|
||||
ret_5: 5 日涨幅
|
||||
ret_20: 20 日涨幅
|
||||
ret_60: 60 日涨幅
|
||||
vol_20: 20 日波动率(对数收益率标准差)
|
||||
strength: 强势综合分
|
||||
"""
|
||||
|
||||
rank: int = 0
|
||||
code: str = ""
|
||||
market: str = ""
|
||||
name: str = ""
|
||||
last_close: float = 0.0
|
||||
last_date: int = 0
|
||||
ret_5: float = 0.0
|
||||
ret_20: float = 0.0
|
||||
ret_60: float = 0.0
|
||||
vol_20: float = 0.0
|
||||
strength: float = 0.0
|
||||
|
||||
|
||||
def compute_strength_metrics(
|
||||
closes: pd.Series,
|
||||
w5: float,
|
||||
w20: float,
|
||||
w60: float,
|
||||
vol_adjusted: bool,
|
||||
) -> dict[str, float] | None:
|
||||
"""纯计算函数:给定收盘价序列,返回强势指标字典。
|
||||
|
||||
Args:
|
||||
closes: 收盘价 Series(按时间升序)
|
||||
w5/w20/w60: 三周期权重(自动归一化)
|
||||
vol_adjusted: 是否除以波动率
|
||||
|
||||
Returns:
|
||||
{"ret_5", "ret_20", "ret_60", "vol_20", "strength"} 或 None(数据不足)
|
||||
"""
|
||||
n = len(closes)
|
||||
if n < 65: # 至少需要 61 日算 ret_60,留余量
|
||||
return None
|
||||
|
||||
# 权重归一化
|
||||
w_sum = w5 + w20 + w60
|
||||
if w_sum <= 0:
|
||||
return None
|
||||
w5, w20, w60 = w5 / w_sum, w20 / w_sum, w60 / w_sum
|
||||
|
||||
last = closes.iloc[-1]
|
||||
ret_5 = last / closes.iloc[-6] - 1
|
||||
ret_20 = last / closes.iloc[-21] - 1
|
||||
ret_60 = last / closes.iloc[-61] - 1
|
||||
|
||||
# 20 日波动率(对数收益率标准差)
|
||||
log_ret = np.log(closes / closes.shift(1))
|
||||
vol_20 = float(log_ret.rolling(20).std().iloc[-1])
|
||||
|
||||
if vol_20 <= 0 or np.isnan(vol_20):
|
||||
return None
|
||||
|
||||
raw = w5 * ret_5 + w20 * ret_20 + w60 * ret_60
|
||||
strength = raw / vol_20 if vol_adjusted else raw
|
||||
|
||||
if np.isnan(strength):
|
||||
return None
|
||||
|
||||
return {
|
||||
"ret_5": float(ret_5),
|
||||
"ret_20": float(ret_20),
|
||||
"ret_60": float(ret_60),
|
||||
"vol_20": vol_20,
|
||||
"strength": float(strength),
|
||||
}
|
||||
|
||||
|
||||
class StrengthRanker:
|
||||
"""全市场强势股排名器。
|
||||
|
||||
用法::
|
||||
|
||||
ranker = StrengthRanker(preset="steady")
|
||||
results = ranker.rank(top_n=50)
|
||||
for r in results[:5]:
|
||||
print(f"#{r.rank} {r.market}{r.code} strength={r.strength:.2f}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vipdoc_path: str | Path | None = None,
|
||||
preset: str = "steady",
|
||||
w5: float | None = None,
|
||||
w20: float | None = None,
|
||||
w60: float | None = None,
|
||||
vol_adjusted: bool | None = None,
|
||||
min_listed_days: int = 65,
|
||||
min_amount: float = 0.0,
|
||||
) -> None:
|
||||
"""初始化排名器。
|
||||
|
||||
Args:
|
||||
vipdoc_path: vipdoc 目录路径,None 则自动检测
|
||||
preset: 预设模式 steady/breakout/balanced
|
||||
w5/w20/w60: 自定义权重(非 None 时覆盖预设)
|
||||
vol_adjusted: 自定义波动率惩罚开关(非 None 时覆盖预设)
|
||||
min_listed_days: 最小上市天数(默认 65,保证能算 60 日涨幅)
|
||||
min_amount: 最近 5 日日均成交额下限(默认 0 不过滤,单位:元)
|
||||
"""
|
||||
if preset not in STRENGTH_PRESETS:
|
||||
raise ValueError(f"未知预设 '{preset}',可选: {list(STRENGTH_PRESETS.keys())}")
|
||||
cfg = STRENGTH_PRESETS[preset]
|
||||
self._preset = preset
|
||||
self._w5 = w5 if w5 is not None else cfg["w5"]
|
||||
self._w20 = w20 if w20 is not None else cfg["w20"]
|
||||
self._w60 = w60 if w60 is not None else cfg["w60"]
|
||||
self._vol_adjusted = vol_adjusted if vol_adjusted is not None else cfg["vol_adjusted"]
|
||||
self._min_listed_days = min_listed_days
|
||||
self._min_amount = min_amount
|
||||
self._vipdoc = resolve_vipdoc(vipdoc_path)
|
||||
|
||||
@property
|
||||
def preset(self) -> str:
|
||||
"""当前预设名称。"""
|
||||
return self._preset
|
||||
|
||||
def rank(
|
||||
self,
|
||||
universe: str = "all",
|
||||
top_n: int = 50,
|
||||
workers: int = 0,
|
||||
progress_callback: Any = None,
|
||||
) -> list[StrengthResult]:
|
||||
"""扫描全市场并返回强势股排名。
|
||||
|
||||
Args:
|
||||
universe: all/sh/sz/<文件路径>
|
||||
top_n: 返回前 N 名,0=全部
|
||||
workers: 并发进程数(0=串行,4-8 推荐)
|
||||
progress_callback: 回调(current, total, name)
|
||||
|
||||
Returns:
|
||||
按 strength 降序排列的 StrengthResult 列表
|
||||
"""
|
||||
files = self._collect_files(universe)
|
||||
if not files:
|
||||
return []
|
||||
total = len(files)
|
||||
|
||||
if workers <= 0:
|
||||
results = self._rank_serial(files, total, progress_callback)
|
||||
else:
|
||||
results = self._rank_parallel(files, total, workers, progress_callback)
|
||||
|
||||
# 排序 + 赋名次
|
||||
results.sort(key=lambda r: r.strength, reverse=True)
|
||||
for i, r in enumerate(results):
|
||||
r.rank = i + 1
|
||||
|
||||
if top_n > 0:
|
||||
results = results[:top_n]
|
||||
return results
|
||||
|
||||
def _collect_files(self, universe: str) -> list[tuple[Path, str, str]]:
|
||||
"""收集 A 股 .day 文件列表(复用 scanner 的逻辑)。"""
|
||||
exchanges: list[str] = []
|
||||
if universe in ("all", "sz"):
|
||||
exchanges.append("sz")
|
||||
if universe in ("all", "sh"):
|
||||
exchanges.append("sh")
|
||||
|
||||
# 从文件列表模式读取
|
||||
if universe not in ("all", "sh", "sz"):
|
||||
return self._collect_from_file(universe)
|
||||
|
||||
files: list[tuple[Path, str, str]] = []
|
||||
for exchange in exchanges:
|
||||
lday_dir = self._vipdoc / exchange / "lday"
|
||||
if not lday_dir.is_dir():
|
||||
continue
|
||||
for filepath in sorted(lday_dir.glob("*.day")):
|
||||
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
|
||||
continue
|
||||
code = filepath.name.lower()[2:8]
|
||||
files.append((filepath, exchange.upper(), code))
|
||||
return files
|
||||
|
||||
def _collect_from_file(self, filepath: str) -> list[tuple[Path, str, str]]:
|
||||
"""从文件读取股票列表(每行 "市场 代码")。"""
|
||||
path = Path(filepath)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"股票列表文件不存在: {filepath}")
|
||||
|
||||
files: list[tuple[Path, str, str]] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
market_str = parts[0].upper()
|
||||
code = parts[1]
|
||||
else:
|
||||
continue
|
||||
exchange = market_str.lower()
|
||||
day_file = self._vipdoc / exchange / "lday" / f"{exchange}{code}.day"
|
||||
if day_file.is_file():
|
||||
files.append((day_file, market_str, code))
|
||||
return files
|
||||
|
||||
def _rank_serial(
|
||||
self,
|
||||
files: list[tuple[Path, str, str]],
|
||||
total: int,
|
||||
progress_callback: Any,
|
||||
) -> list[StrengthResult]:
|
||||
"""串行扫描。"""
|
||||
results: list[StrengthResult] = []
|
||||
for idx, (filepath, market, code) in enumerate(files):
|
||||
if progress_callback:
|
||||
progress_callback(idx, total, filepath.name)
|
||||
try:
|
||||
r = self._compute_one(filepath, market, code)
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "done")
|
||||
return results
|
||||
|
||||
def _rank_parallel(
|
||||
self,
|
||||
files: list[tuple[Path, str, str]],
|
||||
total: int,
|
||||
workers: int,
|
||||
progress_callback: Any,
|
||||
) -> list[StrengthResult]:
|
||||
"""并发扫描(ProcessPoolExecutor)。"""
|
||||
import concurrent.futures
|
||||
|
||||
tasks = [
|
||||
(
|
||||
str(fp),
|
||||
mkt,
|
||||
code,
|
||||
self._w5,
|
||||
self._w20,
|
||||
self._w60,
|
||||
self._vol_adjusted,
|
||||
self._min_listed_days,
|
||||
self._min_amount,
|
||||
)
|
||||
for fp, mkt, code in files
|
||||
]
|
||||
|
||||
results: list[StrengthResult] = []
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
future_map = {ex.submit(_compute_strength_one, *t): i for i, t in enumerate(tasks)}
|
||||
done = 0
|
||||
for fut in concurrent.futures.as_completed(future_map):
|
||||
done += 1
|
||||
idx = future_map[fut]
|
||||
if progress_callback:
|
||||
progress_callback(done, total, files[idx][0].name)
|
||||
try:
|
||||
r = fut.result()
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "done")
|
||||
return results
|
||||
|
||||
def _compute_one(self, filepath: Path, market: str, code: str) -> StrengthResult | None:
|
||||
"""计算单只股票的强势分。"""
|
||||
bars = read_daily_bars(filepath)
|
||||
if len(bars) < self._min_listed_days:
|
||||
return None
|
||||
|
||||
closes = pd.Series([b.close for b in bars])
|
||||
|
||||
# 成交额过滤(最近 5 日平均值)
|
||||
if self._min_amount > 0:
|
||||
recent_amount = float(np.mean([b.amount for b in bars[-5:]]))
|
||||
if recent_amount < self._min_amount:
|
||||
return None
|
||||
|
||||
metrics = compute_strength_metrics(
|
||||
closes, self._w5, self._w20, self._w60, self._vol_adjusted
|
||||
)
|
||||
if metrics is None:
|
||||
return None
|
||||
|
||||
last_bar = bars[-1]
|
||||
return StrengthResult(
|
||||
code=code,
|
||||
market=market,
|
||||
last_close=last_bar.close,
|
||||
last_date=last_bar.year * 10000 + last_bar.month * 100 + last_bar.day,
|
||||
ret_5=metrics["ret_5"],
|
||||
ret_20=metrics["ret_20"],
|
||||
ret_60=metrics["ret_60"],
|
||||
vol_20=metrics["vol_20"],
|
||||
strength=metrics["strength"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_json(results: list[StrengthResult], preset: str, data_date: int) -> str:
|
||||
"""将排名结果序列化为 JSON 字符串。"""
|
||||
data = {
|
||||
"scan_time": datetime.now().isoformat(timespec="seconds"),
|
||||
"preset": preset,
|
||||
"preset_desc": STRENGTH_PRESETS.get(preset, {}).get("desc", ""),
|
||||
"data_date": data_date,
|
||||
"total_ranked": len(results),
|
||||
"ranking": [
|
||||
{
|
||||
"rank": r.rank,
|
||||
"code": r.code,
|
||||
"market": r.market,
|
||||
"name": r.name,
|
||||
"last_close": r.last_close,
|
||||
"last_date": r.last_date,
|
||||
"ret_5": r.ret_5,
|
||||
"ret_20": r.ret_20,
|
||||
"ret_60": r.ret_60,
|
||||
"vol_20": r.vol_20,
|
||||
"strength": r.strength,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
return json.dumps(data, ensure_ascii=False, indent=2, default=_json_default)
|
||||
|
||||
@staticmethod
|
||||
def to_table(results: list[StrengthResult], preset: str, data_date: int) -> str:
|
||||
"""将排名结果格式化为表格字符串。"""
|
||||
if not results:
|
||||
return "无有效排名结果"
|
||||
|
||||
desc = STRENGTH_PRESETS.get(preset, {}).get("desc", "")
|
||||
lines = [
|
||||
f"[*] 强势股排名 [{preset}] 共 {len(results)} 只",
|
||||
f" 数据截止: {_fmt_date(data_date)} | {desc}",
|
||||
"═" * 96,
|
||||
f"{'排名':>4} {'代码':<10} {'名称':<8} {'现价':>10} "
|
||||
f"{'5日':>8} {'20日':>8} {'60日':>8} {'波动率':>8} {'强势分':>8}",
|
||||
"─" * 96,
|
||||
]
|
||||
|
||||
for r in results:
|
||||
medal = (
|
||||
" *1*"
|
||||
if r.rank == 1
|
||||
else " *2*"
|
||||
if r.rank == 2
|
||||
else " *3*"
|
||||
if r.rank == 3
|
||||
else " "
|
||||
)
|
||||
name = r.name[:6] if r.name else ""
|
||||
lines.append(
|
||||
f"{medal}{r.rank:>2} {r.market}{r.code:<9} {name:<8} "
|
||||
f"{r.last_close:>9.2f} {r.ret_5:>7.2%} {r.ret_20:>7.2%} "
|
||||
f"{r.ret_60:>7.2%} {r.vol_20:>7.4f} {r.strength:>8.2f}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _compute_strength_one(
|
||||
filepath: str,
|
||||
market: str,
|
||||
code: str,
|
||||
w5: float,
|
||||
w20: float,
|
||||
w60: float,
|
||||
vol_adjusted: bool,
|
||||
min_listed_days: int,
|
||||
min_amount: float,
|
||||
) -> StrengthResult | None:
|
||||
"""顶层函数(供 ProcessPoolExecutor 调用)。"""
|
||||
bars = read_daily_bars(filepath)
|
||||
if len(bars) < min_listed_days:
|
||||
return None
|
||||
|
||||
closes = pd.Series([b.close for b in bars])
|
||||
|
||||
if min_amount > 0:
|
||||
recent = float(np.mean([b.amount for b in bars[-5:]]))
|
||||
if recent < min_amount:
|
||||
return None
|
||||
|
||||
metrics = compute_strength_metrics(closes, w5, w20, w60, vol_adjusted)
|
||||
if metrics is None:
|
||||
return None
|
||||
|
||||
last = bars[-1]
|
||||
return StrengthResult(
|
||||
code=code,
|
||||
market=market,
|
||||
last_close=last.close,
|
||||
last_date=last.year * 10000 + last.month * 100 + last.day,
|
||||
ret_5=metrics["ret_5"],
|
||||
ret_20=metrics["ret_20"],
|
||||
ret_60=metrics["ret_60"],
|
||||
vol_20=metrics["vol_20"],
|
||||
strength=metrics["strength"],
|
||||
)
|
||||
|
||||
|
||||
def _fmt_date(d: int) -> str:
|
||||
"""YYYYMMDD 整数 → YYYY-MM-DD 字符串。"""
|
||||
s = str(d)
|
||||
return f"{s[:4]}-{s[4:6]}-{s[6:]}" if len(s) == 8 else str(d)
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""JSON 序列化辅助(numpy 标量等)。"""
|
||||
if hasattr(obj, "item"):
|
||||
return obj.item()
|
||||
raise TypeError(f"无法序列化 {type(obj)}")
|
||||
@@ -98,3 +98,71 @@ async def history_fund_flow(
|
||||
"""获取个股历史日线资金流向。"""
|
||||
df = await client.get_history_fund_flow(market_from_str(market), code, start, count)
|
||||
return _df_response(df)
|
||||
|
||||
|
||||
@router.get("/market/strength", response_model=DataFrameResponse)
|
||||
async def market_strength(
|
||||
preset: str = Query(
|
||||
"steady",
|
||||
description="预设模式: steady(中长期稳健) / breakout(近期妖股) / balanced(均衡)",
|
||||
),
|
||||
w5: float | None = Query(None, description="自定义 5 日权重(覆盖预设)"),
|
||||
w20: float | None = Query(None, description="自定义 20 日权重(覆盖预设)"),
|
||||
w60: float | None = Query(None, description="自定义 60 日权重(覆盖预设)"),
|
||||
vol_adjusted: bool | None = Query(None, description="波动率惩罚开关(覆盖预设)"),
|
||||
top_n: int = Query(50, ge=1, le=5000, description="返回前 N 名"),
|
||||
universe: str = Query("all", description="范围: all/sh/sz"),
|
||||
min_listed_days: int = Query(65, ge=30, description="最小上市天数"),
|
||||
min_amount: float = Query(0.0, ge=0, description="最近 5 日日均成交额下限(元)"),
|
||||
vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"),
|
||||
) -> DataFrameResponse:
|
||||
"""全市场强势股排名(基于本地通达信 .day 日线文件)。
|
||||
|
||||
按 5/20/60 日涨幅加权合成强势分。三种预设:
|
||||
|
||||
- **steady**: 中长期稳健(60日主导 + 波动率惩罚),选出稳着涨的票
|
||||
- **breakout**: 近期妖股爆发(5日主导,纯涨幅),选出短期最猛的票
|
||||
- **balanced**: 三周期均衡 + 波动率调整
|
||||
|
||||
注意:需要本地 vipdoc 数据,扫描 ~5000 只约 30-60 秒。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from easy_tdx.screen.strength import StrengthRanker
|
||||
|
||||
ranker = StrengthRanker(
|
||||
vipdoc_path=vipdoc,
|
||||
preset=preset,
|
||||
w5=w5,
|
||||
w20=w20,
|
||||
w60=w60,
|
||||
vol_adjusted=vol_adjusted,
|
||||
min_listed_days=min_listed_days,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
|
||||
# Web 端用线程池执行,避免阻塞事件循环(扫描全市场耗时较长)
|
||||
# 注:在协程内用 get_running_loop() 而非 get_event_loop(),
|
||||
# 后者在 Python 3.12+ 已弃用。
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None, lambda: ranker.rank(universe=universe, top_n=top_n)
|
||||
)
|
||||
|
||||
records = [
|
||||
{
|
||||
"rank": r.rank,
|
||||
"code": r.code,
|
||||
"market": r.market,
|
||||
"name": r.name,
|
||||
"last_close": r.last_close,
|
||||
"last_date": r.last_date,
|
||||
"ret_5": r.ret_5,
|
||||
"ret_20": r.ret_20,
|
||||
"ret_60": r.ret_60,
|
||||
"vol_20": r.vol_20,
|
||||
"strength": r.strength,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
return DataFrameResponse(data=records, count=len(records))
|
||||
|
||||
Reference in New Issue
Block a user