622 lines
31 KiB
Markdown
622 lines
31 KiB
Markdown
# 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 project # fill sessions the feed has not published yet
|
|
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.
|
|
|
|
**One session forward, unpinned.** Both figures above measure *interpolation* --
|
|
error in the middle of a gap whose far end is anchored. `project` has no far end,
|
|
so neither bounds it. Walking from a published value one session forward and
|
|
comparing against the next published value:
|
|
|
|
| Fund | Projected | Published | Error |
|
|
|-------|------------|------------|---------|
|
|
| ORCBI | 19.092910 | 19.092721 | 0.0010% |
|
|
| ORC42 | 16.808301 | 16.808106 | 0.0012% |
|
|
|
|
About eighty times tighter than the interior interpolation bound, and two orders
|
|
of magnitude under the 0.1% reporting threshold. That is unsurprising -- one day of
|
|
tracking error on a basket with exactly known weights has very little room to go
|
|
wrong -- but it is measured rather than assumed, and `assemble` re-measures it
|
|
every time a published value lands on a date that was projected.
|
|
|
|
**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 project # funds -> data/observed.srf (weekend fill)
|
|
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.
|
|
|
|
|
|
### When the feed publishes
|
|
|
|
Determined empirically, not guessed. The S3 object carries its own write time:
|
|
|
|
```
|
|
last-modified: Fri, 28 Aug 2026 12:00:39 GMT <- OR-CSP.json
|
|
last-modified: Fri, 28 Aug 2026 12:00:39 GMT <- index.json (identical)
|
|
navDate served: 2026-08-27
|
|
```
|
|
|
|
Identical timestamps across files means one pipeline writes them all. Checked
|
|
against all eight Internet Archive captures of the feed, 8/8 consistent:
|
|
|
|
| capture (UTC) | dow | navDate | dow | gap |
|
|
|------------------|-----|------------|-----|-----|
|
|
| 2025-02-15 06:03 | Sat | 2025-02-13 | Thu | 2 |
|
|
| 2025-04-22 23:48 | Tue | 2025-04-21 | Mon | 1 |
|
|
| 2025-08-28 13:57 | Thu | 2025-08-27 | Wed | 1 |
|
|
| 2026-01-15 02:19 | Thu | 2026-01-13 | Tue | 2 |
|
|
| 2026-03-31 15:46 | Tue | 2026-03-30 | Mon | 1 |
|
|
| 2026-05-12 23:51 | Tue | 2026-05-11 | Mon | 1 |
|
|
| 2026-08-02 07:45 | Sun | 2026-07-30 | Thu | 3 |
|
|
| 2026-08-28 21:17 | Fri | 2026-08-27 | Thu | 1 |
|
|
|
|
**Publish at ~12:00 UTC each weekday, carrying the previous business day's unit
|
|
value.** The Sunday capture is the informative one: a three-day-old value only
|
|
fits if weekends do not publish, so Friday's value does not appear until Monday.
|
|
|
|
Confirmed directly from three consecutive job runs at 13:00 UTC:
|
|
|
|
| Run (UTC) | dow | navDate served | outcome |
|
|
|---------------------|-----|----------------|----------------------------|
|
|
| 2026-08-29 13:00:00 | Sat | 2026-08-27 | already recorded, no-op |
|
|
| 2026-08-30 13:00:00 | Sun | 2026-08-27 | already recorded, no-op |
|
|
| 2026-08-31 13:00:00 | Mon | 2026-08-28 | recorded |
|
|
|
|
So the weekend blackout is real and is not a scheduling artefact. **Friday's unit
|
|
value does not exist in this feed until Monday around 12:00 UTC**, and no cron
|
|
time can change that:
|
|
|
|
```
|
|
Mon 12:00 -> Fri Thu 12:00 -> Wed
|
|
Tue 12:00 -> Mon Fri 12:00 -> Thu
|
|
Wed 12:00 -> Tue Sat (none) -> Thu
|
|
Sun (none) -> Thu
|
|
```
|
|
|
|
From Friday's publish until Monday's, the newest value the feed can offer is
|
|
Thursday's. `project` is what fills that; see below. The plan's own logged-in UI
|
|
does have Friday's value on Saturday, so the recordkeeper is ahead of this
|
|
marketing export -- but that path needs an authenticated session and this one does
|
|
not.
|
|
|
|
Two lesser hazards, neither of which has bitten yet:
|
|
|
|
- The object ships **no `cache-control` and no `expires`**, so CloudFront falls
|
|
back to a distribution default TTL. A POP serving a pre-publish copy at 13:00
|
|
would look exactly like the weekend blackout.
|
|
- That copy **cannot be forced**. A query-string cache-buster, `Cache-Control:
|
|
no-cache` and `Pragma: no-cache` all return a byte-identical cached response
|
|
with the `date` header frozen and `age` still climbing. The only available
|
|
response is to detect and retry later.
|
|
|
|
The practical consequence for scheduling is that anything before 12:00 UTC reads
|
|
the *previous* day's publish and is therefore two business days behind. 3:30am ET
|
|
-- 07:30 UTC in EDT, 08:30 in EST -- is on the wrong side of that line.
|
|
|
|
### Steady state
|
|
|
|
Schedule at **13:00 UTC**, expressed in UTC rather than a local zone: the feed's
|
|
schedule is a UTC cron, so DST never enters into it. That leaves about an hour of
|
|
margin after the publish -- measured at 59m23s against an observed `last-modified`
|
|
of 12:00:39.
|
|
|
|
```sh
|
|
zfin-vestwell record --data-dir /data && \
|
|
zfin-vestwell project --data-dir /data && \
|
|
zfin-vestwell assemble --data-dir /data --out /cache --offline
|
|
```
|
|
|
|
`record` does the one feed fetch and persists it; `project` fills any session the
|
|
underlying funds already have but the feed has not published; `assemble --offline`
|
|
then merges purely from disk. Three steps rather than one so a failure is
|
|
attributable, and `assemble` stays independently useful -- after a `reconstruct` on
|
|
a workstation syncs new `data/*.srf` across, `assemble` alone refreshes the cache.
|
|
|
|
Running all seven days is what makes `project` worth having: Saturday is the run
|
|
that fills Friday. `record` is idempotent per navDate and `project` per
|
|
`(date, value)`, so the extra runs append nothing they should not, and `assemble`
|
|
re-stamps `#!expires` and keeps clients from falling through to the provider path.
|
|
|
|
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.
|
|
|
|
### Filling the weekend: `project`
|
|
|
|
The feed publishes on weekdays; the underlying Vanguard funds do not have that
|
|
gap. zfin's NAV refresh runs at 3:30am ET, so by Saturday 13:00 UTC the funds'
|
|
Friday closes are in its cache while the feed still reads Thursday. `project`
|
|
walks that difference: it takes the newest **feed** value and compounds the
|
|
weighted total return forward, exactly as the reconstruction does between anchors
|
|
but with nothing at the far end to pin against.
|
|
|
|
The trigger is a comparison, not a date calculation:
|
|
|
|
| Sat 13:00 UTC | funds newest | feed newest | outcome |
|
|
|---------------|--------------|-------------|--------------------------------|
|
|
| Tue-Fri | D-1 | D-1 | no gap, no-op |
|
|
| **Sat** | **Fri** | **Thu** | **project Fri** |
|
|
| Sun | Fri | Thu | same value recomputed, no-op |
|
|
| Mon | Fri | Fri | no gap; the publish supersedes |
|
|
|
|
Nothing in the code knows what a weekend is. A market holiday or an unscheduled
|
|
closure needs no handling either: a day that did not trade has no bar, so it never
|
|
enters the calendar. And if zfin's refresh fails, the funds do not advance, no gap
|
|
is seen, and behaviour degrades to what it was before this existed rather than to
|
|
a wrong number.
|
|
|
|
Projections live in `data/observed.srf` alongside the feed values, marked
|
|
`source::projected`. An absent `source` means `feed`, so every row written before
|
|
this existed still means what it did. Three things depend on the distinction, and
|
|
each would be silently wrong without it: `record` must not treat a projected date
|
|
as already recorded, or Monday's real value would be skipped; a projection must
|
|
not be walked forward from another projection; and `verify`'s live-feed check must
|
|
not read a projection as the feed being stale.
|
|
|
|
Precedence in `assemble` is `observed` > `reconstructed` > `projected` -- a value
|
|
pinned at both ends beats one pinned at a single end. So Monday's publish replaces
|
|
Saturday's estimate automatically, and `assemble` reports the realised error when
|
|
it does. That is the only measurement of extrapolation accuracy there is; the
|
|
cross-validation figures above bound *interpolation*.
|
|
|
|
Measured against a published anchor, one session forward is accurate to about
|
|
**0.001%** for both portfolios -- roughly eighty times tighter than the interior
|
|
interpolation bound, and well under the 0.1% reporting threshold.
|
|
|
|
Two deliberate limits:
|
|
|
|
- **A horizon past five sessions is refused**, and `project` exits non-zero. One
|
|
session is normal, two means the feed missed a weekday publish, and a week means
|
|
the feed is broken rather than closed. Failing stops the `&&` chain, so the last
|
|
good cache keeps being served instead of an ever-longer unpinned walk, and the
|
|
job goes red.
|
|
- **`project` alone depends on zfin's NAV refresh** having run. `record` and
|
|
`assemble` still do not, so the independence claimed under *What runs where* is
|
|
narrowed to this one command rather than given up.
|
|
|
|
Every other failure -- a fund missing from the cache, a navDate the funds have no
|
|
bar for, a walk that cannot be computed -- warns and exits 0. A projection is an
|
|
enrichment, and it must never stop the cache being written.
|
|
|
|
### Running it on the NAS
|
|
|
|
The container is `FROM scratch` with a static musl binary, ~10MB. Built and pushed
|
|
by `.forgejo/workflows/zig-build.yaml` to
|
|
`git.lerch.org/lobo/zfin-vestwell:{<shortsha>,latest}`.
|
|
|
|
Two mounts. `/data` is the syncthing-mirrored repo, which is where the SRF inputs
|
|
live and where `record` and `project` append. `/cache` is zfin-server's cache
|
|
directory: `assemble` populates it, and `project` reads the underlying funds out of
|
|
it.
|
|
|
|
`ZFIN_CACHE_DIR` is required rather than optional here. `project` resolves the
|
|
cache the way zfin does -- `$ZFIN_CACHE_DIR`, else `$XDG_CACHE_HOME/zfin`, else
|
|
`$HOME/.cache/zfin` -- and a `scratch` image has no `/etc/passwd`, so `$HOME` is
|
|
unset and the fallback chain has nothing to land on.
|
|
|
|
```sh
|
|
IMAGE=git.lerch.org/lobo/zfin-vestwell:<shortsha>
|
|
DATA=/home/lobo/shared/zfin-vestwell/data
|
|
CACHE=/data/zfin/cache
|
|
|
|
docker run --rm -v "$DATA:/data" -v "$CACHE:/cache" "$IMAGE" \
|
|
record --data-dir /data && \
|
|
docker run --rm -e ZFIN_CACHE_DIR=/cache -v "$DATA:/data" -v "$CACHE:/cache" "$IMAGE" \
|
|
project --data-dir /data && \
|
|
docker run --rm -v "$DATA:/data" -v "$CACHE:/cache" "$IMAGE" \
|
|
assemble --data-dir /data --out /cache --offline
|
|
```
|
|
|
|
Only the `project` invocation carries `-e ZFIN_CACHE_DIR=/cache`, because it is the
|
|
only one of the three that reads the candle cache. Passing it to all three would be
|
|
harmless, but naming it once makes it obvious which command would break without it.
|
|
|
|
`project` reads the funds from the same `/cache` it hands `assemble` as `--out`;
|
|
they are different symbols under one root, so no extra mount is needed. It reads
|
|
that cache directly rather than asking ZFIN_SERVER over HTTP: this job already runs
|
|
on the host that owns the cache, so a network call would fetch data off the disk it
|
|
has mounted while adding a live-service dependency and an API key to a job that
|
|
otherwise needs neither.
|
|
|
|
The Cronicle event pins `<shortsha>` rather than tracking `:latest`, because the
|
|
workflow moves `:latest` on a push to any branch and this writes into a live
|
|
cache. The deploy job prints the tag to copy.
|
|
|
|
`USER 1000:1000` is baked into the image, and both host directories are owned by
|
|
1000:1000, so no `--user` flag is needed. The uid must be numeric because
|
|
`scratch` has no `/etc/passwd`.
|
|
|
|
Writing into the cache while zfin-server serves from it is safe: zfin's `writeRaw`
|
|
is atomic (tmp + fsync + rename), so a concurrent reader sees either the old file
|
|
or the new one, never a torn one. That same guarantee is what makes `project`
|
|
reading the funds out from under a concurrent NAV refresh safe.
|
|
|
|
ORC42 needs **VIPIX** and **VTIFX** tracked on the NAS in addition to VSMPX, VTPSX
|
|
and VBMPX; they are 2% of its allocation each. ORCBI has been a single static era
|
|
of `VSMPX 36 / VTPSX 24 / VBMPX 40` since 2018-09-01, so it needs nothing beyond
|
|
what was already there. Without the two extra funds `project` declines ORC42 and
|
|
says so, rather than projecting from a partial basket.
|
|
|
|
### What runs where, and why
|
|
|
|
| Command | Needs zfin's candle cache | Runs on |
|
|
|---------------|----------------------------------|-------------|
|
|
| `reconstruct` | yes, five Vanguard funds | workstation |
|
|
| `verify` | yes, cross-validation needs them | workstation |
|
|
| `record` | no | NAS |
|
|
| `project` | yes, the in-force era's funds | NAS |
|
|
| `assemble` | no | NAS |
|
|
|
|
`reconstruct`, `verify` and `project` call the zfin-cache reader; `record` and
|
|
`assemble` do not. `record` and `assemble` between them are still enough to
|
|
maintain a correct cache, which is why a failure in `project` is not allowed to
|
|
stop them.
|
|
|
|
`project` is therefore **the one command with an ordering dependency on the zfin
|
|
NAV-refresh cron**, and it needs VIPIX and VTIFX tracked as well as VSMPX, VTPSX
|
|
and VBMPX. The dependency is comfortable rather than tight: the refresh runs at
|
|
3:30am ET and the job at 13:00 UTC, four and a half hours later. If it has not run,
|
|
the funds have not advanced, `project` sees no gap and does nothing.
|
|
|
|
For everything else the only timing constraint is the Vestwell publish above.
|
|
|
|
### Single writer
|
|
|
|
**Only the NAS runs `record` and `project`.** `data/observed.srf` lives in the
|
|
syncthing-mirrored tree so that it stays version-controlled and visible to the
|
|
workstation, which means two writers would produce `.sync-conflict` files.
|
|
Syncthing carries the appended log back for committing; avoid `git checkout`,
|
|
`stash` or `reset` on that file while a run could be in flight.
|
|
|
|
`.stignore` keeps the build artifacts out of the mirror. Without it syncthing would
|
|
carry ~2GB (`.zig-cache` alone is 1.9GB) against 208K of actual data.
|
|
|
|
|
|
## 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.
|
|
- **Between Friday's publish and Monday's, the newest bar is an estimate.** It
|
|
carries `source::projected` in `data/observed.srf`, `assemble` says so in its
|
|
output, and the published value replaces it when it arrives. The zfin cache
|
|
itself has no source column, so a consumer reading only the cache cannot tell a
|
|
projected bar from a published one -- check the log or `assemble`'s report if
|
|
that distinction matters to you.
|
|
- **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.
|