#!/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): return [ f"symbol::{symbol},era_start::{era_start},basis::{basis}," f"ticker::{t},weight:num:{num(w)}" for t, w in pairs if w > 0 ] 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()