diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8b64a01..47216ef 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -76,7 +76,9 @@ "Bash(mypy src/easy_tdx/backtest/types.py)", "Bash(mypy tests/unit/test_backtest_types.py)", "Bash(mypy src/easy_tdx/backtest/types.py tests/unit/test_backtest_types.py)", - "Bash(mypy *)" + "Bash(mypy *)", + "Bash(git diff *)", + "Bash(echo \"exit: $?\")" ] } } diff --git a/README.md b/README.md index 9a45f57..3790326 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 它是一个完全免费、无需注册、无需 API Key、纯开源的行**情核武器**。 一行命令,A股、港股、美股、期货——K线、报价、资金流向、板块轮动、分时明细、逐笔成交,**毫秒级拉满**。 -**32个技术指标**(MACD、KDJ、RSI、BOLL……连“捉妖大师”和“30日乖离率信号”都给你算好)开箱即用。 +**32个技术指标**(MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。 **缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。 +**内置回测引擎**——写个策略文件,一行命令跑回测,9 个经典策略自带,批量对比哪个最赚钱一目了然。 装上就能跑。**Python API + CLI 双通道**,输出 JSON 天然喂给 AI Agent:Claude Code、OpenClaw、Hermes 直接吃。 -**你不懂 TCP 协议?不用。** -**你不会写量化框架?不用。** +**你不懂 TCP 协议?不用。** +**你不会写量化框架?不用。** +**你想回测验证策略?自带引擎,不用。** **你不想给任何平台付一分钱?完全不用。** `pip install easy-tdx`,30秒后——你屏幕上的数据,和机构看到的**是同一份**。 @@ -265,6 +267,120 @@ easy-tdx chanlun SZ 000001 --period 30MIN `[✓]` 表示确认背驰。趋势背驰(上)代表上涨趋势可能结束,趋势背驰(下)代表下跌趋势可能结束。 +### 回测引擎 + +内置向量回测引擎,加载 Python 策略文件即可跑回测。策略继承 `Strategy` 基类,在 `init()` 注册指标,在 `next()` 逐 bar 生成买卖信号,引擎完成订单模拟、持仓跟踪和绩效分析。 + +**单策略回测:** + +```bash +easy-tdx backtest SZ 300308 --strategy-file strategies/expma_cross.py --count 2000 --cash 1000000 --adjust QFQ --table +``` + +输出示例: + +``` +=== 回测绩效概要 === +总收益率: 1413.51% +年化收益: 40.85% +最大回撤: 76.75% +夏普比率: 0.88 +胜率: 20.8% +交易次数: 24 +``` + +**全策略批量对比:** + +项目自带 `run_all_strategies.py`,一次跑完 `strategies/` 下所有策略并排名: + +```bash +python -X utf8 run_all_strategies.py SZ 300308 --count 2000 --cash 1000000 --adjust QFQ +``` + +输出示例(以 SZ 300308 为例): + +``` +发现 9 个策略文件 +标的: SZ 300308 | K线: 2000 | 资金: 1,000,000 | 复权: QFQ +================================================================================ + +>> 运行策略: bias_reversal ... 完成 (2.4s) +>> 运行策略: bollinger_breakout ... 完成 (0.6s) +>> 运行策略: expma_cross ... 完成 (0.6s) +>> 运行策略: kdj_golden ... 完成 (0.1s) +>> 运行策略: ma_cross ... 完成 (1.4s) +>> 运行策略: macd_cross ... 完成 (2.1s) +>> 运行策略: rsi_reversal ... 完成 (0.2s) +>> 运行策略: turtle_breakout ... 完成 (0.1s) +>> 运行策略: volume_price ... 完成 (6.3s) + +================================================================================ +[*] 策略绩效排名 (按总收益率降序) +================================================================================ + 排名 策略 总收益率 年化收益 最大回撤 夏普 胜率 交易次数 盈亏比 +---------------------------------------------------------------------------------------------------- + *1* 1 expma_cross 1413.51% 40.85% 76.75% 0.88 20.8% 24 6.45 + *2* 2 ma_cross 1258.07% 38.94% 58.01% 0.87 38.2% 55 2.21 + *3* 3 turtle_breakout 905.07% 33.76% 48.30% 0.83 75.0% 4 10.14 + 4 bias_reversal 504.94% 25.47% 42.25% 0.70 66.3% 95 2.08 + 5 macd_cross 387.67% 22.11% 61.08% 0.60 40.0% 85 2.20 + 6 volume_price 247.72% 17.01% 65.73% 0.50 43.3% 254 1.40 + 7 bollinger_breakout 169.65% 13.32% 49.71% 0.44 66.7% 24 1.93 + 8 rsi_reversal 95.89% 8.85% 56.51% 0.33 57.1% 7 2.48 + 9 kdj_golden 89.10% 8.36% 61.86% 0.32 66.7% 3 10.49 +``` + +综合评分(夏普 × 0.4 + 收益/回撤 × 0.3 + 胜率 × 0.3): + +``` + *1* 1 turtle_breakout 23.04 0.83 0.70 75.0% + *2* 2 bias_reversal 20.35 0.70 0.60 66.3% + *3* 3 bollinger_breakout 20.26 0.44 0.27 66.7% +``` + +换一个标的再跑: + +```bash +# 贵州茅台 +python -X utf8 run_all_strategies.py SH 600519 --count 2000 --cash 1000000 --adjust QFQ +``` + +#### 自带策略示例 + +`strategies/` 目录下有 9 个开箱即用的策略文件,可直接用于 `--strategy-file`: + +| 文件 | 策略 | 类型 | 适合行情 | +|------|------|------|----------| +| `ma_cross.py` | 双均线交叉(MA5/MA20) | 趋势跟踪 | 单边趋势 | +| `expma_cross.py` | EMA12/EMA50 交叉 | 趋势跟踪 | 单边趋势(比 MA 更灵敏) | +| `macd_cross.py` | MACD 金叉死叉 | 趋势跟踪 | 中长线趋势 | +| `bollinger_breakout.py` | 布林带突破 | 震荡反转 | 横盘震荡 | +| `rsi_reversal.py` | RSI 超买超卖 | 反转 | 震荡市 | +| `kdj_golden.py` | KDJ 低位金叉/高位死叉 | 反转 | 短线震荡 | +| `turtle_breakout.py` | 海龟交易法(唐安奇通道) | 趋势突破 | 牛市启动 | +| `bias_reversal.py` | 乖离率反转 | 反转 | 震荡回归 | +| `volume_price.py` | 量价配合 | 综合判断 | 放量突破 | + +编写自定义策略只需继承 `Strategy` 基类: + +```python +from easy_tdx.backtest import Strategy +from easy_tdx import MyTT + + +class MyStrategy(Strategy): + def init(self): + self.ma = self.I(MyTT.MA, self.data.close, 10) + + def next(self): + if self.data.close[0] > self.ma[self._bar_index]: + self.buy(size=0) # size=0 表示全仓 + elif self.position["size"] > 0: + self.sell(size=0) # size=0 表示清仓 +``` + +完整 API 参考:[docs/backtest_usage.md](docs/backtest_usage.md) + ### 捉妖大师(重点) 捉妖大师是多周期涨幅共振指标,通过 20/60/120 日涨幅及指数平滑判断短中长线趋势是否同向,用于筛选趋势刚启动的强势股。 @@ -416,6 +532,7 @@ easy-tdx offline sync-all | `symbol-info` | 个股特征快照 | | `indicator` | 技术指标计算(32 个:MACD/KDJ/RSI/BOLL/DMI/ATR...) | | `indicator-list` | 列出可用技术指标 | +| `backtest` | 回测引擎(加载策略文件,输出绩效报告) | | `f10` | F10 公司信息 | | `fund-flow` | 历史资金流向 | | `ex kline` | 扩展市场 K 线 | @@ -935,6 +1052,7 @@ src/easy_tdx/ ├── commands/ # 标准协议命令(无 IO) ├── codec/ # price / volume / datetime / frame / bitmap 编解码 ├── chanlun/ # 缠论技术分析(K线合并/分型/笔/线段/中枢/买卖点/背驰) +├── backtest/ # 回测引擎(Strategy基类/向量化引擎/绩效分析) ├── models/ # 纯 dataclass,无业务逻辑 ├── offline/ # 离线数据读写模块(读取 + 写入同步) └── cli/ # easy-tdx CLI(click) @@ -963,6 +1081,18 @@ ruff format --check src/ tests/ # format check ## Changelog +### 1.8.0 (2026-06-09) + +**回测引擎** — 内置向量回测引擎,支持自定义策略回测和全策略批量对比。 + +- 新增 `backtest` 子包:Strategy 基类、BacktestEngine、OrderSimulator、PortfolioTracker、PerformanceAnalyzer +- 新增 `easy-tdx backtest` CLI 命令,支持 `--strategy-file`、`--cash`、`--commission`、`--adjust` 等参数 +- 绩效报告包含 19 项指标:总收益率、年化收益、最大回撤、夏普比率、索提诺、卡玛、胜率、盈亏比等 +- 新增 `strategies/` 目录,包含 9 个开箱即用的策略示例(MA/EMA/MACD/BOLL/RSI/KDJ/BIAS/海龟/量价) +- 新增 `run_all_strategies.py` 批量对比脚本,一键跑完全部策略并按收益率和综合评分排名 +- 自带策略在 SZ 300308 上 3 年回测:收益率最高 1413%(expma_cross),综合最优 turtle_breakout +- 30+ 离线单元测试覆盖,零网络依赖 + ### 1.7.1 (2026-06-08) **Bug 修复** — 修复缠论笔计算在持续下跌/上涨走势中因"分型陷阱"导致近期笔丢失的问题。 diff --git a/docs/conf.py b/docs/conf.py index aa67349..eb34a9c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,7 +4,7 @@ copyright = "2025, Justin Gu" author = "Justin Gu" # The full version, including alpha/beta/rc tags -release = "1.7.1" +release = "1.8.0" # -- Extensions --------------------------------------------------------------- extensions = [ diff --git a/pyproject.toml b/pyproject.toml index 9831032..f874635 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.7.1" +version = "1.8.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/run_all_strategies.py b/run_all_strategies.py new file mode 100644 index 0000000..642895e --- /dev/null +++ b/run_all_strategies.py @@ -0,0 +1,231 @@ +"""批量回测脚本:依次运行 strategies/ 目录下所有策略并比较结果。 + +用法:: + + python run_all_strategies.py SZ 300308 --count 2000 --cash 1000000 --adjust QFQ + +输出每个策略的绩效指标,并按总收益率排名。 +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +# 确保 easy_tdx 可导入 +sys.path.insert(0, str(Path(__file__).parent / "src")) + +import click + + +@click.command() +@click.argument("market") +@click.argument("code") +@click.option("--count", default=2000, type=int, help="K线数量") +@click.option("--cash", default=1000000.0, type=float, help="初始资金") +@click.option("--commission", default=0.0003, type=float, help="佣金率") +@click.option("--adjust", default="QFQ", help="复权: NONE/QFQ/HFQ") +@click.option("--period", default="DAILY", help="K线周期") +def run_all( + market: str, + code: str, + count: int, + cash: float, + commission: float, + adjust: str, + period: str, +) -> None: + """批量运行 strategies/ 目录下所有策略并比较结果。""" + from easy_tdx.backtest.engine import BacktestEngine + from easy_tdx.backtest.strategy import Strategy + from easy_tdx.cli.parsers import parse_adjust, parse_market, parse_period + from easy_tdx.mac.client import MacClient + + # 1. 发现策略文件 + strategies_dir = Path(__file__).parent / "strategies" + strategy_files = sorted(strategies_dir.glob("*.py")) + if not strategy_files: + click.echo("未找到策略文件 (strategies/*.py)", err=True) + raise SystemExit(1) + + click.echo(f"发现 {len(strategy_files)} 个策略文件") + click.echo(f"标的: {market} {code} | K线: {count} | 资金: {cash:,.0f} | 复权: {adjust}") + click.echo("=" * 80) + + # 2. 获取数据(所有策略共享同一份数据) + mkt = parse_market(market) + click.echo("正在获取行情数据...") + client = MacClient.from_best_host() + client.connect() + try: + df = client.get_stock_kline( + mkt, + code, + period=parse_period(period), + start=0, + count=count, + adjust=parse_adjust(adjust), + ) + finally: + client.close() + click.echo(f"获取到 {len(df)} 条K线数据") + click.echo("=" * 80) + + # 3. 逐个运行策略 + results: list[dict] = [] + + for sf in strategy_files: + strategy_name = sf.stem + click.echo(f"\n>> 运行策略: {strategy_name} ...", nl=False) + + # 加载策略类 + import importlib.util + + spec = importlib.util.spec_from_file_location("strategy_module", sf) + if spec is None or spec.loader is None: + click.echo(" [加载失败]") + continue + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 查找 Strategy 子类 + strategy_cls = None + 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: + strategy_cls = obj + break + except TypeError: + pass + + if strategy_cls is None: + click.echo(" [未找到 Strategy 子类]") + continue + + # 运行回测 + t0 = time.perf_counter() + try: + engine = BacktestEngine( + strategy=strategy_cls, + cash=cash, + commission=commission, + ) + result = engine.run(df) + elapsed = time.perf_counter() - t0 + perf = result.performance + click.echo(f" 完成 ({elapsed:.1f}s)") + + results.append({ + "strategy": strategy_name, + "total_return": perf.get("total_return", 0), + "annual_return": perf.get("annual_return", 0), + "max_drawdown": perf.get("max_drawdown", 0), + "sharpe": perf.get("sharpe", 0), + "sortino": perf.get("sortino", 0), + "calmar": perf.get("calmar", 0), + "win_rate": perf.get("win_rate", 0), + "total_trades": perf.get("total_trades", 0), + "profit_factor": perf.get("profit_factor", 0), + "volatility": perf.get("volatility", 0), + }) + except Exception as e: + elapsed = time.perf_counter() - t0 + click.echo(f" 错误 ({elapsed:.1f}s): {e}") + results.append({ + "strategy": strategy_name, + "error": str(e), + }) + + # 4. 输出排名 + click.echo("\n" + "=" * 80) + click.echo("[*] 策略绩效排名 (按总收益率降序)") + click.echo("=" * 80) + + # 过滤掉有错误的策略 + valid = [r for r in results if "error" not in r] + errored = [r for r in results if "error" in r] + + if not valid: + click.echo("所有策略均运行失败!") + for r in errored: + click.echo(f" {r['strategy']}: {r['error']}") + raise SystemExit(1) + + # 按总收益率排序 + valid.sort(key=lambda x: x["total_return"], reverse=True) + + # 表头 + click.echo( + f"{'排名':>4} {'策略':<22} {'总收益率':>10} {'年化收益':>10} " + f"{'最大回撤':>10} {'夏普':>8} {'胜率':>8} {'交易次数':>8} {'盈亏比':>8}" + ) + click.echo("-" * 100) + + for i, r in enumerate(valid, 1): + medal = " *1*" if i == 1 else " *2*" if i == 2 else " *3*" if i == 3 else " " + click.echo( + f"{medal}{i:>2} {r['strategy']:<22} " + f"{r['total_return']:>9.2%} " + f"{r['annual_return']:>9.2%} " + f"{r['max_drawdown']:>9.2%} " + f"{r['sharpe']:>8.2f} " + f"{r['win_rate']:>7.1%} " + f"{r['total_trades']:>8} " + f"{r['profit_factor']:>8.2f}" + ) + + # 最佳策略详细报告 + best = valid[0] + click.echo("\n" + "=" * 80) + click.echo(f"[BEST] 最佳策略: {best['strategy']}") + click.echo("=" * 80) + click.echo(f" 总收益率: {best['total_return']:.2%}") + click.echo(f" 年化收益: {best['annual_return']:.2%}") + click.echo(f" 最大回撤: {best['max_drawdown']:.2%}") + click.echo(f" 夏普比率: {best['sharpe']:.2f}") + click.echo(f" 索提诺: {best['sortino']:.2f}") + click.echo(f" 卡玛比率: {best['calmar']:.2f}") + click.echo(f" 胜率: {best['win_rate']:.1%}") + click.echo(f" 交易次数: {best['total_trades']}") + click.echo(f" 盈亏比: {best['profit_factor']:.2f}") + click.echo(f" 年化波动: {best['volatility']:.4f}") + + # 综合评分(综合夏普、收益率、回撤) + click.echo("\n" + "=" * 80) + click.echo("[*] 综合评分排名 (Sharpe*0.4 + Ret/DD*0.3 + WinRate*0.3)") + click.echo("=" * 80) + + scored = [] + for r in valid: + # 避免除以零 + ret_dd_ratio = r["annual_return"] / r["max_drawdown"] if r["max_drawdown"] > 1e-6 else 999.0 + score = r["sharpe"] * 0.4 + ret_dd_ratio * 0.3 + r["win_rate"] * 100 * 0.3 + scored.append((r, score)) + + scored.sort(key=lambda x: x[1], reverse=True) + + click.echo( + f"{'排名':>4} {'策略':<22} {'综合评分':>10} {'夏普':>8} {'收益/回撤':>10} {'胜率':>8}" + ) + click.echo("-" * 70) + for i, (r, score) in enumerate(scored, 1): + ret_dd_ratio = r["annual_return"] / r["max_drawdown"] if r["max_drawdown"] > 1e-6 else 999.0 + medal = " *1*" if i == 1 else " *2*" if i == 2 else " *3*" if i == 3 else " " + click.echo( + f"{medal}{i:>2} {r['strategy']:<22} {score:>10.2f} " + f"{r['sharpe']:>8.2f} {ret_dd_ratio:>10.2f} {r['win_rate']:>7.1%}" + ) + + # 报告错误 + if errored: + click.echo("\n[!] 以下策略运行失败:") + for r in errored: + click.echo(f" {r['strategy']}: {r['error']}") + + +if __name__ == "__main__": + run_all() diff --git a/src/easy_tdx/__init__.py b/src/easy_tdx/__init__.py index b83b87e..99b3c78 100644 --- a/src/easy_tdx/__init__.py +++ b/src/easy_tdx/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "save_best_ex_host", ] -__version__ = "1.4.0" +__version__ = "1.8.0" diff --git a/src/easy_tdx/backtest/portfolio.py b/src/easy_tdx/backtest/portfolio.py index 90d1e56..01c13a4 100644 --- a/src/easy_tdx/backtest/portfolio.py +++ b/src/easy_tdx/backtest/portfolio.py @@ -46,17 +46,12 @@ class PortfolioTracker: Args: trades: 交易列表 """ - # 构建 datetime → Trade 映射 - buy_map: dict[int, Trade] = {} - sell_map: dict[int, Trade] = {} - + # 构建 datetime → [Trade] 映射(支持同 bar 多笔交易) + trade_map: dict[int, list[Trade]] = {} for trade in trades: if trade.rejected: continue - if trade.direction == "BUY": - buy_map[trade.datetime] = trade - else: - sell_map[trade.datetime] = trade + trade_map.setdefault(trade.datetime, []).append(trade) # 遍历每个 bar for i in range(self._n): @@ -68,33 +63,32 @@ class PortfolioTracker: self._position[i] = self._position[i - 1] self._avg_price[i] = self._avg_price[i - 1] - # 处理买入 - if dt in buy_map: - trade = buy_map[dt] - cost = trade.size * trade.price + trade.commission + trade.slippage - self._cash[i] -= cost + # 处理该 bar 的所有交易 + for trade in trade_map.get(dt, []): + if trade.direction == "BUY": + cost = trade.size * trade.price + trade.commission + trade.slippage + self._cash[i] -= cost - # 更新均价 - if self._position[i] > 0: - total_cost = self._position[i] * self._avg_price[i] + trade.size * trade.price - self._position[i] += trade.size - self._avg_price[i] = total_cost / self._position[i] - else: - # 新开仓或从空仓开仓 - self._position[i] = trade.size - self._avg_price[i] = trade.price + # 更新均价 + if self._position[i] > 0: + prev_cost = self._position[i] * self._avg_price[i] + total_cost = prev_cost + trade.size * trade.price + self._position[i] += trade.size + self._avg_price[i] = total_cost / self._position[i] + else: + # 新开仓或从空仓开仓 + self._position[i] = trade.size + self._avg_price[i] = trade.price - # 处理卖出 - if dt in sell_map: - trade = sell_map[dt] - proceeds = trade.size * trade.price - trade.commission - trade.slippage - self._cash[i] += proceeds - self._position[i] -= trade.size + elif trade.direction == "SELL": + proceeds = trade.size * trade.price - trade.commission - trade.slippage + self._cash[i] += proceeds + self._position[i] -= trade.size - # 清空持仓时归零 - if self._position[i] <= 0: - self._position[i] = 0.0 - self._avg_price[i] = 0.0 + # 清空持仓时归零 + if self._position[i] <= 0: + self._position[i] = 0.0 + self._avg_price[i] = 0.0 @property def equity_curve(self) -> pd.DataFrame: diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py index 6c208a4..375a28e 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -19,7 +19,7 @@ class Signal: """策略产生的交易信号。 Attributes: - datetime: 信号时间(Unix timestamp 毫秒) + datetime: 信号时间(YYYYMMDD 整数格式,如 20240101) direction: 交易方向 size: 交易数量(0 = 全仓/清仓) price: 限价(None = 市价单) @@ -43,7 +43,7 @@ class Trade: """已成交记录。 Attributes: - datetime: 成交时间(Unix timestamp 毫秒) + datetime: 成交时间(YYYYMMDD 整数格式,如 20240101) direction: 交易方向 size: 成交数量 price: 成交价格 @@ -71,7 +71,7 @@ class Position: """持仓快照。 Attributes: - datetime: 快照时间(Unix timestamp 毫秒) + datetime: 快照时间(YYYYMMDD 整数格式,如 20240101) size: 持仓数量(正=多头,负=空头,0=空仓) avg_price: 平均持仓成本 market_value: 市值 diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py index 082bdeb..a1ce709 100644 --- a/src/easy_tdx/cli/__init__.py +++ b/src/easy_tdx/cli/__init__.py @@ -23,7 +23,7 @@ from ..backtest.cli import backtest @click.group() -@click.version_option(version="1.7.0", prog_name="easy-tdx") +@click.version_option(version="1.8.0", prog_name="easy-tdx") def cli() -> None: """easy-tdx -- 通达信行情数据 CLI(默认 JSON 输出,适合 Agent 使用)。 diff --git a/strategies/turtle_breakout.py b/strategies/turtle_breakout.py index 39766e2..0ee6985 100644 --- a/strategies/turtle_breakout.py +++ b/strategies/turtle_breakout.py @@ -16,7 +16,7 @@ class TurtleStrategy(Strategy): """海龟交易法(唐安奇通道突破)策略。""" def init(self) -> None: - self.upper, self.lower = self.I(MyTT.TAQ, self.data.high, self.data.low, 20) + self.upper, _, self.lower = self.I(MyTT.TAQ, self.data.high, self.data.low, 20) def next(self) -> None: cur = self.data.close[0]