-
Notifications
You must be signed in to change notification settings - Fork 0
fix: persist lifecycle backtests for drift checks #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,12 +4,18 @@ | |
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import copy | ||
| import hashlib | ||
| import json | ||
| import tempfile | ||
| from datetime import date | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import pandas as pd | ||
|
|
||
| from hk_equity_strategies.backtest.orchestrator_runner import SUPPORTED_PROFILES, build_backtest_runner | ||
| from hk_equity_strategies.backtest.orchestrator_runner import _synthetic_market_history as _runner_synthetic_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 | ||
|
|
||
|
|
@@ -25,6 +31,7 @@ | |
| "combo_mode": "dynamic", | ||
| }, | ||
| } | ||
| MIN_SYNTHETIC_DAYS = 700 | ||
|
|
||
|
|
||
| def _result_payload(item: Any) -> dict[str, Any]: | ||
|
|
@@ -40,13 +47,53 @@ def _result_payload(item: Any) -> dict[str, Any]: | |
| } | ||
|
|
||
|
|
||
| def _baseline_param_set_id( | ||
| profile: str, | ||
| params: dict[str, Any], | ||
| ) -> str: | ||
| fingerprint = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:12] | ||
| return f"{profile}_baseline_{fingerprint}" | ||
|
|
||
|
|
||
| def _baseline_identity_params( | ||
| params: dict[str, Any], | ||
| *, | ||
| synthetic_days: int | None, | ||
| ) -> dict[str, Any]: | ||
| identity = copy.deepcopy(params) | ||
| if synthetic_days is not None: | ||
| identity["_synthetic_days"] = synthetic_days | ||
| return identity | ||
|
|
||
|
|
||
| def _clone_market_history(market_history: pd.DataFrame | None) -> pd.DataFrame | None: | ||
| return market_history.copy(deep=True) if market_history is not None else None | ||
|
|
||
|
|
||
| def _shared_market_history(params: dict[str, Any], synthetic_days: int, market_history: pd.DataFrame | None) -> pd.DataFrame: | ||
| if market_history is not None: | ||
| return _clone_market_history(market_history) | ||
| min_history_days = int(params.get("min_history_days", DEFAULT_MIN_HISTORY_DAYS)) | ||
| if int(synthetic_days) < min_history_days: | ||
| raise ValueError(f"synthetic_days must be >= {min_history_days} for profile={params!r}") | ||
| return _runner_synthetic_market_history(days=int(synthetic_days)) | ||
|
Comment on lines
+77
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When callers set Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| def _build_runner(*, profile: str, synthetic_days: int, market_history: pd.DataFrame | None = None): | ||
| return build_backtest_runner( | ||
| profile, | ||
| market_history=market_history, | ||
| synthetic_days=synthetic_days, | ||
| ) | ||
|
|
||
|
|
||
| def run_walk_forward( | ||
| *, | ||
| profile: str, | ||
| windows: tuple[tuple[date, date], ...] = DEFAULT_WINDOWS, | ||
| synthetic_days: int = 700, | ||
| store_root: Path | None = None, | ||
| market_history: Any = None, | ||
| market_history: pd.DataFrame | None = None, | ||
| ) -> dict[str, Any]: | ||
| from quant_platform_kit.strategy_lifecycle.backtest_orchestrator import BacktestOrchestrator | ||
| from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore | ||
|
|
@@ -55,27 +102,69 @@ def run_walk_forward( | |
| raise ValueError(f"unsupported profile={profile!r}; supported={sorted(SUPPORTED_PROFILES)}") | ||
|
|
||
| params = dict(PROFILE_DEFAULTS.get(profile, {"min_history_days": DEFAULT_MIN_HISTORY_DAYS})) | ||
| runner = build_backtest_runner( | ||
| profile, | ||
| market_history=market_history, | ||
| synthetic_days=synthetic_days, | ||
| store_root = store_root or Path("/tmp/hk_equity_wf_store") | ||
| store_root.mkdir(parents=True, exist_ok=True) | ||
| baseline_params = copy.deepcopy(params) | ||
| shared_market_history = _shared_market_history(baseline_params, synthetic_days, market_history) | ||
| with tempfile.TemporaryDirectory(prefix=f"{profile}_wf_", dir=store_root) as scratch_dir: | ||
| scratch_store = PerformanceStore(local_root=Path(scratch_dir)) | ||
| scratch_orchestrator = BacktestOrchestrator(store=scratch_store) | ||
| scratch_orchestrator.register_runner( | ||
| "hk_equity", | ||
| _build_runner( | ||
| profile=profile, | ||
| market_history=_clone_market_history(shared_market_history), | ||
| synthetic_days=synthetic_days, | ||
| ), | ||
| ) | ||
| baseline_runner = _build_runner( | ||
| profile=profile, | ||
| market_history=_clone_market_history(shared_market_history), | ||
| synthetic_days=synthetic_days, | ||
| ) | ||
| baseline_raw = baseline_runner.run( | ||
| profile, | ||
| copy.deepcopy(baseline_params), | ||
| start_date=None, | ||
| end_date=None, | ||
| ) | ||
| via_orch = scratch_orchestrator.run( | ||
| profile, | ||
| domain="hk_equity", | ||
| params=copy.deepcopy(baseline_params), | ||
| param_set_id=f"{profile}_full_compare", | ||
| start_date=None, | ||
| end_date=None, | ||
| ) | ||
| wf_params = copy.deepcopy(baseline_params) | ||
| wf_results = scratch_orchestrator.walk_forward( | ||
| profile, | ||
| domain="hk_equity", | ||
| params=wf_params, | ||
| windows=windows, | ||
| param_set_id=f"{profile}_wf", | ||
| ) | ||
| baseline_store_params = _baseline_identity_params( | ||
| baseline_params, | ||
| synthetic_days=synthetic_days if market_history is None else None, | ||
| ) | ||
| store = PerformanceStore(local_root=store_root or Path("/tmp/hk_equity_wf_store")) | ||
| store = PerformanceStore(local_root=store_root) | ||
| orchestrator = BacktestOrchestrator(store=store) | ||
| orchestrator.register_runner("hk_equity", runner) | ||
|
|
||
| baseline = runner.run(profile, params) | ||
| wf_results = orchestrator.walk_forward( | ||
| profile, | ||
| baseline = orchestrator.persist_result( | ||
| baseline_raw, | ||
| strategy_profile=profile, | ||
| domain="hk_equity", | ||
| params=params, | ||
| windows=windows, | ||
| param_set_id=f"{profile}_wf", | ||
| params=baseline_params, | ||
| param_set_id=_baseline_param_set_id( | ||
| profile, | ||
| baseline_store_params, | ||
| ), | ||
| ) | ||
| return { | ||
| "strategy_profile": profile, | ||
| "domain": "hk_equity", | ||
| "baseline": _result_payload(baseline), | ||
| "orchestrator_full_window": _result_payload(via_orch), | ||
| "walk_forward_folds": [_result_payload(item) for item in wf_results], | ||
| "source": "BacktestOrchestrator.walk_forward", | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def test_drift_workflow_wires_snapshot_repo_and_lifecycle_env() -> 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 "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 "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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import pandas as pd | ||
| import pytest | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| SRC = ROOT / "src" | ||
| QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" | ||
| if str(QPK_SRC) not in sys.path: | ||
| sys.path.insert(0, str(QPK_SRC)) | ||
| if str(SRC) not in sys.path: | ||
| sys.path.insert(0, str(SRC)) | ||
|
|
||
| from hk_equity_strategies.backtest.orchestrator_runner import _synthetic_market_history | ||
| from scripts.run_walk_forward_backtest import _baseline_param_set_id, _clone_market_history, run_walk_forward | ||
|
|
||
|
|
||
| def test_run_walk_forward_persists_lifecycle_baseline(tmp_path: Path) -> None: | ||
| payload = run_walk_forward( | ||
| profile="hk_global_etf_tactical_rotation", | ||
| synthetic_days=700, | ||
| store_root=tmp_path, | ||
| ) | ||
|
|
||
| records = [ | ||
| json.loads(path.read_text(encoding="utf-8")) | ||
| for path in (tmp_path / "backtest" / "hk_equity" / "hk_global_etf_tactical_rotation").glob("*.json") | ||
| ] | ||
|
|
||
| assert payload["baseline"]["sharpe_ratio"] is not None | ||
| baseline_records = [record for record in records if "_baseline_" in record["param_set_id"]] | ||
| assert baseline_records | ||
| assert all(record["params"] == {"min_history_days": 260} for record in baseline_records) | ||
| assert not any("_wf" in record["param_set_id"] for record in records) | ||
| assert payload["orchestrator_full_window"]["sharpe_ratio"] is not None | ||
| assert payload["walk_forward_folds"] | ||
|
|
||
|
|
||
| def test_clone_market_history_returns_independent_dataframe() -> None: | ||
| history = _synthetic_market_history(days=10) | ||
|
|
||
| cloned = _clone_market_history(history) | ||
|
|
||
| assert cloned is not history | ||
| assert cloned is not None | ||
| cloned.loc[:, "close"] = 0.0 | ||
| assert not cloned["close"].equals(history["close"]) | ||
|
|
||
|
|
||
| def test_run_walk_forward_accepts_explicit_market_history(tmp_path: Path) -> None: | ||
| history = _synthetic_market_history(days=1200) | ||
|
|
||
| payload = run_walk_forward( | ||
| profile="hk_global_etf_tactical_rotation", | ||
| synthetic_days=700, | ||
| store_root=tmp_path, | ||
| market_history=history, | ||
| ) | ||
|
|
||
| assert payload["baseline"]["sharpe_ratio"] is not None | ||
| assert isinstance(history, pd.DataFrame) | ||
|
|
||
|
|
||
| def test_baseline_param_set_id_tracks_runner_inputs() -> None: | ||
| first = _baseline_param_set_id( | ||
| "hk_global_etf_tactical_rotation", | ||
| {"min_history_days": 260, "_synthetic_days": 700}, | ||
| ) | ||
| second = _baseline_param_set_id( | ||
| "hk_global_etf_tactical_rotation", | ||
| {"min_history_days": 260, "_synthetic_days": 900}, | ||
| ) | ||
|
|
||
| assert first != second | ||
|
|
||
|
|
||
| def test_run_walk_forward_rejects_too_short_synthetic_history(tmp_path: Path) -> None: | ||
| with pytest.raises(ValueError, match="synthetic_days must be >= 260"): | ||
| run_walk_forward( | ||
| profile="hk_global_etf_tactical_rotation", | ||
| synthetic_days=220, | ||
| store_root=tmp_path, | ||
| ) |
Uh oh!
There was an error while loading. Please reload this page.