216 lines
9.7 KiB
Markdown
216 lines
9.7 KiB
Markdown
# The snapshot model
|
|
|
|
A snapshot is the file [`zfin snapshot`](../reference/cli/snapshot.md)
|
|
writes to `history/<date>-portfolio.srf`, and the thing every
|
|
history- and comparison-oriented command reads back. This page explains
|
|
what a snapshot is *for*, which is also an explanation of what it
|
|
deliberately leaves out. For the day-to-day workflow, see
|
|
[Snapshots and history](../guides/snapshots-and-history.md).
|
|
|
|
## A snapshot is a valuation record
|
|
|
|
A snapshot answers exactly one question:
|
|
|
|
> What was this portfolio worth on date D, decomposed by symbol,
|
|
> account, and tax type?
|
|
|
|
That is the whole contract. A snapshot is **not** a faithful record of
|
|
your lots. It records enough per-lot detail to *decompose a valuation*,
|
|
and stops there.
|
|
|
|
This is a deliberate choice rather than an oversight, and the rest of
|
|
this page is the reasoning -- partly so the decision is auditable, and
|
|
partly so the same field-by-field extension proposals don't have to be
|
|
re-litigated every time someone notices something missing.
|
|
|
|
## Two layers, and which one wins
|
|
|
|
A snapshot file has two layers that are computed independently, and it
|
|
matters which you read:
|
|
|
|
1. **The totals layer** -- the `total`, `tax_type`, and `account` rows.
|
|
This is the **authoritative** valuation. It comes from
|
|
`valuation.portfolioSummary`, the same code path that produces the
|
|
headline numbers in `zfin portfolio`, and it includes
|
|
portfolio-level adjustments.
|
|
2. **The lot layer** -- the `lot` rows. This is a **decomposition
|
|
aid**. It comes from a separate, direct walk over the portfolio's
|
|
lots, and it deliberately carries no portfolio-level adjustments.
|
|
|
|
When the two disagree, **the totals layer wins**. See "Known
|
|
shortcoming" below for the one case where they measurably do.
|
|
|
|
## Why not a faithful lot record?
|
|
|
|
Because zfin already has one, and it is better at the job.
|
|
|
|
The git history of your `portfolio.srf` *is* the faithful lot record.
|
|
It has complete fidelity (every field of every lot, not a projection of
|
|
some of them), and it has true temporal resolution (one revision per
|
|
edit, rather than one file per day).
|
|
[`zfin contributions`](../reference/cli/contributions.md) is built
|
|
directly on it: it walks git revisions, deserializes complete lots, and
|
|
matches them across revisions by a composite key.
|
|
|
|
Making snapshots into a *second* faithful lot record would create two
|
|
records of lot identity with different cadences, different
|
|
completeness, and different provenance -- and therefore an open-ended
|
|
obligation to reconcile them whenever they disagree. Nothing currently
|
|
asks for that.
|
|
|
|
There is also a harder lesson embedded in the contributions code. Even
|
|
with the *complete* lot available, matching lots across time is
|
|
unreliable enough that it needs a fuzzy fallback key alongside the
|
|
strict one, because ordinary bookkeeping breaks strict identity:
|
|
certificate-of-deposit auto-renewals rewrite the open date, account
|
|
renames change the account, and reconciliation tweaks move the open
|
|
price. Cross-time lot matching is a genuinely hard problem that the
|
|
git-revision pipeline solves with real machinery. A daily valuation
|
|
file is not going to solve it as a side effect of carrying one more
|
|
column.
|
|
|
|
## Proposals that were considered and rejected
|
|
|
|
If you are here because a field you want is missing, check this list
|
|
first.
|
|
|
|
### `open_date`
|
|
|
|
The strongest-looking candidate, because without it a snapshot's lots
|
|
have no temporal identity at all, so they cannot be matched to lots in
|
|
any other snapshot.
|
|
|
|
Rejected, because it doesn't actually deliver that. `open_date` is one
|
|
component of the strict lot key; a snapshot carrying it would still
|
|
lack everything the fuzzy fallback needs when the strict key breaks --
|
|
which, per above, is routine. The result would *look* like it enabled
|
|
cross-snapshot lot matching while quietly not doing so, which is worse
|
|
than the honest absence. If cross-snapshot lot keying is genuinely
|
|
needed, the answer is to extend the git-revision pipeline.
|
|
|
|
There is one non-identity argument for `open_date`: holding-period
|
|
analysis, such as decomposing unrealized gains into short- and
|
|
long-term at a past date. `cost_basis` is already emitted, so
|
|
`open_date` is the only missing input. This is a legitimately
|
|
valuation-shaped use, and it is the argument to make if the field is
|
|
ever wanted -- but it is currently hypothetical. zfin has no
|
|
holding-period logic anywhere, and tax-loss harvesting is deliberately
|
|
hand-declared in `accounts.srf` rather than computed from lots. Adding
|
|
a field for a consumer that does not exist is how formats rot.
|
|
|
|
### `split_factor`
|
|
|
|
A stock lot's `shares` is written raw (as transacted) while its `value`
|
|
is split-adjusted, so the two are not related by the obvious
|
|
`shares * price` identity. It is tempting to emit the split factor to
|
|
close that gap.
|
|
|
|
Rejected, because it carries no information. All three quantities are
|
|
already recoverable by algebra from what *is* emitted:
|
|
|
|
```
|
|
raw shares == cost_basis / open_price
|
|
effective shares == value / price
|
|
split_factor == value / (shares * price)
|
|
```
|
|
|
|
`compare.aggregateSnapshotStocks` uses the second of these and
|
|
documents it. Emitting the split factor as well would introduce a
|
|
*second* route to effective shares within a single record: identical
|
|
whenever the writer is correct, divergent exactly when it is buggy, and
|
|
with no rule for which one a reader should trust. That is the same
|
|
two-sources-of-truth problem as the lot-record proposal, in miniature.
|
|
|
|
The "it would let us validate the invariant" argument does not survive
|
|
either: a check comparing the writer's output against the writer's own
|
|
inputs, assigned in the same function moments earlier, cannot detect a
|
|
wrong split factor, a wrong price, or a wrong share count. It can only
|
|
catch a typo in a single expression, which is a unit test's job.
|
|
|
|
### `drip`
|
|
|
|
Rejected as a category error. `zfin contributions` classifies dividend
|
|
reinvestment by reading the `drip` flag from git revisions of
|
|
`portfolio.srf`; it never reads snapshots at all. Adding `drip` here
|
|
would not affect that classification, or anything else.
|
|
|
|
## Known shortcoming: the covered-call gap
|
|
|
|
`sum(stock lot.value)` can slightly **exceed** `total::liquid` in the
|
|
same file.
|
|
|
|
The cause is the two-layer split. When you hold an open, in-the-money
|
|
sold call, the totals layer caps the covered underlying's market value
|
|
at the option's strike price -- the shares are effectively committed at
|
|
that price, so valuing them at the higher market price would overstate
|
|
the portfolio. The lot layer applies no such cap; each lot is marked at
|
|
plain market value.
|
|
|
|
Consequences, in ascending order of obscurity:
|
|
|
|
- `total::liquid` is correct and remains the figure to read.
|
|
- Summing lot values yourself will overstate the liquid total whenever
|
|
such a call was open on the snapshot date. The overstatement is
|
|
bounded by `(market - strike) * covered_shares` across affected
|
|
underlyings.
|
|
- `history.aggregateSnapshotAllocations` derives per-symbol weights by
|
|
dividing summed lot values by the totals-layer liquid figure, so
|
|
those weights can sum to slightly more than 1 in the same
|
|
circumstance.
|
|
|
|
This is documented rather than fixed because it is immaterial in
|
|
practice: it requires an open ITM sold call on the snapshot date, and
|
|
the affected consumer is an allocation weighting whose downstream use
|
|
tolerates the error. Note also that it is **not diagnosable from a
|
|
snapshot alone** -- deciding whether a given gap is a legitimate
|
|
covered-call cap or a writer bug requires the option's `strike`, which
|
|
the format does not emit. Emitting `strike` and `multiplier` is
|
|
therefore the one field addition with a concrete, valuation-shaped
|
|
consumer, should this ever need fixing properly.
|
|
|
|
## Wire-format compatibility
|
|
|
|
The format is [SRF](https://git.lerch.org/lobo/srf), and its
|
|
compatibility behavior is what makes the format safe to extend:
|
|
|
|
- **Default-valued fields are elided on write.** A field equal to its
|
|
default does not appear in the file at all.
|
|
- **Unknown fields are ignored on read.** A reader skips fields it
|
|
doesn't know about.
|
|
- Together these give compatibility in both directions: old readers
|
|
tolerate new files, and new readers tolerate old files.
|
|
|
|
Two constraints follow for anyone extending a record type:
|
|
|
|
- **Every field must have a default.** Fields are matched by name and
|
|
absent ones are filled from their default; a field with no default
|
|
makes every previously-written snapshot fail to parse.
|
|
- **`kind` must stay first.** It is the union discriminator, and the
|
|
reader requires it as the first field on the line.
|
|
|
|
### `snapshot_version` and ambiguous absence
|
|
|
|
Each snapshot carries a `snapshot_version`, currently `1`. Nothing
|
|
reads it to gate behavior, so bumping it is normally documentary.
|
|
|
|
There is one case where it becomes load-bearing. Because default-valued
|
|
fields are elided, a new field whose value happens to equal its default
|
|
is absent from the file -- and a reader cannot tell that apart from "the
|
|
writer predates this field entirely." **Bump `snapshot_version` when,
|
|
and only when, a new field's absence is semantically ambiguous.**
|
|
Novelty alone is not a reason.
|
|
|
|
Worked examples: a `drip: bool = false` or an `open_date: ?Date = null`
|
|
would each require a bump, because absence could mean either "false /
|
|
unknown" or "old writer." A `split_factor: f64 = 1.0` would not, since
|
|
`1.0` means "no adjustment" either way.
|
|
|
|
### Writers and readers are separate deployments
|
|
|
|
Snapshots are often written by a scheduled job and read by interactive
|
|
commands, and those two can be running different builds of zfin for
|
|
weeks at a time. So a newly added field appears only in files written
|
|
after the *writer's* build is refreshed, and never appears in files
|
|
already on disk. `zfin snapshot` never reads existing snapshots, so an
|
|
older writer is safe; just don't assume a field's presence based on the
|
|
version of the binary you happen to be reading with.
|