保留连续性过滤,新增可审计补档工具与备份步骤;周末/节假日与真缺档分开处理,支持重复执行与部分失败续跑。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
203 lines
6.8 KiB
Python
203 lines
6.8 KiB
Python
"""Auditable recent-trading-day snapshot backfill helpers.
|
|
|
|
Planning and backup stay free of provider imports so feature boundary tests remain green.
|
|
The service layer supplies open trading dates from the live calendar and executes sync.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
|
|
MAX_RANGE_TRADING_DAYS = 15
|
|
MAX_RECENT_TRADING_DAYS = 60
|
|
DEFAULT_RECENT_TRADING_DAYS = 60
|
|
|
|
# Tables touched by a successful historical dashboard sync. User / token / model
|
|
# tables must never appear here.
|
|
SNAPSHOT_BACKFILL_WRITE_TABLES = frozenset(
|
|
{
|
|
"dashboard_snapshots",
|
|
"data_snapshots",
|
|
"sync_runs",
|
|
}
|
|
)
|
|
|
|
|
|
def clamp_recent_lookback(lookback: int) -> int:
|
|
value = int(lookback)
|
|
if value < 1:
|
|
raise ValueError("回补交易日数量至少为 1。")
|
|
if value > MAX_RECENT_TRADING_DAYS:
|
|
raise ValueError(f"单次最多回补最近 {MAX_RECENT_TRADING_DAYS} 个交易日。")
|
|
return value
|
|
|
|
|
|
def calendar_window_start(end_date: str, lookback: int) -> str:
|
|
"""Natural-day lower bound large enough to cover lookback open sessions."""
|
|
end = datetime.strptime(end_date, "%Y%m%d").date()
|
|
span = max(40, int(lookback * 2) + 20)
|
|
return (end - timedelta(days=span)).strftime("%Y%m%d")
|
|
|
|
|
|
def select_open_trade_dates(
|
|
calendar_rows: Iterable[dict[str, Any]],
|
|
end_date: str,
|
|
lookback: int,
|
|
) -> list[str]:
|
|
"""Pick the last ``lookback`` open SSE sessions on or before ``end_date``."""
|
|
lookback = clamp_recent_lookback(lookback)
|
|
end = normalize_compact_date(end_date)
|
|
open_dates = sorted(
|
|
{
|
|
normalize_compact_date(str(row.get("cal_date") or ""))
|
|
for row in calendar_rows
|
|
if int(row.get("is_open") or 0) == 1 and row.get("cal_date")
|
|
}
|
|
)
|
|
open_dates = [item for item in open_dates if item <= end]
|
|
if not open_dates:
|
|
raise ValueError("交易日历未返回可用交易日,请检查行情 Token。")
|
|
return open_dates[-lookback:]
|
|
|
|
|
|
def select_open_trade_dates_in_range(
|
|
calendar_rows: Iterable[dict[str, Any]],
|
|
start_date: str,
|
|
end_date: str,
|
|
*,
|
|
maximum: int = MAX_RANGE_TRADING_DAYS,
|
|
) -> tuple[list[str], list[str]]:
|
|
"""Return (open_dates, skipped_non_trading_days) inside an inclusive range."""
|
|
start = normalize_compact_date(start_date)
|
|
end = normalize_compact_date(end_date)
|
|
if start > end:
|
|
raise ValueError("开始日期不能晚于结束日期。")
|
|
open_set = {
|
|
normalize_compact_date(str(row.get("cal_date") or ""))
|
|
for row in calendar_rows
|
|
if int(row.get("is_open") or 0) == 1 and row.get("cal_date")
|
|
}
|
|
open_dates: list[str] = []
|
|
skipped: list[str] = []
|
|
cursor = datetime.strptime(start, "%Y%m%d").date()
|
|
last = datetime.strptime(end, "%Y%m%d").date()
|
|
while cursor <= last:
|
|
compact = cursor.strftime("%Y%m%d")
|
|
if compact in open_set:
|
|
open_dates.append(compact)
|
|
else:
|
|
skipped.append(compact)
|
|
cursor += timedelta(days=1)
|
|
if len(open_dates) > maximum:
|
|
raise ValueError(f"单次最多回补 {maximum} 个交易日。")
|
|
return open_dates, skipped
|
|
|
|
|
|
def classify_snapshot_coverage(
|
|
trade_dates: list[str],
|
|
existing_dates: Iterable[str],
|
|
) -> dict[str, Any]:
|
|
present_set = {
|
|
normalize_compact_date(item)
|
|
for item in existing_dates
|
|
if item
|
|
}
|
|
present = [item for item in trade_dates if item in present_set]
|
|
missing = [item for item in trade_dates if item not in present_set]
|
|
return {
|
|
"trade_dates": list(trade_dates),
|
|
"present": present,
|
|
"missing": missing,
|
|
"present_count": len(present),
|
|
"missing_count": len(missing),
|
|
}
|
|
|
|
|
|
def create_sqlite_backup(
|
|
source_path: Path,
|
|
backup_dir: Path,
|
|
*,
|
|
label: str = "pre-backfill",
|
|
stamped_at: datetime | None = None,
|
|
) -> Path:
|
|
"""Create a timestamped SQLite backup via the native backup API."""
|
|
source = Path(source_path)
|
|
if not source.exists():
|
|
raise FileNotFoundError(f"数据库不存在:{source}")
|
|
stamp = (stamped_at or datetime.now().astimezone()).strftime("%Y%m%d-%H%M%S")
|
|
safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in label).strip("-") or "backup"
|
|
backup_dir = Path(backup_dir)
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
target = backup_dir / f"review-{safe_label}-{stamp}.db"
|
|
source_conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
|
|
try:
|
|
target_conn = sqlite3.connect(target)
|
|
try:
|
|
source_conn.backup(target_conn)
|
|
target_conn.commit()
|
|
finally:
|
|
target_conn.close()
|
|
finally:
|
|
source_conn.close()
|
|
return target
|
|
|
|
|
|
def display_date(compact: str) -> str:
|
|
value = normalize_compact_date(compact)
|
|
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
|
|
|
|
|
|
def normalize_compact_date(value: str) -> str:
|
|
compact = str(value or "").replace("-", "").strip()
|
|
if len(compact) != 8 or not compact.isdigit():
|
|
raise ValueError("日期格式应为 YYYY-MM-DD。")
|
|
datetime.strptime(compact, "%Y%m%d")
|
|
return compact
|
|
|
|
|
|
def build_backfill_audit(
|
|
*,
|
|
mode: str,
|
|
end_date: str,
|
|
lookback: int | None,
|
|
coverage: dict[str, Any],
|
|
skipped_non_trading_days: list[str] | None = None,
|
|
backup_path: str | None = None,
|
|
dry_run: bool = False,
|
|
results: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
results = list(results or [])
|
|
succeeded = [row for row in results if row.get("status") == "success"]
|
|
skipped = [row for row in results if row.get("status") == "skipped"]
|
|
failed = [row for row in results if row.get("status") == "failed"]
|
|
return {
|
|
"ok": not failed,
|
|
"mode": mode,
|
|
"dry_run": dry_run,
|
|
"end_date": display_date(end_date),
|
|
"lookback": lookback,
|
|
"backup_path": backup_path,
|
|
"write_tables": sorted(SNAPSHOT_BACKFILL_WRITE_TABLES),
|
|
"trade_dates": [display_date(item) for item in coverage.get("trade_dates") or []],
|
|
"present": [display_date(item) for item in coverage.get("present") or []],
|
|
"missing": [display_date(item) for item in coverage.get("missing") or []],
|
|
"skipped_non_trading_days": [
|
|
display_date(item) for item in (skipped_non_trading_days or [])
|
|
],
|
|
"present_count": int(coverage.get("present_count") or 0),
|
|
"missing_count": int(coverage.get("missing_count") or 0),
|
|
"results": results,
|
|
"succeeded_count": len(succeeded),
|
|
"skipped_count": len(skipped),
|
|
"failed_count": len(failed),
|
|
"created_dates": [
|
|
str(row.get("trade_date") or "")
|
|
for row in succeeded
|
|
if row.get("action") == "created"
|
|
],
|
|
}
|