alpha-dc-client 0.2.0__tar.gz → 0.2.2__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 (30) hide show
  1. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/.gitignore +4 -0
  2. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/PKG-INFO +4 -4
  3. alpha_dc_client-0.2.2/pyproject.toml +33 -0
  4. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/discovery.py +1 -0
  5. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/io.py +146 -15
  6. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/registry.json +714 -51
  7. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/registry.py +4 -0
  8. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/remote.py +65 -8
  9. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/sync.py +109 -26
  10. alpha_dc_client-0.2.2/src/dc_client/tests/test_review_260918_fixes.py +181 -0
  11. alpha_dc_client-0.2.2/src/dc_client/tests/test_supply_followup.py +278 -0
  12. alpha_dc_client-0.2.2/src/dc_client/tests/test_supply_review_fixes.py +206 -0
  13. alpha_dc_client-0.2.0/pyproject.toml +0 -29
  14. alpha_dc_client-0.2.0/src/dc_client/base.py +0 -69
  15. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/DATA.md +0 -0
  16. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/USAGE.md +0 -0
  17. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/__init__.py +0 -0
  18. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/client.py +0 -0
  19. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/client.pyi +0 -0
  20. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/__init__.py +0 -0
  21. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/__main__.py +0 -0
  22. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/cli.py +0 -0
  23. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/config.py +0 -0
  24. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/data/namespace.py +0 -0
  25. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/exceptions.py +0 -0
  26. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/ops_trader.py +0 -0
  27. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/run_tests.py +0 -0
  28. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/tests/__init__.py +0 -0
  29. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/tests/base.py +0 -0
  30. {alpha_dc_client-0.2.0 → alpha_dc_client-0.2.2}/src/dc_client/tests/test_data.py +0 -0
@@ -163,3 +163,7 @@ ic_backfill_*.err
163
163
  # 港股回补运行时状态(断点续传,本地产物)
164
164
  dc_quant/scripts/_hk_*_progress.json
165
165
  dc_quant/scripts/check/_tmp_*
166
+ dc_quant/scripts/_us_edgar_repull_progress.json
167
+ dc_quant/scripts/check/_layer_reconcile_done.json
168
+ dc_quant/scripts/_cn_index_full_progress.json
169
+ dc_quant/scripts/_etf_fundamentals_progress.json
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alpha-dc-client
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Author-email: Hang GuangLiang <hanguangliang@alphaaidig.com>
5
- Requires-Dist: alpha-dc-common==0.1.24
6
- Requires-Dist: httpx
7
- Requires-Dist: python-dotenv
5
+ Requires-Dist: alpha-dc-common>=0.1.26
6
+ Requires-Dist: requests
8
7
  Provides-Extra: parquet
9
8
  Requires-Dist: duckdb>=0.10; extra == 'parquet'
10
9
  Requires-Dist: pandas>=2.0; extra == 'parquet'
11
10
  Requires-Dist: paramiko>=3.0; extra == 'parquet'
11
+ Requires-Dist: python-dotenv; extra == 'parquet'
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "alpha-dc-client"
7
+ version = "0.2.2" # common 锁定放宽为 >=(内核可独立发版)
8
+ authors = [
9
+ {name = "Hang GuangLiang", email = "hanguangliang@alphaaidig.com"},
10
+ ]
11
+ dependencies = [
12
+ # client.py 直接使用 requests(POST + Timeout/ConnectionError 异常处理),
13
+ # workspace 内曾靠 tushare 等传递带入未暴露,独立安装必炸,必须显式声明
14
+ "requests",
15
+ "alpha-dc-common>=0.1.26" # 内核独立演进发版,client 不强制随发 # +strategies 内核,
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ # 因子 Parquet 同步与读取(dc_client.data):duckdb 读取 + paramiko sftp 兜底通道
20
+ parquet = [
21
+ "duckdb>=0.10",
22
+ "pandas>=2.0",
23
+ "paramiko>=3.0",
24
+ "python-dotenv", # data/config.py 懒加载 .env
25
+ ]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/dc_client"]
29
+ # 测试与 run_tests 不进发布产物(tests/base.py 含硬编码 API token,绝不随 wheel 外发)
30
+ exclude = ["src/dc_client/tests", "src/dc_client/run_tests.py"]
31
+
32
+ [tool.uv.sources]
33
+ alpha-dc-common = { workspace = true }
@@ -235,6 +235,7 @@ def effective_registry(cfg: SyncConfig | None = None) -> Registry:
235
235
  date_column=bundled_meta.date_column,
236
236
  entity_column=bundled_meta.entity_column,
237
237
  tier=bundled_meta.tier,
238
+ month_continuity=bundled_meta.month_continuity,
238
239
  )
239
240
  datasets[name] = meta
240
241
 
@@ -172,6 +172,8 @@ def _sql_str_list(paths: list[Path]) -> str:
172
172
  def _check_month_continuity(files: list[Path], dataset: str) -> None:
173
173
  """月份连续性(P0-3):本地覆盖区间内的中间月缺失即抛错——静默丢整月数据。
174
174
 
175
+ 仅适用于日频表。稀疏事件/报告期表(month_continuity="skip")走
176
+ _check_sparse_files(DC05, review 260918)。
175
177
  区间外(数据开始前/结束后)的月份不要求(新 dataset 起点晚是正常的)。
176
178
  """
177
179
  months = sorted(int(m.group(1)) for f in files
@@ -183,6 +185,8 @@ def _check_month_continuity(files: list[Path], dataset: str) -> None:
183
185
  nxt = cur + 1 if cur % 100 < 12 else (cur // 100 + 1) * 100 + 1
184
186
  while nxt < ym:
185
187
  gaps.append(nxt)
188
+ # 12 月跳月必须基于 nxt 自身推进——用 cur 会在缺口跨年时
189
+ # 把 nxt 跳回 cur 的下一年 1 月,形成死循环(DC05 排查时发现)
186
190
  nxt = nxt + 1 if nxt % 100 < 12 else (nxt // 100 + 1) * 100 + 1
187
191
  cur = ym
188
192
  if gaps:
@@ -192,24 +196,127 @@ def _check_month_continuity(files: list[Path], dataset: str) -> None:
192
196
  f"请 sync 修复或检查数据")
193
197
 
194
198
 
199
+ def _check_sparse_files(
200
+ dataset: str, cfg: SyncConfig, start: date | None = None, end: date | None = None,
201
+ ) -> None:
202
+ """稀疏事件/报告期表的完整性校验(DC05,review 260918)。
203
+
204
+ 事件表(分红/持仓披露等)合法无事件月不落文件,月份连续性检查会把
205
+ 全历史读取误判为缺月。改为对照 sync manifest:manifest 记录过"已同步"
206
+ 的文件若在本地消失,才是真正丢文件,报错;manifest 无记录(未 sync 过
207
+ 或旧版本账本)时只放行——远端清单校验是 sync 的职责。
208
+ """
209
+ manifest_path = cfg.resolved_local_root() / ".manifest.json"
210
+ if not manifest_path.exists():
211
+ return
212
+ import json as _json
213
+
214
+ try:
215
+ manifest = _json.loads(manifest_path.read_text(encoding="utf-8"))
216
+ except (OSError, ValueError) as exc:
217
+ logger.warning("%s: manifest 损坏,跳过稀疏表文件校验: %s", dataset, exc)
218
+ return
219
+ root = cfg.resolved_local_root()
220
+ expected = {
221
+ rel for rel in manifest
222
+ if isinstance(rel, str) and rel.startswith(dataset + "/")
223
+ and (root / rel).suffix == ".parquet"
224
+ }
225
+ def in_window(rel: str) -> bool:
226
+ match = re.search(r"(\d{6})(?=\.parquet$)", Path(rel).name)
227
+ if match is None:
228
+ return True # cannot prove this publication is outside the request
229
+ ym = int(match.group(1))
230
+ return ((start is None or ym >= start.year * 100 + start.month)
231
+ and (end is None or ym <= end.year * 100 + end.month))
232
+
233
+ missing = sorted(rel for rel in expected if in_window(rel) and not (root / rel).is_file())
234
+ if missing:
235
+ raise FileNotFoundError(
236
+ f"{dataset}: sync manifest 记录的已同步文件本地缺失 {missing[:6]}"
237
+ f"{'...' if len(missing) > 6 else ''}——事件表无事件月不落文件是正常的,"
238
+ f"但已发布文件丢失必须报错,请重新 sync")
239
+
240
+
195
241
  def _d(v: date | str | None) -> date | None:
196
242
  if v is None or isinstance(v, date):
197
243
  return v
198
244
  return date.fromisoformat(v)
199
245
 
200
246
 
247
+ _IDENT_RE = None # 惰性编译的 where 列名提取正则
248
+
249
+
250
+ def _where_columns(where: str) -> set[str]:
251
+ """提取 where 表达式引用的裸列名(不含引号字符串/数字/函数/操作符)。
252
+
253
+ 用于判断 where 能否安全下推到某层:只提取 [A-Za-z_][A-Za-z0-9_.]* 形态的
254
+ 标识符,排除带引号字符串字面量;带表别名/点路径的取末段。
255
+ """
256
+ global _IDENT_RE
257
+ import re as _re
258
+ if _IDENT_RE is None:
259
+ _IDENT_RE = _re.compile(r'"([A-Za-z_][A-Za-z0-9_ ]*)"' # 双引号列名
260
+ r"|'[^']*'" # 字符串字面量(丢弃)
261
+ r'|\b([A-Za-z_][A-Za-z0-9_]*)\b') # 裸标识符
262
+ cols: set[str] = set()
263
+ text = where
264
+ for m in _IDENT_RE.finditer(text):
265
+ if m.group(1):
266
+ cols.add(m.group(1))
267
+ text = text.replace(m.group(0), " ", 1) # 双引号列已消费,防再匹配
268
+ elif m.group(2):
269
+ cols.add(m.group(2))
270
+ sql_keywords = {"and", "or", "not", "in", "is", "null", "between", "like",
271
+ "true", "false", "date", "timestamp"}
272
+ return {c for c in cols if c.lower() not in sql_keywords}
273
+
274
+
275
+ def _layer_pushable_where(meta: "LayerMeta", hits: list[str], where: str | None) -> str | None:
276
+ """where 仅在引用列全部为该层可用列时下推(否则跳过,由外层 JOIN 后过滤)。
277
+
278
+ 可用列 = 键列 + 命中列 + 层已声明物理列(columns 已知时)。列未知的层
279
+ 不下推 where(保守,正确性优先)——修复跨层筛选把 pe 条件推进只有
280
+ momentum 的层导致的 DuckDB BinderException(review 2026-09-14 P2)。
281
+ """
282
+ if not where:
283
+ return None
284
+ available: set[str] = set(hits)
285
+ if meta.date_column:
286
+ available.add(meta.date_column)
287
+ if meta.entity_column:
288
+ available.add(meta.entity_column)
289
+ if meta.columns:
290
+ available.update(meta.columns)
291
+ if not available - {meta.date_column, meta.entity_column}:
292
+ return None # 列未知(本机无 footer)——不下推
293
+ refs = _where_columns(where)
294
+ return where if refs <= available else None
295
+
296
+
201
297
  def _date_conditions(meta: LayerMeta, start: date | None,
202
298
  end: date | None, where: str | None) -> str:
299
+ # DC06(review 260918):历史月文件 trade_date 可能是 VARCHAR(旧写入路径),
300
+ # 与 DATE 字面量直接比较会抛 Binder 异常。CAST 到 DATE 统一谓词类型;
301
+ # 解析失败会响亮报错(缺失必须报告,不静默丢行),ISO 字符串无副作用。
302
+ date_expr = f"CAST({meta.date_column} AS DATE)"
203
303
  conds = []
204
304
  if start:
205
- conds.append(f"{meta.date_column} >= DATE '{start.isoformat()}'")
305
+ conds.append(f"{date_expr} >= DATE '{start.isoformat()}'")
206
306
  if end:
207
- conds.append(f"{meta.date_column} <= DATE '{end.isoformat()}'")
307
+ conds.append(f"{date_expr} <= DATE '{end.isoformat()}'")
208
308
  if where:
209
309
  conds.append(f"({where})")
210
310
  return " AND ".join(conds)
211
311
 
212
312
 
313
+ def _dated_source(files: list[Path], meta: LayerMeta) -> str:
314
+ """Normalize the date before user predicates, joins and projections."""
315
+ col = '"' + meta.date_column.replace('"', '""') + '"'
316
+ return (f"(SELECT * REPLACE (CAST({col} AS DATE) AS {col}) "
317
+ f"FROM read_parquet({_sql_str_list(files)}, union_by_name=true))")
318
+
319
+
213
320
  def load_dataset(
214
321
  dataset: str,
215
322
  start: date | str | None = None,
@@ -232,6 +339,10 @@ def load_dataset(
232
339
  if meta is None:
233
340
  meta = _meta(dataset, cfg)
234
341
  start_d, end_d = _d(start), _d(end)
342
+ if meta.month_continuity == "skip":
343
+ # Even on_missing=empty cannot turn a known lost publication into a
344
+ # legitimate month with no events. Check before the no-files return.
345
+ _check_sparse_files(dataset, cfg, start_d, end_d)
235
346
  files = _month_files(
236
347
  cfg.resolved_local_root() / dataset / "monthly", start_d, end_d,
237
348
  allow_missing=(on_missing == "empty"),
@@ -240,7 +351,8 @@ def load_dataset(
240
351
  if on_missing == "empty":
241
352
  return pd_empty(columns, meta.date_column)
242
353
  raise FileNotFoundError(f"{dataset}: 日期范围内无本地数据文件")
243
- _check_month_continuity(files, dataset)
354
+ if meta.month_continuity != "skip":
355
+ _check_month_continuity(files, dataset)
244
356
 
245
357
  cols = list(columns) if columns else None
246
358
  if cols and meta.date_column not in cols:
@@ -248,10 +360,11 @@ def load_dataset(
248
360
  select = ", ".join(f'"{c}"' for c in cols) if cols else "*"
249
361
  cond = _date_conditions(meta, start_d, end_d, where)
250
362
  where_sql = f" WHERE {cond}" if cond else ""
363
+ # DC06:ORDER BY 同样 CAST——union_by_name 下混型日期列(string/date32)
364
+ # 直接排序会和 WHERE 一样抛 Binder 异常
251
365
  sql = (
252
- f"SELECT {select} FROM read_parquet("
253
- f"{_sql_str_list(files)}, union_by_name=true){where_sql} "
254
- f"ORDER BY {meta.date_column}"
366
+ f"SELECT {select} FROM {_dated_source(files, meta)}{where_sql} "
367
+ f"ORDER BY CAST({meta.date_column} AS DATE)"
255
368
  + (f", {meta.entity_column}" if meta.entity_column else "")
256
369
  )
257
370
  return _normalize_frame(_apply_dtype_contract(
@@ -383,24 +496,30 @@ def load_view(
383
496
  LogicalFactorView 语义:filters 由各层文件 schema 自行解析,
384
497
  支持引用各层自有列,如 close 在量价层)。
385
498
  """
499
+ if meta.month_continuity == "skip":
500
+ _check_sparse_files(meta.name, cfg, start_d, end_d)
386
501
  files = _month_files(root / meta.name / "monthly", start_d, end_d,
387
502
  allow_missing=True)
388
503
  if not files:
389
504
  return None
505
+ # C02/DC-READ-01:参与 JOIN 的每层都查月份连续性(此前只查驱动层)——
506
+ # 中间缺月会被 LEFT JOIN 静默变 NULL,业务缺值与文件未同步不可区分。
507
+ # _check_month_continuity 只查现有文件集内部空洞,层合法晚起不误判
508
+ if meta.month_continuity != "skip":
509
+ _check_month_continuity(files, meta.name)
390
510
  hits = hits_map.get(meta.name, [])
391
- cond = _date_conditions(meta, start_d, end_d, where)
511
+ # P2 修复(review 2026-09-14):where 只下推到列覆盖的层做剪枝;
512
+ # 不覆盖的层仅日期裁剪,过滤条件由外层 JOIN 后统一应用
513
+ cond = _date_conditions(meta, start_d, end_d,
514
+ _layer_pushable_where(meta, hits, where))
392
515
  w = f" WHERE {cond}" if cond else ""
393
- inner = (f"SELECT * FROM read_parquet({_sql_str_list(files)}, "
394
- f"union_by_name=true){w}")
516
+ inner = f"SELECT * FROM {_dated_source(files, meta)}{w}"
395
517
  return inner, hits
396
518
 
397
519
  # 驱动层固定 = 视图注册首层(与 LogicalFactorView 的 base 语义一致):
398
520
  # 行集稳定不随投影变化;首层**必需**——缺文件时 empty 模式返回空表、
399
521
  # raise 模式报错,绝不静默换驱动层(换驱动=换行集)。
400
522
  base_meta = layer_metas.get(view.layers[0])
401
- _check_month_continuity(
402
- _month_files(root / view.layers[0] / "monthly", start_d, end_d,
403
- allow_missing=True), view.layers[0])
404
523
  base_sub = _layer_sub(base_meta) if base_meta is not None else None
405
524
  if base_sub is None:
406
525
  if on_missing == "empty":
@@ -411,8 +530,13 @@ def load_view(
411
530
  # P1-8: 只请求键列 → 直接返回驱动层键行集(对齐旧实现)
412
531
  if not wanted:
413
532
  drive_sql = base_sub[0]
414
- sql = f"SELECT {', '.join(key_cols)} FROM ({drive_sql}) a ORDER BY {', '.join(key_cols)}"
415
- return _normalize_frame(_conn().sql(sql).df())
533
+ # DC06:日期键排序 CAST,防混型月文件 Binder 异常
534
+ order_exprs = [f"CAST({c} AS DATE)" if c == anchor.date_column else c
535
+ for c in key_cols]
536
+ sql = (f"SELECT {', '.join(key_cols)} FROM ({drive_sql}) a "
537
+ f"ORDER BY {', '.join(order_exprs)}")
538
+ return _normalize_frame(_apply_dtype_contract(
539
+ _conn().sql(sql).df(), {anchor.date_column: "date32"}))
416
540
 
417
541
  # 命中层(非驱动)缺文件必须抛错(对齐旧 MissingLayerError 语义):
418
542
  # 静默跳过会让结果缺请求列——批量因子读取静默减因子,比报错危险。
@@ -442,7 +566,14 @@ def load_view(
442
566
  select_parts.extend(f"{alias}.{c}" for c in hits)
443
567
 
444
568
  sql = f"SELECT {', '.join(select_parts)} FROM ({drive_sql}) a{joins}"
445
- sql += f" ORDER BY {', '.join(key_cols)}"
569
+ # 外层统一过滤(P2):层内下推只是剪枝优化,语义过滤在这里兜底——
570
+ # 跨层 where(如 pe 在 t1、momentum 在驱动层)此前既炸层又无外层应用
571
+ if where:
572
+ sql += f" WHERE ({where})"
573
+ # DC06:日期键排序 CAST,防混型月文件 Binder 异常
574
+ order_exprs = [f"CAST({c} AS DATE)" if c == anchor.date_column else c
575
+ for c in key_cols]
576
+ sql += f" ORDER BY {', '.join(order_exprs)}"
446
577
  # 契约并集先到先得(与列归属同序:归属层的契约生效)
447
578
  view_dtypes: dict[str, str] = {}
448
579
  for name in view.layers: