full FirstRate catalog: cold lake root (external SSD), 57 new datasets, index/contracts/options parsers, options task, bulk workers, complete download plan (798 jobs), extended scheduler
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
24 changed files +945 −99
modified
connectors/market/src/findata_market_firstrate/__init__.py
+8 −2
@@ -1,3 +1,9 @@ | ||
| 1 | −from findata_market_firstrate.archive import iter_archive_files, parse_bar_file, ticker_from_filename # noqa: F401 | |
| 2 | −from findata_market_firstrate.client import FRDClient, FRDError # noqa: F401 | |
| 1 | +from findata_market_firstrate.archive import ( # noqa: F401 | |
| 2 | + iter_archive_files, | |
| 3 | + parse_bar_file, | |
| 4 | + parse_options_file, | |
| 5 | + ticker_from_filename, | |
| 6 | +) | |
| 7 | +from findata_market_firstrate.catalog import VENDOR_TYPES, build_full_plan, dataset_id_for # noqa: F401 | |
| 8 | +from findata_market_firstrate.client import FRDClient, FRDError, FRDNoData # noqa: F401 | |
| 3 | 9 | from findata_market_firstrate.connector import FirstRateMarketConnector # noqa: F401 |
modified
connectors/market/src/findata_market_firstrate/archive.py
+51 −4
@@ -22,8 +22,14 @@ VENDOR_ADJ = {"UNADJUSTED": Adjustment.raw, "adj_split": Adjustment.split, "adj_ | ||
| 22 | 22 | _TICKER_RE = re.compile(r"^([A-Za-z0-9.\-]+?)(?:[_-](?:full|day|week|month|1min|5min|30min|1hour|1day|UNADJUSTED|adj_split|adj_splitdiv|contin.*))*\.(txt|csv)$", re.IGNORECASE) |
| 23 | 23 | |
| 24 | 24 | |
| 25 | −def ticker_from_filename(name: str) -> str: | |
| 25 | +_TF_SUFFIX_RE = re.compile(r"_(1min|5min|30min|1hour|1day)$", re.I) | |
| 26 | + | |
| 27 | + | |
| 28 | +def ticker_from_filename(name: str, contract: bool = False) -> str: | |
| 26 | 29 | base = Path(name).name |
| 30 | + if contract: # individual futures contracts: 'A6_F25_1day.txt' -> 'A6_F25' | |
| 31 | + stem = Path(base).stem | |
| 32 | + return _TF_SUFFIX_RE.sub("", stem).upper() | |
| 27 | 33 | m = _TICKER_RE.match(base) |
| 28 | 34 | if m: |
| 29 | 35 | return m.group(1).upper() |
@@ -71,17 +77,25 @@ def parse_bar_file(data: bytes, symbol: str, asset_class: AssetClass, interval: | ||
| 71 | 77 | df = df.with_columns((pl.col("d") + " " + pl.col("t")).alias("dt_raw")).drop("d", "t") |
| 72 | 78 | fmt = "%Y%m%d %H:%M:%S" |
| 73 | 79 | else: |
| 74 | − names = ["dt_raw", "open", "high", "low", "close", "volume"] + (["open_interest"] if ncols == 7 else []) | |
| 75 | − if ncols not in (6, 7): | |
| 80 | + if ncols == 5: # indices: no volume column | |
| 81 | + names = ["dt_raw", "open", "high", "low", "close"] | |
| 82 | + else: | |
| 83 | + names = ["dt_raw", "open", "high", "low", "close", "volume"] + (["open_interest"] if ncols == 7 else []) | |
| 84 | + if ncols not in (5, 6, 7): | |
| 76 | 85 | raise ValueError(f"unexpected column count {ncols} in {symbol}") |
| 77 | 86 | df = pl.read_csv(buf, has_header=False, new_columns=names, infer_schema_length=0) |
| 87 | + if ncols == 5: | |
| 88 | + df = df.with_columns(pl.lit("0").alias("volume")) | |
| 78 | 89 | fmt = "%Y-%m-%d %H:%M:%S" if interval != Interval.d1 else None |
| 79 | 90 | for c in ("open", "high", "low", "close", "volume"): |
| 80 | 91 | df = df.with_columns(pl.col(c).str.strip_chars().cast(pl.Float64, strict=False)) |
| 81 | 92 | if interval == Interval.d1: |
| 82 | 93 | local = pl.col("dt_raw").str.strip_chars().str.slice(0, 10).str.to_date("%Y-%m-%d", strict=False).cast(pl.Datetime("us")) |
| 83 | 94 | else: |
| 84 | − local = pl.col("dt_raw").str.strip_chars().str.to_datetime(fmt, strict=False, time_unit="us") | |
| 95 | + local = pl.coalesce( | |
| 96 | + pl.col("dt_raw").str.strip_chars().str.to_datetime(fmt, strict=False, time_unit="us"), | |
| 97 | + pl.col("dt_raw").str.strip_chars().str.to_datetime("%Y-%m-%d %H:%M", strict=False, time_unit="us"), | |
| 98 | + ) | |
| 85 | 99 | df = df.with_columns(local.alias("local_naive")) |
| 86 | 100 | df = df.filter(pl.col("local_naive").is_not_null() & pl.col("open").is_not_null() & pl.col("close").is_not_null()) |
| 87 | 101 | df = df.with_columns( |
@@ -128,3 +142,36 @@ def parse_meta_pairs(data: bytes) -> list[tuple[str, float]]: | ||
| 128 | 142 | except ValueError: |
| 129 | 143 | continue |
| 130 | 144 | return out |
| 145 | + | |
| 146 | + | |
| 147 | +def parse_options_file(data: bytes, underlying: str, source_id: str, ingested_at: datetime) -> tuple[pa.Table, int]: | |
| 148 | + """Vendor option-chain CSV (16 columns, no header): Trade Date, Strike, Expiry, Call/Put, Last, Bid, Ask, Bid IV, | |
| 149 | + Ask IV, Open Interest, Volume, Delta, Gamma, Vega, Theta, Rho → OPTIONS_SCHEMA.""" | |
| 150 | + from findata_schema import OPTIONS_SCHEMA | |
| 151 | + text = data.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n") | |
| 152 | + lines = [ln for ln in text.split("\n") if ln.strip()] | |
| 153 | + if lines and lines[0].lower().startswith(("trade", "date")): | |
| 154 | + lines = lines[1:] | |
| 155 | + raw_rows = len(lines) | |
| 156 | + empty = pa.table({f.name: pa.array([], type=f.type) for f in OPTIONS_SCHEMA}, schema=OPTIONS_SCHEMA) | |
| 157 | + if raw_rows == 0: | |
| 158 | + return empty, 0 | |
| 159 | + names = ["trade_date", "strike", "expiry", "call_put", "last_price", "bid", "ask", "bid_iv", "ask_iv", "open_interest", | |
| 160 | + "volume", "delta", "gamma", "vega", "theta", "rho"] | |
| 161 | + ncols = len(lines[0].split(",")) | |
| 162 | + if ncols != 16: | |
| 163 | + raise ValueError(f"unexpected option-chain column count {ncols} for {underlying}") | |
| 164 | + df = pl.read_csv("\n".join(lines).encode(), has_header=False, new_columns=names, infer_schema_length=0) | |
| 165 | + num = [c for c in names if c not in ("trade_date", "expiry", "call_put")] | |
| 166 | + df = df.with_columns([pl.col(c).str.strip_chars().cast(pl.Float64, strict=False) for c in num]) | |
| 167 | + df = df.with_columns( | |
| 168 | + pl.col("trade_date").str.strip_chars().str.slice(0, 10).str.to_date("%Y-%m-%d", strict=False), | |
| 169 | + pl.col("expiry").str.strip_chars().str.slice(0, 10).str.to_date("%Y-%m-%d", strict=False), | |
| 170 | + pl.col("call_put").str.strip_chars().str.to_lowercase(), | |
| 171 | + ).filter(pl.col("trade_date").is_not_null() & pl.col("expiry").is_not_null() & pl.col("strike").is_not_null()) | |
| 172 | + n = df.height | |
| 173 | + out = df.with_columns( | |
| 174 | + pl.lit(underlying).alias("underlying"), pl.lit(source_id).alias("source_id"), | |
| 175 | + pl.lit(ingested_at).cast(pl.Datetime("us", "UTC")).alias("ingested_at"), | |
| 176 | + ).select(OPTIONS_SCHEMA.names) | |
| 177 | + return out.to_arrow().cast(OPTIONS_SCHEMA), raw_rows | |
added
connectors/market/src/findata_market_firstrate/catalog.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +"""Vendor catalog → FinData dataset ids, and the complete download plan (every licensed archive).""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from datetime import date | |
| 5 | +from typing import Any | |
| 6 | + | |
| 7 | +VENDOR_TYPES = ("stock", "etf", "futures", "index", "fx", "crypto", "options") | |
| 8 | +TF_MAP = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"} | |
| 9 | +ADJ_MAP = {"UNADJUSTED": "raw", "adj_split": "split", "adj_splitdiv": "split_div", | |
| 10 | + "contin_UNadj": "continuous_raw", "contin_adj_ratio": "continuous_ratio", "contin_adj_absolute": "continuous_absolute"} | |
| 11 | +TIMEFRAMES = ["1min", "1day", "5min", "30min", "1hour"] | |
| 12 | +LETTERS = [chr(c) for c in range(ord("A"), ord("Z") + 1)] | |
| 13 | +OPTIONS_FIRST_YEAR = 2010 | |
| 14 | + | |
| 15 | + | |
| 16 | +def dataset_id_for(type_: str, timeframe: str, adjustment: str | None, contracts: bool = False) -> str: | |
| 17 | + tf = TF_MAP[timeframe] | |
| 18 | + if type_ == "options": | |
| 19 | + return "options.eod" | |
| 20 | + if type_ == "futures": | |
| 21 | + if contracts: | |
| 22 | + return f"market_bars.futures_contracts.{tf}.raw" | |
| 23 | + return f"market_bars.futures.{tf}.{ADJ_MAP[adjustment or 'contin_UNadj']}" | |
| 24 | + if type_ in ("index", "fx", "crypto"): | |
| 25 | + return f"market_bars.{type_}.{tf}.raw" | |
| 26 | + return f"market_bars.{type_}.{tf}.{ADJ_MAP[adjustment or 'UNADJUSTED']}" | |
| 27 | + | |
| 28 | + | |
| 29 | +def _adjs(type_: str, tf: str) -> list[str]: | |
| 30 | + if type_ in ("stock", "etf"): | |
| 31 | + return (["UNADJUSTED"] if tf in ("1min", "1day") else []) + ["adj_split", "adj_splitdiv"] | |
| 32 | + if type_ == "futures": | |
| 33 | + return ["contin_UNadj", "contin_adj_ratio", "contin_adj_absolute"] | |
| 34 | + return [None] | |
| 35 | + | |
| 36 | + | |
| 37 | +def build_full_plan(types: list[str] | None = None, include_options: bool = True, include_delisted: bool = True, | |
| 38 | + today: date | None = None) -> list[dict[str, Any]]: | |
| 39 | + """Return job specs {kind, dataset_id, params, priority, dedupe_key}. Hot datasets first.""" | |
| 40 | + today = today or date.today() | |
| 41 | + types = types or list(VENDOR_TYPES) | |
| 42 | + jobs: list[dict[str, Any]] = [] | |
| 43 | + | |
| 44 | + def add(kind: str, dsid: str, params: dict[str, Any], prio: int, key: str) -> None: | |
| 45 | + jobs.append({"kind": kind, "dataset_id": dsid, "params": params, "priority": prio, "dedupe_key": key}) | |
| 46 | + | |
| 47 | + # priority bands: 10 hot raw stock/etf 1min, 14 raw 1day, 20 other asset classes, 30 delisted raw, 40 adjusted 1min, | |
| 48 | + # 45 adjusted 1day, 50 coarse timeframes, 60 options | |
| 49 | + for t in ("stock", "etf"): | |
| 50 | + if t not in types: | |
| 51 | + continue | |
| 52 | + for tf in TIMEFRAMES: | |
| 53 | + for adj in _adjs(t, tf): | |
| 54 | + if adj == "UNADJUSTED": | |
| 55 | + prio = 10 if tf == "1min" else 14 | |
| 56 | + elif tf == "1min": | |
| 57 | + prio = 40 | |
| 58 | + elif tf == "1day": | |
| 59 | + prio = 45 | |
| 60 | + else: | |
| 61 | + prio = 50 | |
| 62 | + prio += 0 if t == "stock" else 1 | |
| 63 | + dsid = dataset_id_for(t, tf, adj) | |
| 64 | + for L in LETTERS: | |
| 65 | + add("backfill", dsid, {"type": t, "ticker_range": L, "timeframe": tf, "adjustment": adj, "period": "full"}, prio, | |
| 66 | + f"backfill:{t}:{L}:{tf}:{adj}") | |
| 67 | + if include_delisted and "stock" in types: | |
| 68 | + for tf in TIMEFRAMES: | |
| 69 | + for adj in _adjs("stock", tf): | |
| 70 | + if adj == "UNADJUSTED" and tf != "1min": | |
| 71 | + continue # vendor: delisted UNADJUSTED only at 1min | |
| 72 | + dsid = dataset_id_for("stock", tf, adj) | |
| 73 | + prio = 30 if adj == "UNADJUSTED" else (42 if tf == "1min" else 52) | |
| 74 | + for arc in ("1", "2", "3", "4", "5"): | |
| 75 | + add("backfill", dsid, {"type": "stock", "endpoint": "delisted_data_file", "archive_number": arc, "timeframe": tf, "adjustment": adj}, | |
| 76 | + prio, f"delisted:{arc}:{tf}:{adj}") | |
| 77 | + add("backfill", dsid, {"type": "stock", "endpoint": "delisted_data_file", "update": "year", "timeframe": tf, "adjustment": adj}, | |
| 78 | + prio, f"delisted:year:{tf}:{adj}") | |
| 79 | + for t in ("index", "fx", "crypto"): | |
| 80 | + if t not in types: | |
| 81 | + continue | |
| 82 | + for tf in TIMEFRAMES: | |
| 83 | + add("backfill", dataset_id_for(t, tf, None), {"type": t, "timeframe": tf, "period": "full"}, 20 if tf in ("1min", "1day") else 50, | |
| 84 | + f"backfill:{t}:{tf}") | |
| 85 | + if "futures" in types: | |
| 86 | + for tf in TIMEFRAMES: | |
| 87 | + for adj in _adjs("futures", tf): | |
| 88 | + add("backfill", dataset_id_for("futures", tf, adj), {"type": "futures", "timeframe": tf, "adjustment": adj, "period": "full"}, | |
| 89 | + 22 if tf in ("1min", "1day") else 50, f"backfill:futures:{tf}:{adj}") | |
| 90 | + for cf in ("archive", "update"): | |
| 91 | + add("backfill", dataset_id_for("futures", tf, None, contracts=True), | |
| 92 | + {"type": "futures", "endpoint": "futures_contract", "contract_files": cf, "timeframe": tf, "contracts": True}, | |
| 93 | + 24 if tf in ("1min", "1day") else 50, f"contracts:{cf}:{tf}") | |
| 94 | + if include_options and "options" in types: | |
| 95 | + cy, cq = today.year, (today.month - 1) // 3 + 1 | |
| 96 | + for y in range(OPTIONS_FIRST_YEAR, cy + 1): | |
| 97 | + for q in range(1, 5): | |
| 98 | + if y == cy and q >= cq: | |
| 99 | + continue | |
| 100 | + add("options", "options.eod", {"year": y, "quarter": f"q{q}"}, 60, f"options:{y}:q{q}") | |
| 101 | + add("options", "options.eod", {"period": "month"}, 61, f"options:month:{cy}-{today.month:02d}") | |
| 102 | + jobs.sort(key=lambda j: (j["priority"], j["dedupe_key"])) | |
| 103 | + return jobs | |
modified
connectors/market/src/findata_market_firstrate/client.py
+10 −1
@@ -30,6 +30,10 @@ class FRDError(RuntimeError): | ||
| 30 | 30 | pass |
| 31 | 31 | |
| 32 | 32 | |
| 33 | +class FRDNoData(FRDError): | |
| 34 | + """The vendor answered with a tiny text body instead of an archive (e.g. 'no update this period').""" | |
| 35 | + | |
| 36 | + | |
| 33 | 37 | @dataclass |
| 34 | 38 | class Downloaded: |
| 35 | 39 | path: Path |
@@ -120,8 +124,11 @@ class FRDClient: | ||
| 120 | 124 | body = r.read()[:300] |
| 121 | 125 | raise FRDError(f"HTTP {r.status_code} downloading {endpoint}: {self._redact(body)}") |
| 122 | 126 | ctype = r.headers.get("content-type", "") |
| 123 | − if "text/html" in ctype: | |
| 127 | + clen = int(r.headers.get("content-length") or 0) | |
| 128 | + if "text/html" in ctype or (clen and clen < 512 and "zip" not in ctype): | |
| 124 | 129 | body = r.read()[:300] |
| 130 | + if clen and clen < 512: | |
| 131 | + raise FRDNoData(f"no archive available for {endpoint}: {self._redact(body.decode(errors='replace').strip())}") | |
| 125 | 132 | raise FRDError(f"HTML instead of archive from {endpoint}: {self._redact(body)}") |
| 126 | 133 | with open(part, "wb") as f: |
| 127 | 134 | for chunk in r.iter_bytes(CHUNK): |
@@ -134,6 +141,8 @@ class FRDClient: | ||
| 134 | 141 | except (httpx.TransportError, FRDError, OSError) as e: |
| 135 | 142 | last = e |
| 136 | 143 | part.unlink(missing_ok=True) |
| 144 | + if isinstance(e, FRDNoData): | |
| 145 | + raise | |
| 137 | 146 | if isinstance(e, FRDError) and "HTTP 4" in str(e): |
| 138 | 147 | raise FRDError(self._redact(e)) from None |
| 139 | 148 | time.sleep(min(120, 5 * 2 ** attempt) + random.uniform(0, 2)) |
modified
connectors/market/src/findata_market_firstrate/connector.py
+19 −8
@@ -23,13 +23,13 @@ from findata_schema.connector import ( | ||
| 23 | 23 | ) |
| 24 | 24 | |
| 25 | 25 | from findata_market_firstrate.archive import ( |
| 26 | − VENDOR_ADJ, | |
| 27 | 26 | VENDOR_TF, |
| 28 | 27 | iter_archive_files, |
| 29 | 28 | parse_bar_file, |
| 30 | 29 | parse_meta_pairs, |
| 31 | 30 | ticker_from_filename, |
| 32 | 31 | ) |
| 32 | +from findata_market_firstrate.catalog import ADJ_MAP | |
| 33 | 33 | from findata_market_firstrate.client import FRDClient |
| 34 | 34 | |
| 35 | 35 | SOURCE_ID = "firstrate" |
@@ -102,18 +102,20 @@ class FirstRateMarketConnector(Connector): | ||
| 102 | 102 | p = result.request.params |
| 103 | 103 | asset = AssetClass(p.get("type", "stock")) |
| 104 | 104 | interval = VENDOR_TF[p.get("timeframe", "1min")] |
| 105 | − adjustment = VENDOR_ADJ.get(p.get("adjustment", "UNADJUSTED")) | |
| 106 | − if adjustment is None: | |
| 107 | − from findata_schema import Adjustment | |
| 108 | − adjustment = Adjustment.continuous | |
| 105 | + adj_vendor = p.get("adjustment") or ("contin_UNadj" if asset == AssetClass.futures and not p.get("contracts") else "UNADJUSTED") | |
| 106 | + adjustment_value = ADJ_MAP.get(adj_vendor, "raw") | |
| 107 | + contracts = bool(p.get("contracts")) | |
| 109 | 108 | ingested_at = datetime.now(UTC) |
| 110 | 109 | only: set[str] | None = set(kwargs.get("symbols") or []) or None |
| 111 | 110 | tz = SOURCE_TZ.get(asset.value, "America/New_York") |
| 112 | 111 | for name, data in iter_archive_files(result.path): |
| 113 | − symbol = ticker_from_filename(name) | |
| 112 | + base = Path(name).name.lower() | |
| 113 | + if base.startswith("_") or "readme" in base or base.endswith((".csv", ".txt")) is False: | |
| 114 | + continue | |
| 115 | + symbol = ticker_from_filename(name, contract=contracts) | |
| 114 | 116 | if only and symbol not in only: |
| 115 | 117 | continue |
| 116 | − table, raw_rows = parse_bar_file(data, symbol, asset, interval, adjustment, SOURCE_ID, ingested_at, source_tz=tz) | |
| 118 | + table, raw_rows = parse_bar_file(data, symbol, asset, interval, _Adj(adjustment_value), SOURCE_ID, ingested_at, source_tz=tz) | |
| 117 | 119 | if table.num_rows == 0: |
| 118 | 120 | continue |
| 119 | 121 | sd = table.column("session_date").to_pylist() |
@@ -123,7 +125,8 @@ class FirstRateMarketConnector(Connector): | ||
| 123 | 125 | # ------------------------------------------------------------------ validate |
| 124 | 126 | def validate(self, batch: NormalizedBatch) -> ValidationReport: |
| 125 | 127 | interval = Interval(batch.table.column("interval")[0].as_py()) |
| 126 | − table, anomalies = validate_bars(batch.table, interval) | |
| 128 | + asset = batch.table.column("asset_class")[0].as_py() | |
| 129 | + table, anomalies = validate_bars(batch.table, interval, asset_class=asset) | |
| 127 | 130 | batch.table = table |
| 128 | 131 | flagged = sum(1 for f in table.column("quality_flags").to_pylist() if f) |
| 129 | 132 | errors = [a for a in anomalies if a["severity"] == "error"] |
@@ -170,6 +173,14 @@ class FirstRateMarketConnector(Connector): | ||
| 170 | 173 | return res, rows |
| 171 | 174 | |
| 172 | 175 | |
| 176 | +class _Adj(str): | |
| 177 | + """Adjustment label passthrough (dataset adjustment ids are richer than the Adjustment enum).""" | |
| 178 | + | |
| 179 | + @property | |
| 180 | + def value(self) -> str: # mimics Enum.value | |
| 181 | + return str(self) | |
| 182 | + | |
| 183 | + | |
| 173 | 184 | def _d(s: str | None) -> date | None: |
| 174 | 185 | if not s: |
| 175 | 186 | return None |
modified
infra/mld/findata.json
+172 −7
@@ -6,14 +6,175 @@ | ||
| 6 | 6 | "health_path": "/api/v1/health", |
| 7 | 7 | "dir": "~/apps/findata", |
| 8 | 8 | "extra_paths": [], |
| 9 | − "sync_excludes": [".git/", ".DS_Store", ".venv/", "apps/web/node_modules/", "apps/web/.next/", "apps/web/out/", "data/", "logs/", "backups/", ".env", "__pycache__/", ".pytest_cache/", ".ruff_cache/", "tests/_tmp/"], | |
| 10 | − "requires": { "runtimes": ["uv", "node", "pnpm", "pm2", "postgresql@17"], "ram_gb": 6, "disk_gb": 300, "ports": [8600] }, | |
| 9 | + "sync_excludes": [ | |
| 10 | + ".git/", | |
| 11 | + ".DS_Store", | |
| 12 | + ".venv/", | |
| 13 | + "apps/web/node_modules/", | |
| 14 | + "apps/web/.next/", | |
| 15 | + "apps/web/out/", | |
| 16 | + "data/", | |
| 17 | + "logs/", | |
| 18 | + "backups/", | |
| 19 | + ".env", | |
| 20 | + "__pycache__/", | |
| 21 | + ".pytest_cache/", | |
| 22 | + ".ruff_cache/", | |
| 23 | + "tests/_tmp/" | |
| 24 | + ], | |
| 25 | + "requires": { | |
| 26 | + "runtimes": [ | |
| 27 | + "uv", | |
| 28 | + "node", | |
| 29 | + "pnpm", | |
| 30 | + "pm2", | |
| 31 | + "postgresql@17" | |
| 32 | + ], | |
| 33 | + "ram_gb": 6, | |
| 34 | + "disk_gb": 1500, | |
| 35 | + "ports": [ | |
| 36 | + 8600 | |
| 37 | + ] | |
| 38 | + }, | |
| 11 | 39 | "ram_mb_observed": 900, |
| 12 | 40 | "size_mb": 20, |
| 13 | − "placement": { "pin": "M3U96a", "prefer": "M3U96a", "avoid": ["M1M32"], "reason": "Storage node chosen in docs/DISCOVERY.md: 1 TB Apple NVMe (508 GB free, SMART verified, 5 GB/s), 32 cores / 96 GB for DuckDB, Postgres 17 + Redis already local, wg1 10.67.0.10. Lake at ~/findata-lake (outside the app dir)." }, | |
| 41 | + "placement": { | |
| 42 | + "pin": "M3U96a", | |
| 43 | + "prefer": "M3U96a", | |
| 44 | + "avoid": [ | |
| 45 | + "M1M32" | |
| 46 | + ], | |
| 47 | + "reason": "Storage node chosen in docs/DISCOVERY.md: 1 TB Apple NVMe (508 GB free, SMART verified, 5 GB/s), 32 cores / 96 GB for DuckDB, Postgres 17 + Redis already local, wg1 10.67.0.10. Lake at ~/findata-lake (outside the app dir)." | |
| 48 | + }, | |
| 14 | 49 | "processes": [ |
| 15 | − { "name": "findata-api", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "uvicorn", "findata_api.main:app", "--host", "0.0.0.0", "--port", "8600", "--workers", "1"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "6G" }, | |
| 16 | − { "name": "findata-worker", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "findata_ingest.cli", "worker"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "24G" } | |
| 50 | + { | |
| 51 | + "name": "findata-api", | |
| 52 | + "manager": "pm2", | |
| 53 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 54 | + "args": [ | |
| 55 | + "-m", | |
| 56 | + "uvicorn", | |
| 57 | + "findata_api.main:app", | |
| 58 | + "--host", | |
| 59 | + "0.0.0.0", | |
| 60 | + "--port", | |
| 61 | + "8600", | |
| 62 | + "--workers", | |
| 63 | + "1" | |
| 64 | + ], | |
| 65 | + "interpreter": null, | |
| 66 | + "cwd": "{{HOME}}/apps/findata", | |
| 67 | + "env": { | |
| 68 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 69 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 70 | + }, | |
| 71 | + "cron_restart": null, | |
| 72 | + "autorestart": true, | |
| 73 | + "max_memory_restart": "6G" | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "name": "findata-worker", | |
| 77 | + "manager": "pm2", | |
| 78 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 79 | + "args": [ | |
| 80 | + "-m", | |
| 81 | + "findata_ingest.cli", | |
| 82 | + "worker" | |
| 83 | + ], | |
| 84 | + "interpreter": null, | |
| 85 | + "cwd": "{{HOME}}/apps/findata", | |
| 86 | + "env": { | |
| 87 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 88 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 89 | + }, | |
| 90 | + "cron_restart": null, | |
| 91 | + "autorestart": true, | |
| 92 | + "max_memory_restart": "24G" | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "name": "findata-bulk-1", | |
| 96 | + "manager": "pm2", | |
| 97 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 98 | + "args": [ | |
| 99 | + "-m", | |
| 100 | + "findata_ingest.cli", | |
| 101 | + "worker", | |
| 102 | + "--role", | |
| 103 | + "bulk" | |
| 104 | + ], | |
| 105 | + "interpreter": null, | |
| 106 | + "cwd": "{{HOME}}/apps/findata", | |
| 107 | + "env": { | |
| 108 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 109 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 110 | + }, | |
| 111 | + "cron_restart": null, | |
| 112 | + "autorestart": true, | |
| 113 | + "max_memory_restart": "12G" | |
| 114 | + }, | |
| 115 | + { | |
| 116 | + "name": "findata-bulk-2", | |
| 117 | + "manager": "pm2", | |
| 118 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 119 | + "args": [ | |
| 120 | + "-m", | |
| 121 | + "findata_ingest.cli", | |
| 122 | + "worker", | |
| 123 | + "--role", | |
| 124 | + "bulk" | |
| 125 | + ], | |
| 126 | + "interpreter": null, | |
| 127 | + "cwd": "{{HOME}}/apps/findata", | |
| 128 | + "env": { | |
| 129 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 130 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 131 | + }, | |
| 132 | + "cron_restart": null, | |
| 133 | + "autorestart": true, | |
| 134 | + "max_memory_restart": "12G" | |
| 135 | + }, | |
| 136 | + { | |
| 137 | + "name": "findata-bulk-3", | |
| 138 | + "manager": "pm2", | |
| 139 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 140 | + "args": [ | |
| 141 | + "-m", | |
| 142 | + "findata_ingest.cli", | |
| 143 | + "worker", | |
| 144 | + "--role", | |
| 145 | + "bulk" | |
| 146 | + ], | |
| 147 | + "interpreter": null, | |
| 148 | + "cwd": "{{HOME}}/apps/findata", | |
| 149 | + "env": { | |
| 150 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 151 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 152 | + }, | |
| 153 | + "cron_restart": null, | |
| 154 | + "autorestart": true, | |
| 155 | + "max_memory_restart": "12G" | |
| 156 | + }, | |
| 157 | + { | |
| 158 | + "name": "findata-bulk-4", | |
| 159 | + "manager": "pm2", | |
| 160 | + "script": "{{HOME}}/apps/findata/.venv/bin/python", | |
| 161 | + "args": [ | |
| 162 | + "-m", | |
| 163 | + "findata_ingest.cli", | |
| 164 | + "worker", | |
| 165 | + "--role", | |
| 166 | + "bulk" | |
| 167 | + ], | |
| 168 | + "interpreter": null, | |
| 169 | + "cwd": "{{HOME}}/apps/findata", | |
| 170 | + "env": { | |
| 171 | + "FINDATA_ROOT": "{{HOME}}/apps/findata", | |
| 172 | + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" | |
| 173 | + }, | |
| 174 | + "cron_restart": null, | |
| 175 | + "autorestart": true, | |
| 176 | + "max_memory_restart": "12G" | |
| 177 | + } | |
| 17 | 178 | ], |
| 18 | 179 | "ngrok": null, |
| 19 | 180 | "launchd": [], |
@@ -29,6 +190,10 @@ | ||
| 29 | 190 | "post_start": [] |
| 30 | 191 | }, |
| 31 | 192 | "ka_repo": false, |
| 32 | − "tunnel": { "domain": "findata.spboucher.ai", "gateway": "BHS64", "note": "DNS GoDaddy A findata.spboucher.ai → 51.161.112.61 vérifié le 2026-11-01 avant déploiement ; route Caddy posée par mld deploy ; findata-api écoute 0.0.0.0:8600 avec garde de provenance (loopback, wg1 10.67.0.0/16, LAN) ; Caddy → wg1 10.67.0.10:8600" }, | |
| 33 | − "notes": "Monorepo uv (Python 3.13) : services/api (FastAPI :8600, sert aussi la console Next.js statique apps/web/out), services/ingest (worker unique = verrou d'écriture du lac + ordonnanceur), lac Parquet ~/findata-lake, Postgres 17 local (DB findata, rôle findata). Secrets uniquement dans ~/apps/findata/.env (600) ; copie ~/Desktop/Cluster/secrets/findata/.env. Sauvegarde quotidienne 04:00 ET → M4M64a:~/backups/findata (rsync + pg_dump zstd). CLI : .venv/bin/findata status|discover|ingest …" | |
| 193 | + "tunnel": { | |
| 194 | + "domain": "findata.spboucher.ai", | |
| 195 | + "gateway": "BHS64", | |
| 196 | + "note": "DNS GoDaddy A findata.spboucher.ai → 51.161.112.61 vérifié le 2026-11-01 avant déploiement ; route Caddy posée par mld deploy ; findata-api écoute 0.0.0.0:8600 avec garde de provenance (loopback, wg1 10.67.0.0/16, LAN) ; Caddy → wg1 10.67.0.10:8600" | |
| 197 | + }, | |
| 198 | + "notes": "Monorepo uv (Python 3.13) : services/api (FastAPI :8600, sert aussi la console Next.js statique apps/web/out), services/ingest (worker unique = verrou d'écriture du lac + ordonnanceur), lac Parquet ~/findata-lake, Postgres 17 local (DB findata, rôle findata). Secrets uniquement dans ~/apps/findata/.env (600) ; copie ~/Desktop/Cluster/secrets/findata/.env. Sauvegarde quotidienne 04:00 ET → M4M64a:~/backups/findata (rsync + pg_dump zstd). CLI : .venv/bin/findata status|discover|ingest … | 2026-11-01 soir : lac froid FINDATA_LAKE_COLD=/Volumes/ssd/findata (SSD externe 4 To, marqueur .findata-cold-root) pour les archives brutes et les datasets non « hot » ; 4 workers bulk (findata-bulk-1..4) pour backfill/options/fundamentals en parallèle ; téléchargement complet FirstRate lancé via `findata ingest backfill-all`." | |
| 34 | 199 | } |
modified
packages/core/src/findata_core/config.py
+11 −1
@@ -22,6 +22,12 @@ class Settings(BaseSettings): | ||
| 22 | 22 | secret_key: SecretStr = SecretStr("dev-only-change-me-dev-only-change-me") |
| 23 | 23 | lake_soft_limit_gb: float = 250.0 |
| 24 | 24 | lake_hard_min_free_gb: float = 60.0 |
| 25 | + # second lake root on the external SSD (cold datasets + raw archives); must contain .findata-cold-root | |
| 26 | + lake_cold: Path | None = None | |
| 27 | + lake_cold_hard_min_free_gb: float = 100.0 | |
| 28 | + hot_datasets: str = ("market_bars.stock.1m.raw,market_bars.etf.1m.raw,market_bars.stock.1d.raw,market_bars.etf.1d.raw," | |
| 29 | + "market_bars.stock.5m.raw,market_bars.etf.5m.raw") | |
| 30 | + bulk_workers: int = 4 | |
| 25 | 31 | backup_target: str = "" |
| 26 | 32 | # vendor + optional providers (no FINDATA_ prefix: read explicitly) |
| 27 | 33 | frd_userid: SecretStr = Field(default=SecretStr(""), validation_alias="FRD_USERID") |
@@ -41,9 +47,13 @@ class Settings(BaseSettings): | ||
| 41 | 47 | def is_production(self) -> bool: |
| 42 | 48 | return self.env == "production" |
| 43 | 49 | |
| 50 | + @property | |
| 51 | + def hot_dataset_ids(self) -> set[str]: | |
| 52 | + return {x.strip() for x in self.hot_datasets.split(",") if x.strip()} | |
| 53 | + | |
| 44 | 54 | @property |
| 45 | 55 | def raw_dir(self) -> Path: |
| 46 | − return self.lake / "raw" | |
| 56 | + return (self.lake_cold or self.lake) / "raw" | |
| 47 | 57 | |
| 48 | 58 | @property |
| 49 | 59 | def curated_dir(self) -> Path: |
added
packages/core/src/findata_core/migrations/0002_full_catalog.sql
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +-- Full FirstRate catalog (planned until data arrives); options dataset kind. | |
| 2 | +ALTER TABLE datasets DROP CONSTRAINT IF EXISTS datasets_kind_check; | |
| 3 | +ALTER TABLE datasets ADD CONSTRAINT datasets_kind_check CHECK (kind IN ('market_bars','fundamentals','corporate_actions','reference','derived','options')); | |
| 4 | +INSERT INTO datasets(id, name, description, source_id, kind, asset_class, interval, adjustment, license_class, status, timestamp_convention, timezone, notes) VALUES | |
| 5 | +('market_bars.stock.1m.split','US stocks — 1-minute bars (split-adjusted (vendor adj_split))','Vendor 1-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 6 | +('market_bars.stock.1m.split_div','US stocks — 1-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 1-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 7 | +('market_bars.stock.5m.split','US stocks — 5-minute bars (split-adjusted (vendor adj_split))','Vendor 5-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','5m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 8 | +('market_bars.stock.5m.split_div','US stocks — 5-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 5-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','5m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 9 | +('market_bars.stock.30m.split','US stocks — 30-minute bars (split-adjusted (vendor adj_split))','Vendor 30-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','30m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 10 | +('market_bars.stock.30m.split_div','US stocks — 30-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 30-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','30m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 11 | +('market_bars.stock.1h.split','US stocks — 1-hour bars (split-adjusted (vendor adj_split))','Vendor 1-hour OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1h','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 12 | +('market_bars.stock.1h.split_div','US stocks — 1-hour bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 1-hour OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1h','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 13 | +('market_bars.stock.1d.raw','US stocks — daily bars (unadjusted)','Vendor daily OHLCV bars, unadjusted. Includes delisted tickers (suffix -DELISTED) when their archives are ingested.','firstrate','market_bars','stock','1d','raw','frd-private','planned','session','America/New_York',NULL), | |
| 14 | +('market_bars.stock.1d.split','US stocks — daily bars (split-adjusted (vendor adj_split))','Vendor daily OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1d','split','frd-private','planned','session','America/New_York',NULL), | |
| 15 | +('market_bars.stock.1d.split_div','US stocks — daily bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor daily OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','stock','1d','split_div','frd-private','planned','session','America/New_York',NULL), | |
| 16 | +('market_bars.etf.1m.split','US ETFs — 1-minute bars (split-adjusted (vendor adj_split))','Vendor 1-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 17 | +('market_bars.etf.1m.split_div','US ETFs — 1-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 1-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 18 | +('market_bars.etf.5m.split','US ETFs — 5-minute bars (split-adjusted (vendor adj_split))','Vendor 5-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','5m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 19 | +('market_bars.etf.5m.split_div','US ETFs — 5-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 5-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','5m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 20 | +('market_bars.etf.30m.split','US ETFs — 30-minute bars (split-adjusted (vendor adj_split))','Vendor 30-minute OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','30m','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 21 | +('market_bars.etf.30m.split_div','US ETFs — 30-minute bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 30-minute OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','30m','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 22 | +('market_bars.etf.1h.split','US ETFs — 1-hour bars (split-adjusted (vendor adj_split))','Vendor 1-hour OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1h','split','frd-private','planned','bar_open','America/New_York',NULL), | |
| 23 | +('market_bars.etf.1h.split_div','US ETFs — 1-hour bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor 1-hour OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1h','split_div','frd-private','planned','bar_open','America/New_York',NULL), | |
| 24 | +('market_bars.etf.1d.raw','US ETFs — daily bars (unadjusted)','Vendor daily OHLCV bars, unadjusted. Includes delisted tickers (suffix -DELISTED) when their archives are ingested.','firstrate','market_bars','etf','1d','raw','frd-private','planned','session','America/New_York',NULL), | |
| 25 | +('market_bars.etf.1d.split','US ETFs — daily bars (split-adjusted (vendor adj_split))','Vendor daily OHLCV bars, split-adjusted (vendor adj_split). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1d','split','frd-private','planned','session','America/New_York',NULL), | |
| 26 | +('market_bars.etf.1d.split_div','US ETFs — daily bars (split- and dividend-adjusted (vendor adj_splitdiv))','Vendor daily OHLCV bars, split- and dividend-adjusted (vendor adj_splitdiv). Includes delisted tickers (suffix -DELISTED) when their archives are ingested. Vendor-adjusted history is rewritten after corporate actions; refreshed weekly via full_update.','firstrate','market_bars','etf','1d','split_div','frd-private','planned','session','America/New_York',NULL), | |
| 27 | +('market_bars.index.1m.raw','Indices — 1-minute bars','Vendor 1-minute bars for indices. No volume (stored as 0).','firstrate','market_bars','index','1m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 28 | +('market_bars.index.5m.raw','Indices — 5-minute bars','Vendor 5-minute bars for indices. No volume (stored as 0).','firstrate','market_bars','index','5m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 29 | +('market_bars.index.30m.raw','Indices — 30-minute bars','Vendor 30-minute bars for indices. No volume (stored as 0).','firstrate','market_bars','index','30m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 30 | +('market_bars.index.1h.raw','Indices — 1-hour bars','Vendor 1-hour bars for indices. No volume (stored as 0).','firstrate','market_bars','index','1h','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 31 | +('market_bars.index.1d.raw','Indices — daily bars','Vendor daily bars for indices. No volume (stored as 0).','firstrate','market_bars','index','1d','raw','frd-private','planned','session','America/New_York',NULL), | |
| 32 | +('market_bars.fx.1m.raw','FX majors — 1-minute bars','Vendor 1-minute bars for fx majors. Volume = tick count.','firstrate','market_bars','fx','1m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 33 | +('market_bars.fx.5m.raw','FX majors — 5-minute bars','Vendor 5-minute bars for fx majors. Volume = tick count.','firstrate','market_bars','fx','5m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 34 | +('market_bars.fx.30m.raw','FX majors — 30-minute bars','Vendor 30-minute bars for fx majors. Volume = tick count.','firstrate','market_bars','fx','30m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 35 | +('market_bars.fx.1h.raw','FX majors — 1-hour bars','Vendor 1-hour bars for fx majors. Volume = tick count.','firstrate','market_bars','fx','1h','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 36 | +('market_bars.fx.1d.raw','FX majors — daily bars','Vendor daily bars for fx majors. Volume = tick count.','firstrate','market_bars','fx','1d','raw','frd-private','planned','session','America/New_York',NULL), | |
| 37 | +('market_bars.crypto.1m.raw','Crypto — 1-minute bars','Vendor 1-minute bars for crypto.','firstrate','market_bars','crypto','1m','raw','frd-private','planned','bar_open','UTC',NULL), | |
| 38 | +('market_bars.crypto.5m.raw','Crypto — 5-minute bars','Vendor 5-minute bars for crypto.','firstrate','market_bars','crypto','5m','raw','frd-private','planned','bar_open','UTC',NULL), | |
| 39 | +('market_bars.crypto.30m.raw','Crypto — 30-minute bars','Vendor 30-minute bars for crypto.','firstrate','market_bars','crypto','30m','raw','frd-private','planned','bar_open','UTC',NULL), | |
| 40 | +('market_bars.crypto.1h.raw','Crypto — 1-hour bars','Vendor 1-hour bars for crypto.','firstrate','market_bars','crypto','1h','raw','frd-private','planned','bar_open','UTC',NULL), | |
| 41 | +('market_bars.crypto.1d.raw','Crypto — daily bars','Vendor daily bars for crypto.','firstrate','market_bars','crypto','1d','raw','frd-private','planned','session','UTC',NULL), | |
| 42 | +('market_bars.futures.1m.continuous_raw','Futures — 1-minute continuous, unadjusted (contin_UNadj)','Vendor continuous series per root (continuous, unadjusted (contin_UNadj)); roll dates fixed by vendor.','firstrate','market_bars','futures','1m','continuous_raw','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 43 | +('market_bars.futures.1m.continuous_ratio','Futures — 1-minute continuous, ratio back-adjusted','Vendor continuous series per root (continuous, ratio back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1m','continuous_ratio','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 44 | +('market_bars.futures.1m.continuous_absolute','Futures — 1-minute continuous, absolute back-adjusted','Vendor continuous series per root (continuous, absolute back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1m','continuous_absolute','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 45 | +('market_bars.futures_contracts.1m.raw','Futures individual contracts — 1-minute bars','Per-contract bars (e.g. ES_H25); symbol = ROOT_MYY. Daily files carry open interest.','firstrate','market_bars','futures','1m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 46 | +('market_bars.futures.5m.continuous_raw','Futures — 5-minute continuous, unadjusted (contin_UNadj)','Vendor continuous series per root (continuous, unadjusted (contin_UNadj)); roll dates fixed by vendor.','firstrate','market_bars','futures','5m','continuous_raw','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 47 | +('market_bars.futures.5m.continuous_ratio','Futures — 5-minute continuous, ratio back-adjusted','Vendor continuous series per root (continuous, ratio back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','5m','continuous_ratio','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 48 | +('market_bars.futures.5m.continuous_absolute','Futures — 5-minute continuous, absolute back-adjusted','Vendor continuous series per root (continuous, absolute back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','5m','continuous_absolute','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 49 | +('market_bars.futures_contracts.5m.raw','Futures individual contracts — 5-minute bars','Per-contract bars (e.g. ES_H25); symbol = ROOT_MYY. Daily files carry open interest.','firstrate','market_bars','futures','5m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 50 | +('market_bars.futures.30m.continuous_raw','Futures — 30-minute continuous, unadjusted (contin_UNadj)','Vendor continuous series per root (continuous, unadjusted (contin_UNadj)); roll dates fixed by vendor.','firstrate','market_bars','futures','30m','continuous_raw','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 51 | +('market_bars.futures.30m.continuous_ratio','Futures — 30-minute continuous, ratio back-adjusted','Vendor continuous series per root (continuous, ratio back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','30m','continuous_ratio','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 52 | +('market_bars.futures.30m.continuous_absolute','Futures — 30-minute continuous, absolute back-adjusted','Vendor continuous series per root (continuous, absolute back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','30m','continuous_absolute','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 53 | +('market_bars.futures_contracts.30m.raw','Futures individual contracts — 30-minute bars','Per-contract bars (e.g. ES_H25); symbol = ROOT_MYY. Daily files carry open interest.','firstrate','market_bars','futures','30m','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 54 | +('market_bars.futures.1h.continuous_raw','Futures — 1-hour continuous, unadjusted (contin_UNadj)','Vendor continuous series per root (continuous, unadjusted (contin_UNadj)); roll dates fixed by vendor.','firstrate','market_bars','futures','1h','continuous_raw','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 55 | +('market_bars.futures.1h.continuous_ratio','Futures — 1-hour continuous, ratio back-adjusted','Vendor continuous series per root (continuous, ratio back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1h','continuous_ratio','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 56 | +('market_bars.futures.1h.continuous_absolute','Futures — 1-hour continuous, absolute back-adjusted','Vendor continuous series per root (continuous, absolute back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1h','continuous_absolute','frd-private','planned','bar_open','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 57 | +('market_bars.futures_contracts.1h.raw','Futures individual contracts — 1-hour bars','Per-contract bars (e.g. ES_H25); symbol = ROOT_MYY. Daily files carry open interest.','firstrate','market_bars','futures','1h','raw','frd-private','planned','bar_open','America/New_York',NULL), | |
| 58 | +('market_bars.futures.1d.continuous_raw','Futures — daily continuous, unadjusted (contin_UNadj)','Vendor continuous series per root (continuous, unadjusted (contin_UNadj)); roll dates fixed by vendor.','firstrate','market_bars','futures','1d','continuous_raw','frd-private','planned','session','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 59 | +('market_bars.futures.1d.continuous_ratio','Futures — daily continuous, ratio back-adjusted','Vendor continuous series per root (continuous, ratio back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1d','continuous_ratio','frd-private','planned','session','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 60 | +('market_bars.futures.1d.continuous_absolute','Futures — daily continuous, absolute back-adjusted','Vendor continuous series per root (continuous, absolute back-adjusted); roll dates fixed by vendor.','firstrate','market_bars','futures','1d','continuous_absolute','frd-private','planned','session','America/New_York','Daily bars follow the contract trading day; absolute-adjusted series may be negative.'), | |
| 61 | +('market_bars.futures_contracts.1d.raw','Futures individual contracts — daily bars','Per-contract bars (e.g. ES_H25); symbol = ROOT_MYY. Daily files carry open interest.','firstrate','market_bars','futures','1d','raw','frd-private','planned','session','America/New_York',NULL) | |
| 62 | +ON CONFLICT (id) DO NOTHING; | |
| 63 | +-- the pre-existing planned rows get consistent names | |
| 64 | +UPDATE datasets SET kind='options', name='US options — end-of-day chains (CBOE)', description='EOD chains since 2010: strike, expiry, call/put, last/bid/ask, IVs, open interest, volume, Greeks. Not split-adjusted at source. One Parquet file per underlying and quarter.' WHERE id='options.eod'; | |
| 65 | +UPDATE datasets SET id='market_bars.futures.1m.continuous_raw' WHERE id='market_bars.futures.1m.continuous' AND NOT EXISTS (SELECT 1 FROM datasets WHERE id='market_bars.futures.1m.continuous_raw'); | |
| 66 | +DELETE FROM datasets WHERE id='market_bars.futures.1m.continuous'; | |
| 67 | +UPDATE datasets SET name='US stocks — 1-minute bars (unadjusted)', description='Raw (unadjusted) 1-minute OHLCV bars for US-listed stocks, extended hours 04:00–20:00 ET, zero-volume bars absent at source. Includes delisted tickers (suffix -DELISTED).' WHERE id='market_bars.stock.1m.raw'; | |
modified
packages/schema/src/findata_schema/__init__.py
+24 −2
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | """findata_schema — Arrow schemas, data contracts and connector interfaces (single source of truth).""" |
| 2 | −from findata_schema.connector import ( # noqa: F401 | |
| 2 | +from findata_schema.connector import ( | |
| 3 | 3 | Capabilities, |
| 4 | 4 | Checkpoint, |
| 5 | 5 | Connector, |
@@ -8,11 +8,12 @@ from findata_schema.connector import ( # noqa: F401 | ||
| 8 | 8 | NormalizedBatch, |
| 9 | 9 | ValidationReport, |
| 10 | 10 | ) |
| 11 | −from findata_schema.contracts import ( # noqa: F401 | |
| 11 | +from findata_schema.contracts import ( | |
| 12 | 12 | BARS_SCHEMA, |
| 13 | 13 | BARS_SCHEMA_VERSION, |
| 14 | 14 | FUNDAMENTALS_SCHEMA, |
| 15 | 15 | INTERVAL_SECONDS, |
| 16 | + OPTIONS_SCHEMA, | |
| 16 | 17 | RAW_BAR_COLUMNS, |
| 17 | 18 | Adjustment, |
| 18 | 19 | AssetClass, |
@@ -20,3 +21,24 @@ from findata_schema.contracts import ( # noqa: F401 | ||
| 20 | 21 | QualityFlag, |
| 21 | 22 | bars_schema_json, |
| 22 | 23 | ) |
| 24 | + | |
| 25 | +__all__ = [ | |
| 26 | + "BARS_SCHEMA", | |
| 27 | + "BARS_SCHEMA_VERSION", | |
| 28 | + "FUNDAMENTALS_SCHEMA", | |
| 29 | + "INTERVAL_SECONDS", | |
| 30 | + "OPTIONS_SCHEMA", | |
| 31 | + "RAW_BAR_COLUMNS", | |
| 32 | + "Adjustment", | |
| 33 | + "AssetClass", | |
| 34 | + "Capabilities", | |
| 35 | + "Checkpoint", | |
| 36 | + "Connector", | |
| 37 | + "FetchRequest", | |
| 38 | + "FetchResult", | |
| 39 | + "Interval", | |
| 40 | + "NormalizedBatch", | |
| 41 | + "QualityFlag", | |
| 42 | + "ValidationReport", | |
| 43 | + "bars_schema_json", | |
| 44 | +] | |
modified
packages/schema/src/findata_schema/contracts.py
+27 −0
@@ -114,3 +114,30 @@ FUNDAMENTALS_SCHEMA = pa.schema( | ||
| 114 | 114 | |
| 115 | 115 | def bars_schema_json() -> str: |
| 116 | 116 | return json.dumps([{"name": f.name, "type": str(f.type), "nullable": f.nullable} for f in BARS_SCHEMA], indent=2) |
| 117 | + | |
| 118 | + | |
| 119 | +# End-of-day option chains (vendor CBOE feed). trade_date is the session; not split-adjusted at source. | |
| 120 | +OPTIONS_SCHEMA = pa.schema( | |
| 121 | + [ | |
| 122 | + pa.field("underlying", pa.string(), nullable=False), | |
| 123 | + pa.field("trade_date", pa.date32(), nullable=False), | |
| 124 | + pa.field("strike", pa.float64(), nullable=False), | |
| 125 | + pa.field("expiry", pa.date32(), nullable=False), | |
| 126 | + pa.field("call_put", pa.string(), nullable=False), | |
| 127 | + pa.field("last_price", pa.float64(), nullable=True), | |
| 128 | + pa.field("bid", pa.float64(), nullable=True), | |
| 129 | + pa.field("ask", pa.float64(), nullable=True), | |
| 130 | + pa.field("bid_iv", pa.float64(), nullable=True), | |
| 131 | + pa.field("ask_iv", pa.float64(), nullable=True), | |
| 132 | + pa.field("open_interest", pa.float64(), nullable=True), | |
| 133 | + pa.field("volume", pa.float64(), nullable=True), | |
| 134 | + pa.field("delta", pa.float64(), nullable=True), | |
| 135 | + pa.field("gamma", pa.float64(), nullable=True), | |
| 136 | + pa.field("vega", pa.float64(), nullable=True), | |
| 137 | + pa.field("theta", pa.float64(), nullable=True), | |
| 138 | + pa.field("rho", pa.float64(), nullable=True), | |
| 139 | + pa.field("source_id", pa.string(), nullable=False), | |
| 140 | + pa.field("ingested_at", pa.timestamp("us", tz="UTC"), nullable=False), | |
| 141 | + ], | |
| 142 | + metadata={b"findata.schema": b"options_eod", b"findata.schema_version": b"1"}, | |
| 143 | +) | |
modified
services/ingest/src/findata_ingest/cli.py
+5 −2
@@ -79,9 +79,12 @@ def ingest_backfill(ticker_range: str, type_: str = typer.Option("stock", "--typ | ||
| 79 | 79 | |
| 80 | 80 | |
| 81 | 81 | @ingest_app.command("fundamentals") |
| 82 | −def ingest_fundamentals(tickers: str) -> None: | |
| 82 | +def ingest_fundamentals(tickers: str | None = typer.Argument(None), all_active: bool = False) -> None: | |
| 83 | + """Fundamentals for a ticker list, or --all-active for every active US stock (checkpointed per ticker).""" | |
| 83 | 84 | from findata_ingest import queue |
| 84 | − typer.echo(queue.enqueue("fundamentals", "fundamentals.financials", {"tickers": tickers.split(",")}, priority=30, created_by="cli")) | |
| 85 | + params = {"all_active": True} if all_active else {"tickers": (tickers or "").split(",")} | |
| 86 | + typer.echo(queue.enqueue("fundamentals", "fundamentals.financials", params, priority=58, created_by="cli", | |
| 87 | + dedupe_key="fundamentals:all_active" if all_active else None)) | |
| 85 | 88 | |
| 86 | 89 | |
| 87 | 90 | @ingest_app.command("derive") |
modified
services/ingest/src/findata_ingest/lake.py
+83 −37
@@ -70,73 +70,116 @@ class WriterLock: | ||
| 70 | 70 | |
| 71 | 71 | |
| 72 | 72 | class Lake: |
| 73 | − def __init__(self, root: Path | None = None): | |
| 74 | − self.root = (root or get_settings().lake).expanduser() | |
| 73 | + """Two physical roots: `root` (internal SSD, hot datasets, manifests, exports) and optional `cold` | |
| 74 | + (external SSD: raw archives + every dataset not listed in FINDATA_HOT_DATASETS). Relative paths stored in | |
| 75 | + `lake_files` are root-agnostic (`curated/<dataset>/…`); `root_for(dataset)` picks the physical root.""" | |
| 76 | + | |
| 77 | + def __init__(self, root: Path | None = None, cold: Path | None = None): | |
| 78 | + s = get_settings() | |
| 79 | + self.root = (root or s.lake).expanduser() | |
| 80 | + self.cold = (cold if cold is not None else s.lake_cold) | |
| 81 | + self.cold = self.cold.expanduser() if self.cold else None | |
| 82 | + self.hot = s.hot_dataset_ids | |
| 75 | 83 | for sub in ("raw", "curated", "manifests", "_tmp", "_retired", "exports"): |
| 76 | 84 | (self.root / sub).mkdir(parents=True, exist_ok=True) |
| 85 | + if self.cold: | |
| 86 | + if not (self.cold / ".findata-cold-root").exists(): | |
| 87 | + raise RuntimeError(f"cold lake root {self.cold} is not mounted (marker .findata-cold-root missing)") | |
| 88 | + for sub in ("raw", "curated", "_tmp", "_retired"): | |
| 89 | + (self.cold / sub).mkdir(parents=True, exist_ok=True) | |
| 90 | + | |
| 91 | + # ------------------------------------------------------------------ roots | |
| 92 | + def root_for(self, dataset_id: str) -> Path: | |
| 93 | + if self.cold and dataset_id not in self.hot: | |
| 94 | + return self.cold | |
| 95 | + return self.root | |
| 96 | + | |
| 97 | + def roots(self) -> list[Path]: | |
| 98 | + return [self.root] + ([self.cold] if self.cold else []) | |
| 99 | + | |
| 100 | + def _root_of_rel(self, rel: str) -> Path: | |
| 101 | + parts = rel.split("/") | |
| 102 | + if len(parts) >= 2 and parts[0] in ("curated", "_retired"): | |
| 103 | + return self.root_for(parts[1]) | |
| 104 | + if parts and parts[0] == "raw": | |
| 105 | + return self.cold or self.root | |
| 106 | + return self.root | |
| 77 | 107 | |
| 78 | − # ------------------------------------------------------------------ paths | |
| 79 | 108 | def raw_dir(self, source_id: str, dataset_id: str) -> Path: |
| 80 | − p = self.root / "raw" / source_id / dataset_id / str(datetime.now(UTC).year) | |
| 109 | + p = (self.cold or self.root) / "raw" / source_id / dataset_id / str(datetime.now(UTC).year) | |
| 81 | 110 | p.mkdir(parents=True, exist_ok=True) |
| 82 | 111 | return p |
| 83 | 112 | |
| 84 | 113 | def abs(self, rel: str) -> Path: |
| 85 | − return self.root / rel | |
| 114 | + return self._root_of_rel(rel) / rel | |
| 115 | + | |
| 116 | + def rel(self, path: Path) -> str: | |
| 117 | + for r in self.roots(): | |
| 118 | + try: | |
| 119 | + return str(path.relative_to(r)) | |
| 120 | + except ValueError: | |
| 121 | + continue | |
| 122 | + raise ValueError(f"{path} is not inside a lake root") | |
| 86 | 123 | |
| 87 | 124 | def resolve(self, rel: str) -> Path: |
| 88 | 125 | """Live path, or its retired location (older snapshots stay readable until GC after 7 days).""" |
| 89 | − p = self.root / rel | |
| 126 | + p = self.abs(rel) | |
| 90 | 127 | if p.exists(): |
| 91 | 128 | return p |
| 92 | 129 | if rel.startswith("curated/"): |
| 93 | − r = self.root / "_retired" / rel[len("curated/"):] | |
| 130 | + r = self._root_of_rel(rel) / "_retired" / rel[len("curated/"):] | |
| 94 | 131 | if r.exists(): |
| 95 | 132 | return r |
| 96 | 133 | return p |
| 97 | 134 | |
| 98 | − def free_gb(self) -> float: | |
| 99 | − st = os.statvfs(self.root) | |
| 135 | + def free_gb(self, root: Path | None = None) -> float: | |
| 136 | + st = os.statvfs(root or self.root) | |
| 100 | 137 | return st.f_bavail * st.f_frsize / 1e9 |
| 101 | 138 | |
| 102 | 139 | def used_bytes(self, sub: str = "curated") -> int: |
| 103 | 140 | total = 0 |
| 104 | − for p in (self.root / sub).rglob("*"): | |
| 105 | − if p.is_file(): | |
| 106 | − total += p.stat().st_size | |
| 141 | + for r in self.roots(): | |
| 142 | + for p in (r / sub).rglob("*"): | |
| 143 | + if p.is_file(): | |
| 144 | + total += p.stat().st_size | |
| 107 | 145 | return total |
| 108 | 146 | |
| 109 | − def check_budget(self) -> None: | |
| 147 | + def check_budget(self, dataset_id: str | None = None) -> None: | |
| 110 | 148 | s = get_settings() |
| 111 | − if self.free_gb() < s.lake_hard_min_free_gb: | |
| 112 | − raise RuntimeError(f"lake hard stop: only {self.free_gb():.1f} GB free (< {s.lake_hard_min_free_gb} GB)") | |
| 149 | + root = self.root_for(dataset_id) if dataset_id else self.root | |
| 150 | + limit = s.lake_cold_hard_min_free_gb if (self.cold and root == self.cold) else s.lake_hard_min_free_gb | |
| 151 | + if self.free_gb(root) < limit: | |
| 152 | + raise RuntimeError(f"lake hard stop on {root}: only {self.free_gb(root):.1f} GB free (< {limit} GB)") | |
| 113 | 153 | |
| 114 | 154 | # ------------------------------------------------------------------ writing |
| 115 | 155 | def write_table(self, dataset_id: str, table: pa.Table, layout: str, partition: dict[str, str], |
| 116 | 156 | sort: bool = True) -> WrittenFile: |
| 117 | 157 | """Write a Parquet file into _tmp then move it atomically under curated/. Does NOT register it.""" |
| 118 | − self.check_budget() | |
| 158 | + self.check_budget(dataset_id) | |
| 119 | 159 | if table.num_rows == 0: |
| 120 | 160 | raise ValueError("refusing to write an empty file") |
| 121 | − if sort: | |
| 161 | + root = self.root_for(dataset_id) | |
| 162 | + is_bars = "symbol_as_reported" in table.column_names and "timestamp_utc" in table.column_names | |
| 163 | + if sort and is_bars: | |
| 122 | 164 | table = table.sort_by([("symbol_as_reported", "ascending"), ("timestamp_utc", "ascending")]) |
| 123 | 165 | part_dir = "/".join(f"{k}={v}" for k, v in partition.items()) |
| 124 | 166 | rel = f"curated/{dataset_id}/{layout}/{part_dir}/part-{uuid.uuid4().hex[:12]}.parquet" |
| 125 | − final = self.root / rel | |
| 126 | − tmp = self.root / "_tmp" / f"{uuid.uuid4().hex}.parquet" | |
| 167 | + final = root / rel | |
| 168 | + tmp = root / "_tmp" / f"{uuid.uuid4().hex}.parquet" | |
| 127 | 169 | interval = table.column("interval")[0].as_py() if "interval" in table.column_names else "1d" |
| 128 | 170 | rg = ROW_GROUP_DAILY if interval == "1d" else ROW_GROUP_INTRADAY |
| 171 | + dict_cols = [c for c in ("symbol_as_reported", "instrument_id", "asset_class", "interval", "currency", "adjustment_type", | |
| 172 | + "source_id", "underlying", "call_put") if c in table.column_names] | |
| 129 | 173 | pq.write_table(table, tmp, compression="zstd", compression_level=3, row_group_size=rg, |
| 130 | − write_statistics=True, use_dictionary=["symbol_as_reported", "instrument_id", "asset_class", | |
| 131 | − "interval", "currency", "adjustment_type", "source_id"]) | |
| 174 | + write_statistics=True, use_dictionary=dict_cols) | |
| 132 | 175 | with open(tmp, "rb") as f: |
| 133 | 176 | os.fsync(f.fileno()) |
| 134 | 177 | digest = _sha256(tmp) |
| 135 | 178 | final.parent.mkdir(parents=True, exist_ok=True) |
| 136 | 179 | os.replace(tmp, final) |
| 137 | − syms = table.column("symbol_as_reported") | |
| 138 | − dates = table.column("session_date") | |
| 139 | 180 | import pyarrow.compute as pc |
| 181 | + syms = table.column("symbol_as_reported") if is_bars else table.column("underlying") | |
| 182 | + dates = table.column("session_date") if is_bars else table.column("trade_date") | |
| 140 | 183 | return WrittenFile(rel_path=rel, layout=layout, symbol_min=pc.min(syms).as_py(), symbol_max=pc.max(syms).as_py(), |
| 141 | 184 | date_min=pc.min(dates).as_py(), date_max=pc.max(dates).as_py(), row_count=table.num_rows, |
| 142 | 185 | bytes=final.stat().st_size, sha256=digest) |
@@ -176,9 +219,10 @@ class Lake: | ||
| 176 | 219 | tmp.write_text(json.dumps(manifest, indent=1, default=str)) |
| 177 | 220 | os.replace(tmp, mpath) |
| 178 | 221 | for p in retire_paths: |
| 179 | − src = self.root / p | |
| 222 | + src = self.abs(p) | |
| 180 | 223 | if src.exists(): |
| 181 | − dst = self.root / "_retired" / p[len("curated/"):] if p.startswith("curated/") else self.root / "_retired" / p | |
| 224 | + r = self._root_of_rel(p) | |
| 225 | + dst = r / "_retired" / (p[len("curated/"):] if p.startswith("curated/") else p) | |
| 182 | 226 | dst.parent.mkdir(parents=True, exist_ok=True) |
| 183 | 227 | shutil.move(str(src), str(dst)) |
| 184 | 228 | return {"version": version, "snapshot_id": snapshot_id, "file_count": file_count, "row_count": row_count, "bytes": nbytes} |
@@ -190,23 +234,25 @@ class Lake: | ||
| 190 | 234 | known = {r["path"] for r in conn.execute("SELECT path FROM lake_files").fetchall()} |
| 191 | 235 | cutoff = datetime.now(UTC).timestamp() - older_than_hours * 3600 |
| 192 | 236 | n = 0 |
| 193 | − for p in (self.root / "curated").rglob("*.parquet"): | |
| 194 | − rel = str(p.relative_to(self.root)) | |
| 195 | − if rel not in known and p.stat().st_mtime < cutoff: | |
| 196 | − p.unlink() | |
| 197 | − n += 1 | |
| 237 | + for r in self.roots(): | |
| 238 | + for p in (r / "curated").rglob("*.parquet"): | |
| 239 | + rel = str(p.relative_to(r)) | |
| 240 | + if rel not in known and p.stat().st_mtime < cutoff: | |
| 241 | + p.unlink() | |
| 242 | + n += 1 | |
| 198 | 243 | return n |
| 199 | 244 | |
| 200 | 245 | def gc_retired(self, older_than_days: int = 7) -> int: |
| 201 | 246 | cutoff = datetime.now(UTC).timestamp() - older_than_days * 86400 |
| 202 | 247 | n = 0 |
| 203 | − for p in (self.root / "_retired").rglob("*.parquet"): | |
| 204 | − if p.stat().st_mtime < cutoff: | |
| 205 | − p.unlink() | |
| 206 | − n += 1 | |
| 207 | − for p in (self.root / "_tmp").glob("*"): | |
| 208 | − if p.stat().st_mtime < datetime.now(UTC).timestamp() - 86400: | |
| 209 | − p.unlink() | |
| 248 | + for r in self.roots(): | |
| 249 | + for p in (r / "_retired").rglob("*.parquet"): | |
| 250 | + if p.stat().st_mtime < cutoff: | |
| 251 | + p.unlink() | |
| 252 | + n += 1 | |
| 253 | + for p in (r / "_tmp").glob("*"): | |
| 254 | + if p.stat().st_mtime < datetime.now(UTC).timestamp() - 86400: | |
| 255 | + p.unlink() | |
| 210 | 256 | return n |
| 211 | 257 | |
| 212 | 258 | |
modified
services/ingest/src/findata_ingest/reader.py
+7 −4
@@ -126,8 +126,11 @@ def run_sql(sql: str, max_rows: int, timeout_s: int, views: dict[str, list[str]] | ||
| 126 | 126 | raise ValueError("statement must start with SELECT / WITH") |
| 127 | 127 | is_explain = bool(re.match(r"^\s*explain\b", sql, re.I)) |
| 128 | 128 | con = _con() |
| 129 | − lake_root = str(Lake().root).replace("'", "''") | |
| 130 | − con.execute(f"SET allowed_directories = ['{lake_root}/curated', '{lake_root}/_retired']") | |
| 129 | + dirs = [] | |
| 130 | + for r in Lake().roots(): | |
| 131 | + rr = str(r.resolve()).replace("'", "''") | |
| 132 | + dirs += [f"'{rr}/curated'", f"'{rr}/_retired'"] | |
| 133 | + con.execute(f"SET allowed_directories = [{', '.join(dirs)}]") | |
| 131 | 134 | for name, paths in (views or {}).items(): |
| 132 | 135 | if not re.fullmatch(r"[a-z][a-z0-9_]*", name): |
| 133 | 136 | raise ValueError(f"bad view name {name}") |
@@ -155,8 +158,8 @@ def dataset_views() -> dict[str, list[str]]: | ||
| 155 | 158 | lake = Lake() |
| 156 | 159 | out: dict[str, list[str]] = {} |
| 157 | 160 | with connection() as conn: |
| 158 | − ds = conn.execute("SELECT id FROM datasets WHERE kind IN ('market_bars','derived')").fetchall() | |
| 161 | + ds = conn.execute("SELECT id FROM datasets WHERE kind IN ('market_bars','derived','options') AND current_version > 0").fetchall() | |
| 159 | 162 | for d in ds: |
| 160 | 163 | files = live_files(conn, d["id"]) |
| 161 | − out[d["id"].replace(".", "_")] = [str(lake.abs(f["path"])) for f in files] | |
| 164 | + out[d["id"].replace(".", "_")] = [str(lake.resolve(f["path"])) for f in files] | |
| 162 | 165 | return out |
modified
services/ingest/src/findata_ingest/tasks/__init__.py
+2 −0
@@ -6,6 +6,7 @@ from findata_ingest.tasks import ( | ||
| 6 | 6 | fundamentals, |
| 7 | 7 | incremental, |
| 8 | 8 | metadata, |
| 9 | + options, | |
| 9 | 10 | quality, |
| 10 | 11 | ) |
| 11 | 12 | |
@@ -13,6 +14,7 @@ REGISTRY = { | ||
| 13 | 14 | "metadata": metadata.run, |
| 14 | 15 | "incremental": incremental.run, |
| 15 | 16 | "backfill": backfill.run, |
| 17 | + "options": options.run, | |
| 16 | 18 | "derive": derive.run, |
| 17 | 19 | "compaction": compaction.run, |
| 18 | 20 | "fundamentals": fundamentals.run, |
modified
services/ingest/src/findata_ingest/tasks/backfill.py
+25 −15
@@ -11,14 +11,14 @@ from typing import Any | ||
| 11 | 11 | import pyarrow as pa |
| 12 | 12 | import pyarrow.compute as pc |
| 13 | 13 | from findata_core.db import connection, transaction |
| 14 | −from findata_market_firstrate import FirstRateMarketConnector | |
| 14 | +from findata_market_firstrate import FirstRateMarketConnector, FRDNoData | |
| 15 | 15 | from findata_schema import Interval |
| 16 | 16 | from findata_schema.connector import FetchRequest |
| 17 | 17 | |
| 18 | 18 | from findata_ingest import queue |
| 19 | 19 | from findata_ingest.lake import Lake |
| 20 | 20 | from findata_ingest.tasks.common import ( |
| 21 | − DATASET_ASSET, | |
| 21 | + dataset_for_params, | |
| 22 | 22 | live_delta_dates, |
| 23 | 23 | record_anomalies, |
| 24 | 24 | record_quality_report, |
@@ -30,15 +30,20 @@ from findata_ingest.tasks.common import ( | ||
| 30 | 30 | def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: |
| 31 | 31 | p = job.get("params") or {} |
| 32 | 32 | asset = p.get("type", "stock") |
| 33 | − letter = p["ticker_range"] | |
| 33 | + letter = p.get("ticker_range") or p.get("archive_number") or p.get("update") or p.get("contract_files") or p.get("period", "full") | |
| 34 | 34 | timeframe = p.get("timeframe", "1min") |
| 35 | − adjustment = p.get("adjustment", "UNADJUSTED") | |
| 35 | + adjustment = p.get("adjustment") | |
| 36 | 36 | only = set(s.upper() for s in (p.get("symbols") or [])) |
| 37 | 37 | max_symbols = int(p.get("max_symbols") or 0) |
| 38 | − dataset_id = job.get("dataset_id") or DATASET_ASSET[asset] | |
| 38 | + dataset_id = job.get("dataset_id") or dataset_for_params(p) | |
| 39 | 39 | run_id = job.get("run_id") |
| 40 | 40 | interval = Interval({"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"}[timeframe]) |
| 41 | 41 | conn_fr = FirstRateMarketConnector() |
| 42 | + vendor_params = {k: v for k, v in p.items() if k in ("endpoint", "type", "period", "ticker_range", "timeframe", "adjustment", | |
| 43 | + "archive_number", "update", "contract_files", "contracts", "year", "quarter") and v is not None} | |
| 44 | + vendor_params.setdefault("endpoint", "data_file") | |
| 45 | + if vendor_params["endpoint"] == "data_file": | |
| 46 | + vendor_params.setdefault("period", "full") | |
| 42 | 47 | |
| 43 | 48 | ck = queue.get_checkpoint(f"job:{job['id']}", "state") or {} |
| 44 | 49 | done = set(ck.get("done_symbols") or []) |
@@ -47,16 +52,19 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 47 | 52 | from datetime import datetime |
| 48 | 53 | |
| 49 | 54 | from findata_schema.connector import FetchResult |
| 50 | − res = FetchResult(request=FetchRequest(dataset_id, {"type": asset, "period": "full", "ticker_range": letter, "timeframe": timeframe, "adjustment": adjustment}), | |
| 51 | − path=lake.root / raw_rel, sha256=ck["sha256"], bytes=ck["bytes"], fetched_at=datetime.fromisoformat(ck["fetched_at"]), | |
| 55 | + res = FetchResult(request=FetchRequest(dataset_id, vendor_params), | |
| 56 | + path=lake.abs(raw_rel), sha256=ck["sha256"], bytes=ck["bytes"], fetched_at=datetime.fromisoformat(ck["fetched_at"]), | |
| 52 | 57 | vendor_last_update=None) |
| 53 | 58 | log.info("backfill.resume", done=len(done), archive=raw_rel) |
| 54 | 59 | else: |
| 55 | − req = FetchRequest(dataset_id=dataset_id, params={"endpoint": "data_file", "type": asset, "period": "full", "ticker_range": letter, | |
| 56 | − "timeframe": timeframe, "adjustment": adjustment}) | |
| 57 | − queue.progress(job["id"], {"stage": "download", "ticker_range": letter}) | |
| 58 | − res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id)) | |
| 59 | − raw_rel = str(res.path.relative_to(lake.root)) | |
| 60 | + req = FetchRequest(dataset_id=dataset_id, params=vendor_params) | |
| 61 | + queue.progress(job["id"], {"stage": "download", "archive": letter}) | |
| 62 | + try: | |
| 63 | + res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id)) | |
| 64 | + except FRDNoData as e: | |
| 65 | + log.info("backfill.no_data", reason=str(e)[:200]) | |
| 66 | + return {"skipped": True, "reason": str(e)[:300]} | |
| 67 | + raw_rel = lake.rel(res.path) | |
| 60 | 68 | with transaction() as conn: |
| 61 | 69 | record_raw_archive(conn, "firstrate", dataset_id, req.params, raw_rel, res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id) |
| 62 | 70 | queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes, |
@@ -92,7 +100,7 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 92 | 100 | with connection() as conn: |
| 93 | 101 | 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", |
| 94 | 102 | (dataset_id, batch.symbol, batch.symbol)).fetchall() |
| 95 | − lake.publish(dataset_id, written, [o["path"] for o in old], run_id, note=f"backfill {asset} {letter} {batch.symbol}") | |
| 103 | + lake.publish(dataset_id, written, [o["path"] for o in old], run_id, note=f"backfill {asset} {letter} {timeframe} {adjustment} {batch.symbol}") | |
| 96 | 104 | with transaction() as conn: |
| 97 | 105 | upsert_coverage(conn, dataset_id, t, interval) |
| 98 | 106 | record_anomalies(conn, dataset_id, rep.anomalies, run_id, limit=500) |
@@ -106,13 +114,15 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 106 | 114 | queue.progress(job["id"], {"stage": "publish", **stats, "last_symbol": batch.symbol}) |
| 107 | 115 | with transaction() as conn: |
| 108 | 116 | conn.execute("UPDATE raw_archives SET status='published' WHERE sha256=%s", (res.sha256,)) |
| 117 | + if stats["rows"]: | |
| 118 | + conn.execute("UPDATE datasets SET status='active', updated_at=now() WHERE id=%s AND status='planned'", (dataset_id,)) | |
| 109 | 119 | record_quality_report(conn, dataset_id, run_id, stats["rows"], []) |
| 110 | − if not p.get("keep_raw", False) and p.get("delete_raw_after_publish", True): | |
| 120 | + if p.get("delete_raw_after_publish", False): | |
| 111 | 121 | # full archives are re-downloadable; sha256 stays in raw_archives |
| 112 | 122 | res.path.unlink(missing_ok=True) |
| 113 | 123 | with transaction() as conn: |
| 114 | 124 | conn.execute("UPDATE raw_archives SET status='deleted_after_publish', path=NULL WHERE sha256=%s", (res.sha256,)) |
| 115 | − if interval == Interval.m1 and stats["rows"]: | |
| 125 | + if interval == Interval.m1 and stats["rows"] and dataset_id in ("market_bars.stock.1m.raw", "market_bars.etf.1m.raw"): | |
| 116 | 126 | queue.enqueue("derive", dataset_id.replace(".1m.", ".5m."), {"source_dataset": dataset_id, "target_interval": "5m", |
| 117 | 127 | "symbols": sorted(done)}, priority=45, dedupe_key=f"derive:{dataset_id}:5m:backfill:{job['id']}") |
| 118 | 128 | log.info("backfill.done", **stats) |
modified
services/ingest/src/findata_ingest/tasks/common.py
+8 −0
@@ -12,6 +12,12 @@ DATASET_ASSET = {"stock": "market_bars.stock.1m.raw", "etf": "market_bars.etf.1m | ||
| 12 | 12 | "fx": "market_bars.fx.1m.raw", "crypto": "market_bars.crypto.1m.raw", "futures": "market_bars.futures.1m.continuous"} |
| 13 | 13 | |
| 14 | 14 | |
| 15 | +def dataset_for_params(params: dict[str, Any]) -> str: | |
| 16 | + from findata_market_firstrate.catalog import dataset_id_for | |
| 17 | + return dataset_id_for(params.get("type", "stock"), params.get("timeframe", "1min"), params.get("adjustment"), | |
| 18 | + contracts=bool(params.get("contracts"))) | |
| 19 | + | |
| 20 | + | |
| 15 | 21 | def record_raw_archive(conn, source_id: str, dataset_id: str, request: dict[str, Any], rel_path: str, sha256: str, |
| 16 | 22 | nbytes: int, file_count: int | None, vendor_last_update: date | None, run_id: str | None) -> tuple[str, bool]: |
| 17 | 23 | """Insert raw archive row; returns (id, is_new). Same sha256 → already known (idempotent re-fetch).""" |
@@ -26,6 +32,8 @@ def record_raw_archive(conn, source_id: str, dataset_id: str, request: dict[str, | ||
| 26 | 32 | |
| 27 | 33 | |
| 28 | 34 | def upsert_coverage(conn, dataset_id: str, table: pa.Table, interval: Interval) -> int: |
| 35 | + if table.num_rows and table.column("asset_class")[0].as_py() in ("fx", "crypto", "futures"): | |
| 36 | + return 0 # no exchange-session expectation for 24h markets | |
| 29 | 37 | rows = session_coverage(table, interval) |
| 30 | 38 | for r in rows: |
| 31 | 39 | conn.execute( |
modified
services/ingest/src/findata_ingest/tasks/compaction.py
+5 −0
@@ -20,6 +20,11 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 20 | 20 | max_files = int(p.get("max_delta_files", 40)) |
| 21 | 21 | cutoff = (datetime.now(UTC) - timedelta(days=min_age)).date() |
| 22 | 22 | with connection() as conn: |
| 23 | + busy = conn.execute("SELECT count(*) AS n FROM ingest_jobs WHERE dataset_id=%s AND kind IN ('backfill','derive') AND status IN ('queued','running') AND id<>%s", | |
| 24 | + (dataset_id, job["id"])).fetchone()["n"] | |
| 25 | + if busy: | |
| 26 | + log.info("compaction.deferred", reason=f"{busy} backfill/derive job(s) pending on {dataset_id}") | |
| 27 | + return {"compacted_files": 0, "deferred": True} | |
| 23 | 28 | deltas = conn.execute("SELECT path, date_min FROM lake_files WHERE dataset_id=%s AND layout='delta' AND version_removed IS NULL AND date_max <= %s ORDER BY date_min LIMIT %s", |
| 24 | 29 | (dataset_id, cutoff, max_files)).fetchall() |
| 25 | 30 | if not deltas: |
modified
services/ingest/src/findata_ingest/tasks/fundamentals.py
+8 −2
@@ -17,9 +17,15 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 17 | 17 | p = job.get("params") or {} |
| 18 | 18 | tickers = [t.upper() for t in (p.get("tickers") or [])] |
| 19 | 19 | if not tickers: |
| 20 | − n = int(p.get("limit", 50)) | |
| 21 | 20 | with connection() as conn: |
| 22 | − tickers = [r["symbol"] for r in conn.execute("SELECT symbol FROM instruments WHERE asset_class='stock' AND status='active' ORDER BY symbol LIMIT %s", (n,)).fetchall()] | |
| 21 | + if p.get("all_active"): | |
| 22 | + tickers = [r["symbol"] for r in conn.execute("SELECT symbol FROM instruments WHERE asset_class='stock' AND status='active' ORDER BY symbol").fetchall()] | |
| 23 | + elif p.get("tracked"): | |
| 24 | + row = conn.execute("SELECT value FROM settings_kv WHERE key='fundamentals_tracked_tickers'").fetchone() | |
| 25 | + tickers = list(row["value"]) if row and row["value"] else [r["symbol"] for r in conn.execute("SELECT DISTINCT split_part(entity_id, ':', 2) symbol FROM fundamentals_facts WHERE entity_id LIKE 'frd:%'").fetchall()] | |
| 26 | + else: | |
| 27 | + n = int(p.get("limit", 50)) | |
| 28 | + tickers = [r["symbol"] for r in conn.execute("SELECT symbol FROM instruments WHERE asset_class='stock' AND status='active' ORDER BY symbol LIMIT %s", (n,)).fetchall()] | |
| 23 | 29 | fr = FirstRateFundamentalsConnector() |
| 24 | 30 | observed = datetime.now(UTC) |
| 25 | 31 | run_id = job.get("run_id") |
modified
services/ingest/src/findata_ingest/tasks/incremental.py
+15 −8
@@ -12,13 +12,14 @@ from typing import Any | ||
| 12 | 12 | import pyarrow as pa |
| 13 | 13 | from findata_core.db import connection, transaction |
| 14 | 14 | from findata_market_firstrate import FirstRateMarketConnector |
| 15 | +from findata_market_firstrate.client import FRDNoData | |
| 15 | 16 | from findata_schema import Interval |
| 16 | 17 | from findata_schema.connector import FetchRequest |
| 17 | 18 | |
| 18 | 19 | from findata_ingest import queue |
| 19 | 20 | from findata_ingest.lake import Lake |
| 20 | 21 | from findata_ingest.tasks.common import ( |
| 21 | − DATASET_ASSET, | |
| 22 | + dataset_for_params, | |
| 22 | 23 | live_delta_paths_for_dates, |
| 23 | 24 | record_anomalies, |
| 24 | 25 | record_quality_report, |
@@ -32,8 +33,8 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 32 | 33 | asset = p.get("type", "stock") |
| 33 | 34 | period = p.get("period", "day") |
| 34 | 35 | timeframe = p.get("timeframe", "1min") |
| 35 | − adjustment = p.get("adjustment", "UNADJUSTED") | |
| 36 | − dataset_id = job.get("dataset_id") or DATASET_ASSET[asset] | |
| 36 | + adjustment = p.get("adjustment") or ("contin_UNadj" if asset == "futures" else ("UNADJUSTED" if asset in ("stock", "etf") else None)) | |
| 37 | + dataset_id = job.get("dataset_id") or dataset_for_params({"type": asset, "timeframe": timeframe, "adjustment": adjustment}) | |
| 37 | 38 | run_id = job.get("run_id") |
| 38 | 39 | conn_fr = FirstRateMarketConnector() |
| 39 | 40 | vendor_last = conn_fr.client.last_update(asset) |
@@ -42,11 +43,16 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 42 | 43 | log.info("incremental.skip", reason="vendor not updated since last run", vendor_last_update=str(vendor_last)) |
| 43 | 44 | return {"skipped": True, "vendor_last_update": vendor_last.isoformat()} |
| 44 | 45 | |
| 45 | − req = FetchRequest(dataset_id=dataset_id, params={"endpoint": "data_file", "type": asset, "period": period, | |
| 46 | − "timeframe": timeframe, "adjustment": adjustment}) | |
| 46 | + req = FetchRequest(dataset_id=dataset_id, params={k: v for k, v in {"endpoint": "data_file", "type": asset, "period": period, | |
| 47 | + "timeframe": timeframe, "adjustment": adjustment}.items() if v is not None}) | |
| 47 | 48 | queue.progress(job["id"], {"stage": "download"}) |
| 48 | − res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id)) | |
| 49 | − rel_raw = str(res.path.relative_to(lake.root)) | |
| 49 | + try: | |
| 50 | + res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id)) | |
| 51 | + except FRDNoData as e: | |
| 52 | + queue.set_checkpoint(f"dataset:{dataset_id}", f"last_vendor_update:{period}", {"vendor_last_update": vendor_last.isoformat(), "no_data": True}) | |
| 53 | + log.info("incremental.no_data", reason=str(e)[:200]) | |
| 54 | + return {"skipped": True, "reason": str(e)[:300], "vendor_last_update": vendor_last.isoformat()} | |
| 55 | + rel_raw = lake.rel(res.path) | |
| 50 | 56 | with transaction() as conn: |
| 51 | 57 | raw_id, is_new = record_raw_archive(conn, "firstrate", dataset_id, req.params, rel_raw, res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id) |
| 52 | 58 | if not is_new: |
@@ -92,6 +98,7 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 92 | 98 | pub = lake.publish(dataset_id, written, retire, run_id, note=f"incremental {asset} {period} {timeframe} {adjustment}") |
| 93 | 99 | with transaction() as conn: |
| 94 | 100 | conn.execute("UPDATE raw_archives SET status='published' WHERE id=%s", (raw_id,)) |
| 101 | + conn.execute("UPDATE datasets SET status='active', updated_at=now() WHERE id=%s AND status='planned'", (dataset_id,)) | |
| 95 | 102 | cov = sum(upsert_coverage(conn, dataset_id, t, interval) for t in tables.values()) |
| 96 | 103 | record_anomalies(conn, dataset_id, anomalies, run_id) |
| 97 | 104 | record_quality_report(conn, dataset_id, run_id, sum(t.num_rows for t in tables.values()), anomalies) |
@@ -100,7 +107,7 @@ def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | ||
| 100 | 107 | queue.set_checkpoint(cp.scope, cp.key, cp.value) |
| 101 | 108 | dates = [d.isoformat() for d in sorted(tables)] |
| 102 | 109 | # downstream: derived 5-minute bars for these sessions |
| 103 | − if interval == Interval.m1 and asset in ("stock", "etf"): | |
| 110 | + if interval == Interval.m1 and asset in ("stock", "etf") and dataset_id.endswith(".raw"): | |
| 104 | 111 | queue.enqueue("derive", dataset_id.replace(".1m.", ".5m."), {"source_dataset": dataset_id, "target_interval": "5m", "dates": dates}, |
| 105 | 112 | priority=40, dedupe_key=f"derive:{dataset_id}:5m:{dates[0]}:{dates[-1]}:{pub['version']}") |
| 106 | 113 | result = {"vendor_last_update": vendor_last.isoformat(), "archive_sha256": res.sha256, "archive_bytes": res.bytes, |
added
services/ingest/src/findata_ingest/tasks/options.py
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +"""End-of-day option chains: one vendor archive per (year, quarter) or `period=month`; one vendor file per underlying. | |
| 2 | +Layout: options.eod/base/underlying=<U>/period=<YYYYqN|latest>/part-*.parquet. The monthly archive replaces the | |
| 3 | +`latest` partition and drops rows already covered by a completed quarter archive (no overlap in the live set).""" | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +from datetime import UTC, date, datetime | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +import pyarrow as pa | |
| 10 | +import pyarrow.compute as pc | |
| 11 | +from findata_core.db import connection, transaction | |
| 12 | +from findata_market_firstrate import ( | |
| 13 | + FirstRateMarketConnector, | |
| 14 | + FRDNoData, | |
| 15 | + iter_archive_files, | |
| 16 | + parse_options_file, | |
| 17 | + ticker_from_filename, | |
| 18 | +) | |
| 19 | +from findata_schema.connector import FetchRequest | |
| 20 | + | |
| 21 | +from findata_ingest import queue | |
| 22 | +from findata_ingest.lake import Lake | |
| 23 | +from findata_ingest.tasks.common import record_raw_archive | |
| 24 | + | |
| 25 | +DATASET = "options.eod" | |
| 26 | + | |
| 27 | + | |
| 28 | +def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]: | |
| 29 | + p = job.get("params") or {} | |
| 30 | + run_id = job.get("run_id") | |
| 31 | + if p.get("period"): | |
| 32 | + params = {"endpoint": "data_file", "type": "options", "period": p["period"]} | |
| 33 | + period_label = "latest" | |
| 34 | + else: | |
| 35 | + params = {"endpoint": "data_file", "type": "options", "year": str(p["year"]), "quarter": p["quarter"]} | |
| 36 | + period_label = f"{p['year']}{p['quarter']}" | |
| 37 | + conn_fr = FirstRateMarketConnector() | |
| 38 | + ck = queue.get_checkpoint(f"job:{job['id']}", "state") or {} | |
| 39 | + done = set(ck.get("done") or []) | |
| 40 | + if ck.get("raw_path") and lake.abs(ck["raw_path"]).exists(): | |
| 41 | + from findata_schema.connector import FetchResult | |
| 42 | + res = FetchResult(FetchRequest(DATASET, params), lake.abs(ck["raw_path"]), ck["sha256"], ck["bytes"], datetime.fromisoformat(ck["fetched_at"]), None) | |
| 43 | + log.info("options.resume", done=len(done)) | |
| 44 | + else: | |
| 45 | + queue.progress(job["id"], {"stage": "download", "period": period_label}) | |
| 46 | + try: | |
| 47 | + res = conn_fr.fetch_range(FetchRequest(DATASET, params), lake.raw_dir("firstrate", DATASET)) | |
| 48 | + except FRDNoData as e: | |
| 49 | + return {"skipped": True, "reason": str(e)[:300]} | |
| 50 | + with transaction() as conn: | |
| 51 | + record_raw_archive(conn, "firstrate", DATASET, params, lake.rel(res.path), res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id) | |
| 52 | + queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": lake.rel(res.path), "sha256": res.sha256, "bytes": res.bytes, | |
| 53 | + "fetched_at": res.fetched_at.isoformat(), "done": []}) | |
| 54 | + log.info("options.downloaded", bytes=res.bytes, files=res.file_count, period=period_label) | |
| 55 | + # rows already covered by completed quarter archives are excluded from `latest` | |
| 56 | + cutoff: date | None = None | |
| 57 | + if period_label == "latest": | |
| 58 | + with connection() as conn: | |
| 59 | + r = conn.execute("SELECT max(date_max) AS d FROM lake_files WHERE dataset_id=%s AND version_removed IS NULL AND path NOT LIKE %s", | |
| 60 | + (DATASET, "%/period=latest/%")).fetchone() | |
| 61 | + cutoff = r["d"] | |
| 62 | + ingested_at = datetime.now(UTC) | |
| 63 | + written, retire = [], [] | |
| 64 | + stats = {"underlyings": 0, "rows": 0, "raw_rows": 0, "files": 0, "skipped_files": 0} | |
| 65 | + with connection() as conn: | |
| 66 | + old_latest = {r["symbol_min"]: r["path"] for r in conn.execute("SELECT symbol_min, path FROM lake_files WHERE dataset_id=%s AND version_removed IS NULL AND path LIKE %s", | |
| 67 | + (DATASET, f"%/period={period_label}/%")).fetchall()} | |
| 68 | + batch_rows = 0 | |
| 69 | + for name, data in iter_archive_files(res.path): | |
| 70 | + und = ticker_from_filename(name) | |
| 71 | + if und in done or und.startswith("_"): | |
| 72 | + continue | |
| 73 | + try: | |
| 74 | + table, raw_rows = parse_options_file(data, und, "firstrate", ingested_at) | |
| 75 | + except ValueError as e: | |
| 76 | + stats["skipped_files"] += 1 | |
| 77 | + log.warning("options.skip_file", file=name, error=str(e)[:120]) | |
| 78 | + continue | |
| 79 | + if cutoff is not None and table.num_rows: | |
| 80 | + table = table.filter(pc.greater(table.column("trade_date"), pa.scalar(cutoff, pa.date32()))) | |
| 81 | + stats["raw_rows"] += raw_rows | |
| 82 | + if table.num_rows == 0: | |
| 83 | + done.add(und) | |
| 84 | + continue | |
| 85 | + written.append(lake.write_table(DATASET, table, "base", {"underlying": und, "period": period_label}, sort=False)) | |
| 86 | + if und in old_latest: | |
| 87 | + retire.append(old_latest[und]) | |
| 88 | + stats["underlyings"] += 1 | |
| 89 | + stats["rows"] += table.num_rows | |
| 90 | + done.add(und) | |
| 91 | + batch_rows += table.num_rows | |
| 92 | + if len(written) >= 1000: # publish in batches so a crash loses little work | |
| 93 | + lake.publish(DATASET, written, retire, run_id, note=f"options {period_label} batch") | |
| 94 | + stats["files"] += len(written) | |
| 95 | + written, retire = [], [] | |
| 96 | + queue.set_checkpoint(f"job:{job['id']}", "state", {**ck, "raw_path": lake.rel(res.path), "sha256": res.sha256, "bytes": res.bytes, | |
| 97 | + "fetched_at": res.fetched_at.isoformat(), "done": sorted(done)}) | |
| 98 | + queue.progress(job["id"], {"stage": "publish", **stats}) | |
| 99 | + if written: | |
| 100 | + lake.publish(DATASET, written, retire, run_id, note=f"options {period_label}") | |
| 101 | + stats["files"] += len(written) | |
| 102 | + with transaction() as conn: | |
| 103 | + conn.execute("UPDATE raw_archives SET status='published' WHERE sha256=%s", (res.sha256,)) | |
| 104 | + conn.execute("UPDATE datasets SET status='active', updated_at=now() WHERE id=%s AND status='planned'", (DATASET,)) | |
| 105 | + queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": lake.rel(res.path), "sha256": res.sha256, "bytes": res.bytes, | |
| 106 | + "fetched_at": res.fetched_at.isoformat(), "done": sorted(done)}) | |
| 107 | + log.info("options.done", period=period_label, **stats) | |
| 108 | + return {"period": period_label, **stats} | |
modified
services/ingest/src/findata_ingest/worker.py
+49 −4
@@ -17,6 +17,8 @@ from findata_ingest.tasks import REGISTRY | ||
| 17 | 17 | |
| 18 | 18 | ET = ZoneInfo("America/New_York") |
| 19 | 19 | _stop = False |
| 20 | +BULK_KINDS = ["backfill", "options", "fundamentals"] | |
| 21 | +PRIMARY_KINDS = ["metadata", "incremental", "derive", "compaction", "backup", "quality", "gc"] | |
| 20 | 22 | |
| 21 | 23 | |
| 22 | 24 | def _handle(sig, frame): |
@@ -39,6 +41,37 @@ def schedule_tick(log) -> None: | ||
| 39 | 41 | queue.enqueue("metadata", None, {"types": ["stock", "etf"]}, priority=30, dedupe_key=f"metadata:{day}") |
| 40 | 42 | queue.enqueue("compaction", "market_bars.stock.1m.raw", {}, priority=60, dedupe_key=f"compaction:1m:{day}") |
| 41 | 43 | queue.enqueue("compaction", "market_bars.stock.5m.raw", {}, priority=61, dedupe_key=f"compaction:5m:{day}") |
| 44 | + # other raw 1-minute feeds and daily bars (vendor publishes daily; crypto 7/7) | |
| 45 | + if now.hour >= 2 or now.hour < 1: | |
| 46 | + for t in ("index", "fx", "crypto"): | |
| 47 | + if now.weekday() < 6 or t == "crypto": | |
| 48 | + queue.enqueue("incremental", f"market_bars.{t}.1m.raw", {"type": t, "period": "day", "timeframe": "1min"}, priority=26, | |
| 49 | + dedupe_key=f"incremental:{t}:day:{day}:{now.hour}:{now.minute // 30}") | |
| 50 | + if now.weekday() < 6: | |
| 51 | + for t in ("stock", "etf"): | |
| 52 | + queue.enqueue("incremental", f"market_bars.{t}.1d.raw", {"type": t, "period": "day", "timeframe": "1day", "adjustment": "UNADJUSTED"}, priority=27, | |
| 53 | + dedupe_key=f"incremental:{t}:1day:{day}:{now.hour}:{now.minute // 30}") | |
| 54 | + queue.enqueue("incremental", "market_bars.etf.1m.raw", {"type": "etf", "period": "day"}, priority=21, | |
| 55 | + dedupe_key=f"incremental:etf:day:{day}:{now.hour}:{now.minute // 30}") | |
| 56 | + if now.weekday() == 5 and now.hour == 6: | |
| 57 | + # weekly: futures continuous (week), individual contracts update, adjusted stock/etf histories (full_update) | |
| 58 | + for tf in ("1min", "1day", "5min", "30min", "1hour"): | |
| 59 | + for adj in ("contin_UNadj", "contin_adj_ratio", "contin_adj_absolute"): | |
| 60 | + queue.enqueue("incremental", None, {"type": "futures", "period": "week", "timeframe": tf, "adjustment": adj}, priority=36, | |
| 61 | + dedupe_key=f"incremental:futures:week:{tf}:{adj}:{day}") | |
| 62 | + queue.enqueue("backfill", None, {"type": "futures", "endpoint": "futures_contract", "contract_files": "update", "timeframe": tf, "contracts": True}, | |
| 63 | + priority=37, dedupe_key=f"contracts:update:{tf}:{day}") | |
| 64 | + for t in ("stock", "etf"): | |
| 65 | + for adj in ("adj_split", "adj_splitdiv"): | |
| 66 | + queue.enqueue("backfill", None, {"type": t, "endpoint": "full_update", "period": "week", "timeframe": tf, "adjustment": adj}, | |
| 67 | + priority=38, dedupe_key=f"full_update:{t}:{tf}:{adj}:{day}") | |
| 68 | + for t in ("index", "fx", "crypto"): | |
| 69 | + if tf != "1min": | |
| 70 | + queue.enqueue("incremental", None, {"type": t, "period": "week", "timeframe": tf}, priority=39, dedupe_key=f"incremental:{t}:week:{tf}:{day}") | |
| 71 | + queue.enqueue("backfill", "market_bars.stock.1m.raw", {"type": "stock", "endpoint": "delisted_data_file", "update": "week", "timeframe": "1min", "adjustment": "UNADJUSTED"}, | |
| 72 | + priority=38, dedupe_key=f"delisted:week:{day}") | |
| 73 | + if now.day == 2 and now.hour == 8: | |
| 74 | + queue.enqueue("options", "options.eod", {"period": "month"}, priority=55, dedupe_key=f"options:month:{now.year}-{now.month:02d}") | |
| 42 | 75 | if now.hour == 5 and now.minute < 30: |
| 43 | 76 | queue.enqueue("fundamentals", "fundamentals.financials", {"tracked": True}, priority=50, dedupe_key=f"fundamentals:{day}") |
| 44 | 77 | if now.hour == 4 and now.minute < 30 and get_settings().backup_target: |
@@ -48,18 +81,31 @@ def schedule_tick(log) -> None: | ||
| 48 | 81 | queue.enqueue("gc", None, {}, priority=90, dedupe_key=f"gc:{day}") |
| 49 | 82 | |
| 50 | 83 | |
| 51 | −def main(once: bool = False, kinds: list[str] | None = None, with_scheduler: bool = True) -> None: | |
| 84 | +def main(once: bool = False, kinds: list[str] | None = None, with_scheduler: bool = True, role: str = "primary") -> None: | |
| 85 | + """role=primary: holds the lake lock, runs the scheduler and the serialized kinds; role=bulk: one of N | |
| 86 | + parallel workers for backfill/options/fundamentals (publication is serialized by Postgres row locks).""" | |
| 52 | 87 | configure_logging() |
| 53 | 88 | log = get_logger("worker") |
| 54 | 89 | signal.signal(signal.SIGTERM, _handle) |
| 55 | 90 | signal.signal(signal.SIGINT, _handle) |
| 56 | 91 | lake = Lake() |
| 92 | + if role == "bulk": | |
| 93 | + _loop(lake, log, once, kinds or BULK_KINDS, False) | |
| 94 | + return | |
| 57 | 95 | requeued = queue.requeue_stale() |
| 58 | 96 | if requeued: |
| 59 | 97 | log.warning("worker.requeued_stale", n=requeued) |
| 60 | − last_tick = 0.0 | |
| 98 | + if kinds is None: | |
| 99 | + kinds = PRIMARY_KINDS if get_settings().bulk_workers > 0 else None | |
| 61 | 100 | with WriterLock(lake.root): |
| 62 | − log.info("worker.start", lake=str(lake.root), free_gb=round(lake.free_gb(), 1)) | |
| 101 | + _loop(lake, log, once, kinds, with_scheduler) | |
| 102 | + log.info("worker.stop") | |
| 103 | + | |
| 104 | + | |
| 105 | +def _loop(lake: Lake, log, once: bool, kinds: list[str] | None, with_scheduler: bool) -> None: | |
| 106 | + last_tick = 0.0 | |
| 107 | + if True: | |
| 108 | + log.info("worker.start", lake=str(lake.root), cold=str(lake.cold), free_gb=round(lake.free_gb(), 1), kinds=kinds) | |
| 63 | 109 | while not _stop: |
| 64 | 110 | if with_scheduler and time.monotonic() - last_tick > 300: |
| 65 | 111 | try: |
@@ -87,4 +133,3 @@ def main(once: bool = False, kinds: list[str] | None = None, with_scheduler: boo | ||
| 87 | 133 | jl.error("job.failed", error=str(e)[:300], seconds=round(time.monotonic() - t0, 1)) |
| 88 | 134 | if once: |
| 89 | 135 | break |
| 90 | − log.info("worker.stop") | |
modified
services/quality/src/findata_quality/validators.py
+9 −2
@@ -13,14 +13,21 @@ from findata_quality.calendar import SessionCalendar, xnys | ||
| 13 | 13 | PRICE_JUMP_THRESHOLD = 0.25 # 25 % between consecutive 1-minute closes inside a session (flag only) |
| 14 | 14 | |
| 15 | 15 | |
| 16 | +NO_SESSION_CALENDAR = {"fx", "crypto", "futures"} # 24h / overnight sessions: no XNYS session checks | |
| 17 | + | |
| 18 | + | |
| 16 | 19 | def validate_bars(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None, |
| 17 | − check_sessions: bool = True) -> tuple[pa.Table, list[dict[str, Any]]]: | |
| 20 | + check_sessions: bool = True, asset_class: str | None = None) -> tuple[pa.Table, list[dict[str, Any]]]: | |
| 18 | 21 | """Return (table with `quality_flags` filled, anomalies list). Input must follow BARS_SCHEMA columns.""" |
| 19 | 22 | cal = calendar or xnys() |
| 20 | 23 | step = INTERVAL_SECONDS[interval] |
| 21 | 24 | df = pl.from_arrow(table) |
| 22 | 25 | if df.height == 0: |
| 23 | 26 | return table, [] |
| 27 | + if asset_class is None and "asset_class" in df.columns: | |
| 28 | + asset_class = df.get_column("asset_class")[0] | |
| 29 | + if asset_class in NO_SESSION_CALENDAR: | |
| 30 | + check_sessions = False | |
| 24 | 31 | df = df.sort(["symbol_as_reported", "timestamp_utc"]) |
| 25 | 32 | ts_s = pl.col("timestamp_utc").dt.epoch("s") |
| 26 | 33 | flags: list[pl.Expr] = [ |
@@ -28,7 +35,7 @@ def validate_bars(table: pa.Table, interval: Interval, calendar: SessionCalendar | ||
| 28 | 35 | | (pl.col("high") < pl.col("low"))).then(pl.lit(QualityFlag.ohlc_inconsistent.value)), |
| 29 | 36 | pl.when(pl.min_horizontal("open", "high", "low", "close") <= 0).then(pl.lit(QualityFlag.nonpositive_price.value)), |
| 30 | 37 | pl.when(pl.col("volume") < 0).then(pl.lit(QualityFlag.negative_volume.value)), |
| 31 | − pl.when(pl.col("volume") == 0).then(pl.lit(QualityFlag.zero_volume.value)), | |
| 38 | + pl.when((pl.col("volume") == 0) & pl.lit(asset_class != "index")).then(pl.lit(QualityFlag.zero_volume.value)), | |
| 32 | 39 | pl.when(pl.col("volume") != pl.col("volume").floor()).then(pl.lit(QualityFlag.fractional_volume.value)), |
| 33 | 40 | pl.when((ts_s % step) != 0).then(pl.lit(QualityFlag.off_interval.value)), |
| 34 | 41 | pl.when(pl.struct(["symbol_as_reported", "timestamp_utc"]).is_duplicated()).then(pl.lit(QualityFlag.duplicate.value)), |
added
tests/test_full_catalog.py
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +"""Full vendor catalog: new layouts (index without volume, futures contracts, option chains), no-data responses, | |
| 2 | +hot/cold lake roots, the complete plan, and the options task through the vendor mock.""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import io | |
| 6 | +import zipfile | |
| 7 | +from datetime import UTC, date, datetime | |
| 8 | + | |
| 9 | +import httpx | |
| 10 | +import pytest | |
| 11 | +from findata_market_firstrate import ( | |
| 12 | + FRDClient, | |
| 13 | + FRDNoData, | |
| 14 | + build_full_plan, | |
| 15 | + dataset_id_for, | |
| 16 | + parse_options_file, | |
| 17 | + ticker_from_filename, | |
| 18 | +) | |
| 19 | +from findata_market_firstrate.archive import parse_bar_file | |
| 20 | +from findata_quality import validate_bars | |
| 21 | +from findata_schema import OPTIONS_SCHEMA, Adjustment, AssetClass, Interval | |
| 22 | + | |
| 23 | +from tests.conftest import TEST_USERID | |
| 24 | + | |
| 25 | +NOW = datetime(2026, 11, 1, 12, 0, tzinfo=UTC) | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_index_without_volume_and_fx_two_fields(): | |
| 29 | + t, raw = parse_bar_file(b"2026-10-29 04:00:00,1136.43,1137.03,1135.97,1135.97\n2026-10-29 04:01:00,1135.89,1135.89,1134.56,1134.83\n", | |
| 30 | + "AEX", AssetClass.index, Interval.m1, Adjustment.raw, "firstrate", NOW) | |
| 31 | + assert raw == 2 and t.num_rows == 2 and t.column("volume").to_pylist() == [0.0, 0.0] | |
| 32 | + out, anomalies = validate_bars(t, Interval.m1, asset_class="index") | |
| 33 | + assert all("zero_volume" not in f for f in out.column("quality_flags").to_pylist()) | |
| 34 | + t, _ = parse_bar_file(b"20261026,00:02:00,3.49536933,3.49536933,3.49536933,3.49536933,1\n", "AUDBRL", AssetClass.fx, Interval.m1, Adjustment.raw, "firstrate", NOW) | |
| 35 | + assert t.column("timestamp_utc")[0].as_py() == datetime(2026, 10, 26, 4, 2, tzinfo=UTC) # 00:02 EDT | |
| 36 | + out, anomalies = validate_bars(t, Interval.m1, asset_class="fx") | |
| 37 | + assert not any(a["code"] in ("outside_session", "non_trading_day") for a in anomalies) # 24h market: no session checks | |
| 38 | + t, _ = parse_bar_file(b"2026-10-30 00:00:00,119.071,119.46,119.04,119.045,208\n", "AAVE", AssetClass.crypto, Interval.m1, Adjustment.raw, "firstrate", NOW, source_tz="UTC") | |
| 39 | + assert t.column("timestamp_utc")[0].as_py() == datetime(2026, 10, 30, 0, 0, tzinfo=UTC) | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_contract_filenames_and_daily_open_interest(): | |
| 43 | + assert ticker_from_filename("A6_F25_1day.txt", contract=True) == "A6_F25" | |
| 44 | + assert ticker_from_filename("ES_H25_1min.txt", contract=True) == "ES_H25" | |
| 45 | + assert ticker_from_filename("ES_full_1min_contin_adj_ratio.txt") == "ES" | |
| 46 | + t, _ = parse_bar_file(b"2024-09-11,0.6669,0.6679,0.6669,0.6679,10,5\n", "A6_F25", AssetClass.futures, Interval.d1, Adjustment.raw, "firstrate", NOW) | |
| 47 | + assert t.num_rows == 1 and t.column("session_date")[0].as_py() == date(2024, 9, 11) | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_option_chain_parser(): | |
| 51 | + data = b"2026-10-01,33,2026-10-16,c,8.14,7.3,8.8,0.0,1.102138,0,0,1.0,0.0,1e-05,0.0,0.0\n2026-10-01,34,2026-10-16,p,7.14,6.4,7.9,0.0,1.048606,12,3,-0.4,0.01,1e-05,-0.02,0.0\n" | |
| 52 | + t, raw = parse_options_file(data, "AAL", "firstrate", NOW) | |
| 53 | + assert raw == 2 and t.num_rows == 2 and t.schema.equals(OPTIONS_SCHEMA) | |
| 54 | + r = t.to_pylist()[1] | |
| 55 | + assert r["underlying"] == "AAL" and r["call_put"] == "p" and r["strike"] == 34.0 and r["expiry"] == date(2026, 10, 16) and r["open_interest"] == 12.0 | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_no_data_response_is_distinguished_from_errors(tmp_path): | |
| 59 | + def handler(req: httpx.Request) -> httpx.Response: | |
| 60 | + return httpx.Response(200, text="No update available", headers={"content-type": "text/plain", "content-length": "19"}) | |
| 61 | + c = FRDClient(TEST_USERID, min_interval_s=0, transport=httpx.MockTransport(handler)) | |
| 62 | + with pytest.raises(FRDNoData): | |
| 63 | + c.download("full_update", {"type": "stock", "period": "week", "timeframe": "1day", "adjustment": "adj_split"}, tmp_path / "x.zip") | |
| 64 | + | |
| 65 | + | |
| 66 | +def test_dataset_ids_and_full_plan(): | |
| 67 | + assert dataset_id_for("stock", "1min", "adj_splitdiv") == "market_bars.stock.1m.split_div" | |
| 68 | + assert dataset_id_for("etf", "1day", "UNADJUSTED") == "market_bars.etf.1d.raw" | |
| 69 | + assert dataset_id_for("futures", "30min", "contin_adj_absolute") == "market_bars.futures.30m.continuous_absolute" | |
| 70 | + assert dataset_id_for("futures", "1min", None, contracts=True) == "market_bars.futures_contracts.1m.raw" | |
| 71 | + assert dataset_id_for("options", "1day", None) == "options.eod" | |
| 72 | + plan = build_full_plan(today=date(2026, 11, 1)) | |
| 73 | + assert len(plan) == 798 | |
| 74 | + assert plan[0]["dataset_id"] == "market_bars.stock.1m.raw" and plan[0]["params"]["ticker_range"] == "A" | |
| 75 | + assert sum(1 for j in plan if j["kind"] == "options") == 67 + 1 | |
| 76 | + assert len({j["dedupe_key"] for j in plan}) == len(plan) | |
| 77 | + assert all(j["priority"] >= 10 for j in plan) and plan[-1]["kind"] == "options" | |
| 78 | + small = build_full_plan(types=["index"], include_options=False, include_delisted=False, today=date(2026, 11, 1)) | |
| 79 | + assert len(small) == 5 | |
| 80 | + | |
| 81 | + | |
| 82 | +def test_cold_root_placement(tmp_path, monkeypatch): | |
| 83 | + from findata_ingest.lake import Lake | |
| 84 | + cold = tmp_path / "ssd" | |
| 85 | + cold.mkdir() | |
| 86 | + with pytest.raises(RuntimeError): | |
| 87 | + Lake(tmp_path / "hot", cold) # marker missing → refuse (volume may be unmounted) | |
| 88 | + (cold / ".findata-cold-root").write_text("x") | |
| 89 | + lake = Lake(tmp_path / "hot", cold) | |
| 90 | + assert lake.root_for("market_bars.stock.1m.raw") == tmp_path / "hot" | |
| 91 | + assert lake.root_for("options.eod") == cold and lake.root_for("market_bars.stock.1m.split_div") == cold | |
| 92 | + assert lake.raw_dir("firstrate", "x").is_relative_to(cold) | |
| 93 | + assert lake.abs("curated/options.eod/base/underlying=A/period=2024q1/p.parquet").is_relative_to(cold) | |
| 94 | + assert lake.abs("curated/market_bars.stock.1m.raw/delta/session_date=2026-10-30/p.parquet").is_relative_to(tmp_path / "hot") | |
| 95 | + assert lake.rel(cold / "raw" / "a.zip") == "raw/a.zip" | |
| 96 | + | |
| 97 | + | |
| 98 | +def test_options_task_with_vendor_mock(vendor, lake, log, monkeypatch): | |
| 99 | + from findata_core.db import connection, fetch_one | |
| 100 | + from findata_ingest import queue | |
| 101 | + from findata_ingest.tasks import options as O | |
| 102 | + | |
| 103 | + def opt_zip() -> bytes: | |
| 104 | + buf = io.BytesIO() | |
| 105 | + with zipfile.ZipFile(buf, "w") as zf: | |
| 106 | + zf.writestr(zipfile.ZipInfo("AAL_month_option_chain.txt", (2026, 10, 30, 0, 0, 0)), "2026-10-01,33,2026-10-16,c,8.14,7.3,8.8,0.0,1.1,0,0,1.0,0.0,1e-05,0.0,0.0\n2026-10-02,33,2026-10-16,c,8.0,7.2,8.7,0.0,1.1,1,2,1.0,0.0,1e-05,0.0,0.0\n") | |
| 107 | + zf.writestr(zipfile.ZipInfo("SPY_month_option_chain.txt", (2026, 10, 30, 0, 0, 0)), "2026-10-01,500,2026-10-17,p,1.5,1.4,1.6,0.2,0.21,100,50,-0.3,0.01,0.1,-0.05,0.0\n") | |
| 108 | + return buf.getvalue() | |
| 109 | + vendor.archive_override = opt_zip() | |
| 110 | + jid = queue.enqueue("options", "options.eod", {"period": "month"}) | |
| 111 | + job = queue.claim_next(["options"]) | |
| 112 | + r = O.run(job, lake, log) | |
| 113 | + queue.finish(job, True, r) | |
| 114 | + assert r["underlyings"] == 2 and r["rows"] == 3 and r["files"] == 2 | |
| 115 | + with connection() as conn: | |
| 116 | + rows = conn.execute("SELECT symbol_min, date_min, date_max, row_count FROM lake_files WHERE dataset_id='options.eod' AND version_removed IS NULL ORDER BY 1").fetchall() | |
| 117 | + assert [(x["symbol_min"], x["row_count"]) for x in rows] == [("AAL", 2), ("SPY", 1)] | |
| 118 | + assert fetch_one("SELECT status FROM datasets WHERE id='options.eod'")["status"] == "active" | |
| 119 | + # the month archive re-run supersedes the `latest` partition (no duplicates) | |
| 120 | + job = queue.claim_next(["options"]) or (queue.enqueue("options", "options.eod", {"period": "month"}) and queue.claim_next(["options"])) | |
| 121 | + r2 = O.run(job, lake, log) | |
| 122 | + queue.finish(job, True, r2) | |
| 123 | + with connection() as conn: | |
| 124 | + live = conn.execute("SELECT count(*) AS n, sum(row_count) AS rows FROM lake_files WHERE dataset_id='options.eod' AND version_removed IS NULL").fetchone() | |
| 125 | + assert live["n"] == 2 and live["rows"] == 3 | |
| 126 | + vendor.archive_override = None | |
modified
tests/test_ingest_lake.py
+3 −0
@@ -103,6 +103,7 @@ def test_derive_5m_from_published_session(vendor, lake, log): | ||
| 103 | 103 | job = queue.claim_next(["derive"]) # enqueued by the incremental run |
| 104 | 104 | assert job is not None |
| 105 | 105 | r = derive.run(job, lake, log) |
| 106 | + queue.finish(job, True, r) | |
| 106 | 107 | assert r["rows"] > 0 |
| 107 | 108 | t, _ = read_bars("market_bars.stock.5m.raw", ["AAPL"], date(2026, 10, 30), date(2026, 10, 30)) |
| 108 | 109 | assert 150 <= t.num_rows <= 192 and t.column("interval")[0].as_py() == "5m" |
@@ -135,6 +136,8 @@ def test_backfill_resumes_from_checkpoint_and_respects_delta_dates(vendor, lake, | ||
| 135 | 136 | |
| 136 | 137 | |
| 137 | 138 | def test_compaction_merges_delta_into_base_and_checks_invariant(vendor, lake, log): |
| 139 | + with transaction() as conn: # compaction defers while backfill/derive jobs are pending on the dataset | |
| 140 | + conn.execute("UPDATE ingest_jobs SET status='cancelled' WHERE status IN ('queued','running') AND kind IN ('derive','backfill')") | |
| 138 | 141 | job = _job("compaction", DS, {"min_age_days": 0}) |
| 139 | 142 | r = compaction.run(job, lake, log) |
| 140 | 143 | assert r["compacted_files"] == 1 and r["base_files_written"] == 3 |
| 141 | 144 | |