diff --git a/.github/workflows/drift-check.yml b/.github/workflows/drift-check.yml index 1e82ba8..f1885a2 100644 --- a/.github/workflows/drift-check.yml +++ b/.github/workflows/drift-check.yml @@ -10,18 +10,101 @@ on: permissions: contents: read issues: write - id-token: write jobs: + preflight_backtests: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + snapshot_repository_ref: ${{ steps.snapshot-input.outputs.snapshot_repository_ref }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: 92458590a463e7219f0369a3505031ee74414135 + path: external/QuantPlatformKit + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install pandas yfinance==1.2.2 + python -m pip install --no-deps -e . + python -m pip install --no-deps -e external/QuantPlatformKit + + - name: Resolve snapshot repository ref + id: snapshot-input + run: | + set -euo pipefail + snapshot_repository_ref="$( + git ls-remote \ + https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines.git \ + refs/heads/main \ + | awk '{print $1}' + )" + if [[ ! "$snapshot_repository_ref" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Unable to resolve the public snapshot repository main SHA" + exit 1 + fi + echo "snapshot_repository_ref=$snapshot_repository_ref" >> "$GITHUB_OUTPUT" + + - name: Build lifecycle preflight bundle + env: + LIFECYCLE_PREFLIGHT_BUNDLE_ROOT: ${{ runner.temp }}/lifecycle-preflight-bundle + INPUT_ARTIFACT_ROOT: ${{ runner.temp }}/hk-lifecycle-inputs + run: | + set -euo pipefail + end_date="$(date -u -d 'tomorrow' '+%Y-%m-%d')" + python scripts/build_lifecycle_preflight.py \ + --bundle-root "$LIFECYCLE_PREFLIGHT_BUNDLE_ROOT" \ + --input-artifact-root "$INPUT_ARTIFACT_ROOT" \ + --start 2020-08-27 \ + --end "$end_date" + + - name: Upload lifecycle preflight artifact + uses: actions/upload-artifact@v4 + with: + name: lifecycle-preflight-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lifecycle-preflight-bundle + if-no-files-found: error + retention-days: 14 + + - name: Upload normalized market input artifact + uses: actions/upload-artifact@v4 + with: + name: hk-lifecycle-inputs-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/hk-lifecycle-inputs + if-no-files-found: error + retention-days: 14 + drift: - uses: QuantStrategyLab/QuantPlatformKit/.github/workflows/reusable-drift-check.yml@335c7a22bc3f570bd5705427ccc40172eda6b289 + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + needs: preflight_backtests + permissions: + contents: read + issues: write + id-token: write + uses: QuantStrategyLab/QuantPlatformKit/.github/workflows/reusable-drift-check.yml@92458590a463e7219f0369a3505031ee74414135 with: strategy_domain: hk_equity caller_event_name: ${{ github.event_name }} caller_pr_head_repository: ${{ github.event.pull_request.head.repo.full_name || '' }} snapshot_repository: QuantStrategyLab/HkEquitySnapshotPipelines snapshot_checkout_path: external/HkEquitySnapshotPipelines + snapshot_repository_ref: ${{ needs.preflight_backtests.outputs.snapshot_repository_ref }} ai_gateway_service_url: ${{ vars.AI_GATEWAY_SERVICE_URL }} + quant_platform_kit_ref: 92458590a463e7219f0369a3505031ee74414135 + lifecycle_preflight_artifact: lifecycle-preflight-${{ github.run_id }}-${{ github.run_attempt }} secrets: codex_audit_service_url: ${{ secrets.CODEX_AUDIT_SERVICE_URL }} - snapshot_repository_token: ${{ secrets.SNAPSHOT_REPOSITORY_TOKEN }} + snapshot_repository_token: ${{ secrets.SNAPSHOT_REPOSITORY_TOKEN || secrets.QSL_REPO_SYNC_TOKEN || github.token }} diff --git a/scripts/build_lifecycle_preflight.py b/scripts/build_lifecycle_preflight.py new file mode 100644 index 0000000..a38d70e --- /dev/null +++ b/scripts/build_lifecycle_preflight.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Build HK lifecycle baselines and return matrices from real market history.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import math +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Sequence + +import pandas as pd + +from hk_equity_strategies.backtest.combo_simulator import ( + HkComboBacktestConfig, + run_combo_backtest, +) +from hk_equity_strategies.backtest.etf_rotation_simulator import ( + HkRotationBacktestConfig, + run_etf_rotation_backtest, +) +from hk_equity_strategies.backtest.orchestrator_runner import SUPPORTED_PROFILES +from hk_equity_strategies.backtest.yfinance_market_data import download_market_history +from hk_equity_strategies.strategies.hk_equity_combo import ( + PROFILE_NAME as HK_EQUITY_COMBO_PROFILE, +) +from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import ( + DEFAULT_MIN_HISTORY_DAYS, + DEFAULT_UNIVERSE_SYMBOLS, + PROFILE_NAME as HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, + build_target_weights, +) +try: + from .run_walk_forward_backtest import PROFILE_DEFAULTS, run_walk_forward +except ImportError: # Direct execution: python scripts/build_lifecycle_preflight.py + from run_walk_forward_backtest import PROFILE_DEFAULTS, run_walk_forward + + +DOMAIN = "hk_equity" +SNAPSHOT_REPOSITORY = "HkEquitySnapshotPipelines" +BENCHMARK_SYMBOL = "02800" +BENCHMARK_COLUMN = "buy_hold_2800" +MAX_INPUT_AGE_DAYS = 10 + + +def _normalized_market_history(market_history: pd.DataFrame) -> pd.DataFrame: + required = {"date", "symbol", "close"} + if not isinstance(market_history, pd.DataFrame) or market_history.empty: + raise ValueError("market history must be a non-empty DataFrame") + missing_columns = required - set(market_history.columns) + if missing_columns: + raise ValueError( + f"market history is missing columns: {', '.join(sorted(missing_columns))}" + ) + frame = market_history.loc[:, ["date", "symbol", "close"]].copy() + frame["date"] = pd.to_datetime(frame["date"], errors="coerce").dt.tz_localize( + None + ).dt.normalize() + frame["symbol"] = frame["symbol"].astype(str).str.strip().str.removesuffix(".HK") + frame["symbol"] = frame["symbol"].map( + lambda value: value.zfill(5) if value.isdigit() else value + ) + frame["close"] = pd.to_numeric(frame["close"], errors="coerce") + if frame[["date", "symbol", "close"]].isna().any().any(): + raise ValueError("market history contains invalid date, symbol, or close values") + if (frame["close"] <= 0.0).any() or not frame["close"].map(math.isfinite).all(): + raise ValueError("market history close values must be positive and finite") + if frame.duplicated(["date", "symbol"]).any(): + raise ValueError("market history contains duplicate date/symbol rows") + return frame.sort_values(["date", "symbol"]).reset_index(drop=True) + + +def validate_market_history( + market_history: pd.DataFrame, + *, + reference_date: date | None = None, +) -> pd.DataFrame: + """Validate the complete configured universe and optional freshness boundary.""" + + frame = _normalized_market_history(market_history) + required_symbols = set(DEFAULT_UNIVERSE_SYMBOLS) + available_symbols = set(frame["symbol"]) + missing_symbols = sorted(required_symbols - available_symbols) + if missing_symbols: + raise ValueError( + f"market history is missing required symbols: {', '.join(missing_symbols)}" + ) + required_frame = frame.loc[frame["symbol"].isin(required_symbols)] + latest_by_symbol = required_frame.groupby("symbol")["date"].max() + overlap = ( + required_frame.pivot(index="date", columns="symbol", values="close") + .loc[:, list(DEFAULT_UNIVERSE_SYMBOLS)] + .dropna(how="any") + ) + if len(overlap) < DEFAULT_MIN_HISTORY_DAYS: + raise ValueError( + f"market history requires at least {DEFAULT_MIN_HISTORY_DAYS} " + "overlapping trading days" + ) + if reference_date is not None: + stale_symbols: list[str] = [] + future_symbols: list[str] = [] + for symbol, latest_timestamp in latest_by_symbol.items(): + age = reference_date - pd.Timestamp(latest_timestamp).date() + if age < timedelta(0): + future_symbols.append(str(symbol)) + elif age > timedelta(days=MAX_INPUT_AGE_DAYS): + stale_symbols.append(str(symbol)) + if future_symbols: + raise ValueError( + "market history contains future dates for symbols: " + f"{', '.join(sorted(future_symbols))}" + ) + if stale_symbols: + raise ValueError( + "market history is stale for symbols: " + f"{', '.join(sorted(stale_symbols))}" + ) + complete_dates = set(pd.DatetimeIndex(overlap.index)) + return required_frame.loc[required_frame["date"].isin(complete_dates)].reset_index( + drop=True + ) + + +def _signal_fn(history: Any, **kwargs: Any): + return build_target_weights(history, **kwargs) + + +def build_profile_return_matrix( + profile: str, + market_history: pd.DataFrame, +) -> pd.DataFrame: + if profile not in SUPPORTED_PROFILES: + raise ValueError(f"unsupported profile={profile!r}") + frame = validate_market_history(market_history) + params = dict( + PROFILE_DEFAULTS.get( + profile, + {"min_history_days": DEFAULT_MIN_HISTORY_DAYS}, + ) + ) + min_history_days = int(params.get("min_history_days", DEFAULT_MIN_HISTORY_DAYS)) + rotation_config = HkRotationBacktestConfig(min_history_days=min_history_days) + if profile == HK_EQUITY_COMBO_PROFILE: + result = run_combo_backtest( + frame, + _signal_fn, + combo_config=HkComboBacktestConfig( + combo_mode=str(params.get("combo_mode", "dynamic")), + min_history_days=min_history_days, + ), + rotation_config=rotation_config, + strategy_kwargs={"min_history_days": min_history_days}, + ) + elif profile == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: + result = run_etf_rotation_backtest( + frame, + _signal_fn, + config=rotation_config, + strategy_kwargs={"min_history_days": min_history_days}, + ) + else: # Defensive boundary if SUPPORTED_PROFILES gains a new runner type. + raise ValueError(f"no lifecycle return builder registered for profile={profile!r}") + + benchmark = ( + frame.loc[frame["symbol"] == BENCHMARK_SYMBOL] + .set_index("date")["close"] + .sort_index() + .pct_change(fill_method=None) + .fillna(0.0) + .rename(BENCHMARK_COLUMN) + ) + strategy = result.daily_returns.rename(profile) + matrix = pd.concat([strategy, benchmark], axis=1, join="inner").sort_index() + matrix = matrix.replace([float("inf"), float("-inf")], pd.NA).dropna() + if len(matrix) < DEFAULT_MIN_HISTORY_DAYS: + raise ValueError( + f"return matrix for profile={profile!r} has insufficient observations" + ) + matrix.index = pd.DatetimeIndex(matrix.index).tz_localize(None).normalize() + matrix.index.name = "as_of" + return matrix.reset_index() + + +def build_lifecycle_preflight_bundle( + market_history: pd.DataFrame, + *, + bundle_root: Path, +) -> dict[str, Any]: + frame = validate_market_history(market_history) + profiles = sorted(SUPPORTED_PROFILES) + store_root = bundle_root / "data" / "lifecycle_store" + matrix_rows: dict[str, int] = {} + for profile in profiles: + run_walk_forward( + profile=profile, + store_root=store_root, + market_history=frame.copy(deep=True), + ) + backtest_dir = store_root / "backtest" / DOMAIN / profile + if len(list(backtest_dir.glob("backtest_*.json"))) != 1: + raise RuntimeError( + f"expected one persisted lifecycle baseline for profile={profile!r}" + ) + matrix = build_profile_return_matrix(profile, frame.copy(deep=True)) + output_path = ( + bundle_root + / "external" + / SNAPSHOT_REPOSITORY + / "data" + / "output" + / profile + / "portfolio_and_tracker_returns.csv" + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + matrix.to_csv(output_path, index=False, date_format="%Y-%m-%d") + matrix_rows[profile] = len(matrix) + return {"profiles": profiles, "matrix_rows": matrix_rows} + + +def write_market_input_artifact( + market_history: pd.DataFrame, + *, + output_dir: Path, + source: str, + requested_start: str, + requested_end: str, + retrieved_at: str, + yfinance_version: str | None = None, +) -> dict[str, Any]: + frame = validate_market_history(market_history) + serializable = frame.copy() + serializable["date"] = serializable["date"].dt.strftime("%Y-%m-%d") + output_dir.mkdir(parents=True, exist_ok=True) + data_path = output_dir / "market_history.csv.gz" + serializable.to_csv( + data_path, + index=False, + compression={"method": "gzip", "compresslevel": 9, "mtime": 0}, + ) + manifest = { + "schema_version": "hk_lifecycle_market_input.v1", + "source": source, + "requested_start": requested_start, + "requested_end": requested_end, + "retrieved_at": retrieved_at, + "row_count": len(serializable), + "trading_day_count": int(serializable["date"].nunique()), + "symbols": sorted(serializable["symbol"].unique().tolist()), + "first_date": str(serializable["date"].min()), + "last_date": str(serializable["date"].max()), + "profiles": sorted(SUPPORTED_PROFILES), + "sha256": hashlib.sha256(data_path.read_bytes()).hexdigest(), + } + if yfinance_version: + manifest["yfinance_version"] = yfinance_version + (output_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return manifest + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--bundle-root", type=Path, required=True) + parser.add_argument("--input-artifact-root", type=Path, required=True) + parser.add_argument("--start", default="2020-08-27") + parser.add_argument("--end", required=True) + parser.add_argument("--market-history", type=Path) + args = parser.parse_args(argv) + + retrieved_at = datetime.now(timezone.utc).isoformat() + if args.market_history: + market_history = pd.read_csv(args.market_history) + source = "provided_file" + yfinance_version = None + else: + market_history = download_market_history(start=args.start, end=args.end) + source = "yfinance" + yfinance_version = importlib.metadata.version("yfinance") + validated = validate_market_history( + market_history, + reference_date=datetime.now(timezone.utc).date(), + ) + manifest = write_market_input_artifact( + validated, + output_dir=args.input_artifact_root, + source=source, + requested_start=args.start, + requested_end=args.end, + retrieved_at=retrieved_at, + yfinance_version=yfinance_version, + ) + bundle = build_lifecycle_preflight_bundle( + validated, + bundle_root=args.bundle_root, + ) + print( + json.dumps( + { + "source": source, + "last_date": manifest["last_date"], + "row_count": manifest["row_count"], + **bundle, + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hk_equity_strategies/backtest/yfinance_market_data.py b/src/hk_equity_strategies/backtest/yfinance_market_data.py index 54a8ff4..602d3b5 100644 --- a/src/hk_equity_strategies/backtest/yfinance_market_data.py +++ b/src/hk_equity_strategies/backtest/yfinance_market_data.py @@ -60,7 +60,9 @@ def download_close_matrix( close = close.to_frame() close = close.rename(columns={yahoo: symbol for symbol, yahoo in YAHOO_SYMBOLS.items()}) ordered = [symbol for symbol in universe if symbol in close.columns] - close = close.loc[:, ordered].ffill().dropna(how="any") + # Keep only dates with real observations for the whole configured universe. + # Forward-filling here would hide a ticker that stopped publishing quotes. + close = close.loc[:, ordered].dropna(how="any") return close diff --git a/tests/test_build_lifecycle_preflight.py b/tests/test_build_lifecycle_preflight.py new file mode 100644 index 0000000..808d3c0 --- /dev/null +++ b/tests/test_build_lifecycle_preflight.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from datetime import date +from pathlib import Path + +import pandas as pd + +from hk_equity_strategies.backtest.orchestrator_runner import ( + SUPPORTED_PROFILES, + _synthetic_market_history, +) +from scripts import build_lifecycle_preflight as lifecycle + + +def test_script_entrypoint_loads_from_repository_root() -> None: + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, str(root / "scripts" / "build_lifecycle_preflight.py"), "--help"], + cwd=root, + env={**os.environ, "PYTHONPATH": str(root / "src")}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_builds_lifecycle_contract_for_every_configured_profile( + tmp_path: Path, + monkeypatch, +) -> None: + history = _synthetic_market_history(days=320) + bundle_root = tmp_path / "bundle" + + def persist_baseline(*, profile, store_root, **_kwargs): + path = store_root / "backtest" / "hk_equity" / profile / "backtest_v1.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "strategy_profile": profile, + "domain": "hk_equity", + "param_set_id": f"{profile}_baseline", + } + ), + encoding="utf-8", + ) + return {"strategy_profile": profile} + + monkeypatch.setattr(lifecycle, "run_walk_forward", persist_baseline) + + result = lifecycle.build_lifecycle_preflight_bundle( + history, + bundle_root=bundle_root, + ) + + assert result["profiles"] == sorted(SUPPORTED_PROFILES) + for profile in SUPPORTED_PROFILES: + backtests = list( + ( + bundle_root + / "data" + / "lifecycle_store" + / "backtest" + / "hk_equity" + / profile + ).glob("backtest_*.json") + ) + assert len(backtests) == 1 + matrix = pd.read_csv( + bundle_root + / "external" + / "HkEquitySnapshotPipelines" + / "data" + / "output" + / profile + / "portfolio_and_tracker_returns.csv" + ) + assert list(matrix.columns) == ["as_of", profile, "buy_hold_2800"] + assert len(matrix) >= 260 + assert matrix[[profile, "buy_hold_2800"]].notna().all().all() + + +def test_writes_auditable_normalized_market_input(tmp_path: Path) -> None: + history = _synthetic_market_history(days=320) + + manifest = lifecycle.write_market_input_artifact( + history, + output_dir=tmp_path, + source="yfinance", + requested_start="2020-08-27", + requested_end="2026-07-31", + retrieved_at="2026-07-30T07:00:00+00:00", + yfinance_version="1.2.2", + ) + + data_path = tmp_path / "market_history.csv.gz" + assert data_path.is_file() + assert manifest["schema_version"] == "hk_lifecycle_market_input.v1" + assert manifest["source"] == "yfinance" + assert manifest["yfinance_version"] == "1.2.2" + assert manifest["sha256"] == hashlib.sha256(data_path.read_bytes()).hexdigest() + assert manifest["profiles"] == sorted(SUPPORTED_PROFILES) + assert json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8")) == manifest + + +def test_rejects_stale_or_incomplete_market_input() -> None: + history = _synthetic_market_history(days=320) + latest = pd.Timestamp(history["date"].max()).date() + + lifecycle.validate_market_history(history, reference_date=latest) + + incomplete = history.loc[history["symbol"] != "02800"] + try: + lifecycle.validate_market_history(incomplete, reference_date=latest) + except ValueError as exc: + assert "02800" in str(exc) + else: + raise AssertionError("missing benchmark symbol must fail closed") + + try: + lifecycle.validate_market_history( + history, + reference_date=date.fromordinal(latest.toordinal() + 11), + ) + except ValueError as exc: + assert "stale" in str(exc) + else: + raise AssertionError("stale market input must fail closed") + + stale_symbol = history.loc[ + ~( + (history["symbol"] == "02822") + & ( + pd.to_datetime(history["date"]) + > pd.Timestamp(latest) - pd.Timedelta(days=11) + ) + ) + ] + try: + lifecycle.validate_market_history( + stale_symbol, + reference_date=latest, + ) + except ValueError as exc: + assert "02822" in str(exc) + else: + raise AssertionError("a stale individual symbol must fail closed") diff --git a/tests/test_drift_workflow_config.py b/tests/test_drift_workflow_config.py index 281926e..436f058 100644 --- a/tests/test_drift_workflow_config.py +++ b/tests/test_drift_workflow_config.py @@ -1,15 +1,30 @@ from pathlib import Path -def test_drift_workflow_wires_snapshot_repo_and_lifecycle_env() -> None: +QPK_REF = "92458590a463e7219f0369a3505031ee74414135" + + +def test_drift_workflow_builds_and_wires_lifecycle_preflight() -> None: workflow = (Path(__file__).resolve().parents[1] / ".github" / "workflows" / "drift-check.yml").read_text(encoding="utf-8") - assert "uses: QuantStrategyLab/QuantPlatformKit/.github/workflows/reusable-drift-check.yml@335c7a22bc3f570bd5705427ccc40172eda6b289" in workflow + assert "preflight_backtests:" in workflow + assert "python scripts/build_lifecycle_preflight.py" in workflow + assert "--start 2020-08-27" in workflow + assert "lifecycle-preflight-${{ github.run_id }}-${{ github.run_attempt }}" in workflow + assert "hk-lifecycle-inputs-${{ github.run_id }}-${{ github.run_attempt }}" in workflow + assert f"uses: QuantStrategyLab/QuantPlatformKit/.github/workflows/reusable-drift-check.yml@{QPK_REF}" in workflow assert "strategy_domain: hk_equity" in workflow assert "caller_event_name: ${{ github.event_name }}" in workflow assert "caller_pr_head_repository: ${{ github.event.pull_request.head.repo.full_name || '' }}" in workflow assert "snapshot_repository: QuantStrategyLab/HkEquitySnapshotPipelines" in workflow assert "snapshot_checkout_path: external/HkEquitySnapshotPipelines" in workflow + assert "snapshot_repository_ref: ${{ needs.preflight_backtests.outputs.snapshot_repository_ref }}" in workflow + assert f"quant_platform_kit_ref: {QPK_REF}" in workflow + assert "lifecycle_preflight_artifact: lifecycle-preflight-${{ github.run_id }}-${{ github.run_attempt }}" in workflow assert "ai_gateway_service_url: ${{ vars.AI_GATEWAY_SERVICE_URL }}" in workflow assert "codex_audit_service_url: ${{ secrets.CODEX_AUDIT_SERVICE_URL }}" in workflow - assert "snapshot_repository_token: ${{ secrets.SNAPSHOT_REPOSITORY_TOKEN }}" in workflow + assert ( + "snapshot_repository_token: " + "${{ secrets.SNAPSHOT_REPOSITORY_TOKEN || secrets.QSL_REPO_SYNC_TOKEN || github.token }}" + ) in workflow + assert "synthetic" not in workflow.lower() diff --git a/tests/test_yfinance_orchestrator_research.py b/tests/test_yfinance_orchestrator_research.py index d2ae9bf..fdbac14 100644 --- a/tests/test_yfinance_orchestrator_research.py +++ b/tests/test_yfinance_orchestrator_research.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest +import sys +import types from unittest.mock import patch import pandas as pd @@ -10,7 +12,10 @@ run_etf_rotation_profile_backtest, ) from hk_equity_strategies.strategies.hk_equity_combo import PROFILE_NAME as HK_EQUITY_COMBO_PROFILE -from hk_equity_strategies.backtest.yfinance_market_data import download_market_history +from hk_equity_strategies.backtest.yfinance_market_data import ( + download_close_matrix, + download_market_history, +) from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import ( DEFAULT_MIN_HISTORY_DAYS, DEFAULT_UNIVERSE_SYMBOLS, @@ -19,6 +24,32 @@ class YfinanceMarketDataTests(unittest.TestCase): + def test_download_close_matrix_does_not_forward_fill_missing_quotes(self) -> None: + index = pd.bdate_range("2026-07-27", periods=3) + columns = pd.MultiIndex.from_product( + [["Close"], ["2800.HK", "2822.HK"]] + ) + raw = pd.DataFrame( + [ + [20.0, 10.0], + [20.1, None], + [20.2, None], + ], + index=index, + columns=columns, + ) + fake_yfinance = types.SimpleNamespace(download=lambda *_args, **_kwargs: raw) + + with patch.dict(sys.modules, {"yfinance": fake_yfinance}): + close = download_close_matrix( + start="2026-07-27", + end="2026-07-31", + symbols=("02800", "02822"), + ) + + self.assertEqual(len(close), 1) + self.assertEqual(close.index[-1], index[0]) + def test_download_market_history_returns_long_format(self) -> None: index = pd.bdate_range("2023-01-03", periods=300) wide = pd.DataFrame(