#!/usr/bin/env python3 """Regenerate data/model.srf from the Plan Disclosure Booklet glidepath table. The weights are transcribed by machine rather than by hand: 21 projected eras of five to eight funds each is far too much to retype reliably. Usage: curl -sLo .tmp/booklet.html \ https://marcom.vestwell.com/program-description/oregon-college.html python3 tools/gen_model.py .tmp/booklet.html > data/model.srf """ import html import re import sys DASH = "[-\u2010\u2011\u2012\u2013\u2014\u2015\u2212]" FUNDS = ["VSMPX", "VTPSX", "VIPIX", "VBMPX", "VTIFX", "VBIPX", "VTSPX", "VUSXX"] def parse_glidepath(raw): txt = re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", raw))) i = txt.find(" ".join(FUNDS)) if i < 0: sys.exit("glidepath table header not found; booklet layout changed") rows = re.findall( r"(20\d\d)((?:\s+(?:[\d.]+%|" + DASH + r")){8})", txt[i : i + 2600] ) table = {} for year, cells in rows: vals = [ 0.0 if re.fullmatch(DASH, c) else float(c.rstrip("%")) for c in re.split(r"\s+", cells.strip()) ] table[int(year)] = vals if 2042 not in table or len(table) < 20: sys.exit(f"glidepath table parsed but looks wrong: {sorted(table)}") for year, vals in table.items(): if abs(sum(vals) - 100.0) > 0.005: sys.exit(f"enrollment-year {year} weights sum to {sum(vals)}, not 100") return table def num(v): return f"{v:g}" def rows_for(symbol, era_start, basis, pairs): """Render weight rows, refusing anything SRF would mis-parse. This script hand-formats SRF because Python has no binding for the library the Zig side uses. That means the escaping the library would do for free is absent here, so instead of emulating it, reject the input that would need it. A bare comma in a value reads as a field separator; a newline ends the record. Every value written here is an identifier, an ISO date or a number, so a hit means the booklet layout changed under us rather than a quoting bug. """ out = [] for ticker, weight in pairs: if weight <= 0: continue for field, value in ( ("symbol", symbol), ("era_start", era_start), ("basis", basis), ("ticker", ticker), ): if any(c in value for c in ",\n\r"): sys.exit(f"refusing to emit unescaped SRF: {field}={value!r}") out.append( f"symbol::{symbol},era_start::{era_start},basis::{basis}," f"ticker::{ticker},weight:num:{num(weight)}" ) return out def main(): table = parse_glidepath(open(sys.argv[1], encoding="utf-8", errors="replace").read()) out = [] out.append(HEADER.rstrip("\n")) out.append("") out.append(ORCBI_NOTE.rstrip("\n")) out += rows_for( "ORCBI", "2018-09-01", "fitted", [("VSMPX", 36), ("VTPSX", 24), ("VBMPX", 40)], ) out.append("") out.append(ORC42_NOTE.rstrip("\n")) out += rows_for( "ORC42", "2023-07-01", "fitted", [("VSMPX", 54), ("VTPSX", 36), ("VIPIX", 1.67), ("VBMPX", 6.66), ("VTIFX", 1.67)], ) out.append("") out.append( "# Era 2 is the booklet current 2042 row. Validated: the reconstruction\n" "# reproduces all nine ORC42 anchors to within 0.06% using it." ) out += rows_for( "ORC42", "2026-01-01", "fitted", [("VSMPX", 52.8), ("VTPSX", 35.2), ("VIPIX", 2), ("VBMPX", 8), ("VTIFX", 2)], ) # ORC42 in calendar year Y holds what enrollment-year row E = 4068 - Y holds # in the 2026-07-01 cross-section. Y=2026 -> E=2042 reproduces the fitted # current era, which is the one point where the mapping can be checked. for year in range(2027, 2048): enrollment = 4068 - year weights = table.get(enrollment) if weights is None: continue out.append("") out.append( f"# Projected: calendar {year} takes the booklet enrollment-year " f"{enrollment} row." ) out += rows_for( "ORC42", f"{year}-01-01", "projected", list(zip(FUNDS, weights)) ) sys.stdout.write("\n".join(out) + "\n") HEADER = """\ #!srfv1 # Underlying-fund weights for each plan portfolio, by era. # # GENERATED by tools/gen_model.py from the Plan Disclosure Booklet. Regenerate # rather than hand-editing the projected rows; 21 eras is too many to retype # reliably. Hand edits to the `fitted` rows are fine, but keep them in sync. # # These portfolios are unitized fund-of-funds trusts, not mutual funds. The Plan # Disclosure Booklet is explicit that they "reflect changes in value from income # and gains and losses on the sale of the Underlying Funds solely by increasing # or decreasing their Unit Value" -- all income compounds into the unit value and # nothing is distributed. That is why the reconstruction uses each underlying # fund's dividend-adjusted (total-return) close, not its raw NAV. # # Source of the weights: the Plan Disclosure Booklet allocation tables at # https://marcom.vestwell.com/program-description/oregon-college.html # Corroborated for the current era by two live JSON endpoints that agree exactly: # https://vss-api.vestwell.com/plans/oregon-college/portfolios (array form) # https://vss-api.vestwell.com/plans/oregon-college (object form) # # `era_start` is inclusive: a weight row applies to every trading day >= its # era_start, until superseded by a later era for the same symbol. # # `basis` records how much a row is worth trusting: # fitted In effect now or in the past, and validated -- the reconstruction # reproduces every observed anchor to within 0.08% using it. # projected Not yet in effect. Read off the booklet forward table, so it is # the plan's published intent, NOT an observed fact. The booklet # reserves the right to "change the asset allocations ... and change # the selection of Underlying Funds", so confirm before relying on # one. `verify` flags any projected era that has become current. # # NOTE ON FEES: no fee or drag term appears here, and none is needed. The # reconstruction pins the series at both ends of every anchor gap and distributes # the residual geometrically, which absorbs the asset-based fee, cash drag, # trade-date lag and securities-lending income together. `verify` reports the # drag each gap implies, as a cross-check against the booklet's published cost # table ($23.40 per $10,000/yr = 0.234% for ORCBI, $23.91 = 0.239% for ORC42).""" ORCBI_NOTE = """\ # ORCBI -- "Balanced Index", a STATIC portfolio. The booklet: "Static Portfolio # investments remain fixed, subject to periodic rebalancing". No glidepath, so # one era covers its whole life. These weights change only by Board action.""" ORC42_NOTE = """\ # ORC42 -- "College Enrollment Year 2042", a GLIDEPATH portfolio. # # The booklet publishes a full cross-section of every enrollment-year portfolio # as of 2026-07-01. Because all of them follow one glidepath keyed on # years-to-enrollment, that cross-section IS ORC42's forward path shifted in # time: ORC42 in calendar year Y holds what enrollment-year row E = 4068 - Y # holds today. Y=2026 gives E=2042, which matches ORC42's fitted current era, so # the mapping checks out against observed data at the one point where it can. # # Two approximations in the projected rows: # 1. The booklet table is annual, but the booklet also says allocations step # QUARTERLY. So each projected era is up to three quarters coarse, and the # intermediate quarterly steps are not published anywhere. # 2. Era boundaries sit on 1 January. The current boundary was fitted to # 2026-01-01 by grid search over quarter boundaries; every candidate from # 2025-10-01 to 2026-04-01 gives rms <= 0.041%, so the fit cannot resolve # it better than that. # # Neither approximation matters much. Future eras cannot affect a reconstruction # that stops at today, and once daily feed values are appended to anchors.srf # every gap is one session, where pinning makes the weights literally inert (see # the test "weights are inert when anchors are one session apart"). They are # prefilled so the model degrades gracefully instead of silently using 2026 # weights in 2035 if forward recording ever lapses. # # Era 1 is the booklet's max-equity plateau (its 2043/2044/2045 rows). ORC42 # launched 19 years out from enrollment, so it sat on the plateau from # inception.""" if __name__ == "__main__": main()