Skip to content

Commit f56f028

Browse files
committed
Refresh July 30 horizon dashboard
1 parent 73ad131 commit f56f028

6 files changed

Lines changed: 250 additions & 33 deletions

File tree

site/data/manifest.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"schema_version": 3,
3-
"generated_at_utc": "2026-07-30T00:29:41.454374+00:00",
3+
"generated_at_utc": "2026-07-30T21:12:34.167009+00:00",
44
"latest_decision_date": "2026-07-28",
55
"latest_monitor_date": "2026-07-29",
66
"snapshot_count": 17,
77
"evaluation_count": 14,
88
"dashboard_sha256": "786388e9b2b040a8e0e39ad8dc46ecc44d3067b3095281a334da56467729ca9b",
99
"dashboard_bytes": 586173,
10-
"horizon_dashboard_sha256": "20d6bdaa4d619ecca77441801a4f2e63b93c665822979b278cb490999bd4695a",
11-
"horizon_dashboard_bytes": 66861,
12-
"horizon_forecast_arms": 6,
10+
"horizon_dashboard_sha256": "6388d818bd0e7a103662231c17259e119126864b8fd318e70c96148ec56495cb",
11+
"horizon_dashboard_bytes": 128840,
12+
"horizon_forecast_arms": 12,
1313
"source_policy": "sanitized_live_forward_outputs_only"
1414
}

site/horizons.html

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

src/experiments/live_forward_20d.py

Lines changed: 195 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import hashlib
66
import json
7+
from copy import deepcopy
78
from datetime import UTC
89
from pathlib import Path
910
from typing import Any, Iterable
@@ -90,6 +91,95 @@ def _payload_sha256(payload: dict[str, Any]) -> str:
9091
return hashlib.sha256(encoded).hexdigest()
9192

9293

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+
93183
def build_timestamp_safe_nlp_features(
94184
posts: list[Any],
95185
price_dates: pd.DatetimeIndex,
@@ -1751,6 +1841,99 @@ def evaluate_horizon_matched_strategies(
17511841
return result
17521842

17531843

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+
17541937
def build_live_20d_dashboard(
17551938
output_root: Path,
17561939
run_summary: dict[str, Any],
@@ -1812,29 +1995,7 @@ def run_live_forward_20d(
18121995
"holdings": int(settings["holdings"]),
18131996
}
18141997
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)
18381999
universe_cfg = settings["universe"]
18392000
eligible_features, eligibility = prepare_eligible_panel(
18402001
data.loc[data.index.get_level_values("date") <= decision_date],
@@ -2056,6 +2217,11 @@ def run_live_forward_20d(
20562217
price_data,
20572218
decision_date,
20582219
)
2220+
strategy_interim_marks = build_strategy_interim_marks(
2221+
output,
2222+
price_data,
2223+
decision_date,
2224+
)
20592225
strategy_performance: list[dict[str, Any]] = []
20602226
if not strategy_returns.empty:
20612227
for (family, horizon), group in strategy_returns.groupby(
@@ -2131,12 +2297,18 @@ def run_live_forward_20d(
21312297
) if not tactical.empty else 0,
21322298
"matured_forecasts": int(len(matured)),
21332299
"strategy_daily_return_rows": int(len(strategy_returns)),
2300+
"strategy_interim_mark_rows": int(len(strategy_interim_marks)),
21342301
"strategy_performance": strategy_performance,
21352302
"strategy_returns_output": (
21362303
str(output / "strategy_daily_returns.csv")
21372304
if not strategy_returns.empty
21382305
else None
21392306
),
2307+
"strategy_interim_marks_output": (
2308+
str(output / "strategy_interim_marks.csv")
2309+
if not strategy_interim_marks.empty
2310+
else None
2311+
),
21402312
"output": str(daily_dir),
21412313
}
21422314
_write_json(daily_dir / "run_summary.json", summary)

0 commit comments

Comments
 (0)