Files
easy_tdx_max/src/easy_tdx/screen/cli.py
T
Justin Gu a3d6d93122 feat: strategy screener - scan all stocks by signal, rank by backtest performance (v1.9.2)
- Add 'screen' CLI command group with 'scan' and 'rank' subcommands
- scan: offline signal scanning from local .day files, zero network IO
- rank: backtest ranking of scanned signals by sharpe/drawdown/etc
- Two-step workflow: scan outputs JSON, rank reads JSON and evaluates
- Support --universe (all/sh/sz/custom file), --sort, --names
- Support pipe mode: scan ... | rank --from - --table
- New module: src/easy_tdx/screen/{scanner,ranker,cli}.py
- 20 unit tests (offline, no network required)
2026-06-10 03:03:03 +08:00

244 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""screen 命令组 — 策略选股扫描器 CLI。
子命令:
scan — 纯离线扫描信号
rank — 回测排名
"""
from __future__ import annotations
from pathlib import Path
import click
@click.group()
def screen() -> None:
"""策略选股扫描器 — 用策略扫描全市场触发信号的股票。
两步走工作流:
easy-tdx screen scan --strategy strategies/rsi_reversal.py --output signals.json
easy-tdx screen rank --from signals.json --sort sharpe --top 20 --table
"""
# ── scan 子命令 ──────────────────────────────────────────────────────────────
@screen.command()
@click.option("--strategy", "strategy_file", required=True, help="策略文件路径")
@click.option("--output", "output_file", default=None, help="输出 JSON 文件路径(默认 stdout")
@click.option(
"--universe",
default="all",
help="股票范围: all/sh/sz/<文件路径>(默认 all",
)
@click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)")
@click.option("--cash", default=100_000.0, type=float, help="初始资金")
@click.option("--commission", default=0.0003, type=float, help="佣金率")
def scan(
strategy_file: str,
output_file: str | None,
universe: str,
vipdoc: str | None,
cash: float,
commission: float,
) -> None:
"""纯离线扫描全市场,找出触发买入信号的股票。
读取本地通达信 .day 文件,零网络 IO,全市场约 30-60 秒。
示例:
easy-tdx screen scan --strategy strategies/rsi_reversal.py
easy-tdx screen scan --strategy strategies/rsi_reversal.py --output signals.json
easy-tdx screen scan --strategy strategies/rsi_reversal.py --universe sz
"""
strategy_cls = _load_strategy(strategy_file)
strategy_name = strategy_cls.__name__
click.echo(f"策略: {strategy_name}", err=True)
click.echo(f"范围: {universe}", err=True)
from .scanner import SignalScanner
scanner = SignalScanner(
strategy_cls=strategy_cls,
vipdoc_path=vipdoc,
cash=cash,
commission=commission,
)
# 进度回调(输出到 stderr,避免污染 stdout 的 JSON
total_scanned = 0
def on_progress(current: int, total: int, name: str) -> None:
nonlocal total_scanned
total_scanned = total
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}% scanning {name}", nl=False, err=True)
results = scanner.scan(universe=universe, progress_callback=on_progress)
# 生成 JSON
json_str = scanner.to_json(
results=results,
strategy_name=strategy_name,
strategy_file=strategy_file,
total_scanned=total_scanned,
)
# 输出
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)
# ── rank 子命令 ──────────────────────────────────────────────────────────────
@screen.command("rank")
@click.option("--from", "from_source", required=True, help="信号 JSON 文件路径(- 表示 stdin")
@click.option("--strategy", "strategy_file", default=None, help="覆盖策略文件(默认从 JSON 读取)")
@click.option("--sort", "sort_by", default="sharpe", help="排序指标(默认 sharpe")
@click.option("--sort-reverse", is_flag=True, help="升序排列(用于回撤等越小越好的指标)")
@click.option("--top", "top_n", default=20, type=int, help="只显示前 N 名(默认 20")
@click.option("--cash", default=1_000_000.0, type=float, help="初始资金")
@click.option("--count", default=0, type=int, help="使用最近 N 条 K 线,0=全部")
@click.option("--commission", default=0.0003, type=float, help="佣金率")
@click.option("--vipdoc", default=None, help="离线数据目录(默认自动检测)")
@click.option("--names/--no-names", default=False, help="是否在线查询股票名称(默认关闭)")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
def rank_cmd(
from_source: str,
strategy_file: str | None,
sort_by: str,
sort_reverse: bool,
top_n: int,
cash: float,
count: int,
commission: float,
vipdoc: str | None,
names: bool,
use_table: bool,
) -> None:
"""对扫描结果做历史回测并按指标排名。
读取 scan 输出的 JSON,对每只股票跑完整回测,按指定指标排序。
示例:
easy-tdx screen rank --from signals.json --sort sharpe --top 20 --table
easy-tdx screen rank --from signals.json --sort max_drawdown --sort-reverse
easy-tdx screen scan --strategy strats/rsi.py | easy-tdx screen rank --from - --table
"""
from .ranker import SignalRanker, load_signals
# 加载信号
signals, strategy_name, strategy_file_from_json = load_signals(from_source)
if not signals:
click.echo("无信号数据,无需排名")
return
# 确定策略
effective_strategy_file = strategy_file or strategy_file_from_json
if not effective_strategy_file:
click.echo(
"错误: 未指定策略文件,请使用 --strategy 或确保 JSON 包含 strategy_file", err=True
)
raise SystemExit(1)
strategy_cls = _load_strategy(effective_strategy_file)
strategy_name = strategy_cls.__name__
click.echo(f"策略: {strategy_name} | 信号数: {len(signals)} | 排序: {sort_by}", err=True)
ranker = SignalRanker(
strategy_cls=strategy_cls,
vipdoc_path=vipdoc,
cash=cash,
commission=commission,
count=count,
)
# 进度回调(输出到 stderr,避免污染 stdout
def on_progress(current: int, total: int, label: str) -> None:
if label == "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}% backtesting {label}", nl=False, err=True)
entries = ranker.rank(
signals=signals,
sort_by=sort_by,
sort_reverse=sort_reverse,
top_n=top_n,
progress_callback=on_progress,
)
# 补齐名称(可选,需要网络)
if names and entries:
click.echo("\n正在获取股票名称...", err=True)
entries = ranker.enrich_names(entries)
# 输出
if use_table:
click.echo(ranker.to_table(entries, sort_by))
else:
click.echo(ranker.to_json(entries, strategy_name, sort_by))
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
def _load_strategy(strategy_file: str) -> type:
"""加载策略类(复用 backtest.cli 的加载逻辑)。
Args:
strategy_file: 策略文件路径
Returns:
Strategy 子类
"""
import importlib.util
from easy_tdx.backtest.strategy import Strategy
file_path = Path(strategy_file)
if not file_path.exists():
click.echo(f"错误: 策略文件不存在: {strategy_file}", err=True)
raise SystemExit(1)
spec = importlib.util.spec_from_file_location("strategy_module", file_path)
if spec is None or spec.loader is None:
click.echo(f"错误: 无法加载策略文件: {strategy_file}", err=True)
raise SystemExit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 查找 Strategy 子类
for attr_name in dir(module):
obj = getattr(module, attr_name)
try:
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
return obj
except TypeError:
pass
click.echo(f"错误: 文件中未找到 Strategy 子类: {strategy_file}", err=True)
raise SystemExit(1)