feat: operationalize local Tushare Qlib research
This commit is contained in:
+391
-43
@@ -9,7 +9,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple
|
||||
|
||||
@@ -33,6 +33,9 @@ JOINQUANT_TARGET_PACKAGE_RUN_PATH = (
|
||||
JOINQUANT_TARGET_PACKAGE_DOC_PATH = (
|
||||
"docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md"
|
||||
)
|
||||
TUSHARE_RESEARCH_RUN_PATH = (
|
||||
"evidence/tushare/CSI500-2018-2025/5bf19d2d/run.json"
|
||||
)
|
||||
JOINQUANT_TARGET_PACKAGE_RUN_ID = "2457a7c39a276e09e0fabf99e28978e1"
|
||||
JOINQUANT_TARGET_PACKAGE_OBSERVED_AT = "2026-07-26T08:50:15Z"
|
||||
JOINQUANT_TARGET_PACKAGE_BUNDLE_SHA256 = (
|
||||
@@ -47,6 +50,7 @@ SOURCE_PATHS: Tuple[str, ...] = (
|
||||
"dist/bundle_manifest.json",
|
||||
REACHABILITY_PATH,
|
||||
JOINQUANT_TARGET_PACKAGE_RUN_PATH,
|
||||
TUSHARE_RESEARCH_RUN_PATH,
|
||||
"configs/baseline.json",
|
||||
"README.md",
|
||||
"docs/ARCHITECTURE.md",
|
||||
@@ -566,11 +570,209 @@ def _validate_joinquant_target_package_run(
|
||||
}
|
||||
|
||||
|
||||
def _validate_tushare_research_run(
|
||||
project: Path,
|
||||
run: Mapping[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
expected_scope = {
|
||||
"index_code": "000905.SH",
|
||||
"start": "2018-01-01",
|
||||
"end": "2025-12-31",
|
||||
"daily_market_data_from": "2018-01-02",
|
||||
"daily_market_data_through": "2025-12-31",
|
||||
"daily_partitions_completed": 96,
|
||||
"daily_partitions_planned": 96,
|
||||
"daily_rows": 8_813_885,
|
||||
}
|
||||
expected_snapshot = {
|
||||
"selected_jobs": 221,
|
||||
"verified_jobs": 221,
|
||||
"double_verified_jobs": 221,
|
||||
"manifest_sha256": (
|
||||
"744a69828f527594e9f2787280095368c50deffa2f269c359ae65e2e0ac2d5f2"
|
||||
),
|
||||
"selection_sha256": (
|
||||
"66e91fffecba5fa042922c49e339f24212c3febf5b874279866fdca660f54fb5"
|
||||
),
|
||||
}
|
||||
expected_provider = {
|
||||
"verified": True,
|
||||
"data_version": (
|
||||
"5bf19d2da064357ad1802bca4bfa0c0505ed63fe2b24785ec6c56ecff1724963"
|
||||
),
|
||||
"tree_sha256": (
|
||||
"f173b8095fd9a62a63807324fa48bad83f0c282eea3abba871d0b0efd30b199f"
|
||||
),
|
||||
"manifest_sha256": (
|
||||
"27fb6fedb6a4b114eae2aba44505fb6c4d32c7a9ae81e58c724dfadb8dd10ed0"
|
||||
),
|
||||
"file_count": 10_011,
|
||||
"byte_count": 71_707_194,
|
||||
"calendar_sessions": 1_942,
|
||||
"instrument_count": 1_111,
|
||||
"selected_daily_rows": 1_975_455,
|
||||
"observed_membership_snapshots": 97,
|
||||
"effective_membership_snapshots": 96,
|
||||
"same_session_membership_use": False,
|
||||
"price_adjustment_complete": True,
|
||||
"benchmark": "SH000905",
|
||||
"converter_source_matches_current": True,
|
||||
}
|
||||
expected_lineage = {
|
||||
"verified": True,
|
||||
"source_job_count": 221,
|
||||
"matched_fields": [
|
||||
"id",
|
||||
"path",
|
||||
"row_count",
|
||||
"byte_count",
|
||||
"sha256",
|
||||
"request_sha256",
|
||||
],
|
||||
"mismatch_count": 0,
|
||||
"converter_source_matches_current": True,
|
||||
}
|
||||
expected_stability = {
|
||||
"state_db_changed_through_file_verification": True,
|
||||
"state_db_change_scope": "unrelated live downloader jobs",
|
||||
"snapshot_jobs_still_latest": True,
|
||||
"selected_scope_stable": True,
|
||||
"post_read_file_verification": True,
|
||||
"provider_source_jobs": 221,
|
||||
"snapshot_source_jobs": 221,
|
||||
"source_job_full_field_diff": 0,
|
||||
}
|
||||
expected_reproducibility = {
|
||||
"runs": 2,
|
||||
"byte_identical": True,
|
||||
"run_sha256": (
|
||||
"803035a95ee9cf6f90af20cdf7e7024149b97e0113b585209a80f80048be87d3"
|
||||
),
|
||||
"signal_rows": 176_615,
|
||||
"portfolio_days": 1_698,
|
||||
}
|
||||
expected_performance = {
|
||||
"strategy_cumulative_return": 0.2531519864,
|
||||
"benchmark_cumulative_return": 0.7895593835,
|
||||
"max_drawdown": -0.6041641366,
|
||||
}
|
||||
expected_limitations = {
|
||||
"stock_st": "denied",
|
||||
"qlib_limit_threshold": 0.095,
|
||||
"limit_rule_fidelity": (
|
||||
"market-level approximation; not per-symbol daily limit prices"
|
||||
),
|
||||
"research_only": True,
|
||||
}
|
||||
if (
|
||||
run.get("schema_version") != 1
|
||||
or run.get("record_type")
|
||||
!= "quant_os_tushare_qlib_research_run"
|
||||
or run.get("run_id") != "5bf19d2d"
|
||||
or run.get("audit_as_of") != "2026-07-31"
|
||||
or run.get("scope") != expected_scope
|
||||
or run.get("snapshot") != expected_snapshot
|
||||
or run.get("provider") != expected_provider
|
||||
or run.get("lineage") != expected_lineage
|
||||
or run.get("source_stability") != expected_stability
|
||||
or run.get("reproducibility") != expected_reproducibility
|
||||
or run.get("performance") != expected_performance
|
||||
or run.get("limitations") != expected_limitations
|
||||
or run.get("production_ready") is not False
|
||||
or run.get("investment_value_claim") is not False
|
||||
or run.get("gate_credit") != []
|
||||
):
|
||||
raise PublicStatusError(
|
||||
"Tushare CSI500 research machine record drifted"
|
||||
)
|
||||
encoded = json.dumps(run, ensure_ascii=False, sort_keys=True)
|
||||
if ABSOLUTE_PATH_PATTERN.search(encoded):
|
||||
raise PublicStatusError(
|
||||
"Tushare CSI500 research record contains an absolute local path"
|
||||
)
|
||||
path = _safe_repo_path(
|
||||
TUSHARE_RESEARCH_RUN_PATH,
|
||||
label="Tushare CSI500 research run",
|
||||
)
|
||||
if not (project / path).is_file():
|
||||
raise PublicStatusError(
|
||||
"tracked Tushare CSI500 research run is missing"
|
||||
)
|
||||
return {
|
||||
"runId": str(run["run_id"]),
|
||||
"auditAsOf": str(run["audit_as_of"]),
|
||||
"indexCode": str(expected_scope["index_code"]),
|
||||
"scopeStart": str(expected_scope["start"]),
|
||||
"scopeEnd": str(expected_scope["end"]),
|
||||
"marketDataFrom": str(expected_scope["daily_market_data_from"]),
|
||||
"marketDataThrough": str(
|
||||
expected_scope["daily_market_data_through"]
|
||||
),
|
||||
"completedPartitions": int(
|
||||
expected_scope["daily_partitions_completed"]
|
||||
),
|
||||
"plannedPartitions": int(
|
||||
expected_scope["daily_partitions_planned"]
|
||||
),
|
||||
"completedRows": int(expected_scope["daily_rows"]),
|
||||
"snapshotJobs": int(expected_snapshot["selected_jobs"]),
|
||||
"verifiedSnapshotJobs": int(expected_snapshot["verified_jobs"]),
|
||||
"snapshotManifestSha256": str(
|
||||
expected_snapshot["manifest_sha256"]
|
||||
),
|
||||
"snapshotSelectionSha256": str(
|
||||
expected_snapshot["selection_sha256"]
|
||||
),
|
||||
"providerDataVersion": str(expected_provider["data_version"]),
|
||||
"providerTreeSha256": str(expected_provider["tree_sha256"]),
|
||||
"providerManifestSha256": str(
|
||||
expected_provider["manifest_sha256"]
|
||||
),
|
||||
"sourceJobCount": int(expected_lineage["source_job_count"]),
|
||||
"lineageMatchedFields": list(expected_lineage["matched_fields"]),
|
||||
"lineageMismatchCount": int(expected_lineage["mismatch_count"]),
|
||||
"providerFiles": int(expected_provider["file_count"]),
|
||||
"providerBytes": int(expected_provider["byte_count"]),
|
||||
"calendarSessions": int(expected_provider["calendar_sessions"]),
|
||||
"instrumentCount": int(expected_provider["instrument_count"]),
|
||||
"selectedDailyRows": int(
|
||||
expected_provider["selected_daily_rows"]
|
||||
),
|
||||
"observedMembershipSnapshots": int(
|
||||
expected_provider["observed_membership_snapshots"]
|
||||
),
|
||||
"effectiveMembershipSnapshots": int(
|
||||
expected_provider["effective_membership_snapshots"]
|
||||
),
|
||||
"sameSessionMembershipUse": False,
|
||||
"sourceStable": True,
|
||||
"stateDbChangedDuringVerification": True,
|
||||
"converterSourceMatchesCurrent": True,
|
||||
"runSha256": str(expected_reproducibility["run_sha256"]),
|
||||
"signalRows": int(expected_reproducibility["signal_rows"]),
|
||||
"portfolioDays": int(expected_reproducibility["portfolio_days"]),
|
||||
"strategyCumulativeReturn": float(
|
||||
expected_performance["strategy_cumulative_return"]
|
||||
),
|
||||
"benchmarkCumulativeReturn": float(
|
||||
expected_performance["benchmark_cumulative_return"]
|
||||
),
|
||||
"maxDrawdown": float(expected_performance["max_drawdown"]),
|
||||
"stockStAvailable": False,
|
||||
"limitThreshold": float(
|
||||
expected_limitations["qlib_limit_threshold"]
|
||||
),
|
||||
"productionReady": False,
|
||||
"investmentValueClaim": False,
|
||||
"gateCredit": [],
|
||||
}
|
||||
|
||||
|
||||
def _extract_test_summary(facts: Sequence[Any]) -> Tuple[int | None, int | None]:
|
||||
for fact in facts:
|
||||
text = str(fact)
|
||||
match = re.search(
|
||||
r"completed\s+(\d+)\s+tests.*?(\d+)\s+optional-runtime skips",
|
||||
r"completed\s+(\d+)\s+tests.*?(\d+)\s+optional-runtime skips?",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
@@ -618,6 +820,8 @@ def _platform_evidence_links(name: str) -> List[str]:
|
||||
)
|
||||
if "Qlib" in name or "Tushare" in name:
|
||||
links.append("tushare-data")
|
||||
if "Tushare" in name:
|
||||
links.append("tushare-csi500-research-run")
|
||||
if "QMT" in name or "XtTrader" in name:
|
||||
links.append("platform-deployment")
|
||||
return list(dict.fromkeys(links))
|
||||
@@ -719,6 +923,12 @@ def _build_evidence(project: Path) -> List[Dict[str, Any]]:
|
||||
"docs/TUSHARE_LOCAL_DATA.md",
|
||||
"runtime_evidence",
|
||||
),
|
||||
(
|
||||
"tushare-csi500-research-run",
|
||||
"Tushare CSI500 2018-2025 reproducible research run",
|
||||
TUSHARE_RESEARCH_RUN_PATH,
|
||||
"machine_record",
|
||||
),
|
||||
(
|
||||
"platform-deployment",
|
||||
"Platform deployment runbook",
|
||||
@@ -788,13 +998,20 @@ def _build_layers(
|
||||
"claim": "不可变快照、PIT 字段合同和 Tushare→Qlib 技术链路已有本地合同/运行证据;这些 provider-data 路径尚未接成当前 Ridge 五层候选。",
|
||||
"capabilities": [
|
||||
"JQData raw/factor/limits/paused/PIT membership/ST snapshot",
|
||||
"Tushare completed-Parquet inventory and immutable Qlib provider",
|
||||
"Tushare scoped source release and verified immutable Qlib provider",
|
||||
"semantic manifest and data-version verification",
|
||||
],
|
||||
"limitations": [
|
||||
"尚无授权 JQData 真实快照与许可 lineage;Tushare 镜像仅部分完成且缺生产关键字段;当前五层候选仍使用 synthetic 数据。"
|
||||
"Tushare 2018—2025 中证 500 scoped research 链已验证,但 live raw "
|
||||
"仍可变、stock_st 不可用、9.5% 涨跌停为市场级近似,且尚未接入"
|
||||
"当前 Ridge 五层候选;JQData 授权快照与许可 lineage 仍缺失。"
|
||||
],
|
||||
"evidenceLinks": [
|
||||
"architecture",
|
||||
"tushare-data",
|
||||
"tushare-csi500-research-run",
|
||||
"scorecard",
|
||||
],
|
||||
"evidenceLinks": ["architecture", "tushare-data", "scorecard"],
|
||||
"releaseVerified": False,
|
||||
},
|
||||
{
|
||||
@@ -1046,6 +1263,7 @@ def _build_freshness(
|
||||
scorecard: Mapping[str, Any],
|
||||
joinquant_markdown: str,
|
||||
joinquant_target_package_run: Mapping[str, Any],
|
||||
tushare_research_run: Mapping[str, Any],
|
||||
tushare_markdown: str,
|
||||
baseline_shadow_days: int,
|
||||
production_shadow_days: int,
|
||||
@@ -1061,49 +1279,100 @@ def _build_freshness(
|
||||
)
|
||||
joinquant_start, joinquant_end = period_match.groups()
|
||||
|
||||
daily_match = re.search(
|
||||
r"(?m)^\|\s*`daily`\s*\|\s*([\d,]+)\s*\|\s*([\d,]+)\s*\|\s*"
|
||||
r"(\d{4}-\d{2}-\d{2})[—-](\d{4}-\d{2}-\d{2})",
|
||||
tushare_markdown,
|
||||
)
|
||||
plan_match = re.search(
|
||||
r"`daily`\s*原计划覆盖.*?共\s*(\d+)\s*个月;当前完成\s*"
|
||||
r"(\d+)\s*个月,约\s*([\d.]+)%",
|
||||
tushare_markdown,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
tushare_fields = {
|
||||
key: re.search(
|
||||
rf"(?m)^{key}\s*=\s*([^\s]+)\s*$",
|
||||
tushare_markdown,
|
||||
)
|
||||
for key in (
|
||||
"tushare_daily_completed_partitions",
|
||||
"tushare_daily_planned_partitions",
|
||||
"tushare_daily_completed_rows",
|
||||
"tushare_daily_market_data_from",
|
||||
"tushare_daily_market_data_through",
|
||||
)
|
||||
}
|
||||
ready_match = re.search(
|
||||
r"(?m)^production_ready\s*=\s*(true|false)\s*$",
|
||||
tushare_markdown,
|
||||
)
|
||||
if daily_match is None or plan_match is None or ready_match is None:
|
||||
if any(match is None for match in tushare_fields.values()) or ready_match is None:
|
||||
raise PublicStatusError(
|
||||
"Tushare evidence must declare daily coverage and production readiness"
|
||||
)
|
||||
(
|
||||
daily_partitions_text,
|
||||
daily_rows_text,
|
||||
tushare_start,
|
||||
tushare_end,
|
||||
) = daily_match.groups()
|
||||
planned_months_text, completed_months_text, percentage_text = plan_match.groups()
|
||||
completed_partitions = int(daily_partitions_text.replace(",", ""))
|
||||
completed_months = int(completed_months_text)
|
||||
planned_months = int(planned_months_text)
|
||||
if completed_partitions != completed_months:
|
||||
live_completed_partitions = int(
|
||||
tushare_fields["tushare_daily_completed_partitions"]
|
||||
.group(1)
|
||||
.replace(",", "")
|
||||
)
|
||||
live_planned_months = int(
|
||||
tushare_fields["tushare_daily_planned_partitions"]
|
||||
.group(1)
|
||||
.replace(",", "")
|
||||
)
|
||||
live_daily_rows = int(
|
||||
tushare_fields["tushare_daily_completed_rows"]
|
||||
.group(1)
|
||||
.replace(",", "")
|
||||
)
|
||||
live_tushare_start = tushare_fields[
|
||||
"tushare_daily_market_data_from"
|
||||
].group(1)
|
||||
live_tushare_end = tushare_fields[
|
||||
"tushare_daily_market_data_through"
|
||||
].group(1)
|
||||
if (
|
||||
live_planned_months <= 0
|
||||
or live_completed_partitions > live_planned_months
|
||||
or live_daily_rows <= 0
|
||||
):
|
||||
raise PublicStatusError(
|
||||
"Tushare daily partition count differs from completed-month statement"
|
||||
"Tushare daily partition coverage is invalid"
|
||||
)
|
||||
calculated_ratio = completed_months / planned_months
|
||||
recorded_percentage = float(percentage_text)
|
||||
if abs(calculated_ratio * 100.0 - recorded_percentage) > 0.1:
|
||||
for label, value in (
|
||||
("market_data_from", live_tushare_start),
|
||||
("market_data_through", live_tushare_end),
|
||||
):
|
||||
try:
|
||||
date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise PublicStatusError(
|
||||
f"Tushare {label} must be an ISO date"
|
||||
) from exc
|
||||
if live_tushare_start > live_tushare_end:
|
||||
raise PublicStatusError(
|
||||
"Tushare recorded coverage percentage does not match month counts"
|
||||
"Tushare daily market-data start must not follow its end"
|
||||
)
|
||||
completed_partitions = int(
|
||||
tushare_research_run["completedPartitions"]
|
||||
)
|
||||
planned_months = int(tushare_research_run["plannedPartitions"])
|
||||
daily_rows = int(tushare_research_run["completedRows"])
|
||||
tushare_start = str(tushare_research_run["marketDataFrom"])
|
||||
tushare_end = str(tushare_research_run["marketDataThrough"])
|
||||
if (
|
||||
completed_partitions != planned_months
|
||||
or completed_partitions > live_completed_partitions
|
||||
or daily_rows > live_daily_rows
|
||||
or date.fromisoformat(tushare_start)
|
||||
< date.fromisoformat(live_tushare_start)
|
||||
or date.fromisoformat(tushare_end)
|
||||
> date.fromisoformat(live_tushare_end)
|
||||
):
|
||||
raise PublicStatusError(
|
||||
"Tushare scoped research record is inconsistent with live inventory"
|
||||
)
|
||||
calculated_ratio = completed_partitions / planned_months
|
||||
recorded_percentage = calculated_ratio * 100.0
|
||||
production_ready = ready_match.group(1) == "true"
|
||||
if production_ready:
|
||||
if (
|
||||
production_ready
|
||||
or tushare_research_run["productionReady"] is not False
|
||||
or tushare_research_run["investmentValueClaim"] is not False
|
||||
or tushare_research_run["gateCredit"] != []
|
||||
):
|
||||
raise PublicStatusError(
|
||||
"partial Tushare source must not be publicized as production ready"
|
||||
"current Tushare research bridge must not be publicized as production ready"
|
||||
)
|
||||
|
||||
audit_as_of = str(scorecard.get("audit_as_of"))
|
||||
@@ -1159,24 +1428,94 @@ def _build_freshness(
|
||||
},
|
||||
{
|
||||
"id": "tushare-daily-market-data",
|
||||
"label": "Tushare daily 行情覆盖",
|
||||
"status": "partial",
|
||||
"label": "Tushare CSI500 scoped research release",
|
||||
"status": "research_only",
|
||||
"marketDataFrom": tushare_start,
|
||||
"marketDataThrough": tushare_end,
|
||||
"completedPartitions": completed_partitions,
|
||||
"plannedPartitions": planned_months,
|
||||
"completedRows": int(daily_rows_text.replace(",", "")),
|
||||
"completedRows": daily_rows,
|
||||
"completionRatio": round(calculated_ratio, 6),
|
||||
"completionLabel": (
|
||||
f"{completed_partitions}/{planned_months}(约 {recorded_percentage:g}%)"
|
||||
),
|
||||
"value": (
|
||||
f"{completed_partitions}/{planned_months}(约 {recorded_percentage:g}%),"
|
||||
f"行情截至 {tushare_end}"
|
||||
f"{completed_partitions}/{planned_months} scoped daily 分区;"
|
||||
f"{tushare_research_run['verifiedSnapshotJobs']}/"
|
||||
f"{tushare_research_run['snapshotJobs']} source jobs;"
|
||||
f"行情 {tushare_start} — {tushare_end}"
|
||||
),
|
||||
"productionReady": production_ready,
|
||||
"semantics": "marketDataThrough 仅取自 daily 行情分区;绝不使用覆盖更晚的 trade_cal 日期。",
|
||||
"evidenceLinks": ["tushare-data", "scorecard"],
|
||||
"investmentValueClaim": False,
|
||||
"gateCredit": [],
|
||||
"snapshotJobs": int(tushare_research_run["snapshotJobs"]),
|
||||
"verifiedSnapshotJobs": int(
|
||||
tushare_research_run["verifiedSnapshotJobs"]
|
||||
),
|
||||
"providerDataVersion": str(
|
||||
tushare_research_run["providerDataVersion"]
|
||||
),
|
||||
"providerTreeSha256": str(
|
||||
tushare_research_run["providerTreeSha256"]
|
||||
),
|
||||
"providerManifestSha256": str(
|
||||
tushare_research_run["providerManifestSha256"]
|
||||
),
|
||||
"sourceJobCount": int(
|
||||
tushare_research_run["sourceJobCount"]
|
||||
),
|
||||
"lineageMatchedFields": list(
|
||||
tushare_research_run["lineageMatchedFields"]
|
||||
),
|
||||
"lineageMismatchCount": int(
|
||||
tushare_research_run["lineageMismatchCount"]
|
||||
),
|
||||
"providerFiles": int(tushare_research_run["providerFiles"]),
|
||||
"providerBytes": int(tushare_research_run["providerBytes"]),
|
||||
"calendarSessions": int(
|
||||
tushare_research_run["calendarSessions"]
|
||||
),
|
||||
"instrumentCount": int(
|
||||
tushare_research_run["instrumentCount"]
|
||||
),
|
||||
"selectedDailyRows": int(
|
||||
tushare_research_run["selectedDailyRows"]
|
||||
),
|
||||
"observedMembershipSnapshots": int(
|
||||
tushare_research_run["observedMembershipSnapshots"]
|
||||
),
|
||||
"effectiveMembershipSnapshots": int(
|
||||
tushare_research_run["effectiveMembershipSnapshots"]
|
||||
),
|
||||
"sameSessionMembershipUse": False,
|
||||
"sourceStable": True,
|
||||
"stateDbChangedDuringVerification": True,
|
||||
"converterSourceMatchesCurrent": True,
|
||||
"runSha256": str(tushare_research_run["runSha256"]),
|
||||
"signalRows": int(tushare_research_run["signalRows"]),
|
||||
"portfolioDays": int(tushare_research_run["portfolioDays"]),
|
||||
"strategyCumulativeReturn": float(
|
||||
tushare_research_run["strategyCumulativeReturn"]
|
||||
),
|
||||
"benchmarkCumulativeReturn": float(
|
||||
tushare_research_run["benchmarkCumulativeReturn"]
|
||||
),
|
||||
"maxDrawdown": float(tushare_research_run["maxDrawdown"]),
|
||||
"semantics": (
|
||||
"96/96 只表示 2018—2025 scoped daily 输入完整且 221/221 "
|
||||
"source jobs 经双重校验;全局 ledger 因无关下载任务发生变化,"
|
||||
"但 scoped jobs 仍为 latest;lineage 工具按 id/path/row_count/"
|
||||
"byte_count/sha256/request_sha256 验证 converter 221/221 "
|
||||
"零差异。"
|
||||
"universe 成分次一交易日生效。该 Qlib "
|
||||
"run 未使用逐股涨跌停,stock_st 不可用,不代表 production-ready "
|
||||
"或投资价值。"
|
||||
),
|
||||
"evidenceLinks": [
|
||||
"tushare-csi500-research-run",
|
||||
"tushare-data",
|
||||
"scorecard",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "jqdata-real-snapshot",
|
||||
@@ -1413,6 +1752,9 @@ def build_public_status(
|
||||
joinquant_target_package_run_raw = _load_json(
|
||||
project / JOINQUANT_TARGET_PACKAGE_RUN_PATH
|
||||
)
|
||||
tushare_research_run_raw = _load_json(
|
||||
project / TUSHARE_RESEARCH_RUN_PATH
|
||||
)
|
||||
baseline = _load_json(project / "configs" / "baseline.json")
|
||||
platform_markdown = (project / "docs" / "PLATFORM_MATRIX.md").read_text(
|
||||
encoding="utf-8"
|
||||
@@ -1501,6 +1843,10 @@ def build_public_status(
|
||||
project,
|
||||
joinquant_target_package_run_raw,
|
||||
)
|
||||
tushare_research_run = _validate_tushare_research_run(
|
||||
project,
|
||||
tushare_research_run_raw,
|
||||
)
|
||||
maturity80 = _build_maturity80(
|
||||
project,
|
||||
production_80_standard,
|
||||
@@ -1582,7 +1928,7 @@ def build_public_status(
|
||||
"JoinQuant portable_momentum_smoke hosted daily backtest (not Baseline/TargetPackage evidence)",
|
||||
"Qlib native momentum fixture",
|
||||
"Qlib Alpha158 + LightGBM fixture",
|
||||
"Tushare completed-partition Qlib momentum smoke",
|
||||
"Tushare CSI500 2018—2025 scoped Qlib momentum reproducibility run",
|
||||
],
|
||||
"gateCoverageLabel": f"{passed}/{len(gates)} passed",
|
||||
"productionReady": bool(maturity80["qualified"]),
|
||||
@@ -1630,6 +1976,7 @@ def build_public_status(
|
||||
scorecard=scorecard,
|
||||
joinquant_markdown=joinquant_markdown,
|
||||
joinquant_target_package_run=joinquant_target_package_run,
|
||||
tushare_research_run=tushare_research_run,
|
||||
tushare_markdown=tushare_markdown,
|
||||
baseline_shadow_days=baseline_shadow_days,
|
||||
production_shadow_days=production_shadow_days,
|
||||
@@ -1672,6 +2019,7 @@ def build_public_status(
|
||||
"production80Assessment": PRODUCTION_80_ASSESSMENT_PATH,
|
||||
"bundleManifest": "dist/bundle_manifest.json",
|
||||
"releaseReachability": REACHABILITY_PATH,
|
||||
"tushareResearchRun": TUSHARE_RESEARCH_RUN_PATH,
|
||||
"platformMatrix": "docs/PLATFORM_MATRIX.md",
|
||||
"baselineConfig": "configs/baseline.json",
|
||||
"sourceSetSha256": source_set_sha,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify exact lineage from a Tushare scoped manifest to a Qlib provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from adapters.tushare_local import MANIFEST_NAME, verify_qlib_provider
|
||||
|
||||
|
||||
MATCHED_FIELDS = (
|
||||
("id", "job_id"),
|
||||
("path", "path"),
|
||||
("row_count", "row_count"),
|
||||
("byte_count", "byte_count"),
|
||||
("sha256", "sha256"),
|
||||
("request_sha256", "request_sha256"),
|
||||
)
|
||||
|
||||
|
||||
class TushareLineageError(RuntimeError):
|
||||
"""Raised when a scoped manifest and provider do not match exactly."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _load_object(path: Path, *, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise TushareLineageError(f"cannot read {label}: {path}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise TushareLineageError(f"{label} must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_snapshot(
|
||||
snapshot: Mapping[str, Any],
|
||||
) -> Sequence[Mapping[str, Any]]:
|
||||
if snapshot.get("format") != "quant-os.tushare-scoped-snapshot/v2":
|
||||
raise TushareLineageError("source snapshot must use scoped format v2")
|
||||
manifest_sha = str(snapshot.get("manifest_sha256") or "")
|
||||
without_manifest_sha = dict(snapshot)
|
||||
without_manifest_sha.pop("manifest_sha256", None)
|
||||
computed_manifest_sha = hashlib.sha256(
|
||||
_canonical_json(without_manifest_sha)
|
||||
).hexdigest()
|
||||
if manifest_sha != computed_manifest_sha:
|
||||
raise TushareLineageError("source snapshot manifest SHA-256 mismatch")
|
||||
|
||||
jobs = snapshot.get("selected_jobs")
|
||||
if not isinstance(jobs, list) or not jobs:
|
||||
raise TushareLineageError("source snapshot has no selected_jobs")
|
||||
selection_identity = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for raw_job in jobs:
|
||||
if not isinstance(raw_job, Mapping):
|
||||
raise TushareLineageError("source snapshot job must be an object")
|
||||
key = (
|
||||
str(raw_job.get("api_name") or ""),
|
||||
str(raw_job.get("partition_key") or ""),
|
||||
)
|
||||
if not all(key) or key in seen:
|
||||
raise TushareLineageError(
|
||||
"source snapshot has empty or duplicate job keys"
|
||||
)
|
||||
seen.add(key)
|
||||
path = Path(str(raw_job.get("path") or ""))
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise TushareLineageError(
|
||||
"source snapshot job paths must stay relative to the mirror"
|
||||
)
|
||||
observed = raw_job.get("observed")
|
||||
if raw_job.get("verified") is not True or not isinstance(
|
||||
observed,
|
||||
Mapping,
|
||||
):
|
||||
raise TushareLineageError(
|
||||
"every source snapshot job must carry verified observations"
|
||||
)
|
||||
if (
|
||||
observed.get("row_count") != raw_job.get("row_count")
|
||||
or observed.get("size_bytes") != raw_job.get("byte_count")
|
||||
or observed.get("sha256") != raw_job.get("sha256")
|
||||
):
|
||||
raise TushareLineageError(
|
||||
"source snapshot observed file evidence is inconsistent"
|
||||
)
|
||||
selection_identity.append(
|
||||
{
|
||||
field: value
|
||||
for field, value in raw_job.items()
|
||||
if field not in {"observed", "verified"}
|
||||
}
|
||||
)
|
||||
computed_selection_sha = hashlib.sha256(
|
||||
_canonical_json({"selected_jobs": selection_identity})
|
||||
).hexdigest()
|
||||
if computed_selection_sha != snapshot.get("selection_sha256"):
|
||||
raise TushareLineageError("source snapshot selection SHA-256 mismatch")
|
||||
|
||||
verification = snapshot.get("verification")
|
||||
if (
|
||||
not isinstance(verification, Mapping)
|
||||
or verification.get("verified_jobs") != len(jobs)
|
||||
or verification.get("double_verified_jobs") != len(jobs)
|
||||
or verification.get(
|
||||
"selected_jobs_still_latest_after_verification"
|
||||
)
|
||||
is not True
|
||||
):
|
||||
raise TushareLineageError(
|
||||
"source snapshot lacks closed double-verification evidence"
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def _compare_source_jobs(
|
||||
snapshot_jobs: Sequence[Mapping[str, Any]],
|
||||
provider_jobs: Sequence[Mapping[str, Any]],
|
||||
) -> None:
|
||||
def key(job: Mapping[str, Any]) -> tuple[str, str]:
|
||||
return (
|
||||
str(job.get("api_name") or ""),
|
||||
str(job.get("partition_key") or ""),
|
||||
)
|
||||
|
||||
snapshot_by_key = {key(job): job for job in snapshot_jobs}
|
||||
provider_by_key = {key(job): job for job in provider_jobs}
|
||||
if len(snapshot_by_key) != len(snapshot_jobs):
|
||||
raise TushareLineageError("source snapshot has duplicate job keys")
|
||||
if len(provider_by_key) != len(provider_jobs):
|
||||
raise TushareLineageError("provider manifest has duplicate job keys")
|
||||
if set(snapshot_by_key) != set(provider_by_key):
|
||||
missing = sorted(set(snapshot_by_key).difference(provider_by_key))
|
||||
extra = sorted(set(provider_by_key).difference(snapshot_by_key))
|
||||
raise TushareLineageError(
|
||||
"source job sets differ: "
|
||||
f"missing_from_provider={missing[:5]}, "
|
||||
f"extra_in_provider={extra[:5]}"
|
||||
)
|
||||
mismatches: list[str] = []
|
||||
for job_key in sorted(snapshot_by_key):
|
||||
source = snapshot_by_key[job_key]
|
||||
provider = provider_by_key[job_key]
|
||||
for source_field, provider_field in MATCHED_FIELDS:
|
||||
if source.get(source_field) != provider.get(provider_field):
|
||||
mismatches.append(
|
||||
f"{job_key[0]}:{job_key[1]}:{source_field}"
|
||||
)
|
||||
if mismatches:
|
||||
raise TushareLineageError(
|
||||
"source job fields differ: " + ", ".join(mismatches[:12])
|
||||
)
|
||||
|
||||
|
||||
def verify_lineage(
|
||||
source_manifest: str | Path,
|
||||
provider_dir: str | Path,
|
||||
) -> dict[str, Any]:
|
||||
source_path = Path(source_manifest).expanduser().resolve()
|
||||
provider = Path(provider_dir).expanduser().resolve()
|
||||
snapshot = _load_object(source_path, label="source snapshot")
|
||||
snapshot_jobs = _validate_snapshot(snapshot)
|
||||
provider_verification = verify_qlib_provider(provider)
|
||||
provider_manifest = _load_object(
|
||||
provider / MANIFEST_NAME,
|
||||
label="provider manifest",
|
||||
)
|
||||
provider_jobs = provider_manifest.get("source_jobs")
|
||||
if not isinstance(provider_jobs, list):
|
||||
raise TushareLineageError("provider manifest has no source_jobs")
|
||||
_compare_source_jobs(snapshot_jobs, provider_jobs)
|
||||
return {
|
||||
"artifact_type": "quant_os_tushare_source_provider_lineage",
|
||||
"ok": True,
|
||||
"snapshot_format": snapshot["format"],
|
||||
"snapshot_manifest_sha256": snapshot["manifest_sha256"],
|
||||
"snapshot_selection_sha256": snapshot["selection_sha256"],
|
||||
"source_job_count": len(snapshot_jobs),
|
||||
"matched_fields": [
|
||||
source_field for source_field, _ in MATCHED_FIELDS
|
||||
],
|
||||
"mismatch_count": 0,
|
||||
"provider": provider_verification,
|
||||
"tool_source_sha256": hashlib.sha256(
|
||||
Path(__file__).resolve().read_bytes()
|
||||
).hexdigest(),
|
||||
"gate_credit": [],
|
||||
"investment_value_claim": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
||||
destination = path.expanduser().resolve()
|
||||
if destination.exists() and destination.is_dir():
|
||||
raise ValueError("output points to a directory")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
provider_bytes = (
|
||||
json.dumps(
|
||||
dict(payload),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=destination.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(provider_bytes)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, destination)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
def _main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-manifest", required=True)
|
||||
parser.add_argument("--provider-dir", required=True)
|
||||
parser.add_argument("--output-json")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
result = verify_lineage(
|
||||
args.source_manifest,
|
||||
args.provider_dir,
|
||||
)
|
||||
if args.output_json:
|
||||
output = Path(args.output_json).expanduser().resolve()
|
||||
provider = Path(args.provider_dir).expanduser().resolve()
|
||||
if output == provider or provider in output.parents:
|
||||
raise ValueError(
|
||||
"output-json must be outside the immutable provider "
|
||||
"directory"
|
||||
)
|
||||
_write_json(output, result)
|
||||
except (OSError, ValueError, TushareLineageError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(
|
||||
json.dumps(
|
||||
result,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
@@ -8,6 +8,10 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from adapters.tushare_local import (
|
||||
TushareMirrorError,
|
||||
build_qlib_provider,
|
||||
@@ -16,6 +20,20 @@ from adapters.tushare_local import (
|
||||
)
|
||||
|
||||
|
||||
def _assert_json_outside_provider(
|
||||
output_json: Optional[str],
|
||||
provider_dir: str,
|
||||
) -> None:
|
||||
if output_json is None:
|
||||
return
|
||||
destination = Path(output_json).expanduser().resolve()
|
||||
provider = Path(provider_dir).expanduser().resolve()
|
||||
if destination == provider or provider in destination.parents:
|
||||
raise ValueError(
|
||||
"output-json must be outside the immutable provider directory"
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Optional[str], payload: Mapping[str, Any]) -> None:
|
||||
if path is None:
|
||||
return
|
||||
@@ -52,6 +70,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
build.add_argument("--end", required=True)
|
||||
build.add_argument("--market-name", default="tushare_a")
|
||||
build.add_argument("--benchmark-symbol", default="SH999999")
|
||||
build.add_argument(
|
||||
"--benchmark-index-code",
|
||||
help="use real Tushare index_daily data, for example 000905.SH",
|
||||
)
|
||||
build.add_argument(
|
||||
"--universe-index-code",
|
||||
help="build point-in-time market intervals from index_weight snapshots",
|
||||
)
|
||||
build.add_argument(
|
||||
"--expected-constituent-count",
|
||||
type=int,
|
||||
help=(
|
||||
"required for unknown index codes; known indices such as "
|
||||
"000905.SH use a built-in exact count"
|
||||
),
|
||||
)
|
||||
build.add_argument("--minimum-observations", type=int, default=60)
|
||||
build.add_argument(
|
||||
"--allow-unadjusted",
|
||||
@@ -81,6 +115,10 @@ def _main(argv: Optional[list[str]] = None) -> int:
|
||||
verify_files=not args.skip_file_verification,
|
||||
)
|
||||
elif args.command == "build":
|
||||
_assert_json_outside_provider(
|
||||
args.output_json,
|
||||
args.output_dir,
|
||||
)
|
||||
result = build_qlib_provider(
|
||||
args.mirror_root,
|
||||
args.output_dir,
|
||||
@@ -91,8 +129,15 @@ def _main(argv: Optional[list[str]] = None) -> int:
|
||||
minimum_observations=args.minimum_observations,
|
||||
allow_unadjusted=args.allow_unadjusted,
|
||||
expand_range=args.ohlc_policy == "expand-range",
|
||||
benchmark_index_code=args.benchmark_index_code,
|
||||
universe_index_code=args.universe_index_code,
|
||||
expected_constituent_count=args.expected_constituent_count,
|
||||
)
|
||||
else:
|
||||
_assert_json_outside_provider(
|
||||
args.output_json,
|
||||
args.provider_dir,
|
||||
)
|
||||
result = verify_qlib_provider(args.provider_dir)
|
||||
_write_json(args.output_json, result)
|
||||
except (OSError, ValueError, TushareMirrorError) as exc:
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
"""Create an auditable, scoped manifest from a live local Tushare mirror.
|
||||
|
||||
The command is intentionally read-only with respect to the mirror. It records
|
||||
the latest completed job for every selected ``(api_name, partition_key)`` in one
|
||||
SQLite read transaction, then optionally verifies the immutable Parquet files.
|
||||
It never reads Tushare credentials and never copies source data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
TOOL_VERSION = "2"
|
||||
DEFAULT_APIS = (
|
||||
"daily",
|
||||
"adj_factor",
|
||||
"trade_cal",
|
||||
"stock_basic",
|
||||
"index_daily",
|
||||
"index_weight",
|
||||
)
|
||||
DEFAULT_INDEX_CODES = ("000905.SH",)
|
||||
BLOCKING_LIVE_STATUSES = frozenset(("running", "deferred"))
|
||||
_MONTH_RE = re.compile(r"(?:^|[/_])month=(\d{4}-\d{2})(?:$|[/_])")
|
||||
_YEAR_RE = re.compile(r"(?:^|[/_])year=(\d{4})(?:$|[/_])")
|
||||
_INDEX_CODE_RE = re.compile(
|
||||
r"(?:^|/)(?:ts_code|index_code)=([^/]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_API_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
_INDEX_INPUT_RE = re.compile(r"^([A-Za-z0-9]+)[._]([A-Za-z0-9]+)$")
|
||||
|
||||
|
||||
class TushareSnapshotError(RuntimeError):
|
||||
"""Raised when a requested snapshot cannot be frozen safely."""
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
|
||||
def _canonical_json(payload: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _normalize_index_code(raw: str) -> str:
|
||||
value = raw.strip().upper()
|
||||
match = _INDEX_INPUT_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"invalid index code {raw!r}; expected a value such as 000905.SH"
|
||||
)
|
||||
return f"{match.group(1)}.{match.group(2)}"
|
||||
|
||||
|
||||
def _partition_index_code(partition_key: str) -> Optional[str]:
|
||||
match = _INDEX_CODE_RE.search(partition_key)
|
||||
if match is None:
|
||||
return None
|
||||
raw = match.group(1)
|
||||
try:
|
||||
return _normalize_index_code(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _partition_month(partition_key: str) -> Optional[str]:
|
||||
match = _MONTH_RE.search(partition_key)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _partition_year(partition_key: str) -> Optional[int]:
|
||||
match = _YEAR_RE.search(partition_key)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _month_keys(start: date, end: date) -> list[str]:
|
||||
year = start.year
|
||||
month = start.month
|
||||
result: list[str] = []
|
||||
while (year, month) <= (end.year, end.month):
|
||||
result.append(f"{year:04d}-{month:02d}")
|
||||
month += 1
|
||||
if month == 13:
|
||||
year += 1
|
||||
month = 1
|
||||
return result
|
||||
|
||||
|
||||
def _parse_values(values: Sequence[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
result.extend(part.strip() for part in value.split(",") if part.strip())
|
||||
return result
|
||||
|
||||
|
||||
def _validate_apis(apis: Iterable[str]) -> tuple[str, ...]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in apis:
|
||||
api = raw.strip()
|
||||
if not _API_RE.fullmatch(api):
|
||||
raise ValueError(f"invalid API name: {raw!r}")
|
||||
if api not in seen:
|
||||
seen.add(api)
|
||||
result.append(api)
|
||||
if not result:
|
||||
raise ValueError("at least one API must be requested")
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _scope_reason(
|
||||
api_name: str,
|
||||
partition_key: str,
|
||||
*,
|
||||
apis: set[str],
|
||||
months: set[str],
|
||||
years: set[int],
|
||||
index_weight_years: set[int],
|
||||
index_codes: set[str],
|
||||
) -> tuple[bool, str]:
|
||||
if api_name not in apis:
|
||||
return False, "api_not_requested"
|
||||
|
||||
if api_name in {"daily", "adj_factor"}:
|
||||
month = _partition_month(partition_key)
|
||||
if month is None:
|
||||
return False, "unrecognized_partition"
|
||||
return (True, "in_scope") if month in months else (
|
||||
False,
|
||||
"outside_date_range",
|
||||
)
|
||||
|
||||
if api_name == "trade_cal":
|
||||
year = _partition_year(partition_key)
|
||||
if year is None:
|
||||
return False, "unrecognized_partition"
|
||||
return (True, "in_scope") if year in years else (
|
||||
False,
|
||||
"outside_date_range",
|
||||
)
|
||||
|
||||
if api_name in {"index_daily", "index_weight"}:
|
||||
code = _partition_index_code(partition_key)
|
||||
if code not in index_codes:
|
||||
return False, "different_index_code"
|
||||
year = _partition_year(partition_key)
|
||||
if year is None:
|
||||
return False, "unrecognized_partition"
|
||||
accepted_years = (
|
||||
index_weight_years if api_name == "index_weight" else years
|
||||
)
|
||||
return (True, "in_scope") if year in accepted_years else (
|
||||
False,
|
||||
"outside_date_range",
|
||||
)
|
||||
|
||||
if api_name == "stock_basic":
|
||||
return True, "in_scope"
|
||||
|
||||
month = _partition_month(partition_key)
|
||||
if month is not None:
|
||||
return (True, "in_scope") if month in months else (
|
||||
False,
|
||||
"outside_date_range",
|
||||
)
|
||||
year = _partition_year(partition_key)
|
||||
if year is not None:
|
||||
return (True, "in_scope") if year in years else (
|
||||
False,
|
||||
"outside_date_range",
|
||||
)
|
||||
return True, "in_scope"
|
||||
|
||||
|
||||
def _state_file_identity(path: Path) -> dict[str, Any]:
|
||||
stat = path.stat()
|
||||
return {
|
||||
"device": stat.st_dev,
|
||||
"inode": stat.st_ino,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
"path": "data/state.sqlite3",
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
def _wal_identity(state_path: Path) -> Optional[dict[str, int]]:
|
||||
wal_path = state_path.with_name(state_path.name + "-wal")
|
||||
if not wal_path.exists():
|
||||
return None
|
||||
stat = wal_path.stat()
|
||||
return {
|
||||
"inode": stat.st_ino,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
def _candidate_query(
|
||||
connection: sqlite3.Connection,
|
||||
api_name: str,
|
||||
*,
|
||||
months: Sequence[str],
|
||||
years: Sequence[int],
|
||||
index_codes: Sequence[str],
|
||||
) -> list[sqlite3.Row]:
|
||||
columns = (
|
||||
"id, api_name, partition_key, status, row_count, byte_count, "
|
||||
"sha256, file_path, completed_at, params_json, fields"
|
||||
)
|
||||
where = ["api_name = ?", "status = 'completed'"]
|
||||
parameters: list[Any] = [api_name]
|
||||
|
||||
if api_name in {"daily", "adj_factor"}:
|
||||
placeholders = ",".join("?" for _ in months)
|
||||
where.append(f"partition_key IN ({placeholders})")
|
||||
parameters.extend(f"month={month}" for month in months)
|
||||
elif api_name == "trade_cal":
|
||||
clauses = []
|
||||
for year in years:
|
||||
clauses.append("partition_key LIKE ?")
|
||||
parameters.append(f"%year={year}%")
|
||||
where.append("(" + " OR ".join(clauses) + ")")
|
||||
elif api_name in {"index_daily", "index_weight"}:
|
||||
code_clauses = []
|
||||
for code in index_codes:
|
||||
code_clauses.append("partition_key LIKE ?")
|
||||
parameters.append(f"%={code.replace('.', '_')}%")
|
||||
year_clauses = []
|
||||
for year in years:
|
||||
year_clauses.append("partition_key LIKE ?")
|
||||
parameters.append(f"%year={year}%")
|
||||
where.append("(" + " OR ".join(code_clauses) + ")")
|
||||
where.append("(" + " OR ".join(year_clauses) + ")")
|
||||
|
||||
query = f"SELECT {columns} FROM jobs WHERE " + " AND ".join(where)
|
||||
return list(connection.execute(query, parameters))
|
||||
|
||||
|
||||
def _assert_scope_complete(
|
||||
selected: Sequence[sqlite3.Row],
|
||||
*,
|
||||
apis: Sequence[str],
|
||||
months: Sequence[str],
|
||||
years: Sequence[int],
|
||||
index_weight_years: Sequence[int],
|
||||
index_codes: Sequence[str],
|
||||
) -> None:
|
||||
by_api: dict[str, list[sqlite3.Row]] = defaultdict(list)
|
||||
for row in selected:
|
||||
by_api[str(row["api_name"])].append(row)
|
||||
|
||||
missing: list[str] = []
|
||||
for api in apis:
|
||||
rows = by_api.get(api, [])
|
||||
if api in {"daily", "adj_factor"}:
|
||||
found = {
|
||||
value
|
||||
for value in (
|
||||
_partition_month(str(row["partition_key"])) for row in rows
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
missing.extend(
|
||||
f"{api}:month={month}" for month in months if month not in found
|
||||
)
|
||||
elif api == "trade_cal":
|
||||
found = {
|
||||
value
|
||||
for value in (
|
||||
_partition_year(str(row["partition_key"])) for row in rows
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
missing.extend(
|
||||
f"{api}:year={year}" for year in years if year not in found
|
||||
)
|
||||
elif api in {"index_daily", "index_weight"}:
|
||||
found = {
|
||||
(
|
||||
_partition_index_code(str(row["partition_key"])),
|
||||
_partition_year(str(row["partition_key"])),
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
required_years = (
|
||||
index_weight_years if api == "index_weight" else years
|
||||
)
|
||||
missing.extend(
|
||||
f"{api}:{code}/year={year}"
|
||||
for code in index_codes
|
||||
for year in required_years
|
||||
if (code, year) not in found
|
||||
)
|
||||
elif not rows:
|
||||
missing.append(f"{api}:no_completed_partition")
|
||||
|
||||
if missing:
|
||||
preview = ", ".join(missing[:12])
|
||||
suffix = "" if len(missing) <= 12 else f" (+{len(missing) - 12} more)"
|
||||
raise TushareSnapshotError(
|
||||
f"requested scope is incomplete; missing {preview}{suffix}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source_file(mirror_root: Path, raw_path: str) -> tuple[Path, str]:
|
||||
path = Path(raw_path)
|
||||
if not path.is_absolute():
|
||||
path = mirror_root / path
|
||||
resolved = path.expanduser().resolve(strict=True)
|
||||
root = mirror_root.resolve(strict=True)
|
||||
try:
|
||||
relative = resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise TushareSnapshotError(
|
||||
"source file escapes the declared mirror root"
|
||||
) from exc
|
||||
return resolved, relative.as_posix()
|
||||
|
||||
|
||||
def _parquet_row_count(path: Path) -> int:
|
||||
try:
|
||||
import pyarrow.parquet as parquet
|
||||
except ImportError as exc:
|
||||
raise TushareSnapshotError(
|
||||
"Parquet row verification requires pyarrow; install the Qlib "
|
||||
"research requirements or use --no-verify explicitly"
|
||||
) from exc
|
||||
try:
|
||||
metadata = parquet.ParquetFile(path).metadata
|
||||
except Exception as exc:
|
||||
raise TushareSnapshotError(
|
||||
f"cannot read Parquet metadata for {path.name}: {exc}"
|
||||
) from exc
|
||||
return int(metadata.num_rows)
|
||||
|
||||
|
||||
def _verify_job_file(
|
||||
mirror_root: Path,
|
||||
row: sqlite3.Row,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
raw_path = row["file_path"]
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has no source file path"
|
||||
)
|
||||
path, relative = _resolve_source_file(mirror_root, raw_path)
|
||||
stat = path.stat()
|
||||
observed_size = int(stat.st_size)
|
||||
expected_size = row["byte_count"]
|
||||
if expected_size is None or observed_size != int(expected_size):
|
||||
raise TushareSnapshotError(
|
||||
f"size mismatch for job {row['id']} ({relative}): "
|
||||
f"expected {expected_size}, observed {observed_size}"
|
||||
)
|
||||
|
||||
expected_sha = str(row["sha256"] or "").lower()
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_sha):
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has no valid SHA-256"
|
||||
)
|
||||
observed_sha = _sha256_file(path)
|
||||
if observed_sha != expected_sha:
|
||||
raise TushareSnapshotError(
|
||||
f"SHA-256 mismatch for job {row['id']} ({relative})"
|
||||
)
|
||||
|
||||
expected_rows = row["row_count"]
|
||||
observed_rows = _parquet_row_count(path)
|
||||
if expected_rows is None or observed_rows != int(expected_rows):
|
||||
raise TushareSnapshotError(
|
||||
f"row-count mismatch for job {row['id']} ({relative}): "
|
||||
f"expected {expected_rows}, observed {observed_rows}"
|
||||
)
|
||||
return relative, {
|
||||
"row_count": observed_rows,
|
||||
"sha256": observed_sha,
|
||||
"size_bytes": observed_size,
|
||||
}
|
||||
|
||||
|
||||
def _relative_job_path(mirror_root: Path, row: sqlite3.Row) -> str:
|
||||
raw_path = row["file_path"]
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has no source file path"
|
||||
)
|
||||
_, relative = _resolve_source_file(mirror_root, raw_path)
|
||||
return relative
|
||||
|
||||
|
||||
def _expected_job_metadata(row: sqlite3.Row) -> tuple[int, int, str]:
|
||||
try:
|
||||
row_count = int(row["row_count"])
|
||||
byte_count = int(row["byte_count"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has incomplete count metadata"
|
||||
) from exc
|
||||
if row_count < 0 or byte_count < 0:
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has negative count metadata"
|
||||
)
|
||||
source_sha = str(row["sha256"] or "").lower()
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", source_sha):
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has no valid SHA-256"
|
||||
)
|
||||
return row_count, byte_count, source_sha
|
||||
|
||||
|
||||
def _build_table_summaries(
|
||||
jobs: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
grouped: dict[str, list[Mapping[str, Any]]] = defaultdict(list)
|
||||
for job in jobs:
|
||||
grouped[str(job["api_name"])].append(job)
|
||||
summaries: dict[str, dict[str, Any]] = {}
|
||||
for api_name in sorted(grouped):
|
||||
rows = grouped[api_name]
|
||||
partitions = sorted(str(row["partition_key"]) for row in rows)
|
||||
summaries[api_name] = {
|
||||
"bytes": sum(int(row["byte_count"]) for row in rows),
|
||||
"first_partition": partitions[0],
|
||||
"last_partition": partitions[-1],
|
||||
"partitions": len(rows),
|
||||
"rows": sum(int(row["row_count"]) for row in rows),
|
||||
"shadowed_completed_jobs": sum(
|
||||
int(row["shadowed_completed_count"]) for row in rows
|
||||
),
|
||||
}
|
||||
return summaries
|
||||
|
||||
|
||||
def _request_sha256(row: sqlite3.Row) -> str:
|
||||
raw_params = row["params_json"]
|
||||
if not isinstance(raw_params, str):
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has no params_json provenance"
|
||||
)
|
||||
try:
|
||||
params = json.loads(raw_params)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TushareSnapshotError(
|
||||
f"completed job {row['id']} has invalid params_json provenance"
|
||||
) from exc
|
||||
request = {
|
||||
"api_name": str(row["api_name"]),
|
||||
"partition_key": str(row["partition_key"]),
|
||||
"fields": str(row["fields"] or ""),
|
||||
"params": params,
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(request)).hexdigest()
|
||||
|
||||
|
||||
def _latest_completed_ids(
|
||||
state_path: Path,
|
||||
) -> dict[tuple[str, str], int]:
|
||||
connection = sqlite3.connect(
|
||||
state_path.as_uri() + "?mode=ro",
|
||||
uri=True,
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT api_name, partition_key, MAX(id)
|
||||
FROM jobs
|
||||
WHERE status = 'completed'
|
||||
GROUP BY api_name, partition_key
|
||||
"""
|
||||
)
|
||||
return {
|
||||
(str(api_name), str(partition_key)): int(job_id)
|
||||
for api_name, partition_key, job_id in rows
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def create_snapshot(
|
||||
mirror_root: str | Path,
|
||||
*,
|
||||
start: str | date,
|
||||
end: str | date,
|
||||
apis: Sequence[str] = DEFAULT_APIS,
|
||||
index_codes: Sequence[str] = DEFAULT_INDEX_CODES,
|
||||
verify: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Read and verify one deterministic Tushare mirror selection."""
|
||||
|
||||
root = Path(mirror_root).expanduser().resolve(strict=True)
|
||||
state_path = root / "data" / "state.sqlite3"
|
||||
if not state_path.is_file():
|
||||
raise TushareSnapshotError(
|
||||
"mirror is missing its data/state.sqlite3 catalog"
|
||||
)
|
||||
|
||||
start_date = (
|
||||
start if isinstance(start, date) else date.fromisoformat(str(start))
|
||||
)
|
||||
end_date = end if isinstance(end, date) else date.fromisoformat(str(end))
|
||||
if start_date > end_date:
|
||||
raise ValueError("start must be on or before end")
|
||||
|
||||
requested_apis = _validate_apis(apis)
|
||||
requested_codes = tuple(
|
||||
dict.fromkeys(_normalize_index_code(code) for code in index_codes)
|
||||
)
|
||||
if (
|
||||
{"index_daily", "index_weight"} & set(requested_apis)
|
||||
and not requested_codes
|
||||
):
|
||||
raise ValueError("index APIs require at least one --index-codes value")
|
||||
|
||||
months = _month_keys(start_date, end_date)
|
||||
years = list(range(start_date.year, end_date.year + 1))
|
||||
index_weight_years = (
|
||||
[start_date.year - 1, *years]
|
||||
if "index_weight" in requested_apis
|
||||
else years
|
||||
)
|
||||
month_set = set(months)
|
||||
year_set = set(years)
|
||||
index_weight_year_set = set(index_weight_years)
|
||||
code_set = set(requested_codes)
|
||||
api_set = set(requested_apis)
|
||||
|
||||
identity_before = _state_file_identity(state_path)
|
||||
wal_before = _wal_identity(state_path)
|
||||
read_started_at = _utc_now()
|
||||
connection = sqlite3.connect(
|
||||
state_path.as_uri() + "?mode=ro",
|
||||
uri=True,
|
||||
timeout=30,
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
connection.execute("BEGIN")
|
||||
required_columns = {
|
||||
"id",
|
||||
"api_name",
|
||||
"partition_key",
|
||||
"status",
|
||||
"row_count",
|
||||
"byte_count",
|
||||
"sha256",
|
||||
"file_path",
|
||||
"completed_at",
|
||||
"fields",
|
||||
"params_json",
|
||||
}
|
||||
available_columns = {
|
||||
str(row["name"])
|
||||
for row in connection.execute("PRAGMA table_info(jobs)")
|
||||
}
|
||||
missing_columns = required_columns - available_columns
|
||||
if missing_columns:
|
||||
raise TushareSnapshotError(
|
||||
"jobs table is missing required columns: "
|
||||
+ ", ".join(sorted(missing_columns))
|
||||
)
|
||||
|
||||
sqlite_identity = {
|
||||
"data_version": int(
|
||||
connection.execute("PRAGMA data_version").fetchone()[0]
|
||||
),
|
||||
"journal_mode": str(
|
||||
connection.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
),
|
||||
"page_count": int(
|
||||
connection.execute("PRAGMA page_count").fetchone()[0]
|
||||
),
|
||||
"page_size": int(
|
||||
connection.execute("PRAGMA page_size").fetchone()[0]
|
||||
),
|
||||
"schema_version": int(
|
||||
connection.execute("PRAGMA schema_version").fetchone()[0]
|
||||
),
|
||||
"user_version": int(
|
||||
connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
),
|
||||
}
|
||||
|
||||
scoped_completed: list[sqlite3.Row] = []
|
||||
for api_name in requested_apis:
|
||||
api_years = (
|
||||
index_weight_years
|
||||
if api_name == "index_weight"
|
||||
else years
|
||||
)
|
||||
rows = _candidate_query(
|
||||
connection,
|
||||
api_name,
|
||||
months=months,
|
||||
years=api_years,
|
||||
index_codes=requested_codes,
|
||||
)
|
||||
scoped_completed.extend(
|
||||
row
|
||||
for row in rows
|
||||
if _scope_reason(
|
||||
str(row["api_name"]),
|
||||
str(row["partition_key"]),
|
||||
apis=api_set,
|
||||
months=month_set,
|
||||
years=year_set,
|
||||
index_weight_years=index_weight_year_set,
|
||||
index_codes=code_set,
|
||||
)[0]
|
||||
)
|
||||
|
||||
live_rows = list(
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT id, api_name, partition_key, status
|
||||
FROM jobs
|
||||
WHERE status IN ('running', 'deferred')
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
read_finished_at = _utc_now()
|
||||
identity_after = _state_file_identity(state_path)
|
||||
wal_after = _wal_identity(state_path)
|
||||
|
||||
excluded_live_jobs: list[dict[str, Any]] = []
|
||||
blocking_live_jobs: list[sqlite3.Row] = []
|
||||
for row in live_rows:
|
||||
in_scope, reason = _scope_reason(
|
||||
str(row["api_name"]),
|
||||
str(row["partition_key"]),
|
||||
apis=api_set,
|
||||
months=month_set,
|
||||
years=year_set,
|
||||
index_weight_years=index_weight_year_set,
|
||||
index_codes=code_set,
|
||||
)
|
||||
if in_scope and str(row["status"]) in BLOCKING_LIVE_STATUSES:
|
||||
blocking_live_jobs.append(row)
|
||||
else:
|
||||
excluded_live_jobs.append(
|
||||
{
|
||||
"api_name": str(row["api_name"]),
|
||||
"id": int(row["id"]),
|
||||
"partition_key": str(row["partition_key"]),
|
||||
"reason": reason,
|
||||
"status": str(row["status"]),
|
||||
}
|
||||
)
|
||||
if blocking_live_jobs:
|
||||
details = ", ".join(
|
||||
f"{row['api_name']}:{row['partition_key']}#{row['id']}"
|
||||
f"[{row['status']}]"
|
||||
for row in blocking_live_jobs[:12]
|
||||
)
|
||||
raise TushareSnapshotError(
|
||||
"requested scope contains running/deferred jobs; retry after they "
|
||||
f"reach a terminal state: {details}"
|
||||
)
|
||||
|
||||
grouped: dict[tuple[str, str], list[sqlite3.Row]] = defaultdict(list)
|
||||
for row in scoped_completed:
|
||||
grouped[(str(row["api_name"]), str(row["partition_key"]))].append(row)
|
||||
latest_rows: list[sqlite3.Row] = []
|
||||
shadow_counts: dict[tuple[str, str], int] = {}
|
||||
for key, rows in grouped.items():
|
||||
ordered = sorted(rows, key=lambda row: int(row["id"]))
|
||||
latest_rows.append(ordered[-1])
|
||||
shadow_counts[key] = len(ordered) - 1
|
||||
latest_rows.sort(
|
||||
key=lambda row: (
|
||||
str(row["api_name"]),
|
||||
str(row["partition_key"]),
|
||||
int(row["id"]),
|
||||
)
|
||||
)
|
||||
|
||||
_assert_scope_complete(
|
||||
latest_rows,
|
||||
apis=requested_apis,
|
||||
months=months,
|
||||
years=years,
|
||||
index_weight_years=index_weight_years,
|
||||
index_codes=requested_codes,
|
||||
)
|
||||
|
||||
selected_jobs: list[dict[str, Any]] = []
|
||||
for row in latest_rows:
|
||||
key = (str(row["api_name"]), str(row["partition_key"]))
|
||||
row_count, byte_count, expected_sha = _expected_job_metadata(row)
|
||||
if verify:
|
||||
relative_path, observed = _verify_job_file(root, row)
|
||||
else:
|
||||
relative_path = _relative_job_path(root, row)
|
||||
observed = None
|
||||
job: dict[str, Any] = {
|
||||
"api_name": key[0],
|
||||
"byte_count": byte_count,
|
||||
"completed_at": row["completed_at"],
|
||||
"id": int(row["id"]),
|
||||
"partition_key": key[1],
|
||||
"path": relative_path,
|
||||
"row_count": row_count,
|
||||
"request_sha256": _request_sha256(row),
|
||||
"sha256": expected_sha,
|
||||
"shadowed_completed_count": shadow_counts[key],
|
||||
"verified": verify,
|
||||
}
|
||||
if observed is not None:
|
||||
job["observed"] = observed
|
||||
selected_jobs.append(job)
|
||||
|
||||
latest_ids_after_verification = _latest_completed_ids(state_path)
|
||||
stale_jobs = [
|
||||
{
|
||||
"api_name": job["api_name"],
|
||||
"partition_key": job["partition_key"],
|
||||
"selected_job_id": job["id"],
|
||||
"latest_job_id": latest_ids_after_verification.get(
|
||||
(job["api_name"], job["partition_key"])
|
||||
),
|
||||
}
|
||||
for job in selected_jobs
|
||||
if latest_ids_after_verification.get(
|
||||
(job["api_name"], job["partition_key"])
|
||||
)
|
||||
!= job["id"]
|
||||
]
|
||||
if stale_jobs:
|
||||
raise TushareSnapshotError(
|
||||
"selected jobs changed during file verification: "
|
||||
f"{stale_jobs[:12]}"
|
||||
)
|
||||
if verify:
|
||||
for row, job in zip(latest_rows, selected_jobs):
|
||||
relative_path, observed = _verify_job_file(root, row)
|
||||
if (
|
||||
relative_path != job["path"]
|
||||
or observed != job.get("observed")
|
||||
):
|
||||
raise TushareSnapshotError(
|
||||
"selected source bytes changed during double verification"
|
||||
)
|
||||
identity_after_verification = _state_file_identity(state_path)
|
||||
wal_after_verification = _wal_identity(state_path)
|
||||
|
||||
selection_identity = [
|
||||
{
|
||||
key: value
|
||||
for key, value in job.items()
|
||||
if key not in {"observed", "verified"}
|
||||
}
|
||||
for job in selected_jobs
|
||||
]
|
||||
selection_sha = hashlib.sha256(
|
||||
_canonical_json({"selected_jobs": selection_identity})
|
||||
).hexdigest()
|
||||
changed_while_reading = (
|
||||
identity_before != identity_after or wal_before != wal_after
|
||||
)
|
||||
status_counts = Counter(str(row["status"]) for row in live_rows)
|
||||
manifest: dict[str, Any] = {
|
||||
"as_of": read_started_at,
|
||||
"excluded_live_jobs": excluded_live_jobs,
|
||||
"format": "quant-os.tushare-scoped-snapshot/v2",
|
||||
"hash_algorithms": {
|
||||
"canonical_json": (
|
||||
"UTF-8 JSON; keys sorted; separators ',' and ':'; "
|
||||
"ensure_ascii=false; allow_nan=false"
|
||||
),
|
||||
"manifest_sha256": (
|
||||
"SHA-256 of canonical_json(the complete manifest excluding "
|
||||
"the manifest_sha256 member)"
|
||||
),
|
||||
"request_sha256": (
|
||||
"SHA-256 of canonical_json({api_name, partition_key, fields, "
|
||||
"params}), with params_json parsed as JSON and without "
|
||||
"embedding it in this manifest"
|
||||
),
|
||||
"selection_sha256": (
|
||||
"SHA-256 of canonical_json({selected_jobs: [...]}), excluding "
|
||||
"each job's observed and verified members so verify/no-verify "
|
||||
"runs over the same SQLite selection have the same identity"
|
||||
),
|
||||
},
|
||||
"mirror": {
|
||||
"label": root.name,
|
||||
"state_db": {
|
||||
"changed_while_reading": changed_while_reading,
|
||||
"changed_through_file_verification": (
|
||||
identity_before != identity_after_verification
|
||||
or wal_before != wal_after_verification
|
||||
),
|
||||
"file_after": identity_after,
|
||||
"file_after_verification": identity_after_verification,
|
||||
"file_before": identity_before,
|
||||
"read_finished_at": read_finished_at,
|
||||
"read_started_at": read_started_at,
|
||||
"sqlite_snapshot": sqlite_identity,
|
||||
"wal_after": wal_after,
|
||||
"wal_after_verification": wal_after_verification,
|
||||
"wal_before": wal_before,
|
||||
},
|
||||
},
|
||||
"scope": {
|
||||
"apis": list(requested_apis),
|
||||
"end": end_date.isoformat(),
|
||||
"index_codes": list(requested_codes),
|
||||
"partition_policy": (
|
||||
"calendar partitions overlapping [start,end]; timeless "
|
||||
"stock_basic partitions; exact requested index codes; "
|
||||
"index_weight also includes start.year-1 as the pre-start "
|
||||
"point-in-time membership anchor"
|
||||
),
|
||||
"index_weight_anchor_year": (
|
||||
start_date.year - 1
|
||||
if "index_weight" in requested_apis
|
||||
else None
|
||||
),
|
||||
"start": start_date.isoformat(),
|
||||
},
|
||||
"selected_jobs": selected_jobs,
|
||||
"selection_sha256": selection_sha,
|
||||
"summary": {
|
||||
"excluded_live_job_status_counts": dict(sorted(status_counts.items())),
|
||||
"excluded_live_jobs": len(excluded_live_jobs),
|
||||
"selected_bytes": sum(job["byte_count"] for job in selected_jobs),
|
||||
"selected_jobs": len(selected_jobs),
|
||||
"selected_rows": sum(job["row_count"] for job in selected_jobs),
|
||||
"shadowed_completed_jobs": sum(
|
||||
job["shadowed_completed_count"] for job in selected_jobs
|
||||
),
|
||||
"tables": _build_table_summaries(selected_jobs),
|
||||
},
|
||||
"tool": {
|
||||
"name": "tools/tushare_snapshot.py",
|
||||
"version": TOOL_VERSION,
|
||||
},
|
||||
"verification": {
|
||||
"mode": "size+sha256+parquet_row_count" if verify else "not_performed",
|
||||
"verified_jobs": len(selected_jobs) if verify else 0,
|
||||
"double_verified_jobs": len(selected_jobs) if verify else 0,
|
||||
"selected_jobs_still_latest_after_verification": True,
|
||||
},
|
||||
}
|
||||
manifest["manifest_sha256"] = hashlib.sha256(
|
||||
_canonical_json(manifest)
|
||||
).hexdigest()
|
||||
return manifest
|
||||
|
||||
|
||||
def write_manifest(path: str | Path, manifest: Mapping[str, Any]) -> Path:
|
||||
destination = Path(path).expanduser().resolve()
|
||||
if destination.exists() and destination.is_dir():
|
||||
raise ValueError(f"output points to a directory: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = (
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=destination.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, destination)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
return destination
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--mirror-root", required=True)
|
||||
parser.add_argument("--start", required=True)
|
||||
parser.add_argument("--end", required=True)
|
||||
parser.add_argument(
|
||||
"--apis",
|
||||
nargs="+",
|
||||
default=list(DEFAULT_APIS),
|
||||
help="space- or comma-separated API names",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index-codes",
|
||||
nargs="+",
|
||||
default=list(DEFAULT_INDEX_CODES),
|
||||
help="space- or comma-separated Tushare index codes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="verify file size, SHA-256 and Parquet row count (default: true)",
|
||||
)
|
||||
parser.add_argument("--output", "--output-json", dest="output", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def _main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
manifest = create_snapshot(
|
||||
args.mirror_root,
|
||||
start=args.start,
|
||||
end=args.end,
|
||||
apis=_parse_values(args.apis),
|
||||
index_codes=_parse_values(args.index_codes),
|
||||
verify=args.verify,
|
||||
)
|
||||
output = write_manifest(args.output, manifest)
|
||||
except (OSError, ValueError, sqlite3.Error, TushareSnapshotError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"manifest_sha256": manifest["manifest_sha256"],
|
||||
"output": str(output),
|
||||
"selected_jobs": manifest["summary"]["selected_jobs"],
|
||||
"verified_jobs": manifest["verification"]["verified_jobs"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
Reference in New Issue
Block a user