feat: operationalize local Tushare Qlib research

This commit is contained in:
2026-07-31 01:26:49 +08:00
parent f81c116bfe
commit 6275370afc
21 changed files with 4438 additions and 401 deletions
+391 -43
View File
@@ -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 仍为 latestlineage 工具按 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,
+281
View File
@@ -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())
+45
View File
@@ -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:
+977
View File
@@ -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())