quantcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ """核心模块 - 因子计算、回测引擎、数据管理
2
+
3
+ Modules:
4
+ - data: 数据获取、缓存、清洗
5
+ - factor: 因子计算引擎
6
+ - backtest: 回测引擎 (基于 Backtrader)
7
+ """
8
+
9
+ from .data import (
10
+ DataManager,
11
+ DataConfig,
12
+ DataQualityReport,
13
+ )
14
+
15
+ from .factor import (
16
+ FactorEngine,
17
+ Factor,
18
+ FactorEvaluation,
19
+ FactorRegistry,
20
+ )
21
+
22
+ from .backtest import (
23
+ BacktestEngine,
24
+ BacktestConfig,
25
+ BacktestResult,
26
+ Strategy,
27
+ Trade,
28
+ quick_backtest,
29
+ run_from_dm,
30
+ )
31
+
32
+ __all__ = [
33
+ # Data
34
+ "DataManager",
35
+ "DataConfig",
36
+ "DataQualityReport",
37
+ # Factor
38
+ "FactorEngine",
39
+ "Factor",
40
+ "FactorEvaluation",
41
+ "FactorRegistry",
42
+ # Backtest
43
+ "BacktestEngine",
44
+ "BacktestConfig",
45
+ "BacktestResult",
46
+ "Strategy",
47
+ "Trade",
48
+ "quick_backtest",
49
+ "run_from_dm",
50
+ ]
@@ -0,0 +1,534 @@
1
+ """回测模块 - 基于 Backtrader
2
+
3
+ 功能:
4
+ 1. 回测引擎 (BacktestEngine)
5
+ 2. 策略基类 (Strategy)
6
+ 3. 回测结果 (BacktestResult)
7
+
8
+ Usage:
9
+ >>> from quantcli.core import BacktestEngine, Strategy, BacktestConfig
10
+ >>> from quantcli.core.data import DataManager
11
+ >>> config = BacktestConfig(initial_capital=1000000, fee=0.0003)
12
+ >>> engine = BacktestEngine(dm, config)
13
+ >>> engine.add_data("600519", df)
14
+ >>> result = engine.run(DualMAStrategy)
15
+ """
16
+
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Dict, List, Optional, Any, Type, Union
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ import pandas as pd
24
+ import numpy as np
25
+
26
+ import backtrader as bt
27
+ from backtrader import Strategy as BtStrategy
28
+
29
+ from ..utils import get_logger, parse_date, format_date
30
+ from ..datasources import create_datasource
31
+
32
+ logger = get_logger(__name__)
33
+
34
+
35
+ # =============================================================================
36
+ # 配置类
37
+ # =============================================================================
38
+
39
+ @dataclass
40
+ class BacktestConfig:
41
+ """回测配置"""
42
+ initial_capital: float = 1000000.0 # 初始资金
43
+ fee: float = 0.0003 # 手续费率
44
+ slippage: float = 0.0005 # 滑点
45
+ benchmark: str = "000300.SH" # 基准指数
46
+ start_date: Optional[Any] = None # 开始日期
47
+ end_date: Optional[Any] = None # 结束日期
48
+ timezone: str = "Asia/Shanghai" # 时区
49
+
50
+
51
+ @dataclass
52
+ class Trade:
53
+ """交易记录"""
54
+ date: Any
55
+ symbol: str
56
+ side: str # "buy" / "sell"
57
+ price: float
58
+ quantity: int
59
+ fee: float
60
+ pnl: Optional[float] = None
61
+
62
+
63
+ @dataclass
64
+ class BacktestResult:
65
+ """回测结果
66
+
67
+ Attributes:
68
+ total_return: 总收益率
69
+ annual_return: 年化收益率
70
+ max_drawdown: 最大回撤
71
+ sharpe: 夏普比率
72
+ sortino: 索提诺比率
73
+ win_rate: 胜率
74
+ profit_factor: 盈亏比
75
+ total_trades: 总交易次数
76
+ trades: 交易记录 DataFrame
77
+ equity_curve: 资金曲线 DataFrame
78
+ """
79
+ total_return: float = 0.0
80
+ annual_return: float = 0.0
81
+ max_drawdown: float = 0.0
82
+ sharpe: float = 0.0
83
+ sortino: float = 0.0
84
+ win_rate: float = 0.0
85
+ profit_factor: float = 0.0
86
+ total_trades: int = 0
87
+ trades: pd.DataFrame = None
88
+ equity_curve: pd.DataFrame = None
89
+ benchmark_curve: pd.DataFrame = None
90
+
91
+ def __post_init__(self):
92
+ if self.trades is None:
93
+ self.trades = pd.DataFrame()
94
+ if self.equity_curve is None:
95
+ self.equity_curve = pd.DataFrame()
96
+
97
+ def to_dict(self) -> Dict:
98
+ return {
99
+ "total_return": self.total_return,
100
+ "annual_return": self.annual_return,
101
+ "max_drawdown": self.max_drawdown,
102
+ "sharpe": self.sharpe,
103
+ "sortino": self.sortino,
104
+ "win_rate": self.win_rate,
105
+ "profit_factor": self.profit_factor,
106
+ "total_trades": self.total_trades,
107
+ }
108
+
109
+
110
+ # =============================================================================
111
+ # 数据源适配器
112
+ # =============================================================================
113
+
114
+ class PandasData(bt.feeds.PandasData):
115
+ """Pandas 数据源适配器 - 将 DataFrame 转换为 Backtrader 格式"""
116
+
117
+ params = (
118
+ ("datetime", None),
119
+ ("open", "open"),
120
+ ("high", "high"),
121
+ ("low", "low"),
122
+ ("close", "close"),
123
+ ("volume", "volume"),
124
+ ("openinterest", -1),
125
+ )
126
+
127
+
128
+ class MultiData(bt.feeds.PandasData):
129
+ """多标的数据源"""
130
+
131
+ params = (
132
+ ("datetime", None),
133
+ ("open", "open"),
134
+ ("high", "high"),
135
+ ("low", "low"),
136
+ ("close", "close"),
137
+ ("volume", "volume"),
138
+ )
139
+
140
+
141
+ # =============================================================================
142
+ # 策略基类
143
+ # =============================================================================
144
+
145
+ class Strategy(BtStrategy):
146
+ """策略基类 - 继承 Backtrader Strategy
147
+
148
+ 子类示例:
149
+ >>> class DualMA(Strategy):
150
+ >>> name = "双均线策略"
151
+ >>> params = {"fast": 5, "slow": 20}
152
+ >>>
153
+ >>> def __init__(self):
154
+ >>> super().__init__()
155
+ >>> self.ma_fast = bt.indicators.SMA(self.data.close, period=self.params.fast)
156
+ >>> self.ma_slow = bt.indicators.SMA(self.data.close, period=self.params.slow)
157
+ >>>
158
+ >>> def next(self):
159
+ >>> if self.ma_fast[0] > self.ma_slow[0] and self.ma_fast[-1] <= self.ma_slow[-1]:
160
+ >>> self.buy()
161
+ >>> elif self.ma_fast[0] < self.ma_slow[0] and self.ma_fast[-1] >= self.ma_slow[-1]:
162
+ >>> self.sell()
163
+ """
164
+
165
+ name = "BaseStrategy"
166
+ params = {}
167
+
168
+ def __init__(self):
169
+ super().__init__()
170
+ # 订单记录
171
+ self.order = None
172
+ self.trade_log = []
173
+
174
+ # 交易统计
175
+ self.wins = 0
176
+ self.losses = 0
177
+ self.gross_profit = 0.0
178
+ self.gross_loss = 0.0
179
+
180
+ def notify_order(self, order):
181
+ """订单状态通知"""
182
+ if order.status in [order.Submitted, order.Accepted]:
183
+ return
184
+
185
+ if order.status in [order.Completed]:
186
+ if order.isbuy():
187
+ self.trade_log.append({
188
+ "date": self.datas[0].datetime.date(0),
189
+ "symbol": self.datas[0]._name,
190
+ "side": "buy",
191
+ "price": order.executed.price,
192
+ "quantity": order.executed.size,
193
+ "fee": order.executed.comm,
194
+ })
195
+ else:
196
+ pnl = (order.executed.price - order.orders[0].created.price) * order.executed.size if len(self) > 1 else 0
197
+ self.trade_log.append({
198
+ "date": self.datas[0].datetime.date(0),
199
+ "symbol": self.datas[0]._name,
200
+ "side": "sell",
201
+ "price": order.executed.price,
202
+ "quantity": order.executed.size,
203
+ "fee": order.executed.comm,
204
+ "pnl": pnl,
205
+ })
206
+
207
+ # 更新统计
208
+ if pnl > 0:
209
+ self.wins += 1
210
+ self.gross_profit += pnl
211
+ else:
212
+ self.losses += 1
213
+ self.gross_loss += abs(pnl)
214
+
215
+ elif order.status in [order.Canceled, order.Margin, order.Rejected]:
216
+ logger.warning(f"Order rejected: {order.status}")
217
+
218
+ self.order = None
219
+
220
+ def log(self, txt, dt=None):
221
+ """日志输出"""
222
+ dt = dt or self.datas[0].datetime.date(0)
223
+ logger.info(f"{dt}: {txt}")
224
+
225
+ def notify_trade(self, trade):
226
+ """交易完成通知"""
227
+ if trade.isclosed:
228
+ logger.info(f"Trade: {trade.getdataname()} - PnL: {trade.pnl:.2f}")
229
+
230
+
231
+ # =============================================================================
232
+ # 回测引擎
233
+ # =============================================================================
234
+
235
+ class BacktestEngine:
236
+ """回测引擎 - 封装 Cerebro
237
+
238
+ Usage:
239
+ >>> engine = BacktestEngine(dm, config)
240
+ >>> engine.add_data("600519", df) # df 包含 open/high/low/close/volume
241
+ >>> result = engine.run(DualMAStrategy)
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ config: Optional[BacktestConfig] = None
247
+ ):
248
+ """初始化回测引擎
249
+
250
+ Args:
251
+ config: 回测配置
252
+ """
253
+ self.config = config or BacktestConfig()
254
+ self.cerebro = bt.Cerebro()
255
+
256
+ # 设置资金
257
+ self.cerebro.broker.setcash(self.config.initial_capital)
258
+
259
+ # 设置手续费
260
+ self.cerebro.broker.setcommission(commission=self.config.fee)
261
+
262
+ # 设置滑点
263
+ self.cerebro.broker.set_slippage_perc(self.config.slippage)
264
+
265
+ # 分析器
266
+ self.cerebro.addanalyzer(bt.analyzers.DrawDown)
267
+ self.cerebro.addanalyzer(bt.analyzers.SharpeRatio, riskfreerate=0.02, annualize=True)
268
+ self.cerebro.addanalyzer(bt.analyzers.Returns)
269
+ self.cerebro.addanalyzer(bt.analyzers.TimeReturn, _name="time_return")
270
+
271
+ # 交易记录
272
+ self.cerebro.addobserver(bt.observers.Trades)
273
+
274
+ # 数据源
275
+ self._data_feeds: Dict[str, Any] = {}
276
+
277
+ def add_data(
278
+ self,
279
+ symbol: str,
280
+ df: pd.DataFrame,
281
+ fromdate: Optional[Any] = None,
282
+ todate: Optional[Any] = None
283
+ ):
284
+ """添加数据源
285
+
286
+ Args:
287
+ symbol: 股票代码
288
+ df: DataFrame (必须包含: date, open, high, low, close, volume)
289
+ fromdate: 开始日期
290
+ todate: 结束日期
291
+ """
292
+ # 确保日期列
293
+ df = df.copy()
294
+ if "date" in df.columns:
295
+ df["datetime"] = pd.to_datetime(df["date"])
296
+ df.set_index("datetime", inplace=True)
297
+ elif not isinstance(df.index, pd.DatetimeIndex):
298
+ df.index = pd.to_datetime(df.index)
299
+
300
+ # 过滤日期
301
+ if fromdate:
302
+ df = df[df.index >= parse_date(fromdate)]
303
+ if todate:
304
+ df = df[df.index <= parse_date(todate)]
305
+
306
+ # 创建数据源
307
+ data = PandasData(dataname=df, datetime=None)
308
+ data._name = symbol
309
+
310
+ self.cerebro.adddata(data)
311
+ self._data_feeds[symbol] = data
312
+
313
+ logger.info(f"Added data: {symbol} ({len(df)} rows)")
314
+
315
+ def add_data_from_datasource(
316
+ self,
317
+ symbol: str,
318
+ dm: Any,
319
+ start_date: Any,
320
+ end_date: Any
321
+ ):
322
+ """从 DataManager 添加数据
323
+
324
+ Args:
325
+ symbol: 股票代码
326
+ dm: DataManager 实例
327
+ start_date: 开始日期
328
+ end_date: 结束日期
329
+ """
330
+ df = dm.get_daily(symbol, start_date, end_date)
331
+ if not df.empty:
332
+ self.add_data(symbol, df, start_date, end_date)
333
+
334
+ def run(
335
+ self,
336
+ strategy_cls: Type[Strategy],
337
+ **strategy_params
338
+ ) -> BacktestResult:
339
+ """运行回测
340
+
341
+ Args:
342
+ strategy_cls: 策略类 (继承 Strategy)
343
+ **strategy_params: 策略参数
344
+
345
+ Returns:
346
+ BacktestResult: 回测结果
347
+ """
348
+ # 添加策略
349
+ self.cerebro.addstrategy(strategy_cls, **strategy_params)
350
+
351
+ # 设置分析器
352
+ if not self.cerebro.analyzers:
353
+ self.cerebro.addanalyzer(bt.analyzers.DrawDown)
354
+ self.cerebro.addanalyzer(bt.analyzers.SharpeRatio)
355
+
356
+ # 运行回测
357
+ results = self.cerebro.run()
358
+
359
+ # 获取策略结果
360
+ strategy_result = results[0]
361
+
362
+ # 构建结果
363
+ backtest_result = self._build_result(strategy_result)
364
+
365
+ logger.info(f"Backtest finished: Return={backtest_result.total_return:.2%}, "
366
+ f"MaxDD={backtest_result.max_drawdown:.2%}, "
367
+ f"Sharpe={backtest_result.sharpe:.2f}")
368
+
369
+ return backtest_result
370
+
371
+ def _build_result(self, strategy_result: Strategy) -> BacktestResult:
372
+ """构建回测结果"""
373
+ # 获取分析器数据
374
+ analyzers = strategy_result.analyzers
375
+
376
+ # 最大回撤
377
+ dd = analyzers.drawdown.get_analysis()
378
+ max_drawdown = dd.get("max", {}).get("drawdown", 0) / 100 if dd else 0
379
+
380
+ # 夏普比率
381
+ sharpe = analyzers.sharperatio.get_analysis()
382
+ sharpe_ratio = sharpe.get("sharperatio", 0)
383
+
384
+ # 时间收益
385
+ time_returns = analyzers.time_return.get_analysis()
386
+ returns_series = pd.Series(time_returns)
387
+
388
+ # 计算收益指标
389
+ total_return = (1 + returns_series).prod() - 1 if len(returns_series) > 0 else 0
390
+
391
+ # 年化收益率 (假设252交易日)
392
+ n_years = len(returns_series) / 252
393
+ annual_return = (1 + total_return) ** (1 / n_years) - 1 if n_years > 0 else 0
394
+
395
+ # 索提诺比率
396
+ downside_returns = returns_series[returns_series < 0]
397
+ downside_std = downside_returns.std() if len(downside_returns) > 0 else 0
398
+ sortino = (annual_return - 0.02) / downside_std if downside_std > 0 else 0
399
+
400
+ # 交易统计
401
+ trades_df = pd.DataFrame(strategy_result.trade_log)
402
+ if not trades_df.empty:
403
+ total_trades = len(trades_df)
404
+ wins = (trades_df.get("pnl", 0) > 0).sum() if "pnl" in trades_df.columns else 0
405
+ win_rate = wins / total_trades if total_trades > 0 else 0
406
+
407
+ profit = trades_df[trades_df.get("pnl", 0) > 0]["pnl"].sum() if "pnl" in trades_df.columns else 0
408
+ loss = abs(trades_df[trades_df.get("pnl", 0) < 0]["pnl"].sum()) if "pnl" in trades_df.columns else 1
409
+ profit_factor = profit / loss if loss > 0 else 0
410
+ else:
411
+ total_trades = 0
412
+ win_rate = 0
413
+ profit_factor = 0
414
+
415
+ # 资金曲线
416
+ equity_curve = self._get_equity_curve(strategy_result)
417
+
418
+ return BacktestResult(
419
+ total_return=total_return,
420
+ annual_return=annual_return,
421
+ max_drawdown=max_drawdown,
422
+ sharpe=sharpe_ratio,
423
+ sortino=sortino,
424
+ win_rate=win_rate,
425
+ profit_factor=profit_factor,
426
+ total_trades=total_trades,
427
+ trades=trades_df,
428
+ equity_curve=equity_curve,
429
+ )
430
+
431
+ def _get_equity_curve(self, strategy_result: Strategy) -> pd.DataFrame:
432
+ """获取资金曲线"""
433
+ try:
434
+ analyzer = strategy_result.analyzers.timers
435
+ if hasattr(analyzer, "get_analysis"):
436
+ data = analyzer.get_analysis()
437
+ if data:
438
+ dates = [datetime.fromtimestamp(k / 1000) for k in data.keys()]
439
+ values = list(data.values())
440
+ return pd.DataFrame({
441
+ "date": dates,
442
+ "equity": values
443
+ }).set_index("date")
444
+ except Exception as e:
445
+ logger.debug(f"Could not get equity curve: {e}")
446
+
447
+ # 备选方案: 使用 broker value
448
+ try:
449
+ values = []
450
+ dates = []
451
+ for i, data in enumerate(strategy_result.datas[0]):
452
+ dates.append(strategy_result.datas[0].datetime.date(i))
453
+ values.append(strategy_result.broker.getvalue())
454
+
455
+ return pd.DataFrame({
456
+ "date": dates,
457
+ "equity": values
458
+ }).set_index("date")
459
+ except:
460
+ pass
461
+
462
+ return pd.DataFrame()
463
+
464
+ def plot(self, **kwargs):
465
+ """绘图
466
+
467
+ Args:
468
+ **kwargs: backtrader plot 参数
469
+ """
470
+ self.cerebro.plot(**kwargs)
471
+
472
+
473
+ # =============================================================================
474
+ # 便捷函数
475
+ # =============================================================================
476
+
477
+ def quick_backtest(
478
+ symbol: str,
479
+ df: pd.DataFrame,
480
+ strategy_cls: Type[Strategy],
481
+ initial_capital: float = 1000000.0,
482
+ fee: float = 0.0003,
483
+ **strategy_params
484
+ ) -> BacktestResult:
485
+ """快速回测 - 单行代码运行回测
486
+
487
+ Args:
488
+ symbol: 股票代码
489
+ df: 数据 DataFrame
490
+ strategy_cls: 策略类
491
+ initial_capital: 初始资金
492
+ fee: 手续费率
493
+ **strategy_params: 策略参数
494
+
495
+ Returns:
496
+ BacktestResult
497
+ """
498
+ config = BacktestConfig(initial_capital=initial_capital, fee=fee)
499
+ engine = BacktestEngine(config)
500
+ engine.add_data(symbol, df)
501
+ return engine.run(strategy_cls, **strategy_params)
502
+
503
+
504
+ def run_from_dm(
505
+ dm: Any,
506
+ symbol: str,
507
+ strategy_cls: Type[Strategy],
508
+ start_date: Any,
509
+ end_date: Any,
510
+ initial_capital: float = 1000000.0,
511
+ fee: float = 0.0003,
512
+ **strategy_params
513
+ ) -> BacktestResult:
514
+ """从 DataManager 运行回测
515
+
516
+ Args:
517
+ dm: DataManager 实例
518
+ symbol: 股票代码
519
+ strategy_cls: 策略类
520
+ start_date: 开始日期
521
+ end_date: 结束日期
522
+ initial_capital: 初始资金
523
+ fee: 手续费率
524
+ **strategy_params: 策略参数
525
+ """
526
+ config = BacktestConfig(
527
+ initial_capital=initial_capital,
528
+ fee=fee,
529
+ start_date=start_date,
530
+ end_date=end_date
531
+ )
532
+ engine = BacktestEngine(config)
533
+ engine.add_data_from_datasource(symbol, dm, start_date, end_date)
534
+ return engine.run(strategy_cls, **strategy_params)