|
4 | 4 |
|
5 | 5 | import hashlib |
6 | 6 | import json |
| 7 | +from copy import deepcopy |
7 | 8 | from datetime import UTC |
8 | 9 | from pathlib import Path |
9 | 10 | from typing import Any, Iterable |
@@ -90,6 +91,95 @@ def _payload_sha256(payload: dict[str, Any]) -> str: |
90 | 91 | return hashlib.sha256(encoded).hexdigest() |
91 | 92 |
|
92 | 93 |
|
| 94 | +def _canonical_payload_sha256(payload: dict[str, Any]) -> str: |
| 95 | + def string_keys(value: Any) -> Any: |
| 96 | + if isinstance(value, dict): |
| 97 | + return { |
| 98 | + str(key): string_keys(local) |
| 99 | + for key, local in value.items() |
| 100 | + } |
| 101 | + if isinstance(value, (list, tuple)): |
| 102 | + return [string_keys(local) for local in value] |
| 103 | + return value |
| 104 | + |
| 105 | + return _payload_sha256(string_keys(payload)) |
| 106 | + |
| 107 | + |
| 108 | +def _training_data_revision(config: dict[str, Any]) -> int: |
| 109 | + return int(config.get("live_forward_20d", {}).get("training_data_revision", 1)) |
| 110 | + |
| 111 | + |
| 112 | +def _experiment_design_payload(config: dict[str, Any]) -> dict[str, Any]: |
| 113 | + """Remove the operational data revision from the frozen experiment design.""" |
| 114 | + design = deepcopy(config) |
| 115 | + design.get("live_forward_20d", {}).pop("training_data_revision", None) |
| 116 | + return design |
| 117 | + |
| 118 | + |
| 119 | +def _ensure_experiment_manifest(path: Path, config: dict[str, Any]) -> None: |
| 120 | + """Freeze design while allowing an audited monotonic training-data repair.""" |
| 121 | + config_sha256 = _payload_sha256(config) |
| 122 | + if not path.exists(): |
| 123 | + _write_json( |
| 124 | + path, |
| 125 | + { |
| 126 | + "status": "frozen_paper_live_experiment", |
| 127 | + "experiment_version": config.get("experiment", {}).get( |
| 128 | + "id", |
| 129 | + "live_forward_20d_v1", |
| 130 | + ), |
| 131 | + "config_sha256": config_sha256, |
| 132 | + "design_sha256": _canonical_payload_sha256( |
| 133 | + _experiment_design_payload(config) |
| 134 | + ), |
| 135 | + "training_data_revision": _training_data_revision(config), |
| 136 | + "config": config, |
| 137 | + "config_history": [], |
| 138 | + "created_utc": pd.Timestamp.now(tz=UTC).isoformat(), |
| 139 | + }, |
| 140 | + ) |
| 141 | + return |
| 142 | + |
| 143 | + frozen = json.loads(path.read_text(encoding="utf-8")) |
| 144 | + if frozen.get("config_sha256") == config_sha256: |
| 145 | + return |
| 146 | + old_config = frozen.get("config", {}) |
| 147 | + old_design_sha = _canonical_payload_sha256( |
| 148 | + _experiment_design_payload(old_config) |
| 149 | + ) |
| 150 | + new_design_sha = _canonical_payload_sha256( |
| 151 | + _experiment_design_payload(config) |
| 152 | + ) |
| 153 | + old_revision = _training_data_revision(old_config) |
| 154 | + new_revision = _training_data_revision(config) |
| 155 | + if old_design_sha != new_design_sha or new_revision <= old_revision: |
| 156 | + raise RuntimeError( |
| 157 | + "Output root already contains a different frozen experiment config" |
| 158 | + ) |
| 159 | + |
| 160 | + history = list(frozen.get("config_history", [])) |
| 161 | + history.append( |
| 162 | + { |
| 163 | + "config_sha256": frozen.get("config_sha256"), |
| 164 | + "training_data_revision": old_revision, |
| 165 | + "config": old_config, |
| 166 | + "superseded_utc": pd.Timestamp.now(tz=UTC).isoformat(), |
| 167 | + "reason": "training_data_revision_advanced", |
| 168 | + } |
| 169 | + ) |
| 170 | + frozen.update( |
| 171 | + { |
| 172 | + "config_sha256": config_sha256, |
| 173 | + "design_sha256": new_design_sha, |
| 174 | + "training_data_revision": new_revision, |
| 175 | + "config": config, |
| 176 | + "config_history": history, |
| 177 | + "updated_utc": pd.Timestamp.now(tz=UTC).isoformat(), |
| 178 | + } |
| 179 | + ) |
| 180 | + _write_json(path, frozen) |
| 181 | + |
| 182 | + |
93 | 183 | def build_timestamp_safe_nlp_features( |
94 | 184 | posts: list[Any], |
95 | 185 | price_dates: pd.DatetimeIndex, |
@@ -1751,6 +1841,99 @@ def evaluate_horizon_matched_strategies( |
1751 | 1841 | return result |
1752 | 1842 |
|
1753 | 1843 |
|
| 1844 | +def build_strategy_interim_marks( |
| 1845 | + output_root: Path, |
| 1846 | + prices: pd.DataFrame, |
| 1847 | + as_of: pd.Timestamp, |
| 1848 | +) -> pd.DataFrame: |
| 1849 | + """Mark each currently entered strategy at the latest official close.""" |
| 1850 | + price_dates = pd.DatetimeIndex( |
| 1851 | + prices.index.get_level_values("date").unique() |
| 1852 | + ).sort_values() |
| 1853 | + as_of_pos = price_dates.get_indexer([as_of])[0] |
| 1854 | + if as_of_pos < 0: |
| 1855 | + return pd.DataFrame() |
| 1856 | + opened = prices["open"].unstack("ticker") |
| 1857 | + close_column = "adj_close" if "adj_close" in prices else "close" |
| 1858 | + closed = prices[close_column].unstack("ticker") |
| 1859 | + candidates: dict[ |
| 1860 | + tuple[str, int], |
| 1861 | + list[tuple[pd.Timestamp, Path, dict[str, Any]]], |
| 1862 | + ] = {} |
| 1863 | + for manifest_path in output_root.glob("forecasts/*/*/manifest.json"): |
| 1864 | + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) |
| 1865 | + if not manifest.get("strategy_rebalance", False): |
| 1866 | + continue |
| 1867 | + target_path = manifest_path.parent / "strategy_rebalance_target.csv" |
| 1868 | + if not target_path.exists(): |
| 1869 | + continue |
| 1870 | + decision = pd.Timestamp(manifest["decision_date"]) |
| 1871 | + decision_pos = price_dates.get_indexer([decision])[0] |
| 1872 | + if decision_pos < 0 or decision_pos + 1 > as_of_pos: |
| 1873 | + continue |
| 1874 | + key = (str(manifest["family"]), int(manifest["horizon_sessions"])) |
| 1875 | + candidates.setdefault(key, []).append((decision, target_path, manifest)) |
| 1876 | + |
| 1877 | + rows: list[dict[str, Any]] = [] |
| 1878 | + for (family, horizon), items in candidates.items(): |
| 1879 | + decision, target_path, manifest = max(items, key=lambda item: item[0]) |
| 1880 | + decision_pos = price_dates.get_indexer([decision])[0] |
| 1881 | + entry_pos = decision_pos + 1 |
| 1882 | + entry_date = price_dates[entry_pos] |
| 1883 | + target = pd.read_csv(target_path) |
| 1884 | + if not {"ticker", "weight"}.issubset(target.columns): |
| 1885 | + raise ValueError(f"Invalid strategy target schema: {target_path}") |
| 1886 | + weights = target.set_index(target["ticker"].astype(str))["weight"].astype( |
| 1887 | + float |
| 1888 | + ) |
| 1889 | + entry = opened.loc[entry_date].reindex(weights.index) |
| 1890 | + mark = closed.loc[as_of].reindex(weights.index) |
| 1891 | + usable = ( |
| 1892 | + weights.notna() |
| 1893 | + & weights.gt(0) |
| 1894 | + & entry.notna() |
| 1895 | + & entry.gt(0) |
| 1896 | + & mark.notna() |
| 1897 | + & mark.gt(0) |
| 1898 | + ) |
| 1899 | + if not usable.all(): |
| 1900 | + missing = weights.index[~usable].astype(str).tolist() |
| 1901 | + raise RuntimeError( |
| 1902 | + f"Invalid interim mark prices or weights for {family} h{horizon} " |
| 1903 | + f"vintage {decision.date()}: {', '.join(missing[:10])}" |
| 1904 | + ) |
| 1905 | + weights = weights / weights.sum() |
| 1906 | + gross_mark = float(((mark / entry - 1.0) * weights).sum()) |
| 1907 | + spy_mark = float("nan") |
| 1908 | + if "SPY" in opened.columns and "SPY" in closed.columns: |
| 1909 | + spy_mark = float( |
| 1910 | + closed.loc[as_of, "SPY"] / opened.loc[entry_date, "SPY"] - 1.0 |
| 1911 | + ) |
| 1912 | + rows.append( |
| 1913 | + { |
| 1914 | + "experiment_version": manifest["experiment_version"], |
| 1915 | + "family": family, |
| 1916 | + "horizon_sessions": horizon, |
| 1917 | + "rebalance_decision_date": str(decision.date()), |
| 1918 | + "entry_date": str(entry_date.date()), |
| 1919 | + "mark_date": str(as_of.date()), |
| 1920 | + "completed_open_to_open_sessions": int(as_of_pos - entry_pos), |
| 1921 | + "gross_mark_to_close": gross_mark, |
| 1922 | + "spy_mark_to_close": spy_mark, |
| 1923 | + "active_mark_vs_spy": float(gross_mark - spy_mark), |
| 1924 | + "initial_holdings": int(len(weights)), |
| 1925 | + "status": "interim_official_close_mark", |
| 1926 | + } |
| 1927 | + ) |
| 1928 | + result = pd.DataFrame(rows) |
| 1929 | + if not result.empty: |
| 1930 | + result = result.sort_values( |
| 1931 | + ["family", "horizon_sessions"] |
| 1932 | + ).reset_index(drop=True) |
| 1933 | + result.to_csv(output_root / "strategy_interim_marks.csv", index=False) |
| 1934 | + return result |
| 1935 | + |
| 1936 | + |
1754 | 1937 | def build_live_20d_dashboard( |
1755 | 1938 | output_root: Path, |
1756 | 1939 | run_summary: dict[str, Any], |
@@ -1812,29 +1995,7 @@ def run_live_forward_20d( |
1812 | 1995 | "holdings": int(settings["holdings"]), |
1813 | 1996 | } |
1814 | 1997 | experiment_manifest_path = output / "experiment_manifest.json" |
1815 | | - config_sha256 = _payload_sha256(config) |
1816 | | - if experiment_manifest_path.exists(): |
1817 | | - frozen_experiment = json.loads( |
1818 | | - experiment_manifest_path.read_text(encoding="utf-8") |
1819 | | - ) |
1820 | | - if frozen_experiment.get("config_sha256") != config_sha256: |
1821 | | - raise RuntimeError( |
1822 | | - "Output root already contains a different frozen experiment config" |
1823 | | - ) |
1824 | | - else: |
1825 | | - _write_json( |
1826 | | - experiment_manifest_path, |
1827 | | - { |
1828 | | - "status": "frozen_paper_live_experiment", |
1829 | | - "experiment_version": config.get("experiment", {}).get( |
1830 | | - "id", |
1831 | | - "live_forward_20d_v1", |
1832 | | - ), |
1833 | | - "config_sha256": config_sha256, |
1834 | | - "config": config, |
1835 | | - "created_utc": pd.Timestamp.now(tz=UTC).isoformat(), |
1836 | | - }, |
1837 | | - ) |
| 1998 | + _ensure_experiment_manifest(experiment_manifest_path, config) |
1838 | 1999 | universe_cfg = settings["universe"] |
1839 | 2000 | eligible_features, eligibility = prepare_eligible_panel( |
1840 | 2001 | data.loc[data.index.get_level_values("date") <= decision_date], |
@@ -2056,6 +2217,11 @@ def run_live_forward_20d( |
2056 | 2217 | price_data, |
2057 | 2218 | decision_date, |
2058 | 2219 | ) |
| 2220 | + strategy_interim_marks = build_strategy_interim_marks( |
| 2221 | + output, |
| 2222 | + price_data, |
| 2223 | + decision_date, |
| 2224 | + ) |
2059 | 2225 | strategy_performance: list[dict[str, Any]] = [] |
2060 | 2226 | if not strategy_returns.empty: |
2061 | 2227 | for (family, horizon), group in strategy_returns.groupby( |
@@ -2131,12 +2297,18 @@ def run_live_forward_20d( |
2131 | 2297 | ) if not tactical.empty else 0, |
2132 | 2298 | "matured_forecasts": int(len(matured)), |
2133 | 2299 | "strategy_daily_return_rows": int(len(strategy_returns)), |
| 2300 | + "strategy_interim_mark_rows": int(len(strategy_interim_marks)), |
2134 | 2301 | "strategy_performance": strategy_performance, |
2135 | 2302 | "strategy_returns_output": ( |
2136 | 2303 | str(output / "strategy_daily_returns.csv") |
2137 | 2304 | if not strategy_returns.empty |
2138 | 2305 | else None |
2139 | 2306 | ), |
| 2307 | + "strategy_interim_marks_output": ( |
| 2308 | + str(output / "strategy_interim_marks.csv") |
| 2309 | + if not strategy_interim_marks.empty |
| 2310 | + else None |
| 2311 | + ), |
2140 | 2312 | "output": str(daily_dir), |
2141 | 2313 | } |
2142 | 2314 | _write_json(daily_dir / "run_summary.json", summary) |
|
0 commit comments