qka 2.2.4.dev2__tar.gz → 2.2.5.dev3__tar.gz

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.
Files changed (31) hide show
  1. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/PKG-INFO +11 -8
  2. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/README.md +10 -7
  3. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/__init__.py +2 -1
  4. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/__init__.py +2 -1
  5. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/data.py +237 -22
  6. qka-2.2.5.dev3/qka/core/simulate.py +81 -0
  7. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/SKILL.md +3 -1
  8. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/data.md +48 -1
  9. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/.github/workflows/release.yml +0 -0
  10. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/.gitignore +0 -0
  11. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/.vscode/settings.json +0 -0
  12. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/CHANGELOG.md +0 -0
  13. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/LICENSE +0 -0
  14. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/pyproject.toml +0 -0
  15. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/cli.py +0 -0
  16. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/accessor.py +0 -0
  17. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/analysis.py +0 -0
  18. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/backtest.py +0 -0
  19. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/broker.py +0 -0
  20. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/indicator.py +0 -0
  21. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/report.py +0 -0
  22. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/sizing.py +0 -0
  23. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/core/strategy.py +0 -0
  24. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/utils/__init__.py +0 -0
  25. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/qka/utils/logger.py +0 -0
  26. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/analysis.md +0 -0
  27. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/backtest.md +0 -0
  28. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/broker.md +0 -0
  29. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/sizing.md +0 -0
  30. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/skills/qka/references/strategy.md +0 -0
  31. {qka-2.2.4.dev2 → qka-2.2.5.dev3}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.2.4.dev2
3
+ Version: 2.2.5.dev3
4
4
  Summary: QKA(快量化 / Quant Kit for A-shares)- 简洁易用的 A 股量化回测框架
5
5
  Project-URL: Home, https://github.com/zsrl/qka
6
6
  Project-URL: Repository, https://github.com/zsrl/qka
@@ -135,13 +135,16 @@ print(bt.metrics['sharpe_ratio']) # 夏普比率
135
135
 
136
136
  ## 核心能力
137
137
 
138
- - **多数据源** — baostock(默认)、akshare、QMT
139
- - **预计算指标** — ta 库全部 60+ 指标,`('ta.trend.sma_indicator', 'close', 5)` 格式直接透传
140
- - **事件驱动回测** — 按日推进,`self.get()` 横截面 + `self.history()` 窗口序列
141
- - **仓位管理** — `sizing.percent()` / `sizing.fixed_amount()` / `sizing.fixed_shares()` / `sizing.atr_risk()`
142
- - **交易模拟** — 佣金万 2.5、印花税万 5(仅卖出)、滑点 0.1%,最低佣金 5 元
143
- - **绩效指标** — 总收益率、年化、夏普比率、最大回撤、Calmar、胜率、盈亏比等 13 项
144
- - **基准对比** — 支持沪深 300(或指定指数)对比
138
+ - **数据获取** — 写个股票代码就能取到本地,自动缓存、自动补齐最新数据,重复使用秒读
139
+ - **技术指标** — 均线、MACD、RSI、ATR 等 ta 库全部指标,外加 α/β、夏普、最大回撤等内置指标,声明一下即可,策略里不用自己 rolling
140
+ - **估值字段** — 换手率、涨跌幅、停牌与 ST 标记、市盈率、市净率等,需要哪些就取哪些
141
+ - **模拟行情** — 一条 `Simulate` 就能造出单边上涨、单边下跌、来回震荡的走势,不必等真实行情,用来验证策略是不是真在按逻辑交易
142
+ - **多标的回测** — 一次传一篮子股票,`self.get()` 取当日横截面,`self.history()` 取历史窗口
143
+ - **仓位管理** — 按资金比例、按金额、按股数、按 ATR 风险四种算法
144
+ - **交易成本** — 佣金、印花税、滑点均已内置,也可自行调整
145
+ - **绩效指标** — 总收益、年化、夏普、最大回撤、Calmar、胜率、盈亏比等 13 项,跑完即有
146
+ - **回测报告** — 净值曲线、回撤曲线、月度收益热力图、逐笔交易明细,一键生成 HTML,浏览器直接打开
147
+ - **基准对比** — 传入基准指数即可对比收益、计算 β / α
145
148
 
146
149
  ## 文档
147
150
 
@@ -85,13 +85,16 @@ print(bt.metrics['sharpe_ratio']) # 夏普比率
85
85
 
86
86
  ## 核心能力
87
87
 
88
- - **多数据源** — baostock(默认)、akshare、QMT
89
- - **预计算指标** — ta 库全部 60+ 指标,`('ta.trend.sma_indicator', 'close', 5)` 格式直接透传
90
- - **事件驱动回测** — 按日推进,`self.get()` 横截面 + `self.history()` 窗口序列
91
- - **仓位管理** — `sizing.percent()` / `sizing.fixed_amount()` / `sizing.fixed_shares()` / `sizing.atr_risk()`
92
- - **交易模拟** — 佣金万 2.5、印花税万 5(仅卖出)、滑点 0.1%,最低佣金 5 元
93
- - **绩效指标** — 总收益率、年化、夏普比率、最大回撤、Calmar、胜率、盈亏比等 13 项
94
- - **基准对比** — 支持沪深 300(或指定指数)对比
88
+ - **数据获取** — 写个股票代码就能取到本地,自动缓存、自动补齐最新数据,重复使用秒读
89
+ - **技术指标** — 均线、MACD、RSI、ATR 等 ta 库全部指标,外加 α/β、夏普、最大回撤等内置指标,声明一下即可,策略里不用自己 rolling
90
+ - **估值字段** — 换手率、涨跌幅、停牌与 ST 标记、市盈率、市净率等,需要哪些就取哪些
91
+ - **模拟行情** — 一条 `Simulate` 就能造出单边上涨、单边下跌、来回震荡的走势,不必等真实行情,用来验证策略是不是真在按逻辑交易
92
+ - **多标的回测** — 一次传一篮子股票,`self.get()` 取当日横截面,`self.history()` 取历史窗口
93
+ - **仓位管理** — 按资金比例、按金额、按股数、按 ATR 风险四种算法
94
+ - **交易成本** — 佣金、印花税、滑点均已内置,也可自行调整
95
+ - **绩效指标** — 总收益、年化、夏普、最大回撤、Calmar、胜率、盈亏比等 13 项,跑完即有
96
+ - **回测报告** — 净值曲线、回撤曲线、月度收益热力图、逐笔交易明细,一键生成 HTML,浏览器直接打开
97
+ - **基准对比** — 传入基准指数即可对比收益、计算 β / α
95
98
 
96
99
  ## 文档
97
100
 
@@ -12,6 +12,7 @@ except PackageNotFoundError:
12
12
  __version__ = "0.1.0" # fallback version
13
13
 
14
14
  # 核心功能直接导入
15
+ from qka.core.simulate import Simulate
15
16
  from qka.core.data import Data
16
17
  from qka.core.accessor import DataAccessor
17
18
  from qka.core.backtest import Backtest
@@ -25,7 +26,7 @@ from qka import core, utils
25
26
 
26
27
  __all__ = [
27
28
  # 核心功能
28
- 'Data', 'Backtest', 'Strategy', 'Broker', 'DataAccessor', 'SizingAccessor',
29
+ 'Data', 'Simulate', 'Backtest', 'Strategy', 'Broker', 'DataAccessor', 'SizingAccessor',
29
30
  # 分析
30
31
  'Analysis', 'Segment', 'AlphaBeta',
31
32
  # 子模块
@@ -5,6 +5,7 @@ QKA Core 模块
5
5
 
6
6
 
7
7
  # 数据相关
8
+ from .simulate import Simulate
8
9
  from .data import Data
9
10
  from .accessor import DataAccessor
10
11
  # 回测相关
@@ -16,7 +17,7 @@ from .sizing import SizingAccessor
16
17
 
17
18
  __all__ = [
18
19
  # 数据相关
19
- 'Data', 'DataAccessor',
20
+ 'Data', 'Simulate', 'DataAccessor',
20
21
  # 回测相关
21
22
  'Backtest', 'Strategy', 'Broker',
22
23
  # 仓位管理
@@ -13,9 +13,10 @@ import pyarrow as pa
13
13
  import pyarrow.parquet as pq
14
14
  import baostock as bs
15
15
  import dask.dataframe as dd
16
- from typing import List, Dict, Optional, Callable
16
+ from typing import List, Optional, Callable, Union
17
17
  from qka.utils.logger import logger
18
18
  from qka.core import indicator
19
+ from qka.core.simulate import Simulate
19
20
 
20
21
  # qka 内置指标裸名集合,用于 dispatch 识别(无需 qka. 前缀)
21
22
  _QKA_BUILTIN_NAMES = frozenset({
@@ -63,9 +64,26 @@ class Data():
63
64
  "pcfNcfTTM", # 市现率(TTM)
64
65
  ]
65
66
 
67
+ # ── 模拟数据源默认值 ──
68
+ SIM_START = '2020-01-02' # 模拟行情默认起点(调用方未给 start_date 时使用)
69
+ SIM_START_PRICE = 20.0 # 起始价
70
+ SIM_LIMIT = 0.10 # 单日涨跌幅截断(模拟涨跌停)
71
+ SIM_FUND_PHI = 0.97 # 基本面扰动的持续性(半衰期 ≈ 23 个交易日)
72
+ # 估值列参数:field -> (基准值, 对价格的对数弹性, 基本面自身对数波动(稳态 std))
73
+ # 弹性 = 估值随价格等比变动的比例。弹性≈1 ⇒ 估值是价格的复刻(错);
74
+ # 弹性小甚至为负 ⇒ 盈利同步增长抵消了价格,估值走出自己的路(对)。
75
+ # 目标量级参照真实缓存实测:corr(peTTM,close)≈-0.09 / pbMRQ≈+0.78 /
76
+ # psTTM≈-0.59 / pcfNcfTTM≈-0.59。
77
+ SIM_EXTRA_BASE = {
78
+ 'peTTM': (15.0, +0.05, 0.130),
79
+ 'pbMRQ': (1.8, +0.40, 0.070),
80
+ 'psTTM': (2.2, -0.30, 0.120),
81
+ 'pcfNcfTTM': (12.0, -0.80, 0.300),
82
+ }
83
+
66
84
  def __init__(
67
85
  self,
68
- symbols: Optional[List[str]] = None,
86
+ symbols: Optional[List[Union[str, Simulate]]] = None,
69
87
  benchmark: Optional[str] = None,
70
88
  period: str = '1d',
71
89
  adjust: str = 'qfq',
@@ -80,7 +98,22 @@ class Data():
80
98
  初始化数据对象
81
99
 
82
100
  Args:
83
- symbols: 股票代码列表,baostock 格式如 ['sz.000001', 'sh.600000']
101
+ symbols: 标的列表,元素**可以是真实代码字符串,也可以是 Simulate 对象**。
102
+ 真实代码用 baostock 格式,如 ['sz.000001', 'sh.600000'];Simulate 对象
103
+ 表示一只由 qka 现场生成的模拟标的,两者可以混排。写法完全同构,区别只在
104
+ 元素本身:
105
+
106
+ ```python
107
+ Data(symbols=[
108
+ 'sh.600900',
109
+ Simulate('sim.a', drift=0.0, reversion=0.08, vol=0.018),
110
+ ])
111
+ ```
112
+
113
+ 模拟标的同样支持 `indicators` / `extra_fields` / `warmup`,行为与真实源
114
+ 一致(列名同样是 {symbol}|{field},扩展列同样是「不请求就不生成」)。
115
+ 模拟标的的行情区间由 get(start_date, end_date) 决定 —— 用 baostock 的
116
+ 交易日历把区间内的真实交易日取出来,一 日一根,与真实源同构。
84
117
  benchmark: 基准代码,如 'sh.000300'。基准数据以 benchmark| 前缀加入最终 DataFrame,
85
118
  仅供辅助计算(β/α 等),不参与指标计算
86
119
  period: 数据周期,如 '1d'(日线)、'1m'(分钟)
@@ -117,8 +150,27 @@ class Data():
117
150
  indicators=lambda df: df.assign(ma5=df['close'].rolling(5).mean())
118
151
  ```
119
152
  函数接收单只股票的 DataFrame,返回添加了额外列的 DataFrame。
153
+
120
154
  """
121
- self.symbols = symbols or []
155
+ # ── 归一化 symbols:str = 真实标的,Simulate 对象 = 模拟标的,可混排 ──
156
+ names, sim_specs = [], {}
157
+ for item in (symbols or []):
158
+ if isinstance(item, Simulate):
159
+ name = item.symbol
160
+ sim_specs[name] = item
161
+ elif isinstance(item, str):
162
+ name = item
163
+ else:
164
+ raise TypeError(
165
+ f"symbols 的元素必须是 str 或 Simulate,got {type(item)}: {item!r}"
166
+ )
167
+ if name in names:
168
+ raise ValueError(f"symbols 中存在重复标的: {name}")
169
+ names.append(name)
170
+
171
+ self.symbols = names # 全部标的(保持入参顺序)
172
+ self._sim_specs = sim_specs # {代码: Simulate}
173
+ self._real_symbols = [n for n in names if n not in sim_specs]
122
174
  self.benchmark = benchmark
123
175
  self.period = period
124
176
  self.adjust = adjust
@@ -158,10 +210,145 @@ class Data():
158
210
  else:
159
211
  self.datadir = Path(datadir)
160
212
 
161
- self.datadir.mkdir(parents=True, exist_ok=True)
213
+ # 只有真实标的才需要缓存目录;纯模拟标的(_real_symbols 为空)不落盘
214
+ if self._real_symbols:
215
+ self.datadir.mkdir(parents=True, exist_ok=True)
162
216
 
163
217
  self.target_dir = self.datadir / self.source / self.period / (self.adjust or "bfq")
164
- self.target_dir.mkdir(parents=True, exist_ok=True)
218
+ if self._real_symbols:
219
+ self.target_dir.mkdir(parents=True, exist_ok=True)
220
+
221
+ def _trading_index(self, start_date: str, end_date: str) -> pd.DatetimeIndex:
222
+ """
223
+ 取 [start_date, end_date] 内的真实交易日,作为模拟行情的日期轴。
224
+
225
+ 数据来自 baostock 的交易日历接口 query_trade_dates,故调用方需已登录。
226
+
227
+ Args:
228
+ start_date / end_date: 'YYYY-MM-DD'
229
+
230
+ Returns:
231
+ pd.DatetimeIndex,名称 'date'(与真实缓存 parquet 的索引名一致)
232
+
233
+ Raises:
234
+ RuntimeError: baostock 查询失败,或区间内一个交易日都没有
235
+ """
236
+ rs = bs.query_trade_dates(start_date=start_date, end_date=end_date)
237
+ if rs.error_code != '0':
238
+ raise RuntimeError(
239
+ f"获取交易日历失败({start_date} ~ {end_date}): "
240
+ f"[{rs.error_code}] {rs.error_msg}"
241
+ )
242
+ dates = []
243
+ while rs.next():
244
+ calendar_date, is_trading_day = rs.get_row_data()
245
+ if is_trading_day == '1':
246
+ dates.append(calendar_date)
247
+ if not dates:
248
+ raise RuntimeError(
249
+ f"区间内没有交易日: {start_date} ~ {end_date},请检查日期范围"
250
+ )
251
+ idx = pd.DatetimeIndex(pd.to_datetime(dates), name='date')
252
+ return idx
253
+
254
+ def _generate_symbol(self, symbol: str, idx: pd.DatetimeIndex) -> pd.DataFrame:
255
+ """
256
+ 生成一只模拟标的的行情数据(纯内存,不走缓存、不下载)。
257
+
258
+ 对数价格按「带趋势的均值回复」演化:
259
+ r_t = drift + reversion * (logAnchor - logP_{t-1}) + vol * eps_t
260
+ eps_t ~ N(0, 1)。drift=0 且 reversion=0 时退化为纯随机游走。
261
+
262
+ Args:
263
+ symbol: Simulate 标的的代码
264
+ idx: 日期轴(真实交易日,取自 _trading_index)
265
+
266
+ Returns:
267
+ pd.DataFrame,索引即 idx,含 6 个基础列,
268
+ 并按 self.extra_fields 追加扩展列 —— 与真实数据源行为一致(不给就不生成)。
269
+ """
270
+ spec = self._sim_specs[symbol]
271
+ drift, reversion, vol = spec.drift, spec.reversion, spec.vol
272
+
273
+ bars = len(idx)
274
+ rng = np.random.default_rng() # 每次新建 ⇒ 每次调用都是新行情
275
+
276
+ # 1. 对数价格路径(锚点固定在起点价上,保证「震荡」真是围着起点来回)
277
+ log_p = np.empty(bars)
278
+ log_p[0] = np.log(self.SIM_START_PRICE)
279
+ anchor = log_p[0]
280
+ eps = rng.standard_normal(bars)
281
+ for t in range(1, bars):
282
+ log_p[t] = log_p[t - 1] + drift + reversion * (anchor - log_p[t - 1]) + vol * eps[t]
283
+ close = np.exp(log_p)
284
+
285
+ # 2. 涨跌停截断(相对前收),避免造出真实市场不会有的单日暴涨
286
+ step_ret = np.clip(np.diff(close) / close[:-1], -self.SIM_LIMIT, self.SIM_LIMIT)
287
+ close = np.concatenate([[close[0]], close[0] * np.cumprod(1.0 + step_ret)])
288
+
289
+ # 3. OHLC:保证 high >= max(open, close)、low <= min(open, close)
290
+ preclose = np.concatenate([[close[0]], close[:-1]])
291
+ open_ = preclose * (1.0 + rng.normal(0.0, 0.002, bars))
292
+ high = np.maximum(open_, close) * (1.0 + np.abs(rng.normal(0.0, 0.003, bars)))
293
+ low = np.minimum(open_, close) * (1.0 - np.abs(rng.normal(0.0, 0.003, bars)))
294
+
295
+ volume = rng.integers(1_000_000, 5_000_000, bars).astype(float)
296
+ df = pd.DataFrame({
297
+ 'open': open_, 'high': high, 'low': low, 'close': close,
298
+ 'volume': volume, 'amount': volume * close,
299
+ }, index=idx)
300
+
301
+ # 4. 扩展列:只生成请求的字段(与真实源「不给就没有」一致)
302
+ log_ratio = np.log(close / close[0])
303
+ for field in self.extra_fields:
304
+ if field == 'preclose':
305
+ df[field] = preclose
306
+ elif field == 'pctChg':
307
+ df[field] = (close / preclose - 1.0) * 100.0
308
+ elif field == 'tradestatus':
309
+ df[field] = 1
310
+ elif field == 'isST':
311
+ df[field] = 0
312
+ elif field == 'turn':
313
+ df[field] = np.abs(rng.normal(2.0, 0.6, bars))
314
+ elif field in self.SIM_EXTRA_BASE:
315
+ base, elasticity, idio_std = self.SIM_EXTRA_BASE[field]
316
+ # 估值 = 基准值 × 价格弹性项 × 基本面自身波动项
317
+ df[field] = base * np.exp(
318
+ elasticity * log_ratio
319
+ + self._sim_fundamental_walk(rng, bars, idio_std)
320
+ )
321
+ return df
322
+
323
+ def _sim_fundamental_walk(self, rng, bars: int, idio_std: float) -> np.ndarray:
324
+ """
325
+ 生成一条「基本面」对数扰动序列(OU 均值回复过程)。
326
+
327
+ 为什么需要它:真实世界里 PE = 市值 / 净利润,净利润是**独立于价格**演化
328
+ 的慢变量,所以真实估值与股价并不同步(实测长江电力 corr(peTTM, close)
329
+ ≈ -0.09)。若估值列直接写成价格的等比缩放,该相关系数会逼近 +1,
330
+ 「低 PE 买入」这类策略在模拟数据上会被等价测成「价格策略」,语义失真。
331
+
332
+ 模型:z_t = phi * z_{t-1} + sigma * eps_t
333
+ 取 sigma = idio_std * sqrt(1 - phi^2) ⇒ 稳态标准差恰为 idio_std,
334
+ 故入参语义就是「估值对数偏离的量级」,可直接与真实数据对照。
335
+
336
+ Args:
337
+ rng: numpy Generator
338
+ bars: 生成长度
339
+ idio_std: 基本面自身的稳态对数波动
340
+
341
+ Returns:
342
+ np.ndarray,形状 (bars,),z[0] = 0(对齐基准值起点)
343
+ """
344
+ phi = self.SIM_FUND_PHI
345
+ sigma = idio_std * np.sqrt(1.0 - phi * phi)
346
+ z = np.empty(bars)
347
+ z[0] = 0.0
348
+ eps = rng.standard_normal(bars)
349
+ for t in range(1, bars):
350
+ z[t] = phi * z[t - 1] + sigma * eps[t]
351
+ return z
165
352
 
166
353
  def _cache_missing_extra_fields(self, path: Path) -> bool:
167
354
  """检查已有 parquet 缓存是否缺少 extra_fields 指定的列。"""
@@ -318,13 +505,15 @@ class Data():
318
505
  """
319
506
  获取历史数据。
320
507
 
321
- 并发下载所有股票数据,应用因子计算,并返回合并后的数据。
508
+ 并发下载所有股票数据,应用因子计算,并返回合并后的数据。真实标的读本地缓存
509
+ (必要时下载),模拟标的(Simulate)按同一日期轴现场生成 —— 两者写法完全同构。
322
510
 
323
511
  Args:
324
512
  lazy: 是否以懒加载模式返回 dask DataFrame(支持大规模数据分区迭代)。
325
513
  默认 False,返回 compute() 后的 pandas DataFrame(向后兼容)。
326
514
  start_date: 起始日期,格式 YYYY-MM-DD。用于从缓存中截取数据范围,
327
- 避免全量加载。传 None 表示从最早可用数据开始。
515
+ 避免全量加载。传 None 表示从最早可用数据开始;模拟标的在
516
+ start_date=None 时从 SIM_START 起算。
328
517
  end_date: 截止日期,格式 YYYY-MM-DD。传 None 表示到最新可用数据。
329
518
 
330
519
  Returns:
@@ -334,6 +523,8 @@ class Data():
334
523
 
335
524
  注意:有指标时,start_date 会自动向后扩展 max_window 个交易日读取缓存,
336
525
  确保指标有足够的预热数据。最终返回的 DataFrame 仍严格限定在 [start_date, end_date]。
526
+
527
+ 模拟标的的日期轴取自 baostock 交易日历(区间内一日一根),因此需要能登录 baostock。
337
528
  """
338
529
  if not self.symbols:
339
530
  return pd.DataFrame()
@@ -364,8 +555,8 @@ class Data():
364
555
  download_start = read_start.strftime("%Y-%m-%d") if read_start is not None else None
365
556
  download_end = end_date
366
557
 
367
- # 筛选需要网络下载的股票(含基准)
368
- all_symbols = list(self.symbols)
558
+ # 需要下载的真实标的(含基准);模拟标的全在内存生成,永不下载
559
+ all_symbols = list(self._real_symbols)
369
560
  if self.benchmark and self.benchmark not in all_symbols:
370
561
  all_symbols.append(self.benchmark)
371
562
  need_download = [
@@ -373,16 +564,28 @@ class Data():
373
564
  if self._needs_download(s, download_start, download_end)
374
565
  ]
375
566
 
376
- # 仅当有股票需要下载时才登录 baostock
567
+ # 模拟标的的日期轴:区间内的真实交易日(随指标预热一起向前扩展)
568
+ need_calendar = bool(self._sim_specs)
569
+ sim_start = sim_end = None
570
+ if need_calendar:
571
+ sim_start = (read_start.strftime("%Y-%m-%d")
572
+ if read_start is not None else self.SIM_START)
573
+ sim_end = (pd.Timestamp(end_date) if end_date is not None
574
+ else pd.Timestamp.now().floor('D')).strftime("%Y-%m-%d")
575
+
576
+ # 需要下载、或需要模拟交易日历时,登录 baostock
377
577
  bs_logged_in = False
378
- if need_download and self.source == 'baostock':
578
+ if (need_download and self.source == 'baostock') or need_calendar:
379
579
  lg = bs.login()
380
580
  if lg.error_code != '0':
381
581
  raise RuntimeError(f"baostock 登录失败: {lg.error_msg}")
382
582
  bs_logged_in = True
383
583
 
384
584
  errors = []
585
+ sim_index = None
385
586
  try:
587
+ if need_calendar:
588
+ sim_index = self._trading_index(sim_start, sim_end)
386
589
  if need_download:
387
590
  if self.source == 'baostock':
388
591
  for symbol in tqdm(need_download, desc="下载数据"):
@@ -422,11 +625,18 @@ class Data():
422
625
  # 懒加载模式:返回 dask DataFrame,列名 {symbol}|{factor}
423
626
  dfs = []
424
627
  for symbol in self.symbols:
425
- parquet_path = self.target_dir / f"{symbol}.parquet"
426
- if not parquet_path.exists():
427
- logger.warning(f"数据文件不存在,跳过: {parquet_path}")
428
- continue
429
- ddf = dd.read_parquet(str(parquet_path), filters=pq_filters)
628
+ if symbol in self._sim_specs:
629
+ # clear_divisions:真实缓存读出的 divisions 恒为未知((None, None)),
630
+ # 模拟帧若带已知 divisions,混合 concat 时 dask 会在 min/max 上炸
631
+ ddf = dd.from_pandas(
632
+ self._generate_symbol(symbol, sim_index), npartitions=1,
633
+ ).clear_divisions()
634
+ else:
635
+ parquet_path = self.target_dir / f"{symbol}.parquet"
636
+ if not parquet_path.exists():
637
+ logger.warning(f"数据文件不存在,跳过: {parquet_path}")
638
+ continue
639
+ ddf = dd.read_parquet(str(parquet_path), filters=pq_filters)
430
640
  ddf['returns'] = ddf['close'].diff() / ddf['close'].shift(1)
431
641
  ddf = self._apply_indicators(ddf)
432
642
  column_mapping = {col: f'{symbol}|{col}' for col in ddf.columns}
@@ -457,11 +667,16 @@ class Data():
457
667
  # 全量模式(默认)
458
668
  dfs = []
459
669
  for symbol in self.symbols:
460
- parquet_path = self.target_dir / f"{symbol}.parquet"
461
- if not parquet_path.exists():
462
- logger.warning(f"数据文件不存在,跳过: {parquet_path}")
463
- continue
464
- df = dd.read_parquet(str(parquet_path), filters=pq_filters)
670
+ if symbol in self._sim_specs:
671
+ df = dd.from_pandas(
672
+ self._generate_symbol(symbol, sim_index), npartitions=1,
673
+ ).clear_divisions()
674
+ else:
675
+ parquet_path = self.target_dir / f"{symbol}.parquet"
676
+ if not parquet_path.exists():
677
+ logger.warning(f"数据文件不存在,跳过: {parquet_path}")
678
+ continue
679
+ df = dd.read_parquet(str(parquet_path), filters=pq_filters)
465
680
  df['returns'] = df['close'].diff() / df['close'].shift(1)
466
681
  df = self._apply_indicators(df)
467
682
  column_mapping = {col: f'{symbol}|{col}' for col in df.columns}
@@ -0,0 +1,81 @@
1
+ """
2
+ QKA 模拟标的数据模块
3
+
4
+ 提供 Simulate 类:描述一只由 qka 现场生成的行情序列(不下载、不读缓存)。
5
+ 把它放进 Data(symbols=[...]) 即可,写法与真实标的完全同构。
6
+ """
7
+
8
+ from qka.utils.logger import logger
9
+
10
+
11
+ class Simulate:
12
+ """
13
+ 模拟标的:一只由 qka 现场造出来的行情。
14
+
15
+ 用法与真实标的写法完全一致,区别只在 symbols 里放的是它而不是代码字符串:
16
+
17
+ ```python
18
+ from qka import Data, Simulate
19
+
20
+ data = Data(symbols=[
21
+ 'sh.600900', # 真实标的,照常下载
22
+ Simulate('sim.a', drift=0.0, reversion=0.08, vol=0.018),
23
+ ])
24
+ df = data.get(start_date='2023-01-01', end_date='2025-12-31')
25
+ ```
26
+
27
+ 行情按「带趋势的均值回复」在对数价格上演化:
28
+
29
+ r_t = drift + reversion * (logAnchor - logP_{t-1}) + vol * eps_t
30
+
31
+ eps_t ~ N(0, 1)。三个参数各管一件事:
32
+
33
+ - drift (μ):日漂移。正=上涨、负=下跌、0=无方向
34
+ - reversion (θ):均值回复强度。越大越黏在中枢附近(半衰期 = ln2 / θ);0 = 不回复
35
+ - vol (σ):日波动率。如 0.015 约等于每日上下 1.5%
36
+
37
+ drift=0 且 reversion=0 时退化为纯随机游走。
38
+
39
+ Attributes:
40
+ symbol (str): 标的代码,同时用作 DataFrame 的列前缀(如 sim.a|close)
41
+ drift (float): 日漂移 μ
42
+ reversion (float): 均值回复强度 θ
43
+ vol (float): 日波动率 σ
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ symbol: str,
49
+ drift: float = 0.0,
50
+ reversion: float = 0.0,
51
+ vol: float = 0.012,
52
+ ):
53
+ """
54
+ 初始化模拟标的。
55
+
56
+ Args:
57
+ symbol: 标的代码,自定义即可(如 'sim.a')。不能为空,且不能与同一
58
+ Data 里的其他标的重复
59
+ drift: 日漂移 μ,正=上涨、负=下跌、0=无方向,默认 0
60
+ reversion: 均值回复强度 θ,必须 >= 0(越大越黏在中枢附近),默认 0
61
+ vol: 日波动率 σ,必须 > 0(如 0.015 约等于每日上下 1.5%),默认 0.012
62
+ """
63
+ if not isinstance(symbol, str) or not symbol.strip():
64
+ raise ValueError(f"symbol 必须是非空字符串,got {symbol!r}")
65
+ self.symbol = symbol.strip()
66
+ self.drift = float(drift)
67
+ self.reversion = float(reversion)
68
+ self.vol = float(vol)
69
+
70
+ if self.reversion < 0:
71
+ raise ValueError(f"reversion 必须 >= 0,got {self.reversion}")
72
+ if self.vol <= 0:
73
+ raise ValueError(f"vol 必须 > 0,got {self.vol}")
74
+
75
+ logger.debug(f"Simulate 标的已创建: {self}")
76
+
77
+ def __repr__(self) -> str:
78
+ return (
79
+ f"Simulate(symbol={self.symbol!r}, drift={self.drift}, "
80
+ f"reversion={self.reversion}, vol={self.vol})"
81
+ )
@@ -5,11 +5,12 @@ description: 使用 QKA(快量化)框架生成 A 股量化回测策略、选
5
5
 
6
6
  # QKA 框架
7
7
 
8
- 简洁易用的 A 股量化回测框架,共六个公开类:
8
+ 简洁易用的 A 股量化回测框架,共七个公开类:
9
9
 
10
10
  | 类 | 全限定名 | 作用 |
11
11
  |-----|------|------|
12
12
  | Data | `qka.Data` | 行情数据加载 + 指标预计算 |
13
+ | Simulate | `qka.Simulate` | 模拟标的 — 描述一只由 qka 现场生成的行情 |
13
14
  | Strategy | `qka.Strategy` | 策略基类 — 实现 `on_bar` 做交易决策 |
14
15
  | Broker | `qka.Broker` | 虚拟券商 — 执行买卖,管理资金和持仓 |
15
16
  | SizingAccessor | `qka.SizingAccessor` | 仓位计算 — 四种仓位方法 |
@@ -23,6 +24,7 @@ description: 使用 QKA(快量化)框架生成 A 股量化回测策略、选
23
24
  | 模块 | 文件 | 内容 |
24
25
  |------|------|------|
25
26
  | Data | `references/data.md` | 数据加载、指标预计算(ta 库全部指标 + qka 内置指标)、`get()` |
27
+ | Simulate | `references/data.md` | 模拟标的:`Simulate(symbol, drift, reversion, vol)`,放进 `Data(symbols=[...])` |
26
28
  | Strategy | `references/strategy.md` | 策略基类、`on_bar`、`self.get()`、`self.history()` |
27
29
  | Backtest | `references/backtest.md` | `run()`、`bt.metrics`、`bt.results`、`bt.trade_history` |
28
30
  | Broker | `references/broker.md` | `buy()`、`sell()` |
@@ -20,7 +20,7 @@ data = Data(
20
20
 
21
21
  | 参数 | 类型 | 默认值 | 说明 |
22
22
  |------|------|--------|------|
23
- | `symbols` | `list[str]` | `None` | A 股代码,baostock 格式 `sz.000001`、`sh.600000` |
23
+ | `symbols` | `list[str \| Simulate]` | `None` | 标的列表。元素可以是 A 股代码(baostock 格式 `sz.000001`、`sh.600000`),也可以是 `Simulate` 对象(模拟标的),两者可混排,见下方「模拟标的」 |
24
24
  | `period` | `str` | `'1d'` | 数据周期,当前仅支持 `'1d'` |
25
25
  | `adjust` | `str` | `'qfq'` | 复权方式:`'qfq'` 前复权,`'hfq'` 后复权,`'bfq'` 不复权 |
26
26
  | `benchmark` | `str` | `None` | 基准指数代码,如 `'sh.000300'`。下载后在 `get()` 结果中追加 `benchmark|returns` 列 |
@@ -46,6 +46,53 @@ data = Data(
46
46
  |------|------|------|
47
47
  | `returns` | `close.diff() / close.shift(1)` | 日收益率,始终存在,无需在 indicators 中声明 |
48
48
 
49
+ ### 模拟标的
50
+
51
+ `symbols` 的元素除了真实代码字符串,还可以是 `Simulate` 对象——一只由 qka **现场生成**的行情,不下载、不读缓存。写法与真实标的完全同构:
52
+
53
+ ```python
54
+ from qka import Data, Simulate
55
+
56
+ data = Data(symbols=[
57
+ 'sh.600900', # 真实标的,照常下载
58
+ Simulate('sim.a', drift=0.0, reversion=0.08, vol=0.018), # 模拟标的
59
+ ])
60
+ df = data.get(start_date='2023-01-01', end_date='2025-12-31')
61
+ ```
62
+
63
+ `Simulate(symbol, drift=0.0, reversion=0.0, vol=0.012)` 的构造参数:
64
+
65
+ | 参数 | 类型 | 默认值 | 说明 |
66
+ |------|------|--------|------|
67
+ | `symbol` | `str` | 必填 | 标的代码,自定义即可(如 `'sim.a'`)。同时用作列前缀 `sim.a|close`;不能为空,也不能与同一 `Data` 内其他标的重复 |
68
+ | `drift` | `float` | `0.0` | 日漂移 μ。正=上涨、负=下跌、0=无方向 |
69
+ | `reversion` | `float` | `0.0` | 均值回复强度 θ,必须 `>= 0`。越大越黏在中枢附近(半衰期 = `ln2/θ`);`0` = 不回复 |
70
+ | `vol` | `float` | `0.012` | 日波动率 σ,必须 `> 0`。如 `0.015` 约等于每日上下 1.5% |
71
+
72
+ 行情按「带趋势的均值回复」在对数价格上演化:
73
+
74
+ ```
75
+ r_t = drift + reversion * (logAnchor - logP_{t-1}) + vol * eps_t eps_t ~ N(0, 1)
76
+ ```
77
+
78
+ `drift=0` 且 `reversion=0` 时退化为纯随机游走。常见组合:
79
+
80
+ | 想要的行情 | 写法 |
81
+ |------|------|
82
+ | 单边上涨 | `Simulate('sim.up', drift=0.002)` |
83
+ | 单边下跌 | `Simulate('sim.dn', drift=-0.003)` |
84
+ | 来回震荡 | `Simulate('sim.side', reversion=0.08, vol=0.018)` |
85
+ | 纯随机 | `Simulate('sim.rand')` |
86
+
87
+ 与真实标的的异同:
88
+
89
+ - **日期轴**:取 `get(start_date, end_date)` 区间内的**真实交易日**(来自 baostock 交易日历,一日一根)。因此模拟数据同样需要能登录 baostock,且 `start_date=None` 时从 `Data.SIM_START`(`'2020-01-02'`)起算
90
+ - **指标预热**:与真实源一致——有 `indicators` / `warmup` 时日期轴会自动向前扩展,返回结果仍严格限定在 `[start_date, end_date]`
91
+ - **`indicators` / `extra_fields` / `warmup`**:全部照常生效,列名与真实源同样是 `{symbol}|{field}`,扩展列同样是「不请求就不生成」
92
+ - **`benchmark`**:与标的是真是模拟无关——`Backtest.run(benchmark=...)` 照常生效,基准始终走真实数据源单独加载
93
+ - **每次不同**:没有随机种子参数,每次调用都是新一批行情(不可复现)
94
+ - **不落盘**:模拟标的不写缓存;`symbols` 里若全是模拟标的,连 `datadir` 都不会创建
95
+
49
96
  ### extra_fields 扩展字段
50
97
 
51
98
  `extra_fields` 追加 baostock `query_history_k_data_plus` 接口支持的扩展列(选股/估值用),列名同样遵循 `{symbol}|{field}` 约定(如 `sz.000001|peTTM`),数值自动转为 `float64`。**不是指标,不参与 indicators 预计算**,是随行情一起下载的原始字段。
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes