Skip to content

Commit 46ca4ea

Browse files
authored
Add last-valid fallback for feature snapshots (#105)
1 parent 6a27360 commit 46ca4ea

3 files changed

Lines changed: 409 additions & 3 deletions

File tree

src/quant_platform_kit/common/feature_snapshot.py

Lines changed: 338 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
import hashlib
66
import json
7+
import shutil
78
import tempfile
89
from dataclasses import dataclass
9-
from datetime import timezone
10+
from datetime import datetime, timezone
1011
from pathlib import Path
11-
from typing import Iterable
12+
from typing import Any, Iterable, Mapping
1213

1314
import pandas as pd
1415

@@ -17,6 +18,13 @@
1718
DEFAULT_MAX_SNAPSHOT_MONTH_LAG = 1
1819
DEFAULT_SNAPSHOT_MANIFEST_SUFFIX = ".manifest.json"
1920
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+
)
2028
_MANIFEST_DIAGNOSTIC_FIELDS = (
2129
"price_as_of",
2230
"universe_as_of",
@@ -251,6 +259,73 @@ def load_feature_snapshot_guarded(
251259
expected_config_name: str | None = None,
252260
expected_config_path: str | None = None,
253261
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,
254329
) -> FeatureSnapshotGuardResult:
255330
raw_path = str(path or "").strip()
256331
if not raw_path:
@@ -310,7 +385,7 @@ def load_feature_snapshot_guarded(
310385
),
311386
)
312387

313-
result = load_feature_snapshot_guarded(
388+
result = _load_feature_snapshot_guarded_without_fallback(
314389
str(local_snapshot_path),
315390
run_as_of=run_as_of,
316391
required_columns=required_columns,
@@ -769,3 +844,263 @@ def load_feature_snapshot_guarded(
769844
actual_config_sha256=actual_config_sha256,
770845
),
771846
)
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)

src/quant_platform_kit/common/feature_snapshot_runtime.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
class FeatureSnapshotRuntimeSettings:
2323
feature_snapshot_path: str | None
2424
feature_snapshot_manifest_path: str | None = None
25+
feature_snapshot_fallback_mode: str | None = None
26+
feature_snapshot_fallback_cache_dir: str | None = None
27+
feature_snapshot_fallback_max_stale_days: int | None = None
2528
strategy_config_path: str | None = None
2629
strategy_config_source: str | None = None
2730
dry_run_only: bool = False
@@ -148,6 +151,9 @@ def evaluate_feature_snapshot_strategy(
148151
expected_config_name=runtime_config_name,
149152
expected_config_path=runtime_config_path,
150153
expected_contract_version=runtime_adapter.snapshot_contract_version,
154+
fallback_mode=runtime_settings.feature_snapshot_fallback_mode,
155+
fallback_cache_dir=runtime_settings.feature_snapshot_fallback_cache_dir,
156+
fallback_max_stale_days=runtime_settings.feature_snapshot_fallback_max_stale_days,
151157
)
152158
guard_metadata = dict(guard_result.metadata)
153159
if on_guard_metadata is not None:

0 commit comments

Comments
 (0)