qka 2.3.0__tar.gz → 2.3.1.dev2__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.3.0 → qka-2.3.1.dev2}/PKG-INFO +1 -1
  2. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/data.py +115 -20
  3. {qka-2.3.0 → qka-2.3.1.dev2}/.github/workflows/release.yml +0 -0
  4. {qka-2.3.0 → qka-2.3.1.dev2}/.gitignore +0 -0
  5. {qka-2.3.0 → qka-2.3.1.dev2}/.vscode/settings.json +0 -0
  6. {qka-2.3.0 → qka-2.3.1.dev2}/CHANGELOG.md +0 -0
  7. {qka-2.3.0 → qka-2.3.1.dev2}/LICENSE +0 -0
  8. {qka-2.3.0 → qka-2.3.1.dev2}/README.md +0 -0
  9. {qka-2.3.0 → qka-2.3.1.dev2}/pyproject.toml +0 -0
  10. {qka-2.3.0 → qka-2.3.1.dev2}/qka/__init__.py +0 -0
  11. {qka-2.3.0 → qka-2.3.1.dev2}/qka/cli.py +0 -0
  12. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/__init__.py +0 -0
  13. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/accessor.py +0 -0
  14. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/analysis.py +0 -0
  15. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/backtest.py +0 -0
  16. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/broker.py +0 -0
  17. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/indicator.py +0 -0
  18. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/report.py +0 -0
  19. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/simulate.py +0 -0
  20. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/sizing.py +0 -0
  21. {qka-2.3.0 → qka-2.3.1.dev2}/qka/core/strategy.py +0 -0
  22. {qka-2.3.0 → qka-2.3.1.dev2}/qka/utils/__init__.py +0 -0
  23. {qka-2.3.0 → qka-2.3.1.dev2}/qka/utils/logger.py +0 -0
  24. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/SKILL.md +0 -0
  25. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/analysis.md +0 -0
  26. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/backtest.md +0 -0
  27. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/broker.md +0 -0
  28. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/data.md +0 -0
  29. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/sizing.md +0 -0
  30. {qka-2.3.0 → qka-2.3.1.dev2}/skills/qka/references/strategy.md +0 -0
  31. {qka-2.3.0 → qka-2.3.1.dev2}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.3.0
3
+ Version: 2.3.1.dev2
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
@@ -4,6 +4,7 @@ QKA数据模块
4
4
  提供统一的数据获取、缓存和管理功能,支持多数据源、多周期、多因子的数据获取。
5
5
  """
6
6
 
7
+ import os
7
8
  from pathlib import Path
8
9
  from tqdm import tqdm
9
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -30,6 +31,23 @@ def _is_qka_indicator(ind_type):
30
31
  return (isinstance(ind_type, str) and
31
32
  (ind_type.startswith('qka.') or ind_type in _QKA_BUILTIN_NAMES))
32
33
 
34
+
35
+ # 当日日线数据「可能已经生成」的时刻(本地时间)。一天以它为界切成前后两个时段:
36
+ # 17:00 之前,数据源手里最新的一根还是上一交易日,当天这根根本还没生成;
37
+ # 17:00 之后,当天的已经出完了,要补也就这一次。
38
+ DATA_READY_HOUR = 17
39
+
40
+
41
+ def _data_period(ts) -> tuple:
42
+ """把时刻归入「数据时段」——(日期,是否已过当日数据生成时刻)。
43
+
44
+ 同一个时段内数据源不会多出任何东西,对同一只标的重复询问纯属白跑一趟。
45
+ 休市日、节假日尤其明显:日历上没有交易日,缓存永远「缺今天」,若不记时段
46
+ 就会每次请求都去问一遍,永远问不到结果。
47
+ """
48
+ return (ts.date(), ts.hour >= DATA_READY_HOUR)
49
+
50
+
33
51
  class Data():
34
52
  """
35
53
  数据管理类
@@ -72,6 +90,10 @@ class Data():
72
90
  # 无估值/换手率等扩展列(季报级 financials 不是每日序列,不接入)。
73
91
  TICKFLOW_EXTRA_FIELDS = []
74
92
 
93
+ # parquet 元数据键:这只标的「已确认起点」——数据源在该日期之前没有更早的数据。
94
+ # 没有它,请求起点只要早于缓存首日(往往只是撞上长假)就得白跑一趟网络去确认。
95
+ VERIFIED_FROM_KEY = b'qka_verified_from'
96
+
75
97
  # ── 模拟数据源默认值 ──
76
98
  SIM_START = '2020-01-02' # 模拟行情默认起点(调用方未给 start_date 时使用)
77
99
  SIM_START_PRICE = 20.0 # 起始价
@@ -376,6 +398,43 @@ class Data():
376
398
  z[t] = phi * z[t - 1] + sigma * eps[t]
377
399
  return z
378
400
 
401
+ def _checked_recently(self, path: Path) -> bool:
402
+ """本时段是否已经问过数据源(以缓存文件的修改时间当记号)。
403
+
404
+ 缓存文件每次被写都会刷新修改时间:首次下载、补到新数据是写出来的;
405
+ 问了但数据源没给新数据时也会主动刷新一次(见 _download 末尾)。
406
+ 两种情形都意味着「本时段已经核对过,数据源不会再给新东西」。
407
+ """
408
+ if not path.exists():
409
+ return False
410
+ mtime = pd.Timestamp.fromtimestamp(path.stat().st_mtime)
411
+ return _data_period(mtime) == _data_period(pd.Timestamp.now())
412
+
413
+ def _mark_checked(self, path: Path) -> None:
414
+ """留下「本时段已问过」的记号"""
415
+ if path.exists():
416
+ os.utime(path, None)
417
+
418
+ def _read_verified_from(self, path: Path):
419
+ """读「已确认起点」;没有记录(老缓存)返回 None"""
420
+ try:
421
+ meta = pq.read_schema(path).metadata or {}
422
+ except Exception:
423
+ return None
424
+ raw = meta.get(self.VERIFIED_FROM_KEY)
425
+ if not raw:
426
+ return None
427
+ try:
428
+ return pd.Timestamp(raw.decode())
429
+ except Exception:
430
+ return None
431
+
432
+ def _write_verified_from(self, path: Path, table: pa.Table, verified_from) -> None:
433
+ """把「已确认起点」写进 parquet 元数据后落盘(数据本身原样保留)"""
434
+ meta = dict(table.schema.metadata or {})
435
+ meta[self.VERIFIED_FROM_KEY] = verified_from.strftime('%Y-%m-%d').encode()
436
+ pq.write_table(table.replace_schema_metadata(meta), path)
437
+
379
438
  def _cache_missing_extra_fields(self, path: Path) -> bool:
380
439
  """检查已有 parquet 缓存是否缺少 extra_fields 指定的列。"""
381
440
  if not self.extra_fields or not path.exists():
@@ -419,6 +478,10 @@ class Data():
419
478
  只补下载缺失的部分(前面缺失补前面,后面缺失补后面),合并去重写回。
420
479
  若缓存缺少 extra_fields 指定的列(如从无扩展字段升级到有),则全量重新下载。
421
480
 
481
+ 每次问完数据源都会留下痕迹:补到新数据就写文件(修改时间自然刷新),
482
+ 没补到就刷新修改时间 + 记下「已确认起点」。这两样让重复的请求不必再问网络,
483
+ 详见 _needs_download。
484
+
422
485
  Args:
423
486
  symbol: 股票代码
424
487
  download_start: 下载起始日期,格式 YYYY-MM-DD。None 表示拉全量(1990-01-01)
@@ -448,7 +511,10 @@ class Data():
448
511
  if len(df) == 0:
449
512
  raise RuntimeError(f"{symbol}: 数据源({self.source})返回空数据")
450
513
  table = pa.Table.from_pandas(df)
451
- pq.write_table(table, path)
514
+ # 这次就是从这个起点问的,一并记下「已确认起点」
515
+ self._write_verified_from(
516
+ path, table, pd.Timestamp(download_start or default_start)
517
+ )
452
518
  return path
453
519
 
454
520
  # ── 增量更新:检查缓存覆盖,补缺失范围 ──
@@ -461,17 +527,21 @@ class Data():
461
527
  pieces = [existing]
462
528
  changed = False
463
529
 
464
- # 往前补
530
+ # 往前补(并记下「这个起点已确认没有更早的数据」,免得下次为同一个缺口白问)
531
+ old_verified = self._read_verified_from(path)
532
+ verified_from = old_verified
465
533
  req_start = pd.Timestamp(download_start) if download_start else None
466
- if req_start is not None and req_start < cache_min:
467
- end_before = (cache_min - pd.Timedelta(days=1)).strftime("%Y-%m-%d")
468
- df_before = self._fetch(
469
- symbol, start_date=download_start, end_date=end_before,
470
- extra_fields=merged_extra,
471
- )
472
- if len(df_before) > 0:
473
- pieces.insert(0, df_before)
474
- changed = True
534
+ if req_start is not None and (old_verified is None or req_start < old_verified):
535
+ if req_start < cache_min:
536
+ end_before = (cache_min - pd.Timedelta(days=1)).strftime("%Y-%m-%d")
537
+ df_before = self._fetch(
538
+ symbol, start_date=download_start, end_date=end_before,
539
+ extra_fields=merged_extra,
540
+ )
541
+ if len(df_before) > 0:
542
+ pieces.insert(0, df_before)
543
+ changed = True
544
+ verified_from = req_start
475
545
 
476
546
  # 往后补
477
547
  # 口径与 _needs_download 保持一致:按「日」比较。若用带时刻的 now(),
@@ -491,13 +561,24 @@ class Data():
491
561
  pieces.append(df_after)
492
562
  changed = True
493
563
 
564
+ if not changed and verified_from == old_verified:
565
+ # 问过了,数据源这段确实没有新数据 —— 留个记号,本时段内不再重复问。
566
+ # 失败(抛异常)时不走到这里,所以网络故障该重试还是会重试。
567
+ self._mark_checked(path)
568
+ return path
569
+
494
570
  if changed:
495
571
  combined = pd.concat(pieces)
496
572
  combined = combined[~combined.index.duplicated(keep='last')]
497
573
  combined = combined.sort_index()
498
574
  table = pa.Table.from_pandas(combined)
499
- pq.write_table(table, path)
575
+ else:
576
+ table = pa.Table.from_pandas(existing) # 数据没动,只是要更新「已确认起点」
500
577
 
578
+ if verified_from is not None:
579
+ self._write_verified_from(path, table, verified_from)
580
+ else:
581
+ pq.write_table(table, path)
501
582
  return path
502
583
 
503
584
  def _needs_download(
@@ -508,6 +589,11 @@ class Data():
508
589
  """
509
590
  判断股票是否需要网络下载。
510
591
  缓存不存在、不覆盖请求范围、或需要拉最新数据时返回 True。
592
+
593
+ 「缺最新数据」这件事按本时段去重(见 _data_period):一天以数据生成时刻
594
+ 切成前后两段,同一段里问过一次就够了 —— 数据源在这段时间里不会多出东西。
595
+ 休市日、节假日的缓存末日永远早于「今天」,若不按时段去重就会每次请求都
596
+ 白问一趟,还永远问不到结果。
511
597
  """
512
598
  path = self.target_dir / f"{symbol}.parquet"
513
599
  if not path.exists() or self._cache_missing_extra_fields(path):
@@ -517,15 +603,24 @@ class Data():
517
603
  return False
518
604
  cache_min = existing.index.min()
519
605
  cache_max = existing.index.max()
520
- today = pd.Timestamp.now().floor('D')
521
606
 
522
- if download_start is not None and pd.Timestamp(download_start) < cache_min:
523
- return True
524
- if download_end is not None and pd.Timestamp(download_end) > cache_max:
525
- return True
526
- # 无 end_date 时检查是否有最新数据
527
- if download_end is None and cache_max < today:
528
- return True
607
+ # 往前:起点早于缓存首日才有缺口。但这个缺口往往只是撞上长假(缓存首日
608
+ # 之后才是交易日),所以要看它是否已经被确认过 —— 没确认过才值得再问一趟。
609
+ if download_start is not None:
610
+ req_start = pd.Timestamp(download_start)
611
+ if req_start < cache_min:
612
+ verified_from = self._read_verified_from(path)
613
+ if verified_from is None or req_start < verified_from:
614
+ return True
615
+
616
+ # 往后:终点晚于缓存末日(给了 end_date 就是确定的缺口,直接补)。
617
+ if download_end is not None:
618
+ return pd.Timestamp(download_end) > cache_max
619
+
620
+ # 无 end_date = 要一直要到今天。缺的只是「今天」这一段时,本时段问过就不必再问:
621
+ # 数据源在这段时间里不会多出东西,休市日 / 盘前盘中尤其明显,否则每次请求都要白问一趟。
622
+ if cache_max < pd.Timestamp.now().floor('D'):
623
+ return not self._checked_recently(path)
529
624
  return False
530
625
 
531
626
  def get(self, lazy: bool = False, start_date: str = None, end_date: str = None):
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes