Files
2026-07-13 13:26:28 +08:00

282 lines
9.4 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
import polars as pl
from utils.downloading import (
atomic_write_parquet,
create_base_parser,
load_dotenv,
load_section,
print_download_summary,
print_dry_run_notice,
print_section,
require_env,
resolve_data_dir,
resolve_storage_path,
save_dataset_profile,
)
def collect_series(config: dict[str, object]) -> dict[str, dict[str, str]]:
descriptions = config.get("descriptions", {})
series_cfg = config.get("series", {})
series_map: dict[str, dict[str, str]] = {}
for frequency, info in series_cfg.items():
for symbol in info.get("symbols", []):
series_map[str(symbol)] = {
"frequency": str(frequency),
"description": str(descriptions.get(symbol, "")),
"group": str(frequency),
}
return series_map
def build_metadata(
config: dict[str, object], series_map: dict[str, dict[str, str]]
) -> pl.DataFrame:
rows = [
{
"series": symbol.lower(),
"source_id": symbol,
"native_frequency": meta["frequency"],
"group": meta["group"],
"description": meta["description"],
"kind": "observed",
"formula": None,
}
for symbol, meta in sorted(series_map.items())
]
for derived in config.get("derived", []):
rows.append(
{
"series": str(derived["name"]),
"source_id": None,
"native_frequency": "derived_daily",
"group": "derived",
"description": str(derived.get("description", "")),
"kind": "derived",
"formula": str(derived.get("formula", "")),
}
)
return pl.DataFrame(rows)
def combine_aligned(existing_path: Path, new_df: pl.DataFrame) -> pl.DataFrame:
if not existing_path.exists():
return new_df.sort("date")
existing = pl.read_parquet(existing_path)
combined = (
pl.concat([existing, new_df], how="diagonal_relaxed")
.unique(subset=["date"], keep="last", maintain_order=True)
.sort("date")
)
for col in combined.columns:
if col != "date":
combined = combined.with_columns(pl.col(col).forward_fill())
return combined
def combine_raw(existing_path: Path, new_df: pl.DataFrame) -> pl.DataFrame:
if not existing_path.exists():
return new_df.sort(["series", "date"])
existing = pl.read_parquet(existing_path)
return (
pl.concat([existing, new_df], how="vertical_relaxed")
.unique(subset=["series", "date"], keep="last", maintain_order=True)
.sort(["series", "date"])
)
def compute_derived(df: pl.DataFrame, derived_defs: list[dict[str, str]]) -> pl.DataFrame:
result = df
for series_def in derived_defs:
name = str(series_def.get("name", ""))
formula = str(series_def.get("formula", ""))
if not name or not formula or "-" not in formula:
continue
left, right = [part.strip().lower() for part in formula.split("-", 1)]
if left in result.columns and right in result.columns:
result = result.with_columns((pl.col(left) - pl.col(right)).alias(name))
return result
def main() -> None:
parser = create_base_parser("Download macro data from FRED")
parser.add_argument(
"--config",
type=Path,
default=Path(__file__).parent / "config.yaml",
help="Path to macro config",
)
parser.add_argument("--series", "-s", type=str, help="Download a single FRED series only")
parser.add_argument("--list", action="store_true", help="List configured series")
parser.add_argument(
"--update",
action="store_true",
help="Extend the configured end date to today and append new rows",
)
args = parser.parse_args()
load_dotenv()
config = load_section(args.config, "macro")
series_map = collect_series(config)
if args.list:
print("Configured macro series:")
for symbol, meta in series_map.items():
print(f" {symbol:10s} ({meta['frequency']:9s}) {meta['description']}")
return
if args.dry_run:
print_dry_run_notice()
api_key = require_env(
"FRED_API_KEY", "Get free key at: https://fred.stlouisfed.org/docs/api/api_key.html"
)
from ml4t.data.providers.fred import FREDProvider
data_root = resolve_data_dir(args.data_path)
storage_path = resolve_storage_path(data_root, config.get("storage_path"), "macro")
outputs = config.get("outputs", {})
aligned_path = storage_path / str(outputs.get("aligned_file", "fred_macro.parquet"))
raw_path = storage_path / str(outputs.get("raw_file", "fred_macro_raw.parquet"))
metadata_path = storage_path / str(outputs.get("metadata_file", "fred_macro_metadata.parquet"))
start_date = str(config.get("start", "2000-01-01"))
end_date = date.today().isoformat() if args.update else str(config.get("end", "2025-12-31"))
if args.series:
series_id = args.series.upper()
if series_id not in series_map:
print(f"ERROR: unknown series {series_id}")
sys.exit(1)
requested = {series_id: series_map[series_id]}
else:
requested = series_map
print_section("MACRO DATA DOWNLOAD (FRED)")
print(f"Config: {args.config}")
print(f"Output: {aligned_path}")
print(f"Date range: {start_date} to {end_date}")
print(f"Series: {len(requested)}")
if args.dry_run:
print_download_summary(
{
"series": len(requested),
"start_date": start_date,
"end_date": end_date,
"output_file": str(aligned_path),
"raw_file": str(raw_path),
"metadata_file": str(metadata_path),
},
dry_run=True,
)
return
storage_path.mkdir(parents=True, exist_ok=True)
provider = FREDProvider(api_key=api_key)
request_start = start_date
if args.update and aligned_path.exists():
last_date = pl.read_parquet(aligned_path).select(pl.col("date").max()).item()
if last_date is not None:
request_start = (last_date + timedelta(days=1)).isoformat()
daily_frames: list[pl.DataFrame] = []
raw_frames: list[pl.DataFrame] = []
failed: list[str] = []
print("\nDownloading configured series...\n")
for series_id, meta in requested.items():
frequency = meta["frequency"]
description = meta["description"]
print(f" {series_id} ({frequency})...", end=" ", flush=True)
try:
frame = provider.fetch_ohlcv(
series_id,
start=request_start,
end=end_date,
frequency=frequency,
)
if frame.is_empty():
print("EMPTY")
failed.append(series_id)
continue
series_col = series_id.lower()
series_frame = frame.select(
[
pl.col("timestamp").cast(pl.Date).alias("date"),
pl.col("close").alias(series_col),
]
)
daily_frames.append(series_frame)
raw_frames.append(
series_frame.rename({series_col: "value"}).with_columns(
pl.lit(series_col).alias("series")
)
)
print(f"OK ({len(frame):,} rows) [{description}]")
except Exception as exc:
print(f"ERROR ({exc})")
failed.append(series_id)
provider.close()
if not daily_frames:
print("ERROR: no macro series downloaded")
sys.exit(1)
dates = pl.date_range(
datetime.strptime(request_start, "%Y-%m-%d"),
datetime.strptime(end_date, "%Y-%m-%d"),
eager=True,
)
aligned = pl.DataFrame({"date": dates})
for series_frame in daily_frames:
series_name = next(col for col in series_frame.columns if col != "date")
aligned = aligned.join(series_frame, on="date", how="left")
aligned = aligned.with_columns(pl.col(series_name).forward_fill())
aligned = compute_derived(aligned, list(config.get("derived", [])))
raw_df = pl.concat(raw_frames, how="vertical_relaxed").sort(["series", "date"])
aligned = (
combine_aligned(aligned_path, aligned) if args.update and aligned_path.exists() else aligned
)
raw_df = combine_raw(raw_path, raw_df) if args.update and raw_path.exists() else raw_df
metadata_df = build_metadata(config, series_map)
atomic_write_parquet(aligned, aligned_path)
atomic_write_parquet(raw_df, raw_path)
atomic_write_parquet(metadata_df, metadata_path)
profile_path = save_dataset_profile(
aligned, aligned_path, source="BookMacroDownloader", timestamp_col="date", symbol_col=None
)
print_download_summary(
{
"rows": len(aligned),
"columns": len(aligned.columns),
"failed_series": len(failed),
"date_range": f"{aligned['date'].min()} to {aligned['date'].max()}",
"output_file": str(aligned_path),
"raw_file": str(raw_path),
"metadata_file": str(metadata_path),
"profile_file": str(profile_path),
}
)
if __name__ == "__main__":
main()