|
4 | 4 |
|
5 | 5 | import hashlib |
6 | 6 | import json |
| 7 | +import shutil |
7 | 8 | import tempfile |
8 | 9 | from dataclasses import dataclass |
9 | | -from datetime import timezone |
| 10 | +from datetime import datetime, timezone |
10 | 11 | from pathlib import Path |
11 | | -from typing import Iterable |
| 12 | +from typing import Any, Iterable, Mapping |
12 | 13 |
|
13 | 14 | import pandas as pd |
14 | 15 |
|
|
17 | 18 | DEFAULT_MAX_SNAPSHOT_MONTH_LAG = 1 |
18 | 19 | DEFAULT_SNAPSHOT_MANIFEST_SUFFIX = ".manifest.json" |
19 | 20 | DEFAULT_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_artifacts" |
| 21 | +DEFAULT_FEATURE_SNAPSHOT_FALLBACK_MODE = "none" |
| 22 | +FEATURE_SNAPSHOT_FALLBACK_MODE_NONE = "none" |
| 23 | +FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID = "last_valid" |
| 24 | +DEFAULT_FEATURE_SNAPSHOT_FALLBACK_MAX_STALE_DAYS = 3 |
| 25 | +DEFAULT_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR = ( |
| 26 | + DEFAULT_ARTIFACT_CACHE_DIR / "last_valid_feature_snapshots" |
| 27 | +) |
20 | 28 | _MANIFEST_DIAGNOSTIC_FIELDS = ( |
21 | 29 | "price_as_of", |
22 | 30 | "universe_as_of", |
@@ -251,6 +259,73 @@ def load_feature_snapshot_guarded( |
251 | 259 | expected_config_name: str | None = None, |
252 | 260 | expected_config_path: str | None = None, |
253 | 261 | expected_contract_version: str | None = None, |
| 262 | + fallback_mode: str | None = DEFAULT_FEATURE_SNAPSHOT_FALLBACK_MODE, |
| 263 | + fallback_cache_dir: str | Path | None = None, |
| 264 | + fallback_max_stale_days: int | None = DEFAULT_FEATURE_SNAPSHOT_FALLBACK_MAX_STALE_DAYS, |
| 265 | +) -> FeatureSnapshotGuardResult: |
| 266 | + """Load a guarded snapshot, optionally falling back to the last valid artifact.""" |
| 267 | + |
| 268 | + fallback_context = _feature_snapshot_fallback_context( |
| 269 | + path=path, |
| 270 | + manifest_path=manifest_path, |
| 271 | + expected_strategy_profile=expected_strategy_profile, |
| 272 | + expected_config_name=expected_config_name, |
| 273 | + expected_contract_version=expected_contract_version, |
| 274 | + required_columns=required_columns, |
| 275 | + snapshot_date_columns=snapshot_date_columns, |
| 276 | + cache_dir=fallback_cache_dir, |
| 277 | + ) |
| 278 | + result = _load_feature_snapshot_guarded_without_fallback( |
| 279 | + path, |
| 280 | + run_as_of=run_as_of, |
| 281 | + required_columns=required_columns, |
| 282 | + snapshot_date_columns=snapshot_date_columns, |
| 283 | + max_snapshot_month_lag=max_snapshot_month_lag, |
| 284 | + manifest_path=manifest_path, |
| 285 | + require_manifest=require_manifest, |
| 286 | + expected_strategy_profile=expected_strategy_profile, |
| 287 | + expected_config_name=expected_config_name, |
| 288 | + expected_config_path=expected_config_path, |
| 289 | + expected_contract_version=expected_contract_version, |
| 290 | + ) |
| 291 | + normalized_fallback_mode = normalize_feature_snapshot_fallback_mode(fallback_mode) |
| 292 | + if result.metadata.get("snapshot_guard_decision") == "proceed": |
| 293 | + if normalized_fallback_mode == FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID: |
| 294 | + _write_feature_snapshot_last_valid(fallback_context, result.metadata) |
| 295 | + return result |
| 296 | + if normalized_fallback_mode != FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID: |
| 297 | + return result |
| 298 | + |
| 299 | + fallback_result = _load_feature_snapshot_last_valid( |
| 300 | + fallback_context, |
| 301 | + run_as_of=run_as_of, |
| 302 | + required_columns=required_columns, |
| 303 | + snapshot_date_columns=snapshot_date_columns, |
| 304 | + max_snapshot_month_lag=max_snapshot_month_lag, |
| 305 | + require_manifest=require_manifest, |
| 306 | + expected_strategy_profile=expected_strategy_profile, |
| 307 | + expected_config_name=expected_config_name, |
| 308 | + expected_config_path=expected_config_path, |
| 309 | + expected_contract_version=expected_contract_version, |
| 310 | + failed_metadata=result.metadata, |
| 311 | + max_stale_days=fallback_max_stale_days, |
| 312 | + ) |
| 313 | + return fallback_result if fallback_result is not None else result |
| 314 | + |
| 315 | + |
| 316 | +def _load_feature_snapshot_guarded_without_fallback( |
| 317 | + path: str, |
| 318 | + *, |
| 319 | + run_as_of, |
| 320 | + required_columns: Iterable[str] | None = None, |
| 321 | + snapshot_date_columns: Iterable[str] = DEFAULT_SNAPSHOT_DATE_COLUMNS, |
| 322 | + max_snapshot_month_lag: int = DEFAULT_MAX_SNAPSHOT_MONTH_LAG, |
| 323 | + manifest_path: str | None = None, |
| 324 | + require_manifest: bool = False, |
| 325 | + expected_strategy_profile: str | None = None, |
| 326 | + expected_config_name: str | None = None, |
| 327 | + expected_config_path: str | None = None, |
| 328 | + expected_contract_version: str | None = None, |
254 | 329 | ) -> FeatureSnapshotGuardResult: |
255 | 330 | raw_path = str(path or "").strip() |
256 | 331 | if not raw_path: |
@@ -310,7 +385,7 @@ def load_feature_snapshot_guarded( |
310 | 385 | ), |
311 | 386 | ) |
312 | 387 |
|
313 | | - result = load_feature_snapshot_guarded( |
| 388 | + result = _load_feature_snapshot_guarded_without_fallback( |
314 | 389 | str(local_snapshot_path), |
315 | 390 | run_as_of=run_as_of, |
316 | 391 | required_columns=required_columns, |
@@ -769,3 +844,263 @@ def load_feature_snapshot_guarded( |
769 | 844 | actual_config_sha256=actual_config_sha256, |
770 | 845 | ), |
771 | 846 | ) |
| 847 | + |
| 848 | + |
| 849 | +def normalize_feature_snapshot_fallback_mode(value: object) -> str: |
| 850 | + mode = str(value or DEFAULT_FEATURE_SNAPSHOT_FALLBACK_MODE).strip().lower().replace("-", "_") |
| 851 | + aliases = { |
| 852 | + "": FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 853 | + "off": FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 854 | + "disabled": FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 855 | + "false": FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 856 | + "none": FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 857 | + "last": FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 858 | + "last_valid": FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 859 | + "last_valid_snapshot": FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 860 | + } |
| 861 | + normalized = aliases.get(mode, mode) |
| 862 | + if normalized not in { |
| 863 | + FEATURE_SNAPSHOT_FALLBACK_MODE_NONE, |
| 864 | + FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 865 | + }: |
| 866 | + raise ValueError( |
| 867 | + "unsupported feature snapshot fallback mode " |
| 868 | + f"{value!r}; supported: none, last_valid" |
| 869 | + ) |
| 870 | + return normalized |
| 871 | + |
| 872 | + |
| 873 | +def _feature_snapshot_fallback_context( |
| 874 | + *, |
| 875 | + path: str, |
| 876 | + manifest_path: str | None, |
| 877 | + expected_strategy_profile: str | None, |
| 878 | + expected_config_name: str | None, |
| 879 | + expected_contract_version: str | None, |
| 880 | + required_columns: Iterable[str] | None, |
| 881 | + snapshot_date_columns: Iterable[str], |
| 882 | + cache_dir: str | Path | None, |
| 883 | +) -> dict[str, Any]: |
| 884 | + raw_path = str(path or "").strip() |
| 885 | + raw_manifest = str(manifest_path or "").strip() |
| 886 | + payload = { |
| 887 | + "path": raw_path, |
| 888 | + "manifest_path": raw_manifest, |
| 889 | + "expected_strategy_profile": str(expected_strategy_profile or "").strip(), |
| 890 | + "expected_config_name": str(expected_config_name or "").strip(), |
| 891 | + "expected_contract_version": str(expected_contract_version or "").strip(), |
| 892 | + "required_columns": tuple(str(column) for column in (required_columns or ())), |
| 893 | + "snapshot_date_columns": tuple(str(column) for column in snapshot_date_columns), |
| 894 | + } |
| 895 | + digest = hashlib.sha256( |
| 896 | + json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") |
| 897 | + ).hexdigest()[:24] |
| 898 | + cache_root = Path(cache_dir or DEFAULT_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR) / digest |
| 899 | + snapshot_suffix = Path(raw_path).suffix or ".snapshot" |
| 900 | + return { |
| 901 | + "cache_root": cache_root, |
| 902 | + "record_path": cache_root / "record.json", |
| 903 | + "snapshot_path": cache_root / f"snapshot{snapshot_suffix}", |
| 904 | + "manifest_path": cache_root / "snapshot.manifest.json", |
| 905 | + "source_path": raw_path, |
| 906 | + "source_manifest_path": raw_manifest or _resolve_manifest_reference(raw_path, manifest_path), |
| 907 | + "cache_key_payload": payload, |
| 908 | + } |
| 909 | + |
| 910 | + |
| 911 | +def _write_feature_snapshot_last_valid( |
| 912 | + fallback_context: Mapping[str, Any], |
| 913 | + metadata: Mapping[str, Any], |
| 914 | +) -> None: |
| 915 | + source_snapshot = _path_from_metadata( |
| 916 | + metadata.get("snapshot_local_path") or metadata.get("snapshot_path") |
| 917 | + ) |
| 918 | + if source_snapshot is None or not source_snapshot.exists(): |
| 919 | + return |
| 920 | + |
| 921 | + cache_root = Path(fallback_context["cache_root"]) |
| 922 | + snapshot_path = Path(fallback_context["snapshot_path"]) |
| 923 | + manifest_path = Path(fallback_context["manifest_path"]) |
| 924 | + record_path = Path(fallback_context["record_path"]) |
| 925 | + cache_root.mkdir(parents=True, exist_ok=True) |
| 926 | + shutil.copy2(source_snapshot, snapshot_path) |
| 927 | + |
| 928 | + source_manifest = _path_from_metadata( |
| 929 | + metadata.get("snapshot_manifest_local_path") or metadata.get("snapshot_manifest_path") |
| 930 | + ) |
| 931 | + manifest_copied = False |
| 932 | + if source_manifest is not None and source_manifest.exists(): |
| 933 | + shutil.copy2(source_manifest, manifest_path) |
| 934 | + manifest_copied = True |
| 935 | + |
| 936 | + record = { |
| 937 | + "schema_version": "feature_snapshot_last_valid.v1", |
| 938 | + "saved_at": datetime.now(timezone.utc).isoformat(), |
| 939 | + "source_path": fallback_context["source_path"], |
| 940 | + "source_manifest_path": fallback_context["source_manifest_path"], |
| 941 | + "snapshot_path": str(snapshot_path), |
| 942 | + "manifest_path": str(manifest_path) if manifest_copied else None, |
| 943 | + "snapshot_as_of": _json_safe_value(metadata.get("snapshot_as_of")), |
| 944 | + "snapshot_sha256": _sha256_file(snapshot_path), |
| 945 | + "manifest_sha256": _sha256_file(manifest_path) if manifest_copied else None, |
| 946 | + "cache_key_payload": fallback_context["cache_key_payload"], |
| 947 | + } |
| 948 | + record_path.write_text(json.dumps(record, sort_keys=True, indent=2), encoding="utf-8") |
| 949 | + |
| 950 | + |
| 951 | +def _load_feature_snapshot_last_valid( |
| 952 | + fallback_context: Mapping[str, Any], |
| 953 | + *, |
| 954 | + run_as_of, |
| 955 | + required_columns: Iterable[str] | None, |
| 956 | + snapshot_date_columns: Iterable[str], |
| 957 | + max_snapshot_month_lag: int, |
| 958 | + require_manifest: bool, |
| 959 | + expected_strategy_profile: str | None, |
| 960 | + expected_config_name: str | None, |
| 961 | + expected_config_path: str | None, |
| 962 | + expected_contract_version: str | None, |
| 963 | + failed_metadata: Mapping[str, Any], |
| 964 | + max_stale_days: int | None, |
| 965 | +) -> FeatureSnapshotGuardResult | None: |
| 966 | + record_path = Path(fallback_context["record_path"]) |
| 967 | + snapshot_path = Path(fallback_context["snapshot_path"]) |
| 968 | + manifest_path = Path(fallback_context["manifest_path"]) |
| 969 | + if not record_path.exists() or not snapshot_path.exists(): |
| 970 | + return _feature_snapshot_fallback_failure( |
| 971 | + failed_metadata, |
| 972 | + reason="last_valid_missing", |
| 973 | + fallback_context=fallback_context, |
| 974 | + ) |
| 975 | + try: |
| 976 | + record = json.loads(record_path.read_text(encoding="utf-8")) |
| 977 | + except Exception as exc: |
| 978 | + return _feature_snapshot_fallback_failure( |
| 979 | + failed_metadata, |
| 980 | + reason=f"last_valid_record_invalid:{type(exc).__name__}:{exc}", |
| 981 | + fallback_context=fallback_context, |
| 982 | + ) |
| 983 | + if not isinstance(record, Mapping): |
| 984 | + return _feature_snapshot_fallback_failure( |
| 985 | + failed_metadata, |
| 986 | + reason="last_valid_record_invalid:not_mapping", |
| 987 | + fallback_context=fallback_context, |
| 988 | + ) |
| 989 | + stale_reason = _feature_snapshot_fallback_stale_reason(record, max_stale_days=max_stale_days) |
| 990 | + if stale_reason: |
| 991 | + return _feature_snapshot_fallback_failure( |
| 992 | + failed_metadata, |
| 993 | + reason=stale_reason, |
| 994 | + fallback_context=fallback_context, |
| 995 | + record=record, |
| 996 | + ) |
| 997 | + if require_manifest and not manifest_path.exists(): |
| 998 | + return _feature_snapshot_fallback_failure( |
| 999 | + failed_metadata, |
| 1000 | + reason="last_valid_manifest_missing", |
| 1001 | + fallback_context=fallback_context, |
| 1002 | + record=record, |
| 1003 | + ) |
| 1004 | + |
| 1005 | + fallback_result = _load_feature_snapshot_guarded_without_fallback( |
| 1006 | + str(snapshot_path), |
| 1007 | + run_as_of=run_as_of, |
| 1008 | + required_columns=required_columns, |
| 1009 | + snapshot_date_columns=snapshot_date_columns, |
| 1010 | + max_snapshot_month_lag=max_snapshot_month_lag, |
| 1011 | + manifest_path=str(manifest_path) if manifest_path.exists() else None, |
| 1012 | + require_manifest=require_manifest, |
| 1013 | + expected_strategy_profile=expected_strategy_profile, |
| 1014 | + expected_config_name=expected_config_name, |
| 1015 | + expected_config_path=expected_config_path, |
| 1016 | + expected_contract_version=expected_contract_version, |
| 1017 | + ) |
| 1018 | + metadata = dict(fallback_result.metadata) |
| 1019 | + if metadata.get("snapshot_guard_decision") != "proceed": |
| 1020 | + return _feature_snapshot_fallback_failure( |
| 1021 | + failed_metadata, |
| 1022 | + reason=str(metadata.get("fail_reason") or metadata.get("no_op_reason") or "last_valid_guard_failed"), |
| 1023 | + fallback_context=fallback_context, |
| 1024 | + record=record, |
| 1025 | + ) |
| 1026 | + |
| 1027 | + metadata.update( |
| 1028 | + { |
| 1029 | + "feature_snapshot_path": fallback_context["source_path"], |
| 1030 | + "snapshot_path": fallback_context["source_path"], |
| 1031 | + "snapshot_manifest_path": fallback_context["source_manifest_path"], |
| 1032 | + "snapshot_local_path": str(snapshot_path), |
| 1033 | + "snapshot_manifest_local_path": str(manifest_path) if manifest_path.exists() else None, |
| 1034 | + "artifact_fallback_used": True, |
| 1035 | + "artifact_fallback_mode": FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 1036 | + "artifact_fallback_reason": failed_metadata.get("fail_reason") |
| 1037 | + or failed_metadata.get("no_op_reason"), |
| 1038 | + "artifact_fallback_saved_at": record.get("saved_at"), |
| 1039 | + "artifact_fallback_cache_dir": str(fallback_context["cache_root"]), |
| 1040 | + "artifact_fallback_snapshot_path": str(snapshot_path), |
| 1041 | + "artifact_fallback_manifest_path": str(manifest_path) if manifest_path.exists() else None, |
| 1042 | + } |
| 1043 | + ) |
| 1044 | + return FeatureSnapshotGuardResult(frame=fallback_result.frame, metadata=metadata) |
| 1045 | + |
| 1046 | + |
| 1047 | +def _feature_snapshot_fallback_failure( |
| 1048 | + failed_metadata: Mapping[str, Any], |
| 1049 | + *, |
| 1050 | + reason: str, |
| 1051 | + fallback_context: Mapping[str, Any], |
| 1052 | + record: Mapping[str, Any] | None = None, |
| 1053 | +) -> FeatureSnapshotGuardResult: |
| 1054 | + metadata = dict(failed_metadata) |
| 1055 | + metadata.update( |
| 1056 | + { |
| 1057 | + "artifact_fallback_used": False, |
| 1058 | + "artifact_fallback_mode": FEATURE_SNAPSHOT_FALLBACK_MODE_LAST_VALID, |
| 1059 | + "artifact_fallback_fail_reason": reason, |
| 1060 | + "artifact_fallback_cache_dir": str(fallback_context["cache_root"]), |
| 1061 | + } |
| 1062 | + ) |
| 1063 | + if record is not None: |
| 1064 | + metadata["artifact_fallback_saved_at"] = record.get("saved_at") |
| 1065 | + return FeatureSnapshotGuardResult(frame=None, metadata=metadata) |
| 1066 | + |
| 1067 | + |
| 1068 | +def _feature_snapshot_fallback_stale_reason( |
| 1069 | + record: Mapping[str, Any], |
| 1070 | + *, |
| 1071 | + max_stale_days: int | None, |
| 1072 | +) -> str | None: |
| 1073 | + if max_stale_days is None: |
| 1074 | + return None |
| 1075 | + if int(max_stale_days) < 0: |
| 1076 | + return "last_valid_max_stale_days_negative" |
| 1077 | + saved_at = record.get("saved_at") |
| 1078 | + try: |
| 1079 | + saved_ts = pd.Timestamp(saved_at) |
| 1080 | + except Exception: |
| 1081 | + return "last_valid_saved_at_invalid" |
| 1082 | + if pd.isna(saved_ts): |
| 1083 | + return "last_valid_saved_at_invalid" |
| 1084 | + if saved_ts.tzinfo is None: |
| 1085 | + saved_ts = saved_ts.tz_localize(timezone.utc) |
| 1086 | + now_ts = pd.Timestamp(datetime.now(timezone.utc)) |
| 1087 | + if saved_ts < now_ts - pd.Timedelta(days=int(max_stale_days)): |
| 1088 | + return "last_valid_stale:saved_at=" + str(saved_at) |
| 1089 | + return None |
| 1090 | + |
| 1091 | + |
| 1092 | +def _path_from_metadata(value: object) -> Path | None: |
| 1093 | + text = str(value or "").strip() |
| 1094 | + if not text or text.startswith("gs://"): |
| 1095 | + return None |
| 1096 | + return Path(text) |
| 1097 | + |
| 1098 | + |
| 1099 | +def _json_safe_value(value: object) -> object: |
| 1100 | + if isinstance(value, pd.Timestamp): |
| 1101 | + return value.isoformat() |
| 1102 | + if isinstance(value, datetime): |
| 1103 | + return value.isoformat() |
| 1104 | + if isinstance(value, (str, int, float, bool)) or value is None: |
| 1105 | + return value |
| 1106 | + return str(value) |
0 commit comments