zfin-vestwell/README.md

18 KiB

zfin-vestwell

Daily unit-value history for the two Oregon College Savings Plan (Embark) portfolios held in the finance repo: ORCBI and ORC42.

zfin-vestwell reconstruct        # rebuild data/ORCBI.srf and data/ORC42.srf
zfin-vestwell verify             # check them against every evidence source
zfin-vestwell record             # append today's published value to the log
zfin-vestwell assemble --out DIR # write zfin cache files for it to serve

The problem

ORCBI and ORC42 are not securities in the ordinary sense. They are unitized fund-of-funds trust portfolios inside a 529 plan: no ticker, no CUSIP, no market-data provider coverage. Tiingo, Yahoo and TwelveData all 404 them.

The plan publishes a daily unit value, but only the current one. There is no historical endpoint, no downloadable series, and no third-party archive of the numbers. So history has to be reconstructed.

Property ORCBI ORC42
Plan name Balanced Index College Enrollment Year 2042
Kind static glidepath, steps quarterly
Feed fund id T6GF10080002 T6GFRD204202
Inception 2018-09-01 2023-07-01
Sessions reconstructed 2006 792

Method

1. Anchors: the only observed values

Nine exact values per portfolio, in data/anchors.srf.

Launch. Every portfolio in the plan launched at exactly $10.00. Verified by back-solving the feed's own inception field, which is the annualized since-inception return: nav / (1 + inception)^years lands between 10.0005 and 10.0038 for all 37 priced portfolios. The Plan Disclosure Booklet agrees on the date -- "The Portfolios commenced operations in September 2018."

Seven Internet Archive captures of the live feed, spanning 2025-02-13 to 2026-07-30, each carrying full 6-decimal values.

The current feed value.

Keep data/anchors.srf. The wayback rows are not recoverable if the Internet Archive prunes those captures, and nothing else publishes these numbers.

2. Interpolation between anchors

Within each gap, walk the trading-day calendar compounding the weighted total return of the underlying Vanguard funds, read from zfin's candle cache (adj_close). Weights come from data/model.srf, per era, so a glidepath step mid-gap needs no special handling.

Total return, not raw NAV, because the plan's own booklet says these portfolios "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; nothing is distributed. The underlying mutual funds do distribute, so their raw NAV drops on every ex-date. A raw-NAV basket under-tracks by roughly the blended yield -- measured at +1.5% to -1.6% drift over four months on ORCBI. That is why ticker:: + price_ratio:: is the wrong tool for these symbols.

3. Pinning

The walk lands near but not exactly on the closing anchor. Take the ratio and distribute it geometrically across the gap, so both endpoints are exact.

This step matters more than the model. No fee term appears anywhere in this project, and none is needed: the asset-based fee, cash drag, trade-date lag and securities-lending income are all roughly constant-rate effects, and a constant-rate misfit is exactly what geometric distribution removes. An unpinned walk across ORCBI's 6.5-year pre-2025 gap is off by -1.29%; pinned, that same gap predicts a held-out interior anchor to +0.013%.

Accuracy

verify measures it two independent ways.

Leave-one-out cross-validation. Drop an interior anchor, pin its neighbours, predict it. This is the number that matters, because it measures error in the middle of an un-anchored gap.

Fund Worst interior error
ORCBI 0.079%
ORC42 0.060%

Against hand-typed values. 16 values per portfolio, recovered from the finance repo's snapshots and never fed into the model, so agreement is evidence rather than a fit. Compared at lag=1, since the portal publishes the prior session's value.

Fund n mean stdev max
ORCBI 16 +0.0075 0.0089 0.0287 (0.15%)
ORC42 16 +0.0035 0.0075 0.0312 (0.19%)

Those values are rounded to 2dp at source, which is already +/-0.005 of noise.

Implied drag is reported for information, not enforced. It should sit near the plan's published cost table (0.234%/yr for ORCBI, 0.239% for ORC42); a fee is not identifiable over a short gap, so per-gap figures are noisy by nature.

Serving it to zfin

ORC42 and ORCBI have no provider coverage, so zfin can only see them if something puts files in its candle cache. That is what assemble does.

zfin-vestwell record                    # feed -> data/observed.srf (idempotent)
zfin-vestwell assemble --out "$CACHE"   # series + log + feed -> cache files

assemble writes <out>/<SYMBOL>/candles_daily.srf and candles_meta.srf, and needs no zfin cache of its own -- only the committed series and the observation log. Run it on whatever host owns the cache zfin reads. On ZFIN_SERVER that makes the symbols available to every client, because a read never triggers a refetch: the server serves a present file as-is.

Verified against a real zfin:

$ zfin diagnose ORC42
local      newest 2026-08-27, TTL still in the future, external
adj basis  1970-01-01 - no dividends or splits cached, nothing to restate
tracked    NO - no normal run fetches this symbol
server     the server will never refresh this symbol on its own
provider   tiingo FAILED: NotFound

Every line there is load-bearing. The cache is fresh and tagged external; the adjustment-restatement path cannot fire because there are no corporate actions; the symbol is untracked so no refresh sweep touches it; and no provider carries it, which is precisely why the cache has to be populated this way.

Deployment order matters

assemble writes provider::external. A zfin that does not know that enum variant treats the meta file as a cache miss, takes the cold-start path, gets a unanimous 404, and writes a negative-cache marker over candles_daily.srf -- the history is then gone.

So deploy a zfin carrying the variant to both the client and ZFIN_SERVER before populating a cache they read. That is an operational precondition, not something this program checks: the enum is verified at compile time against the zfin this links against, which says nothing about the zfin on the consuming side.

There is deliberately no default for --out, so nothing is written anywhere by accident.

Steady state

record is idempotent per navDate, so a daily job is safe -- the feed republishes the same value all weekend. Suggested cron/Cronicle shape:

zfin-vestwell record && zfin-vestwell assemble --out "$ZFIN_CACHE_DIR"

assemble also fetches the feed itself, so the cache is current even on the run where record had nothing new to add. Observed values always beat reconstructed ones for the same date, and assemble reports any date where they disagreed by more than 0.1% -- a sustained count there means the model has drifted.

Limitations

  • The series is derived, not observed, except at the nine anchors. Every row carries source::anchor or source::reconstructed so no consumer can confuse the two.
  • It cannot be regenerated from this repo alone. reconstruct needs about ten years of dividend-adjusted daily prices for five institutional-class Vanguard funds, read from zfin's cache -- which lives outside this repo, is not versioned, and is populated through rate-limited provider APIs. A fresh clone on a fresh machine cannot rebuild data/ORCBI.srf or data/ORC42.srf. That is why they are committed rather than gitignored.
  • It is stable under re-basing, but not under a corporate-action correction. zfin re-bases its adjusted series whenever adjustments are restated, rescaling every historical bar of a fund. The reconstruction consumes only per-day ratios of adjusted closes, so any rescaling cancels exactly -- there is a test for this (output is invariant to rescaling the input price series), including independent per-fund factors. What is not stable: a provider adding or restating a dividend changes the single daily ratio spanning its ex-date, and scale invariance does not help there.
  • ORC42's projected eras are published intent, not fact. The booklet's glidepath table is a cross-section of every enrollment-year portfolio as of 2026-07-01, and because all of them follow one glidepath keyed on years-to-enrollment it maps onto ORC42's future: ORC42 in calendar year Y holds what enrollment-year row E = 4068 - Y holds today. Y=2026 gives E=2042, matching ORC42's fitted current era, so the mapping is confirmed at the one point where that is possible. Everything past 2027-01-01 is marked basis::projected and carries two approximations: the table is annual while allocations actually step quarterly, and era boundaries are placed on 1 January. The booklet also reserves the right to "change the asset allocations ... and change the selection of Underlying Funds", so verify flags any projected era that has become current.
  • ORC42's fitted glidepath boundary is approximate. 2026-01-01 was chosen by grid search; every candidate from 2025-10-01 to 2026-04-01 gives rms <= 0.041%, so the result is insensitive to it.
  • ORCBI's pre-2025 weights are an assumption. That stretch spans a pre-Vestwell (Sumday-era) administrator change and no primary source for the fund lineup that far back was located. Cross-validation says it reconstructs fine, but the weights are unverified there.
  • These are instrument prices, not portfolio history. The finance repo has only held these funds since 2026-02-26. Pre-2026 values are the security's price series and must never be written into history/*-portfolio.srf, which records what was actually owned.

Files

Path Role
data/anchors.srf The nine observed values, with provenance. Irreplaceable.
data/model.srf Fund weights per era, prefilled to 2047. Generated.
data/observed.srf Append-only log of values read from the feed. Grows daily.
data/recorded.srf Hand-typed values, used only to verify.
data/ORCBI.srf, data/ORC42.srf Generated. Daily unit values, per-row provenance.

Maintenance

Going forward, no reconstruction is needed. Append each day's published value to data/anchors.srf as source::feed and the derived portion becomes a progressively smaller fraction of the series. Once anchors are daily the allocation model stops mattering entirely: pinning between two consecutive sessions reproduces both endpoints exactly whatever the weights say, which the test weights are inert when anchors are one session apart locks down. The model then matters only for the historical backfill and for filling any gap where forward recording lapsed.

ORC42's glidepath is prefilled to 2047, annually, from the booklet's forward table. Nothing needs doing each quarter. When verify reports

model era 2027-01-01 is `projected` but is now in force ...

confirm that era against the booklet's current allocation table, correct it if the plan changed, and flip it to basis::fitted. To regenerate the projected rows after a booklet revision:

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

verify cross-checks the last row against the live feed and fails if the committed series has gone stale.

The feed's broken Content-Encoding

The feed responds with content-encoding: base64. The body is not base64; it is plain JSON. The header is simply wrong, it is set on every file in that S3 prefix, and it survives every request variant.

Zig 0.16's std.http.Client rejects it, correctly per spec, and reports it as error.HttpHeadersInvalid. Worse, a head-parse failure never marks the connection closing, so with keep-alive on a poisoned connection returns to the pool and teardown can block.

src/feed.zig works around it without a hand-rolled HTTP stack: receiveHead is a thin wrapper over two separately-public steps, and only the second fails. So it takes the raw head bytes, drops the offending line, and hands them to the same public parser -- keeping TLS, connection handling and body framing in the standard library.

Development

zig build                                   # build the exe -- run this, see below
zig build test                              # 87 tests
zig build coverage -Dcoverage-threshold=80  # coverage floor
zlint --deny-warnings --fix <files>
zig fmt <files>

Reading the underlying prices requires zfin's cache to be populated for VSMPX, VTPSX, VBMPX, VIPIX and VTIFX. zfin quote <SYM> fills it.

Depending on zfin

src/cache_files.zig imports zfin and uses zfin.Candle, zfin.cache.Store and Store.cacheCandles rather than re-declaring those types and serializing them here. Two reasons, both about the destructive path:

  • provider is Store.CandleProvider.external, a compile-checked enum value, not the string "external". A rename upstream is now a build error instead of a cache file that destroys itself on next read.
  • zfin's own writer produces the bytes. The directive block, field separators, type tags, trailing newline and atomic rename are all its code, so the format cannot drift from what its reader expects.

What zfin does not do is validate the series or report a write failure -- cacheCandles returns void and logs. Both gaps are covered: bars are checked before the call, and afterwards the result is read back through zfin's own readCandleMeta, which proves the bytes parse as well as exist.

The zfin library module is lean -- srf, zeit, build_info -- so none of the CLI/TUI dependency tree comes with it.

This checks the producing side only. An older zfin reading the cache is still a hazard, and the installed client and the deployed ZFIN_SERVER are upgraded separately from this repo -- see "Deployment order matters" above.

Writing SRF

Every file this project writes goes through srf.fmt from the srf library, not through format strings. That is a correctness matter, not style: SRF length-prefixes a string value containing a comma (key:24:some, value) because a bare comma reads as a field separator. Hand-formatted records silently lost fields whenever a value contained one -- which is how a comment written into an evidence field first broke anchors.srf.

The library also owns the #!srfv1 line, the #!expires=/#!created= directives, the :num: type tags and the per-record trailing newline. For candles_meta.srf those are exactly the things whose malformation makes zfin treat the file as a cache miss and overwrite candles_daily.srf, so they are much better handled by the library than by this project.

srf.FormatOptions has no precision control and Zig's {d} is shortest-round-trip, which would spend 17 significant digits on a reconstructed value. src/srf_num.zig solves that with SRF's own extension point: a one-field wrapper whose srfFormat method renders fixed decimals. It is the only place in the project that writes SRF field syntax by hand, and it does so because that is the interface the library asks for.

tools/gen_model.py is the exception -- Python has no binding for the library -- so instead of emulating the escaping it refuses to emit any value that would need it.

Coverage, and what is deliberately not covered

Currently 88.84%. The split is not uniform, on purpose:

Module Coverage
recon, verify, data, series, assemble, cache_files, srf_num, civil 100%
candles (parse tested, the file read is not) 95%
feed (both parsers tested, fetchBody is not) 82%
main (CLI wiring; assemble covered end to end) 38%

Every line of logic is tested. What is not tested is mostly the I/O boundary: file reads, the one HTTP call, and CLI plumbing. That end of the program is also verified by running it against real data and checking verify exits zero.

cmdAssemble is the exception and does have an integration test, against a temp-directory fixture. It is the one function that writes into a cache nothing else can rebuild, and the test reads the result back with zfin.cache.Store rather than with our own parser -- proving the bytes parse, not merely that they exist.

src/main.zig calls std.testing.refAllDecls so that main and the command functions compile in the test binary. Without it they are dead-code eliminated, and zig build test can pass while zig build fails on a type error inside main -- which happened during development. Keeping it costs about 12 points of coverage but makes the untested surface visible in the number rather than hiding it. Always run zig build, not just zig build test. The pre-commit hook runs both.

A similar harness for cmdReconstruct, cmdVerify and cmdRecord would close most of the remaining gap and is a reasonable follow-up.