zfin-vestwell/tools/gen_model.py

179 lines
7.5 KiB
Python

#!/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()