Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import csv | |
| from datetime import date, datetime | |
| import gzip | |
| import json | |
| from pathlib import Path | |
| import socket | |
| import sys | |
| import time | |
| from urllib.error import HTTPError, URLError | |
| from urllib.parse import urlencode | |
| from urllib.request import Request, urlopen | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT / "backend")) | |
| from app.data.seed_assets import SEED_ASSETS # noqa: E402 | |
| OUTPUT = ROOT / "backend" / "app" / "data" / "historical_prices_seed.csv.gz" | |
| MANIFEST = ROOT / "backend" / "app" / "data" / "historical_prices_seed_manifest.json" | |
| OUTPUT_TMP = OUTPUT.with_suffix(".csv.gz.tmp") | |
| MANIFEST_TMP = MANIFEST.with_suffix(".json.tmp") | |
| FIELDS = ["ticker", "date", "open", "high", "low", "close", "volume", "provider"] | |
| HTTP_TIMEOUT_SECONDS = 8 | |
| MIN_ACCEPTED_ROWS = 50_000 | |
| socket.setdefaulttimeout(HTTP_TIMEOUT_SECONDS) | |
| def main() -> int: | |
| OUTPUT.parent.mkdir(parents=True, exist_ok=True) | |
| written = 0 | |
| diagnostics = [] | |
| with gzip.open(OUTPUT_TMP, "wt", newline="", encoding="utf-8") as handle: | |
| writer = csv.DictWriter(handle, fieldnames=FIELDS) | |
| writer.writeheader() | |
| for asset in SEED_ASSETS: | |
| ticker = asset["ticker"] | |
| rows, provider, status = fetch_history(ticker) | |
| if len(rows) >= 60: | |
| for row in rows: | |
| row["ticker"] = ticker | |
| row["provider"] = provider | |
| writer.writerow({field: row.get(field, "") for field in FIELDS}) | |
| written += len(rows) | |
| diagnostics.append( | |
| { | |
| "ticker": ticker, | |
| "provider": provider, | |
| "status": status, | |
| "rows": len(rows), | |
| "start": rows[0]["date"] if rows else None, | |
| "end": rows[-1]["date"] if rows else None, | |
| } | |
| ) | |
| print(f"{ticker:8} {provider:12} {status:12} {len(rows):6} rows", flush=True) | |
| time.sleep(0.18) | |
| manifest = { | |
| "created_at": datetime.utcnow().isoformat() + "Z", | |
| "data_policy": "Real public OHLCV cache. Missing assets are not backfilled with synthetic data.", | |
| "output": OUTPUT.name, | |
| "row_count": written, | |
| "asset_count": len([item for item in diagnostics if item["rows"] >= 60]), | |
| "diagnostics": diagnostics, | |
| } | |
| MANIFEST_TMP.write_text(json.dumps(manifest, indent=2), encoding="utf-8") | |
| if written < MIN_ACCEPTED_ROWS: | |
| print( | |
| f"Refusing to replace cache: only {written} rows generated, minimum is {MIN_ACCEPTED_ROWS}. " | |
| f"Temporary file kept at {OUTPUT_TMP}", | |
| flush=True, | |
| ) | |
| return 1 | |
| OUTPUT_TMP.replace(OUTPUT) | |
| MANIFEST_TMP.replace(MANIFEST) | |
| print(f"Wrote {written} rows to {OUTPUT}", flush=True) | |
| return 0 | |
| def fetch_history(ticker: str) -> tuple[list[dict], str, str]: | |
| if "." not in ticker: | |
| rows = fetch_nasdaq(ticker) | |
| if len(rows) >= 60: | |
| return rows, "nasdaq_api", "ok" | |
| rows = fetch_yahoo_chart(ticker) | |
| if len(rows) >= 60: | |
| return rows, "yahoo_chart", "ok" | |
| return [], "none", "missing" | |
| def fetch_stooq(host: str, symbol: str) -> list[dict]: | |
| url = f"{host}/q/d/l/?s={symbol}&i=d" | |
| try: | |
| text = http_get(url).decode("utf-8", errors="replace") | |
| except Exception: | |
| return [] | |
| if not text or "No data" in text[:120]: | |
| return [] | |
| rows = [] | |
| for item in csv.DictReader(text.splitlines()): | |
| parsed = normalize_ohlcv_row(item) | |
| if parsed: | |
| rows.append(parsed) | |
| return sorted(rows, key=lambda row: row["date"]) | |
| def fetch_nasdaq(ticker: str) -> list[dict]: | |
| today = date.today() | |
| params = { | |
| "fromdate": "1990-01-01", | |
| "todate": today.isoformat(), | |
| "limit": "9999", | |
| } | |
| for asset_class in ("stocks", "etf"): | |
| url = f"https://api.nasdaq.com/api/quote/{ticker}/historical?{urlencode({**params, 'assetclass': asset_class})}" | |
| try: | |
| payload = json.loads(http_get(url, nasdaq=True).decode("utf-8", errors="replace")) | |
| except Exception: | |
| continue | |
| data = payload.get("data") if isinstance(payload, dict) else None | |
| raw_rows = (data or {}).get("tradesTable", {}).get("rows", []) | |
| rows = [normalize_nasdaq_row(row) for row in raw_rows] | |
| rows = [row for row in rows if row] | |
| if len(rows) >= 60: | |
| return sorted(rows, key=lambda row: row["date"]) | |
| return [] | |
| def fetch_yahoo_chart(ticker: str) -> list[dict]: | |
| url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}" | |
| params = urlencode({"range": "max", "interval": "1d", "includePrePost": "false", "events": "div,splits"}) | |
| try: | |
| payload = json.loads(http_get(f"{url}?{params}").decode("utf-8", errors="replace")) | |
| except Exception: | |
| return [] | |
| result = payload.get("chart", {}).get("result", []) if isinstance(payload, dict) else [] | |
| if not result: | |
| return [] | |
| data = result[0] | |
| timestamps = data.get("timestamp") or [] | |
| quote = (data.get("indicators", {}).get("quote") or [{}])[0] | |
| rows = [] | |
| for index, timestamp in enumerate(timestamps): | |
| try: | |
| close = value_at(quote.get("close"), index) | |
| if close is None: | |
| continue | |
| rows.append( | |
| { | |
| "date": datetime.utcfromtimestamp(int(timestamp)).date().isoformat(), | |
| "open": value_at(quote.get("open"), index), | |
| "high": value_at(quote.get("high"), index), | |
| "low": value_at(quote.get("low"), index), | |
| "close": close, | |
| "volume": value_at(quote.get("volume"), index), | |
| } | |
| ) | |
| except Exception: | |
| continue | |
| return sorted(rows, key=lambda row: row["date"]) | |
| def http_get(url: str, nasdaq: bool = False) -> bytes: | |
| headers = { | |
| "User-Agent": "Mozilla/5.0 AppleWebKit/537.36 Chrome/124.0 Safari/537.36", | |
| "Accept": "application/json,text/csv,text/plain,*/*", | |
| } | |
| if nasdaq: | |
| headers.update({"Origin": "https://www.nasdaq.com", "Referer": "https://www.nasdaq.com/"}) | |
| request = Request(url, headers=headers) | |
| try: | |
| with urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response: | |
| return response.read() | |
| except (HTTPError, URLError, TimeoutError): | |
| return b"" | |
| def normalize_ohlcv_row(row: dict) -> dict | None: | |
| try: | |
| parsed_date = parse_date(row.get("Date")) | |
| close = parse_number(row.get("Close")) | |
| if not parsed_date or close is None: | |
| return None | |
| return { | |
| "date": parsed_date, | |
| "open": parse_number(row.get("Open")), | |
| "high": parse_number(row.get("High")), | |
| "low": parse_number(row.get("Low")), | |
| "close": close, | |
| "volume": parse_number(row.get("Volume")), | |
| } | |
| except Exception: | |
| return None | |
| def normalize_nasdaq_row(row: dict) -> dict | None: | |
| try: | |
| parsed_date = parse_date(row.get("date")) | |
| close = parse_number(row.get("close")) | |
| if not parsed_date or close is None: | |
| return None | |
| return { | |
| "date": parsed_date, | |
| "open": parse_number(row.get("open")), | |
| "high": parse_number(row.get("high")), | |
| "low": parse_number(row.get("low")), | |
| "close": close, | |
| "volume": parse_number(row.get("volume")), | |
| } | |
| except Exception: | |
| return None | |
| def parse_date(value: str | None) -> str | None: | |
| if not value: | |
| return None | |
| value = value.strip() | |
| for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%b %d, %Y"): | |
| try: | |
| return datetime.strptime(value, fmt).date().isoformat() | |
| except Exception: | |
| continue | |
| return None | |
| def parse_number(value) -> float | None: | |
| if value is None: | |
| return None | |
| cleaned = str(value).replace("$", "").replace(",", "").strip() | |
| if cleaned in {"", "N/A", "--", "nan", "NaN"}: | |
| return None | |
| try: | |
| return float(cleaned) | |
| except Exception: | |
| return None | |
| def value_at(values, index: int) -> float | None: | |
| if not isinstance(values, list) or index >= len(values): | |
| return None | |
| value = values[index] | |
| if value is None: | |
| return None | |
| try: | |
| return float(value) | |
| except Exception: | |
| return None | |
| def stooq_candidates(ticker: str) -> list[str]: | |
| raw = ticker.lower() | |
| suffix_map = { | |
| ".pa": ".fr", | |
| ".de": ".de", | |
| ".mi": ".it", | |
| ".as": ".nl", | |
| ".sw": ".ch", | |
| ".l": ".uk", | |
| } | |
| candidates: list[str] = [] | |
| if "." in raw: | |
| for source_suffix, stooq_suffix in suffix_map.items(): | |
| if raw.endswith(source_suffix): | |
| candidates.append(raw[: -len(source_suffix)] + stooq_suffix) | |
| candidates.append(raw) | |
| else: | |
| candidates.append(f"{raw}.us") | |
| candidates.append(raw) | |
| return list(dict.fromkeys(candidates)) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |