1"""Bounded historical backfill from `period=full&ticker_range=<letter>` archives → base files per (symbol, year).23Streams one vendor file (one ticker, full history) at a time so memory stays flat. Dates that already have a live4delta file are excluded (they are compacted later) so the no-duplicate invariant holds. Resumable: progress5checkpoint `job:<id>/done_symbols`; a restarted job skips symbols already published.6"""7from __future__ import annotations89from typing import Any1011import pyarrow as pa12import pyarrow.compute as pc1314from findata_core.db import connection, transaction15from findata_ingest import queue16from findata_ingest.lake import Lake17from findata_ingest.tasks.common import DATASET_ASSET, live_delta_dates, record_anomalies, record_quality_report, record_raw_archive, upsert_coverage18from findata_market_firstrate import FirstRateMarketConnector19from findata_schema import Interval20from findata_schema.connector import FetchRequest212223def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:24 p = job.get("params") or {}25 asset = p.get("type", "stock")26 letter = p["ticker_range"]27 timeframe = p.get("timeframe", "1min")28 adjustment = p.get("adjustment", "UNADJUSTED")29 only = set(s.upper() for s in (p.get("symbols") or []))30 max_symbols = int(p.get("max_symbols") or 0)31 dataset_id = job.get("dataset_id") or DATASET_ASSET[asset]32 run_id = job.get("run_id")33 interval = Interval({"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"}[timeframe])34 conn_fr = FirstRateMarketConnector()3536 ck = queue.get_checkpoint(f"job:{job['id']}", "state") or {}37 done = set(ck.get("done_symbols") or [])38 raw_rel = ck.get("raw_path")39 if raw_rel and (lake.root / raw_rel).exists():40 from findata_schema.connector import FetchResult41 from datetime import datetime42 res = FetchResult(request=FetchRequest(dataset_id, {"type": asset, "period": "full", "ticker_range": letter, "timeframe": timeframe, "adjustment": adjustment}),43 path=lake.root / raw_rel, sha256=ck["sha256"], bytes=ck["bytes"], fetched_at=datetime.fromisoformat(ck["fetched_at"]),44 vendor_last_update=None)45 log.info("backfill.resume", done=len(done), archive=raw_rel)46 else:47 req = FetchRequest(dataset_id=dataset_id, params={"endpoint": "data_file", "type": asset, "period": "full", "ticker_range": letter,48 "timeframe": timeframe, "adjustment": adjustment})49 queue.progress(job["id"], {"stage": "download", "ticker_range": letter})50 res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id))51 raw_rel = str(res.path.relative_to(lake.root))52 with transaction() as conn:53 record_raw_archive(conn, "firstrate", dataset_id, req.params, raw_rel, res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id)54 queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,55 "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})56 log.info("backfill.downloaded", bytes=res.bytes, files=res.file_count)5758 with connection() as conn:59 delta_dates = live_delta_dates(conn, dataset_id)60 stats = {"symbols": 0, "rows": 0, "files": 0, "skipped_rows_in_delta": 0, "anomalies": 0}61 for batch in conn_fr.normalize(res, symbols=list(only) if only else None):62 if batch.symbol in done:63 continue64 if max_symbols and stats["symbols"] >= max_symbols:65 break66 rep = conn_fr.validate(batch)67 t = batch.table68 if delta_dates:69 mask = pc.invert(pc.is_in(t.column("session_date"), value_set=pa.array(sorted(delta_dates), pa.date32())))70 kept = t.filter(mask)71 stats["skipped_rows_in_delta"] += t.num_rows - kept.num_rows72 t = kept73 if t.num_rows == 0:74 done.add(batch.symbol)75 queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,76 "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})77 continue78 years = pc.year(t.column("session_date"))79 written = []80 for y in sorted(set(years.to_pylist())):81 ty = t.filter(pc.equal(years, y))82 written.append(lake.write_table(dataset_id, ty, "base", {"symbol": batch.symbol, "year": str(y)}))83 # retire previous base files of this symbol/year (re-backfill replaces)84 with connection() as conn:85 old = conn.execute("SELECT path FROM lake_files WHERE dataset_id=%s AND layout='base' AND version_removed IS NULL AND symbol_min=%s AND symbol_max=%s",86 (dataset_id, batch.symbol, batch.symbol)).fetchall()87 lake.publish(dataset_id, written, [o["path"] for o in old], run_id, note=f"backfill {asset} {letter} {batch.symbol}")88 with transaction() as conn:89 upsert_coverage(conn, dataset_id, t, interval)90 record_anomalies(conn, dataset_id, rep.anomalies, run_id, limit=500)91 stats["symbols"] += 192 stats["rows"] += t.num_rows93 stats["files"] += len(written)94 stats["anomalies"] += len(rep.anomalies)95 done.add(batch.symbol)96 queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,97 "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})98 queue.progress(job["id"], {"stage": "publish", **stats, "last_symbol": batch.symbol})99 with transaction() as conn:100 conn.execute("UPDATE raw_archives SET status='published' WHERE sha256=%s", (res.sha256,))101 record_quality_report(conn, dataset_id, run_id, stats["rows"], [])102 if not p.get("keep_raw", False) and p.get("delete_raw_after_publish", True):103 # full archives are re-downloadable; sha256 stays in raw_archives104 res.path.unlink(missing_ok=True)105 with transaction() as conn:106 conn.execute("UPDATE raw_archives SET status='deleted_after_publish', path=NULL WHERE sha256=%s", (res.sha256,))107 if interval == Interval.m1 and stats["rows"]:108 queue.enqueue("derive", dataset_id.replace(".1m.", ".5m."), {"source_dataset": dataset_id, "target_interval": "5m",109 "symbols": sorted(done)}, priority=45, dedupe_key=f"derive:{dataset_id}:5m:backfill:{job['id']}")110 log.info("backfill.done", **stats)111 return stats112